[starnix] Show ZX_INFO_MEMORY_STALL info in /proc/pressure/memory

This CL makes starnix generate /proc/pressure/memory with real
stats, including averages over the last 10/60/300 seconds, that
are computed by Starnix according to data periodically sampled
from Zircon.

The a dedicated service (child of the Starnix runner) is
responsible for keeping track of the averages and, in general,
mediate access to the new PSI services. This provides an injection
point for test. Indeed, a new integration test uses this method
to feed known data to a puppet program running within Starnix
instance and verify that it gets back the expected readings.

Note: The ProcfsTest.ProcPressure test is now embedded in the new
integration test, which tests the cpu and io PSI files too (the
expectation being that in the future they will also be served
over the same protocol, like memory PSI is today).

Bug: 319649348
Change-Id: I73818e4d66db9a3784da4fbbe7a06ebcf02f7bcf
Reviewed-on: https://fuchsia-review.googlesource.com/c/fuchsia/+/1176632
Commit-Queue: Fabio D'Urso <fdurso@google.com>
API-Review: Benjamin Lerman <qsr@google.com>
Reviewed-by: Benjamin Lerman <qsr@google.com>
diff --git a/sdk/fidl/fuchsia.starnix.psi/BUILD.gn b/sdk/fidl/fuchsia.starnix.psi/BUILD.gn
new file mode 100644
index 0000000..4645eca
--- /dev/null
+++ b/sdk/fidl/fuchsia.starnix.psi/BUILD.gn
@@ -0,0 +1,10 @@
+# Copyright 2025 The Fuchsia Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import("//build/fidl/fidl.gni")
+
+fidl("fuchsia.starnix.psi") {
+  sources = [ "provider.fidl" ]
+  public_deps = [ "//zircon/vdso/zx" ]
+}
diff --git a/sdk/fidl/fuchsia.starnix.psi/METADATA.textproto b/sdk/fidl/fuchsia.starnix.psi/METADATA.textproto
new file mode 100644
index 0000000..5c62b08
--- /dev/null
+++ b/sdk/fidl/fuchsia.starnix.psi/METADATA.textproto
@@ -0,0 +1,16 @@
+# proto-file: tools/metadata/proto/metadata.proto
+# proto-message: Metadata
+# https://fuchsia.dev/fuchsia-src/development/source_code/metadata
+
+last_reviewed_date: {
+  year: 2025
+  month: 1
+  day: 27
+}
+
+trackers: {
+  # Buganizer component id for `starnix` public component.
+  issue_tracker: {
+    component_id: 1396624
+  }
+}
diff --git a/sdk/fidl/fuchsia.starnix.psi/OWNERS b/sdk/fidl/fuchsia.starnix.psi/OWNERS
new file mode 100644
index 0000000..e6eed32
--- /dev/null
+++ b/sdk/fidl/fuchsia.starnix.psi/OWNERS
@@ -0,0 +1,2 @@
+include /src/starnix/OWNERS
+
diff --git a/sdk/fidl/fuchsia.starnix.psi/provider.fidl b/sdk/fidl/fuchsia.starnix.psi/provider.fidl
new file mode 100644
index 0000000..69dacec
--- /dev/null
+++ b/sdk/fidl/fuchsia.starnix.psi/provider.fidl
@@ -0,0 +1,33 @@
+// Copyright 2025 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+@available(added=HEAD)
+library fuchsia.starnix.psi;
+
+using zx;
+
+type PsiStats = table {
+    /// Average fraction of time spent stalling in the last 10 seconds (0-1).
+    1: avg10 float64;
+
+    /// Average fraction of time spent stalling in the last 60 seconds (0-1).
+    2: avg60 float64;
+
+    /// Average fraction of time spent stalling in the last 300 seconds (0-1).
+    3: avg300 float64;
+
+    /// Cumulative time spent stalling since boot (using the monotonic clock as
+    /// the time base).
+    4: total zx.Duration;
+};
+
+@discoverable
+open protocol PsiProvider {
+    flexible GetMemoryPressureStats() -> (table {
+        /// Stats about time spent with at least one thread stalling.
+        1: some PsiStats;
+
+        /// Stats about time spent with all threads stalling.
+        2: full PsiStats;
+    }) error zx.Status;
+};
diff --git a/src/starnix/BUILD.gn b/src/starnix/BUILD.gn
index 560ee79..db45feb 100644
--- a/src/starnix/BUILD.gn
+++ b/src/starnix/BUILD.gn
@@ -24,6 +24,7 @@
     "kernel:tests",
     "lib:tests",
     "modules:tests",
+    "psi-provider:tests",
     "runner:tests",
     "tests",
     "tools:tests",
diff --git a/src/starnix/kernel/BUILD.gn b/src/starnix/kernel/BUILD.gn
index d69ace06..16c7470 100644
--- a/src/starnix/kernel/BUILD.gn
+++ b/src/starnix/kernel/BUILD.gn
@@ -145,6 +145,7 @@
     "fs/proc/fs.rs",
     "fs/proc/mod.rs",
     "fs/proc/pid_directory.rs",
+    "fs/proc/pressure_directory.rs",
     "fs/proc/proc_directory.rs",
     "fs/proc/sysctl.rs",
     "fs/proc/sysrq.rs",
@@ -206,6 +207,7 @@
     "task/net.rs",
     "task/pid_table.rs",
     "task/process_group.rs",
+    "task/psi_provider.rs",
     "task/ptrace.rs",
     "task/scheduler.rs",
     "task/seccomp.rs",
@@ -315,7 +317,7 @@
     "//sdk/fidl/fuchsia.scheduler:fuchsia.scheduler_rust",
     "//sdk/fidl/fuchsia.session.power:fuchsia.session.power_rust",
     "//sdk/fidl/fuchsia.starnix.binder:fuchsia.starnix.binder_rust",
-    "//sdk/fidl/fuchsia.starnix.runner:fuchsia.starnix.runner_rust",
+    "//sdk/fidl/fuchsia.starnix.psi:fuchsia.starnix.psi_rust",
     "//sdk/fidl/fuchsia.starnix.runner:fuchsia.starnix.runner_rust",
     "//sdk/fidl/fuchsia.sysinfo:fuchsia.sysinfo_rust",
     "//sdk/fidl/fuchsia.sysmem2:fuchsia.sysmem2_rust",
diff --git a/src/starnix/kernel/fs/proc/mod.rs b/src/starnix/kernel/fs/proc/mod.rs
index d7d4250..7c86df5 100644
--- a/src/starnix/kernel/fs/proc/mod.rs
+++ b/src/starnix/kernel/fs/proc/mod.rs
@@ -4,6 +4,7 @@
 
 mod fs;
 pub mod pid_directory;
+mod pressure_directory;
 mod proc_directory;
 mod sysctl;
 mod sysrq;
diff --git a/src/starnix/kernel/fs/proc/pressure_directory.rs b/src/starnix/kernel/fs/proc/pressure_directory.rs
new file mode 100644
index 0000000..aa7b433
--- /dev/null
+++ b/src/starnix/kernel/fs/proc/pressure_directory.rs
@@ -0,0 +1,225 @@
+// Copyright 2025 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+use crate::task::{CurrentTask, EventHandler, WaitCanceler, Waiter};
+use crate::vfs::buffers::InputBuffer;
+use crate::vfs::{
+    fileops_impl_delegate_read_and_seek, fileops_impl_noop_sync, DynamicFile, DynamicFileBuf,
+    DynamicFileSource, FileObject, FileOps, FileSystemHandle, FsNodeHandle, FsNodeOps,
+    SimpleFileNode, StaticDirectoryBuilder,
+};
+use fidl_fuchsia_starnix_psi::{
+    PsiProviderGetMemoryPressureStatsResponse, PsiProviderSynchronousProxy,
+};
+
+use starnix_logging::{log_error, track_stub};
+use starnix_sync::{FileOpsCore, Locked};
+use starnix_uapi::errno;
+use starnix_uapi::errors::Errno;
+use starnix_uapi::file_mode::mode;
+use starnix_uapi::vfs::FdEvents;
+use std::sync::Arc;
+
+/// Creates the /proc/pressure directory. https://docs.kernel.org/accounting/psi.html
+pub fn pressure_directory(
+    current_task: &CurrentTask,
+    fs: &FileSystemHandle,
+) -> Option<FsNodeHandle> {
+    let Some(psi_provider) = current_task.kernel().psi_provider.get() else {
+        return None;
+    };
+
+    let mut dir = StaticDirectoryBuilder::new(fs);
+    dir.entry(
+        current_task,
+        "memory",
+        MemoryPressureFile::new_node(psi_provider.clone()),
+        mode!(IFREG, 0o666),
+    );
+    dir.entry(
+        current_task,
+        "cpu",
+        StubPressureFile::new_node(StubPressureFileKind::CPU),
+        mode!(IFREG, 0o666),
+    );
+    dir.entry(
+        current_task,
+        "io",
+        StubPressureFile::new_node(StubPressureFileKind::IO),
+        mode!(IFREG, 0o666),
+    );
+    Some(dir.build(current_task))
+}
+
+struct MemoryPressureFileSource {
+    psi_provider: Arc<PsiProviderSynchronousProxy>,
+}
+
+impl DynamicFileSource for MemoryPressureFileSource {
+    fn generate(&self, sink: &mut DynamicFileBuf) -> Result<(), Errno> {
+        let PsiProviderGetMemoryPressureStatsResponse { some, full, .. } = self
+            .psi_provider
+            .get_memory_pressure_stats(zx::MonotonicInstant::INFINITE)
+            .map_err(|e| {
+                log_error!("FIDL error getting memory pressure stats: {e}");
+                errno!(EIO)
+            })?
+            .map_err(|_| errno!(EIO))?;
+
+        let some = some.ok_or_else(|| errno!(EIO))?;
+        writeln!(
+            sink,
+            "some avg10={:.2} avg60={:.2} avg300={:.2} total={}",
+            some.avg10.ok_or_else(|| errno!(EIO))?,
+            some.avg60.ok_or_else(|| errno!(EIO))?,
+            some.avg300.ok_or_else(|| errno!(EIO))?,
+            some.total.ok_or_else(|| errno!(EIO))? / 1000
+        )?;
+
+        let full = full.ok_or_else(|| errno!(EIO))?;
+        writeln!(
+            sink,
+            "full avg10={:.2} avg60={:.2} avg300={:.2} total={}",
+            full.avg10.ok_or_else(|| errno!(EIO))?,
+            full.avg60.ok_or_else(|| errno!(EIO))?,
+            full.avg300.ok_or_else(|| errno!(EIO))?,
+            full.total.ok_or_else(|| errno!(EIO))? / 1000
+        )?;
+
+        Ok(())
+    }
+}
+
+struct MemoryPressureFile {
+    source: DynamicFile<MemoryPressureFileSource>,
+}
+
+impl MemoryPressureFile {
+    pub fn new_node(psi_provider: Arc<PsiProviderSynchronousProxy>) -> impl FsNodeOps {
+        SimpleFileNode::new(move || {
+            Ok(Self {
+                source: DynamicFile::new(MemoryPressureFileSource {
+                    psi_provider: psi_provider.clone(),
+                }),
+            })
+        })
+    }
+}
+
+impl FileOps for MemoryPressureFile {
+    fileops_impl_delegate_read_and_seek!(self, self.source);
+    fileops_impl_noop_sync!();
+
+    /// Pressure notifications are configured by writing to the file.
+    fn write(
+        &self,
+        _locked: &mut Locked<'_, FileOpsCore>,
+        _file: &FileObject,
+        _current_task: &CurrentTask,
+        _offset: usize,
+        data: &mut dyn InputBuffer,
+    ) -> Result<usize, Errno> {
+        // Ignore the request for now.
+
+        track_stub!(TODO("https://fxbug.dev/322873423"), "memory pressure notification setup");
+        Ok(data.drain())
+    }
+
+    fn wait_async(
+        &self,
+        _locked: &mut Locked<'_, FileOpsCore>,
+        _file: &FileObject,
+        _current_task: &CurrentTask,
+        waiter: &Waiter,
+        _events: FdEvents,
+        _handler: EventHandler,
+    ) -> Option<WaitCanceler> {
+        Some(waiter.fake_wait())
+    }
+
+    fn query_events(
+        &self,
+        _locked: &mut Locked<'_, FileOpsCore>,
+        _file: &FileObject,
+        _current_task: &CurrentTask,
+    ) -> Result<FdEvents, Errno> {
+        Ok(FdEvents::empty())
+    }
+}
+
+struct StubPressureFileSource;
+
+impl DynamicFileSource for StubPressureFileSource {
+    fn generate(&self, sink: &mut DynamicFileBuf) -> Result<(), Errno> {
+        writeln!(sink, "some avg10=0.00 avg60=0.00 avg300=0.00 total=0")?;
+        writeln!(sink, "full avg10=0.00 avg60=0.00 avg300=0.00 total=0")?;
+        Ok(())
+    }
+}
+
+#[derive(Clone, Copy)]
+enum StubPressureFileKind {
+    CPU,
+    IO,
+}
+
+struct StubPressureFile {
+    kind: StubPressureFileKind,
+    source: DynamicFile<StubPressureFileSource>,
+}
+
+impl StubPressureFile {
+    pub fn new_node(kind: StubPressureFileKind) -> impl FsNodeOps {
+        SimpleFileNode::new(move || {
+            Ok(Self { kind, source: DynamicFile::new(StubPressureFileSource) })
+        })
+    }
+}
+
+impl FileOps for StubPressureFile {
+    fileops_impl_delegate_read_and_seek!(self, self.source);
+    fileops_impl_noop_sync!();
+
+    /// Pressure notifications are configured by writing to the file.
+    fn write(
+        &self,
+        _locked: &mut Locked<'_, FileOpsCore>,
+        _file: &FileObject,
+        _current_task: &CurrentTask,
+        _offset: usize,
+        data: &mut dyn InputBuffer,
+    ) -> Result<usize, Errno> {
+        // Ignore the request for now.
+
+        track_stub!(
+            TODO("https://fxbug.dev/322873423"),
+            match self.kind {
+                StubPressureFileKind::CPU => "cpu pressure notification setup",
+                StubPressureFileKind::IO => "io pressure notification setup",
+            }
+        );
+        Ok(data.drain())
+    }
+
+    fn wait_async(
+        &self,
+        _locked: &mut Locked<'_, FileOpsCore>,
+        _file: &FileObject,
+        _current_task: &CurrentTask,
+        waiter: &Waiter,
+        _events: FdEvents,
+        _handler: EventHandler,
+    ) -> Option<WaitCanceler> {
+        Some(waiter.fake_wait())
+    }
+
+    fn query_events(
+        &self,
+        _locked: &mut Locked<'_, FileOpsCore>,
+        _file: &FileObject,
+        _current_task: &CurrentTask,
+    ) -> Result<FdEvents, Errno> {
+        Ok(FdEvents::empty())
+    }
+}
diff --git a/src/starnix/kernel/fs/proc/proc_directory.rs b/src/starnix/kernel/fs/proc/proc_directory.rs
index 50e369c..34e5b0e 100644
--- a/src/starnix/kernel/fs/proc/proc_directory.rs
+++ b/src/starnix/kernel/fs/proc/proc_directory.rs
@@ -4,6 +4,7 @@
 
 use crate::device::DeviceMode;
 use crate::fs::proc::pid_directory::pid_directory;
+use crate::fs::proc::pressure_directory::pressure_directory;
 use crate::fs::proc::sysctl::{net_directory, sysctl_directory};
 use crate::fs::proc::sysrq::SysRqNode;
 use crate::mm::PAGE_SIZE;
@@ -12,12 +13,11 @@
 };
 use crate::vfs::buffers::{InputBuffer, OutputBuffer};
 use crate::vfs::{
-    emit_dotdot, fileops_impl_delegate_read_and_seek, fileops_impl_directory,
-    fileops_impl_noop_sync, fileops_impl_seekless, fs_node_impl_dir_readonly, fs_node_impl_symlink,
-    unbounded_seek, BytesFile, BytesFileOps, DirectoryEntryType, DirentSink, DynamicFile,
-    DynamicFileBuf, DynamicFileSource, FileObject, FileOps, FileSystemHandle, FsNode, FsNodeHandle,
-    FsNodeInfo, FsNodeOps, FsStr, FsString, SeekTarget, SimpleFileNode, StaticDirectoryBuilder,
-    StubEmptyFile, SymlinkTarget,
+    emit_dotdot, fileops_impl_directory, fileops_impl_noop_sync, fileops_impl_seekless,
+    fs_node_impl_dir_readonly, fs_node_impl_symlink, unbounded_seek, BytesFile, BytesFileOps,
+    DirectoryEntryType, DirentSink, DynamicFile, DynamicFileBuf, DynamicFileSource, FileObject,
+    FileOps, FileSystemHandle, FsNode, FsNodeHandle, FsNodeInfo, FsNodeOps, FsStr, FsString,
+    SeekTarget, SimpleFileNode, StaticDirectoryBuilder, StubEmptyFile, SymlinkTarget,
 };
 use fuchsia_component::client::connect_to_protocol_sync;
 
@@ -54,7 +54,7 @@
     pub fn new(current_task: &CurrentTask, fs: &FileSystemHandle) -> Arc<ProcDirectory> {
         let kernel = current_task.kernel();
 
-        let nodes = btreemap! {
+        let mut nodes = btreemap! {
             "cpuinfo".into() => fs.create_node(
                 current_task,
                 CpuinfoFile::new_node(),
@@ -122,7 +122,6 @@
                 FsNodeInfo::new_factory(mode!(IFREG, 0o444), FsCred::root()),
             ),
             "sys".into() => sysctl_directory(current_task, fs),
-            "pressure".into() => pressure_directory(current_task, fs),
             "net".into() => net_directory(current_task, fs),
             "uptime".into() => fs.create_node(
                 current_task,
@@ -268,6 +267,10 @@
             ),
         };
 
+        if let Some(pressure_directory) = pressure_directory(current_task, fs) {
+            nodes.insert("pressure".into(), pressure_directory);
+        }
+
         Arc::new(ProcDirectory { nodes })
     }
 }
@@ -541,107 +544,6 @@
     }
 }
 
-/// Creates the /proc/pressure directory. https://docs.kernel.org/accounting/psi.html
-fn pressure_directory(current_task: &CurrentTask, fs: &FileSystemHandle) -> FsNodeHandle {
-    let mut dir = StaticDirectoryBuilder::new(fs);
-    dir.entry(
-        current_task,
-        "memory",
-        PressureFile::new_node(PressureFileKind::MEMORY),
-        mode!(IFREG, 0o666),
-    );
-    dir.entry(
-        current_task,
-        "cpu",
-        PressureFile::new_node(PressureFileKind::CPU),
-        mode!(IFREG, 0o666),
-    );
-    dir.entry(
-        current_task,
-        "io",
-        PressureFile::new_node(PressureFileKind::IO),
-        mode!(IFREG, 0o666),
-    );
-    dir.build(current_task)
-}
-
-struct PressureFileSource {}
-impl DynamicFileSource for PressureFileSource {
-    fn generate(&self, sink: &mut DynamicFileBuf) -> Result<(), Errno> {
-        writeln!(sink, "some avg10={:.2} avg60={:.2} avg300={:.2} total={}", 0, 0, 0, 0)?;
-        writeln!(sink, "full avg10={:.2} avg60={:.2} avg300={:.2} total={}", 0, 0, 0, 0)?;
-        Ok(())
-    }
-}
-
-#[derive(Clone, Copy)]
-enum PressureFileKind {
-    MEMORY,
-    CPU,
-    IO,
-}
-
-struct PressureFile {
-    kind: PressureFileKind,
-    source: DynamicFile<PressureFileSource>,
-}
-
-impl PressureFile {
-    pub fn new_node(kind: PressureFileKind) -> impl FsNodeOps {
-        SimpleFileNode::new(move || {
-            Ok(Self { kind, source: DynamicFile::new(PressureFileSource {}) })
-        })
-    }
-}
-
-impl FileOps for PressureFile {
-    fileops_impl_delegate_read_and_seek!(self, self.source);
-    fileops_impl_noop_sync!();
-
-    /// Pressure notifications are configured by writing to the file.
-    fn write(
-        &self,
-        _locked: &mut Locked<'_, FileOpsCore>,
-        _file: &FileObject,
-        _current_task: &CurrentTask,
-        _offset: usize,
-        data: &mut dyn InputBuffer,
-    ) -> Result<usize, Errno> {
-        // Ignore the request for now.
-
-        track_stub!(
-            TODO("https://fxbug.dev/322873423"),
-            match self.kind {
-                PressureFileKind::MEMORY => "memory pressure notification setup",
-                PressureFileKind::CPU => "cpu pressure notification setup",
-                PressureFileKind::IO => "io pressure notification setup",
-            }
-        );
-        Ok(data.drain())
-    }
-
-    fn wait_async(
-        &self,
-        _locked: &mut Locked<'_, FileOpsCore>,
-        _file: &FileObject,
-        _current_task: &CurrentTask,
-        waiter: &Waiter,
-        _events: FdEvents,
-        _handler: EventHandler,
-    ) -> Option<WaitCanceler> {
-        Some(waiter.fake_wait())
-    }
-
-    fn query_events(
-        &self,
-        _locked: &mut Locked<'_, FileOpsCore>,
-        _file: &FileObject,
-        _current_task: &CurrentTask,
-    ) -> Result<FdEvents, Errno> {
-        Ok(FdEvents::empty())
-    }
-}
-
 struct SysInfo {
     board_name: String,
 }
diff --git a/src/starnix/kernel/meta/starnix_kernel.cml b/src/starnix/kernel/meta/starnix_kernel.cml
index 639376e..eee85f5 100644
--- a/src/starnix/kernel/meta/starnix_kernel.cml
+++ b/src/starnix/kernel/meta/starnix_kernel.cml
@@ -99,6 +99,7 @@
                 "fuchsia.power.system.ActivityGovernor",
                 "fuchsia.recovery.FactoryReset",
                 "fuchsia.scheduler.RoleManager",
+                "fuchsia.starnix.psi.PsiProvider",
                 "fuchsia.starnix.runner.Manager",
                 "fuchsia.sysinfo.SysInfo",
                 "fuchsia.sysmem.Allocator",
diff --git a/src/starnix/kernel/task/kernel.rs b/src/starnix/kernel/task/kernel.rs
index 1737bb4..20122b5 100644
--- a/src/starnix/kernel/task/kernel.rs
+++ b/src/starnix/kernel/task/kernel.rs
@@ -18,7 +18,7 @@
 use crate::task::{
     AbstractUnixSocketNamespace, AbstractVsockSocketNamespace, CurrentTask, HrTimerManager,
     HrTimerManagerHandle, IpTables, KernelStats, KernelThreads, NetstackDevices, PidTable,
-    StopState, Syslog, ThreadGroup, UtsNamespace, UtsNamespaceHandle,
+    PsiProvider, StopState, Syslog, ThreadGroup, UtsNamespace, UtsNamespaceHandle,
 };
 use crate::vdso::vdso_loader::Vdso;
 use crate::vfs::socket::{
@@ -274,6 +274,9 @@
 
     pub stats: Arc<KernelStats>,
 
+    // Proxy to the PSI provider we received from the runner, if any.
+    pub psi_provider: PsiProvider,
+
     /// Resource limits that are exposed, for example, via sysctl.
     pub system_limits: SystemLimits,
 
@@ -436,6 +439,7 @@
             ptrace_scope: AtomicU8::new(0),
             build_version: OnceCell::new(),
             stats: Arc::new(KernelStats::default()),
+            psi_provider: PsiProvider::default(),
             delayed_releaser: Default::default(),
             role_manager,
             syslog: Default::default(),
diff --git a/src/starnix/kernel/task/mod.rs b/src/starnix/kernel/task/mod.rs
index 95f200d..e651053b 100644
--- a/src/starnix/kernel/task/mod.rs
+++ b/src/starnix/kernel/task/mod.rs
@@ -13,6 +13,7 @@
 mod net;
 mod pid_table;
 mod process_group;
+mod psi_provider;
 mod ptrace;
 mod scheduler;
 mod seccomp;
@@ -37,6 +38,7 @@
 pub use net::*;
 pub use pid_table::*;
 pub use process_group::*;
+pub use psi_provider::*;
 pub use ptrace::*;
 pub use scheduler::*;
 pub use seccomp::*;
diff --git a/src/starnix/kernel/task/psi_provider.rs b/src/starnix/kernel/task/psi_provider.rs
new file mode 100644
index 0000000..60f1f69
--- /dev/null
+++ b/src/starnix/kernel/task/psi_provider.rs
@@ -0,0 +1,27 @@
+// Copyright 2025 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+use fuchsia_component::client::connect_to_protocol_sync;
+use std::sync::{Arc, OnceLock};
+
+#[derive(Default)]
+pub struct PsiProvider(
+    OnceLock<Option<Arc<fidl_fuchsia_starnix_psi::PsiProviderSynchronousProxy>>>,
+);
+
+impl PsiProvider {
+    pub fn get(&self) -> Option<&Arc<fidl_fuchsia_starnix_psi::PsiProviderSynchronousProxy>> {
+        self.0
+            .get_or_init(|| {
+                let proxy =
+                    connect_to_protocol_sync::<fidl_fuchsia_starnix_psi::PsiProviderMarker>()
+                        .ok()?;
+                // Our manifest lists PsiProvider as an optional protocol. Let's check if it's
+                // connected for real or not.
+                let _ = proxy.get_memory_pressure_stats(zx::Instant::INFINITE).ok()?;
+                Some(Arc::new(proxy))
+            })
+            .as_ref()
+    }
+}
diff --git a/src/starnix/psi-provider/BUILD.gn b/src/starnix/psi-provider/BUILD.gn
new file mode 100644
index 0000000..f57df7e
--- /dev/null
+++ b/src/starnix/psi-provider/BUILD.gn
@@ -0,0 +1,45 @@
+# Copyright 2025 The Fuchsia Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import("//build/components.gni")
+import("//build/rust/rustc_binary.gni")
+
+group("tests") {
+  testonly = true
+  deps = [ ":psi-provider-tests" ]
+}
+
+rustc_binary("psi-provider-bin") {
+  edition = "2021"
+  version = "0.1.0"
+  with_unit_tests = true
+
+  deps = [
+    "//sdk/fidl/fuchsia.kernel:fuchsia.kernel_rust",
+    "//sdk/fidl/fuchsia.starnix.psi:fuchsia.starnix.psi_rust",
+    "//sdk/rust/zx",
+    "//src/lib/fuchsia",
+    "//src/lib/fuchsia-async",
+    "//src/lib/fuchsia-component",
+    "//third_party/rust_crates:anyhow",
+    "//third_party/rust_crates:futures",
+    "//third_party/rust_crates:log",
+  ]
+
+  sources = [
+    "src/history.rs",
+    "src/main.rs",
+  ]
+
+  configs += [ "//src/starnix/config:starnix_clippy_lints" ]
+}
+
+fuchsia_component("psi-provider") {
+  manifest = "meta/psi-provider.cml"
+  deps = [ ":psi-provider-bin" ]
+}
+
+fuchsia_unittest_package("psi-provider-tests") {
+  deps = [ ":psi-provider-bin_test" ]
+}
diff --git a/src/starnix/psi-provider/meta/psi-provider.cml b/src/starnix/psi-provider/meta/psi-provider.cml
new file mode 100644
index 0000000..88d50d7
--- /dev/null
+++ b/src/starnix/psi-provider/meta/psi-provider.cml
@@ -0,0 +1,28 @@
+// Copyright 2025 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+{
+    include: [ "syslog/client.shard.cml" ],
+    program: {
+        runner: "elf",
+        binary: "bin/psi-provider-bin",
+    },
+    capabilities: [
+        {
+            protocol: [ "fuchsia.starnix.psi.PsiProvider" ],
+        },
+    ],
+    use: [
+        {
+            protocol: [ "fuchsia.kernel.StallResource" ],
+            from: "parent",
+            availability: "optional",
+        },
+    ],
+    expose: [
+        {
+            protocol: [ "fuchsia.starnix.psi.PsiProvider" ],
+            from: "self",
+        },
+    ],
+}
diff --git a/src/starnix/psi-provider/src/history.rs b/src/starnix/psi-provider/src/history.rs
new file mode 100644
index 0000000..92b58218
--- /dev/null
+++ b/src/starnix/psi-provider/src/history.rs
@@ -0,0 +1,209 @@
+// Copyright 2025 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+use std::collections::VecDeque;
+
+/// The intended sampling rate. In practice, variations due to scheduling jitter are expected.
+pub const SAMPLING_RATE: zx::MonotonicDuration = zx::Duration::from_millis(500);
+
+const MAX_RETENTION: zx::MonotonicDuration = zx::Duration::from_seconds(300);
+
+struct Sample {
+    some_value: zx::MonotonicDuration,
+    full_value: zx::MonotonicDuration,
+    timestamp: zx::MonotonicInstant,
+}
+
+/// Stores the latest samples in monotonically-incresing timestamp order, covering up to
+/// `MAX_RETENTION`.
+pub struct History {
+    data: VecDeque<Sample>,
+}
+
+impl History {
+    pub fn new(
+        initial_some_value: zx::MonotonicDuration,
+        initial_full_value: zx::MonotonicDuration,
+        initial_timestamp: zx::MonotonicInstant,
+    ) -> History {
+        let mut data = VecDeque::from([Sample {
+            some_value: initial_some_value,
+            full_value: initial_full_value,
+            timestamp: initial_timestamp,
+        }]);
+
+        // Hint about how big the queue might become.
+        data.reserve(
+            (MAX_RETENTION.into_seconds_f64() / SAMPLING_RATE.into_seconds_f64()).ceil() as usize
+        );
+
+        History { data }
+    }
+
+    /// Inserts a new sample at the end of the queue, then drops elements older than it by more than
+    /// the maximum retention time.
+    pub fn add_new_sample(
+        &mut self,
+        some_value: zx::MonotonicDuration,
+        full_value: zx::MonotonicDuration,
+        timestamp: zx::MonotonicInstant,
+    ) {
+        // Elements must be inserted monotonically.
+        assert!(timestamp > self.data.back().unwrap().timestamp);
+
+        self.data.push_back(Sample { some_value, full_value, timestamp });
+
+        // Cleanup old samples.
+        while self.data.front().unwrap().timestamp + MAX_RETENTION < timestamp {
+            self.data.pop_front();
+        }
+    }
+
+    /// Gets the `some` and `full` values at a given point in time, interpolating if necessary.
+    pub fn query_at(
+        &self,
+        query_timestamp: zx::MonotonicInstant,
+    ) -> (zx::MonotonicDuration, zx::MonotonicDuration) {
+        match self.data.binary_search_by_key(&query_timestamp, |s| s.timestamp) {
+            // Exact match.
+            Ok(index) => {
+                let sample = &self.data[index];
+                (sample.some_value, sample.full_value)
+            }
+
+            // The queried timestamp lies before the beginning of the history. Let's return the
+            // first element.
+            Err(0) => {
+                let sample = &self.data[0];
+                (sample.some_value, sample.full_value)
+            }
+
+            // The queried timestamp lies after the end of the history. Let's return the last
+            // element.
+            Err(index) if index == self.data.len() => {
+                let sample = &self.data[self.data.len() - 1];
+                (sample.some_value, sample.full_value)
+            }
+
+            // The queried timestamp lies between two elements. Let's interpolate.
+            Err(index_after) => {
+                let before = &self.data[index_after - 1];
+                let after = &self.data[index_after];
+                let k = (query_timestamp - before.timestamp).into_seconds_f64()
+                    / (after.timestamp - before.timestamp).into_seconds_f64();
+
+                let some = before.some_value
+                    + zx::MonotonicDuration::from_nanos(
+                        ((after.some_value - before.some_value).into_nanos() as f64 * k) as i64,
+                    );
+                let full = before.full_value
+                    + zx::MonotonicDuration::from_nanos(
+                        ((after.full_value - before.full_value).into_nanos() as f64 * k) as i64,
+                    );
+
+                (
+                    some.clamp(before.some_value, after.some_value),
+                    full.clamp(before.full_value, after.full_value),
+                )
+            }
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_query() {
+        let mut history = History::new(
+            zx::Duration::from_nanos(1000),
+            zx::Duration::from_nanos(500),
+            zx::Instant::from_nanos(1000000000),
+        );
+        history.add_new_sample(
+            zx::Duration::from_nanos(2000),
+            zx::Duration::from_nanos(1000),
+            zx::Instant::from_nanos(1500000000),
+        );
+        history.add_new_sample(
+            zx::Duration::from_nanos(2000),
+            zx::Duration::from_nanos(1000),
+            zx::Instant::from_nanos(2000000000),
+        );
+        history.add_new_sample(
+            zx::Duration::from_nanos(3000),
+            zx::Duration::from_nanos(1500),
+            zx::Instant::from_nanos(2500000000),
+        );
+
+        // Verify that querying before the beginning of the history returns the first values.
+        assert_eq!(
+            history.query_at(zx::Instant::from_nanos(0)),
+            (zx::Duration::from_nanos(1000), zx::Duration::from_nanos(500))
+        );
+
+        // Verify that querying after the end of the history returns the last values.
+        assert_eq!(
+            history.query_at(zx::Instant::from_nanos(3000000000)),
+            (zx::Duration::from_nanos(3000), zx::Duration::from_nanos(1500))
+        );
+
+        // Verify querying timestamps that correspond exactly to elements in the queue.
+        assert_eq!(
+            history.query_at(zx::Instant::from_nanos(1000000000)),
+            (zx::Duration::from_nanos(1000), zx::Duration::from_nanos(500))
+        );
+        assert_eq!(
+            history.query_at(zx::Instant::from_nanos(1500000000)),
+            (zx::Duration::from_nanos(2000), zx::Duration::from_nanos(1000))
+        );
+        assert_eq!(
+            history.query_at(zx::Instant::from_nanos(2000000000)),
+            (zx::Duration::from_nanos(2000), zx::Duration::from_nanos(1000))
+        );
+        assert_eq!(
+            history.query_at(zx::Instant::from_nanos(2500000000)),
+            (zx::Duration::from_nanos(3000), zx::Duration::from_nanos(1500))
+        );
+
+        // Verify that queries whose timestamps lie between elements return interpolated values.
+        assert_eq!(
+            history.query_at(zx::Instant::from_nanos(1100000000)),
+            (zx::Duration::from_nanos(1200), zx::Duration::from_nanos(600))
+        );
+    }
+
+    #[test]
+    fn test_cleanup() {
+        // Verify that, as long as we stay below the `MAX_RETENTION`, no elements are dropped.
+        const INITIAL_TIME: zx::MonotonicInstant = zx::Instant::from_nanos(1000000000);
+        let mut history = History::new(
+            zx::Duration::from_nanos(1000),
+            zx::Duration::from_nanos(500),
+            INITIAL_TIME,
+        );
+        let mut now = INITIAL_TIME;
+        let mut total_inserted_elements = 1; // The ctor already inserted one.
+        loop {
+            now += zx::Duration::from_nanos(1000000000);
+            if now <= INITIAL_TIME + MAX_RETENTION {
+                history.add_new_sample(
+                    zx::Duration::from_nanos(1000),
+                    zx::Duration::from_nanos(500),
+                    now,
+                );
+                total_inserted_elements += 1;
+            } else {
+                break;
+            }
+        }
+        assert_eq!(history.data.len(), total_inserted_elements);
+
+        // Verify that, if we insert one more element, an old element is dropped (or, in other
+        // words, the length of the queue does not change).
+        history.add_new_sample(zx::Duration::from_nanos(1000), zx::Duration::from_nanos(500), now);
+        assert_eq!(history.data.len(), total_inserted_elements);
+    }
+}
diff --git a/src/starnix/psi-provider/src/main.rs b/src/starnix/psi-provider/src/main.rs
new file mode 100644
index 0000000..d43f924
--- /dev/null
+++ b/src/starnix/psi-provider/src/main.rs
@@ -0,0 +1,279 @@
+// Copyright 2025 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+use anyhow::Error;
+use fidl_fuchsia_starnix_psi::{
+    PsiProviderGetMemoryPressureStatsResponse, PsiProviderRequest, PsiProviderRequestStream,
+    PsiStats,
+};
+use fuchsia_component::client::connect_to_protocol_sync;
+use fuchsia_component::server::ServiceFs;
+use futures::{StreamExt, TryStreamExt};
+use log::warn;
+use std::sync::{Arc, Mutex, Weak};
+use {fidl_fuchsia_kernel as fkernel, fuchsia_async as fasync};
+
+mod history;
+use history::{History, SAMPLING_RATE};
+
+trait DataSource: Send + Sync {
+    /// Captures the current timestamp and the memory stall stats.
+    fn capture(&self) -> (zx::MonotonicInstant, zx::MemoryStall);
+}
+
+struct RealDataSource {
+    stall_resource: zx::Resource,
+}
+
+impl DataSource for RealDataSource {
+    fn capture(&self) -> (zx::MonotonicInstant, zx::MemoryStall) {
+        let now = zx::MonotonicInstant::get();
+        let stat = self.stall_resource.memory_stall().unwrap();
+        (now, stat)
+    }
+}
+
+struct PsiProvider {
+    data_source: Box<dyn DataSource>,
+    history: Mutex<History>,
+    history_updater_task: Option<fasync::Task<()>>,
+}
+
+impl PsiProvider {
+    pub fn new(stall_resource: zx::Resource) -> Arc<PsiProvider> {
+        // Initialize history with one sample only (the current data point).
+        let data_source = RealDataSource { stall_resource };
+        let (now, stat) = data_source.capture();
+        let history = History::new(
+            zx::MonotonicDuration::from_nanos(stat.stall_time_some),
+            zx::MonotonicDuration::from_nanos(stat.stall_time_full),
+            now,
+        );
+
+        // Instantiate the PsiProvider along with the task that will periodically update its history
+        // in the background.
+        Arc::new_cyclic(|weak_ptr| {
+            let history_updater_task = fasync::Task::spawn(history_updater(weak_ptr.clone()));
+            PsiProvider {
+                data_source: Box::new(data_source),
+                history: Mutex::new(history),
+                history_updater_task: Some(history_updater_task),
+            }
+        })
+    }
+
+    /// Unlike the regular `new` constructor, this one does not start the background task and lets
+    /// one inject a fake `DataSource`.
+    #[cfg(test)]
+    fn new_for_test(data_source: impl DataSource + 'static) -> Arc<PsiProvider> {
+        let (now, stat) = data_source.capture();
+        let history = History::new(
+            zx::MonotonicDuration::from_nanos(stat.stall_time_some),
+            zx::MonotonicDuration::from_nanos(stat.stall_time_full),
+            now,
+        );
+
+        Arc::new(PsiProvider {
+            data_source: Box::new(data_source),
+            history: Mutex::new(history),
+            history_updater_task: None,
+        })
+    }
+
+    /// Responds to client requests.
+    pub async fn serve(&self, mut stream: PsiProviderRequestStream) -> Result<(), Error> {
+        while let Some(event) = stream.try_next().await? {
+            match event {
+                PsiProviderRequest::GetMemoryPressureStats { responder } => {
+                    let response = self.query();
+                    if let Err(e) = responder.send(Ok(&response)) {
+                        warn!("error responding to stats request: {e}");
+                    }
+                }
+                _ => {}
+            }
+        }
+        Ok(())
+    }
+
+    /// Computes the current stats.
+    fn query(&self) -> PsiProviderGetMemoryPressureStatsResponse {
+        const DURATION_10: zx::MonotonicDuration = zx::Duration::from_seconds(10);
+        const DURATION_60: zx::MonotonicDuration = zx::Duration::from_seconds(60);
+        const DURATION_300: zx::MonotonicDuration = zx::Duration::from_seconds(300);
+        const FACTOR_10: f64 = 1.0 / (DURATION_10.into_nanos() as f64);
+        const FACTOR_60: f64 = 1.0 / (DURATION_60.into_nanos() as f64);
+        const FACTOR_300: f64 = 1.0 / (DURATION_300.into_nanos() as f64);
+
+        let (now, stat) = self.data_source.capture();
+
+        // Retrieve the values 10, 60 and 300 seconds ago from the history.
+        let guard = self.history.lock().unwrap();
+        let (some10, full10) = guard.query_at(now - DURATION_10);
+        let (some60, full60) = guard.query_at(now - DURATION_60);
+        let (some300, full300) = guard.query_at(now - DURATION_300);
+        std::mem::drop(guard);
+
+        let some = PsiStats {
+            avg10: Some(
+                ((stat.stall_time_some - some10.into_nanos()) as f64 * FACTOR_10).clamp(0.0, 1.0),
+            ),
+            avg60: Some(
+                ((stat.stall_time_some - some60.into_nanos()) as f64 * FACTOR_60).clamp(0.0, 1.0),
+            ),
+            avg300: Some(
+                ((stat.stall_time_some - some300.into_nanos()) as f64 * FACTOR_300).clamp(0.0, 1.0),
+            ),
+            total: Some(stat.stall_time_some),
+            ..Default::default()
+        };
+        let full = PsiStats {
+            avg10: Some(
+                ((stat.stall_time_full - full10.into_nanos()) as f64 * FACTOR_10).clamp(0.0, 1.0),
+            ),
+            avg60: Some(
+                ((stat.stall_time_full - full60.into_nanos()) as f64 * FACTOR_60).clamp(0.0, 1.0),
+            ),
+            avg300: Some(
+                ((stat.stall_time_full - full300.into_nanos()) as f64 * FACTOR_300).clamp(0.0, 1.0),
+            ),
+            total: Some(stat.stall_time_full),
+            ..Default::default()
+        };
+
+        PsiProviderGetMemoryPressureStatsResponse {
+            some: Some(some),
+            full: Some(full),
+            ..Default::default()
+        }
+    }
+
+    /// Adds a new sample containing the current stall counters to the history.
+    fn update_history(&self) {
+        let (now, stat) = self.data_source.capture();
+        self.history.lock().unwrap().add_new_sample(
+            zx::MonotonicDuration::from_nanos(stat.stall_time_some),
+            zx::MonotonicDuration::from_nanos(stat.stall_time_full),
+            now,
+        );
+    }
+}
+
+impl Drop for PsiProvider {
+    fn drop(&mut self) {
+        if let Some(task) = self.history_updater_task.take() {
+            let _ = task.cancel();
+        }
+    }
+}
+
+async fn history_updater(target: Weak<PsiProvider>) {
+    let mut deadline = zx::MonotonicInstant::after(SAMPLING_RATE);
+    loop {
+        fasync::Timer::new(deadline).await;
+        deadline += SAMPLING_RATE;
+
+        if let Some(target) = target.upgrade() {
+            target.update_history();
+        }
+    }
+}
+
+fn get_stall_resource() -> Result<zx::Resource, Error> {
+    let proxy = connect_to_protocol_sync::<fkernel::StallResourceMarker>()?;
+    let resource = proxy.get(zx::MonotonicInstant::INFINITE)?;
+    Ok(resource)
+}
+
+enum Services {
+    PsiProvider(PsiProviderRequestStream),
+}
+
+#[fuchsia::main(logging_tags = ["starnix_psi_provider"])]
+async fn main() -> Result<(), Error> {
+    let mut fs = ServiceFs::new_local();
+
+    let psi_provider = match get_stall_resource() {
+        Ok(stall_resource) => {
+            fs.dir("svc").add_fidl_service(Services::PsiProvider);
+            Some(PsiProvider::new(stall_resource))
+        }
+        Err(_) => {
+            warn!("failed to get the optional stall resource, PSI will not be available");
+            None
+        }
+    };
+
+    fs.take_and_serve_directory_handle()?;
+
+    fs.for_each_concurrent(None, |request: Services| async {
+        match request {
+            Services::PsiProvider(stream) => psi_provider
+                .clone()
+                .expect("this service is only offered if the stall resource was found")
+                .serve(stream)
+                .await
+                .expect("failed to serve starnix psi provider"),
+        }
+    })
+    .await;
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[derive(Default)]
+    struct FakeDataSource {
+        result: Mutex<(zx::MonotonicInstant, zx::MemoryStall)>,
+    }
+
+    impl FakeDataSource {
+        fn increment(&self, timestamp: zx::MonotonicDuration, some_factor: f64, full_factor: f64) {
+            let mut guard = self.result.lock().unwrap();
+            guard.0 += timestamp;
+            guard.1.stall_time_some += (timestamp.into_nanos() as f64 * some_factor) as i64;
+            guard.1.stall_time_full += (timestamp.into_nanos() as f64 * full_factor) as i64;
+        }
+    }
+
+    impl DataSource for Arc<FakeDataSource> {
+        fn capture(&self) -> (zx::MonotonicInstant, zx::MemoryStall) {
+            *self.result.lock().unwrap()
+        }
+    }
+
+    #[test]
+    fn test_query() {
+        let data_source = Arc::new(FakeDataSource::default());
+        let psi_provider = PsiProvider::new_for_test(data_source.clone());
+
+        // Simulate the following "some" memory pressure curve:
+        //  - 60% for the first 240 seconds.
+        //  - 72% for the next 50 seconds.
+        //  - 90% for the last 10 seconds.
+        // And simulate half of it for "full", i.e. 30%, 36% and 45%.
+        data_source.increment(zx::Duration::from_seconds(240), 0.60, 0.30);
+        psi_provider.update_history();
+        data_source.increment(zx::Duration::from_seconds(50), 0.72, 0.36);
+        psi_provider.update_history();
+        data_source.increment(zx::Duration::from_seconds(10), 0.90, 0.45);
+        psi_provider.update_history();
+
+        // Verify that the averages match the expectations, using string comparisons for rounding.
+        let result = psi_provider.query();
+        let result_some = result.some.as_ref().unwrap();
+        let result_full = result.full.as_ref().unwrap();
+        assert!(
+            format!("{:.2}", result_some.avg10.unwrap()) == "0.90"
+                && format!("{:.2}", result_some.avg60.unwrap()) == "0.75"
+                && format!("{:.2}", result_some.avg300.unwrap()) == "0.63"
+                && format!("{:.2}", result_full.avg10.unwrap()) == "0.45"
+                && format!("{:.2}", result_full.avg60.unwrap()) == "0.38"
+                && format!("{:.2}", result_full.avg300.unwrap()) == "0.32",
+            "{result:?}"
+        );
+    }
+}
diff --git a/src/starnix/runner/BUILD.gn b/src/starnix/runner/BUILD.gn
index 339c745..3c53ac6 100644
--- a/src/starnix/runner/BUILD.gn
+++ b/src/starnix/runner/BUILD.gn
@@ -64,6 +64,7 @@
     ":starnix_runner_bin",
     ":starnix_runner_component",
     ":starnix_runner_component_manifest",
+    "//src/starnix/psi-provider",
   ]
 }
 
diff --git a/src/starnix/runner/meta/starnix_runner.cml b/src/starnix/runner/meta/starnix_runner.cml
index 17fe84f..4898b8d 100644
--- a/src/starnix/runner/meta/starnix_runner.cml
+++ b/src/starnix/runner/meta/starnix_runner.cml
@@ -11,6 +11,12 @@
         runner: "elf",
         binary: "bin/starnix_runner",
     },
+    children: [
+        {
+            name: "psi-provider",
+            url: "#meta/psi-provider.cm",
+        },
+    ],
     collections: [
         {
             name: "playground",
@@ -138,6 +144,17 @@
             from: "parent",
             to: [ "#kernels" ],
         },
+        {
+            protocol: [ "fuchsia.kernel.StallResource" ],
+            from: "parent",
+            to: "#psi-provider",
+            availability: "optional",
+        },
+        {
+            protocol: [ "fuchsia.starnix.psi.PsiProvider" ],
+            from: "#psi-provider",
+            to: "#kernels",
+        },
     ],
     expose: [
         {
diff --git a/src/starnix/runner/meta/starnix_runner.core_shard.cml b/src/starnix/runner/meta/starnix_runner.core_shard.cml
index 1e97709..791468a 100644
--- a/src/starnix/runner/meta/starnix_runner.core_shard.cml
+++ b/src/starnix/runner/meta/starnix_runner.core_shard.cml
@@ -77,6 +77,7 @@
         {
             protocol: [
                 "fuchsia.hardware.power.statecontrol.Admin",
+                "fuchsia.kernel.StallResource",
                 "fuchsia.kernel.Stats",
                 "fuchsia.kernel.VmexResource",
                 "fuchsia.scheduler.RoleManager",
diff --git a/src/starnix/tests/BUILD.gn b/src/starnix/tests/BUILD.gn
index 4e6f7be..fdaa964 100644
--- a/src/starnix/tests/BUILD.gn
+++ b/src/starnix/tests/BUILD.gn
@@ -19,6 +19,7 @@
     "fp_stack_glue:tests",
     "gvisor:tests",
     "memory_attribution:tests",
+    "psi:tests",
     "reboot:tests",
     "scheduler:tests",
     "suspend:tests",
diff --git a/src/starnix/tests/psi/BUILD.gn b/src/starnix/tests/psi/BUILD.gn
new file mode 100644
index 0000000..29e2980
--- /dev/null
+++ b/src/starnix/tests/psi/BUILD.gn
@@ -0,0 +1,87 @@
+# Copyright 2025 The Fuchsia Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import("//build/components.gni")
+import("//build/fidl/fidl.gni")
+import("//build/rust/rustc_test.gni")
+import("//src/starnix/build/starnix_linux_executable.gni")
+import("//src/starnix/kernel/starnix.gni")
+
+group("tests") {
+  testonly = true
+  deps = [ ":starnix_psi_integration_test" ]
+}
+
+rustc_test("psi_integration_test_bin") {
+  testonly = true
+  edition = "2021"
+  source_root = "src/tests.rs"
+  sources = [
+    "src/fake_psi_provider.rs",
+    "src/puppet.rs",
+    "src/tests.rs",
+  ]
+  deps = [
+    "//sdk/fidl/fuchsia.component:fuchsia.component_rust",
+    "//sdk/fidl/fuchsia.component.decl:fuchsia.component.decl_rust",
+    "//sdk/fidl/fuchsia.process:fuchsia.process_rust",
+    "//sdk/fidl/fuchsia.starnix.psi:fuchsia.starnix.psi_rust",
+    "//sdk/rust/zx",
+    "//src/lib/fidl/rust/fidl",
+    "//src/lib/fuchsia",
+    "//src/lib/fuchsia-async",
+    "//src/lib/fuchsia-component",
+    "//src/lib/fuchsia-component-test",
+    "//src/lib/fuchsia-runtime",
+    "//src/sys/lib/component-events",
+    "//third_party/rust_crates:anyhow",
+    "//third_party/rust_crates:futures",
+    "//third_party/rust_crates:hex",
+    "//third_party/rust_crates:itertools",
+    "//third_party/rust_crates:log",
+    "//third_party/rust_crates:test-case",
+  ]
+}
+
+fuchsia_test_component("psi_test") {
+  manifest = "meta/integration_test.cml"
+  deps = [ ":psi_integration_test_bin" ]
+  test_type = "starnix"
+}
+
+fuchsia_component("container") {
+  testonly = true
+  manifest = "//src/starnix/containers/debian/meta/debian_container.cml"
+}
+
+starnix_linux_executable("linux_psi_puppet") {
+  testonly = true
+  sources = [ "src/puppet.cc" ]
+  deps = [
+    "//src/lib/files",
+    "//src/lib/fxl",
+  ]
+}
+
+fuchsia_component("puppet") {
+  testonly = true
+  manifest = "meta/puppet.cml"
+  deps = [ ":linux_psi_puppet" ]
+}
+
+fuchsia_component("test_realm") {
+  testonly = true
+  manifest = "meta/test_realm.cml"
+}
+
+fuchsia_test_package("starnix_psi_integration_test") {
+  test_components = [ ":psi_test" ]
+  deps = [
+    ":container",
+    ":puppet",
+    ":test_realm",
+    "//src/starnix/containers/debian:container_resources",
+  ]
+  subpackages = [ "//src/starnix/kernel:starnix_kernel_package" ]
+}
diff --git a/src/starnix/tests/psi/meta/integration_test.cml b/src/starnix/tests/psi/meta/integration_test.cml
new file mode 100644
index 0000000..d2c4d04
--- /dev/null
+++ b/src/starnix/tests/psi/meta/integration_test.cml
@@ -0,0 +1,30 @@
+// Copyright 2025 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+{
+    include: [
+        "sys/component/realm_builder.shard.cml",
+        "sys/testing/rust_test_runner.shard.cml",
+        "syslog/client.shard.cml",
+    ],
+    program: {
+        binary: "bin/psi_integration_test_bin",
+    },
+    use: [
+        {
+            event_stream: [ "stopped" ],
+        },
+    ],
+    offer: [
+        {
+            protocol: [ "fuchsia.kernel.VmexResource" ],
+            from: "parent",
+            to: "#realm_builder",
+        },
+        {
+            directory: "boot-kernel",
+            from: "parent",
+            to: "#realm_builder",
+        },
+    ],
+}
diff --git a/src/starnix/tests/psi/meta/puppet.cml b/src/starnix/tests/psi/meta/puppet.cml
new file mode 100644
index 0000000..2bc461d
--- /dev/null
+++ b/src/starnix/tests/psi/meta/puppet.cml
@@ -0,0 +1,9 @@
+// Copyright 2025 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+{
+    program: {
+        runner: "starnix_container",
+        binary: "data/tests/linux_psi_puppet",
+    },
+}
diff --git a/src/starnix/tests/psi/meta/test_realm.cml b/src/starnix/tests/psi/meta/test_realm.cml
new file mode 100644
index 0000000..a795fae
--- /dev/null
+++ b/src/starnix/tests/psi/meta/test_realm.cml
@@ -0,0 +1,65 @@
+// Copyright 2025 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+{
+    include: [ "syslog/client.shard.cml" ],
+    children: [
+        {
+            name: "kernel",
+            url: "starnix_kernel#meta/starnix_kernel.cm",
+        },
+        {
+            name: "debian_container",
+            url: "#meta/container.cm",
+            startup: "eager",
+            environment: "#starnix_kernel_env",
+        },
+    ],
+    collections: [
+        {
+            name: "puppets",
+            environment: "#debian_container_env",
+            durability: "single_run",
+        },
+    ],
+    offer: [
+        {
+            protocol: [ "fuchsia.kernel.VmexResource" ],
+            from: "parent",
+            to: "#kernel",
+        },
+        {
+            directory: "boot-kernel",
+            from: "parent",
+            to: "#kernel",
+        },
+    ],
+    expose: [
+        {
+            protocol: "fuchsia.component.Realm",
+            from: "framework",
+        },
+    ],
+    environments: [
+        {
+            name: "starnix_kernel_env",
+            extends: "realm",
+            runners: [
+                {
+                    runner: "starnix",
+                    from: "#kernel",
+                },
+            ],
+        },
+        {
+            name: "debian_container_env",
+            extends: "realm",
+            runners: [
+                {
+                    runner: "starnix_container",
+                    from: "#debian_container",
+                },
+            ],
+        },
+    ],
+}
diff --git a/src/starnix/tests/psi/src/fake_psi_provider.rs b/src/starnix/tests/psi/src/fake_psi_provider.rs
new file mode 100644
index 0000000..47da602
--- /dev/null
+++ b/src/starnix/tests/psi/src/fake_psi_provider.rs
@@ -0,0 +1,102 @@
+// Copyright 2025 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+use fidl_fuchsia_starnix_psi::{PsiProviderRequest, PsiProviderRequestStream};
+use fuchsia_component_test::LocalComponentHandles;
+use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender};
+use futures::lock::Mutex;
+use futures::{select, Future, FutureExt, SinkExt, StreamExt};
+use std::pin::pin;
+use std::sync::Arc;
+
+pub struct FakePsiProvider {
+    producer_state: Mutex<ProducerState>,
+    receiver_state: Mutex<ReceiverState>,
+}
+
+struct ProducerState {
+    expecting_request: bool,
+    channel: UnboundedSender<PsiProviderRequest>,
+}
+
+struct ReceiverState {
+    channel: UnboundedReceiver<PsiProviderRequest>,
+}
+
+impl FakePsiProvider {
+    pub fn new() -> FakePsiProvider {
+        let (sender, receiver) = futures::channel::mpsc::unbounded();
+
+        FakePsiProvider {
+            producer_state: Mutex::new(ProducerState { expecting_request: false, channel: sender }),
+            receiver_state: Mutex::new(ReceiverState { channel: receiver }),
+        }
+    }
+
+    /// Executes `body` while expecting it to make exactly one request over this fake
+    /// `fuchsia.starnix.runner.PsiProvider`. The request will be forwarded to the provided
+    /// `request_handler` callback, which has to send a reply, if applicable.
+    ///
+    /// Notes:
+    ///  - Calls to this function cannot be nested.
+    ///  - This function panics if the provided `body` completes without ever issuing
+    ///    any request.
+    ///  - This function also panics if `body` issues more than one request.
+    pub async fn with_expected_request<T>(
+        self: Arc<Self>,
+        request_handler: impl FnOnce(PsiProviderRequest) + Send,
+        body: impl Future<Output = T>,
+    ) -> T {
+        let mut receiver_state = self
+            .receiver_state
+            .try_lock()
+            .expect("Cannot have multiple ongoing with_expected_request at the same time");
+
+        // Signal serve() that we expect exactly 1 request.
+        {
+            let mut producer_state = self.producer_state.lock().await;
+            assert!(!producer_state.expecting_request);
+            producer_state.expecting_request = true;
+        }
+
+        // Execute the request handler (up to once) and the body in parallel.
+        let mut body = pin!(body.fuse());
+        let result = {
+            select! {
+                _ = body => panic!("Body returned without issuing the expected request"),
+                request = receiver_state.channel.next() => {
+                    request_handler(request.unwrap());
+                    body.await
+                }
+            }
+        };
+
+        result
+    }
+
+    pub async fn serve(
+        self: Arc<Self>,
+        handles: LocalComponentHandles,
+    ) -> Result<(), anyhow::Error> {
+        let mut fs = fuchsia_component::server::ServiceFs::new();
+        fs.dir("svc").add_fidl_service(|client: PsiProviderRequestStream| client);
+        fs.serve_connection(handles.outgoing_dir).unwrap();
+
+        while let Some(mut client) = fs.next().await {
+            while let Some(request) = client.next().await {
+                let mut producer_state = self.producer_state.lock().await;
+
+                assert!(
+                    producer_state.expecting_request,
+                    "Received request outside of a with_expected_request block"
+                );
+                producer_state.expecting_request = false;
+
+                producer_state.channel.send(request.unwrap()).await.unwrap();
+            }
+        }
+
+        Ok(())
+    }
+}
diff --git a/src/starnix/tests/psi/src/puppet.cc b/src/starnix/tests/psi/src/puppet.cc
new file mode 100644
index 0000000..4e78385
--- /dev/null
+++ b/src/starnix/tests/psi/src/puppet.cc
@@ -0,0 +1,120 @@
+// Copyright 2025 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include <err.h>
+#include <fcntl.h>
+#include <stdlib.h>
+#include <sys/resource.h>
+#include <unistd.h>
+
+#include <iostream>
+#include <string>
+#include <vector>
+
+#include "src/lib/files/file.h"
+#include "src/lib/fxl/strings/join_strings.h"
+#include "src/lib/fxl/strings/string_number_conversions.h"
+#include "src/lib/fxl/strings/string_printf.h"
+
+// Control socket between the puppet program running within Starnix (that is, us) and its Rust
+// controller (puppet.rs).
+//
+// The protocol consists of semicolon-terminated messages, each consisting of
+// hex-encoded parts separated by commas. For instance:
+//  "48454c4c4f,574f524c44;" <=> [ "HELLO", "WORLD" ]
+class ControlSocket {
+ public:
+  explicit ControlSocket(int fd) {
+    socket_ = fdopen(fd, "r+b");
+    if (socket_ == nullptr)
+      err(EXIT_FAILURE, "Failed to wrap socket in a FILE*");
+  }
+
+  ~ControlSocket() { fclose(socket_); }
+
+  FXL_DISALLOW_COPY_ASSIGN_AND_MOVE(ControlSocket);
+
+  std::vector<std::string> ReadMessage() {
+    std::vector<std::string> decoded_parts;
+    std::string current_part;
+    bool more_parts_ahead = true;
+    while (more_parts_ahead) {
+      int val;
+      // Try to read a two-characters hexadecimal number.
+      if (fscanf(socket_, "%2x", &val) == 1) {
+        current_part.push_back(static_cast<char>(val));
+      } else {
+        int c = fgetc(socket_);
+        if (c == ';') {  // End of message?
+          more_parts_ahead = false;
+        } else if (c != ',') {  // Or simply the end of the current part?
+          errx(EXIT_FAILURE, "Unexpected character (%d) in control socket stream", c);
+        }
+
+        // Regardless of whether we got a semicolon or a comma, we have reached the end of the
+        // current part. Add it to the results and start a new one.
+        std::string tmp;
+        current_part.swap(tmp);
+        decoded_parts.push_back(std::move(tmp));
+      }
+    }
+    return decoded_parts;
+  }
+
+  void WriteMessage(const std::vector<std::string>& parts) {
+    std::vector<std::string> encoded_parts;
+    for (size_t i = 0; i < parts.size(); ++i) {
+      if (i != 0) {
+        fputc(',', socket_);
+      }
+      for (char c : parts[i]) {
+        fprintf(socket_, "%02x", c);
+      }
+    }
+    fputc(';', socket_);
+    fflush(socket_);
+  }
+
+ private:
+  FILE* socket_;
+};
+
+int main(int argc, const char** argv) {
+  std::cout << "starting starnix puppet...\n";
+  ControlSocket ctl_socket(3);
+
+  ctl_socket.WriteMessage({"READY"});
+
+  while (true) {
+    std::vector<std::string> command = ctl_socket.ReadMessage();
+    if (command.empty()) {
+      break;
+    }
+
+    std::cout << "executing command:" << fxl::JoinStrings(command, " ") << "\n";
+
+    if (command[0] == "CHECK_EXISTS" && command.size() == 2) {
+      int r = access(command[1].c_str(), F_OK);
+      ctl_socket.WriteMessage({r == 0 ? "YES" : "NO"});
+    } else if (command[0] == "OPEN" && command.size() == 2) {
+      int fd = open(command[1].c_str(), O_RDWR);
+      ctl_socket.WriteMessage({fxl::StringPrintf("%d", fd)});
+    } else if (command[0] == "CLOSE" && command.size() == 2) {
+      int fd = fxl::StringToNumber<int>(command[1]);
+      close(fd);
+    } else if (command[0] == "READ_TO_END" && command.size() == 2) {
+      int fd = fxl::StringToNumber<int>(command[1]);
+      std::string buf;
+      files::ReadFileDescriptorToString(fd, &buf);
+      ctl_socket.WriteMessage({std::move(buf)});
+    } else if (command[0] == "EXIT" && command.size() == 1) {
+      break;
+    } else {
+      errx(EXIT_FAILURE, "Unrecognized command");
+    }
+  }
+
+  std::cout << "stopping starnix puppet...\n";
+  return 0;
+}
diff --git a/src/starnix/tests/psi/src/puppet.rs b/src/starnix/tests/psi/src/puppet.rs
new file mode 100644
index 0000000..c8eb9ac
--- /dev/null
+++ b/src/starnix/tests/psi/src/puppet.rs
@@ -0,0 +1,162 @@
+// Copyright 2025 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+use component_events::events::{EventStream, ExitStatus, Stopped};
+use component_events::matcher::EventMatcher;
+use fidl::endpoints::DiscoverableProtocolMarker;
+use fidl_fuchsia_component::{CreateChildArgs, RealmMarker};
+use fidl_fuchsia_component_decl::{Child, CollectionRef, StartupMode};
+use fidl_fuchsia_starnix_psi::PsiProviderMarker;
+use fuchsia_component_test::{
+    Capability, ChildOptions, RealmBuilder, RealmBuilderParams, RealmInstance, Ref, Route,
+};
+use fuchsia_runtime::{HandleInfo, HandleType};
+use futures::io::BufReader;
+use futures::{AsyncBufReadExt, AsyncWriteExt};
+use itertools::Itertools;
+use log::info;
+use std::sync::Arc;
+use {fidl_fuchsia_process as fprocess, fuchsia_async as fasync};
+
+use crate::fake_psi_provider::FakePsiProvider;
+
+#[derive(Clone, Copy, Debug)]
+pub struct PuppetFileDescriptor(usize);
+
+pub struct PuppetInstance {
+    realm: RealmInstance,
+    events: EventStream,
+
+    /// See the `ControlSocket` class in `puppet.cc` for documentation on the format of messages
+    /// transferred through this socket.
+    ctl_channel: BufReader<fasync::Socket>,
+}
+
+impl PuppetInstance {
+    pub async fn new(fake_psi_provider: Option<Arc<FakePsiProvider>>) -> PuppetInstance {
+        let events = EventStream::open().await.unwrap();
+
+        let builder = RealmBuilder::with_params(
+            RealmBuilderParams::new().from_relative_url("#meta/test_realm.cm"),
+        )
+        .await
+        .unwrap();
+
+        if let Some(fake_psi_provider) = fake_psi_provider {
+            let psi_provider_ref = builder
+                .add_local_child(
+                    "fake_psi_provider",
+                    move |handles| Box::pin(fake_psi_provider.clone().serve(handles)),
+                    ChildOptions::new(),
+                )
+                .await
+                .unwrap();
+
+            builder
+                .add_route(
+                    Route::new()
+                        .capability(Capability::protocol_by_name(PsiProviderMarker::PROTOCOL_NAME))
+                        .from(&psi_provider_ref)
+                        .to(Ref::child("kernel")),
+                )
+                .await
+                .unwrap();
+        }
+
+        info!("building realm and starting eager container");
+        let realm = builder.build().await.unwrap();
+
+        // Create a control channel to be connected to the puppet.
+        let (ctl_puppet_side, ctl_local_side) = zx::Socket::create_stream();
+
+        info!("kernel and container init have requested thread roles, starting puppet");
+        let test_realm = realm.root.connect_to_protocol_at_exposed_dir::<RealmMarker>().unwrap();
+        test_realm
+            .create_child(
+                &CollectionRef { name: "puppets".to_string() },
+                &Child {
+                    name: Some("puppet".to_string()),
+                    url: Some("#meta/puppet.cm".to_string()),
+                    startup: Some(StartupMode::Lazy),
+                    ..Default::default()
+                },
+                CreateChildArgs {
+                    numbered_handles: Some(vec![fprocess::HandleInfo {
+                        id: HandleInfo::new(HandleType::FileDescriptor, 3).as_raw(),
+                        handle: ctl_puppet_side.into(),
+                    }]),
+                    ..Default::default()
+                },
+            )
+            .await
+            .unwrap()
+            .unwrap();
+
+        let mut puppet = PuppetInstance {
+            realm,
+            events,
+            ctl_channel: BufReader::new(fasync::Socket::from_socket(ctl_local_side)),
+        };
+
+        // Synchronization point: wait until the puppet reports readiness.
+        let initial_message = puppet.read_message().await;
+        assert_eq!(initial_message, vec!["READY".to_string()]);
+
+        puppet
+    }
+
+    pub async fn check_exists(&mut self, path: &str) -> bool {
+        self.write_message(&["CHECK_EXISTS", path]).await;
+        let reply = self.read_message().await;
+        reply[0] == "YES"
+    }
+
+    pub async fn open(&mut self, path: &str) -> PuppetFileDescriptor {
+        self.write_message(&["OPEN", path]).await;
+        let reply = self.read_message().await;
+        PuppetFileDescriptor(reply[0].parse().unwrap())
+    }
+
+    pub async fn close(&mut self, fd: PuppetFileDescriptor) {
+        self.write_message(&["CLOSE", &fd.0.to_string()]).await;
+    }
+
+    pub async fn read_to_end(&mut self, fd: PuppetFileDescriptor) -> String {
+        self.write_message(&["READ_TO_END", &fd.0.to_string()]).await;
+        let reply = self.read_message().await;
+        reply[0].clone()
+    }
+
+    async fn write_message(&mut self, msg: &[&str]) {
+        let data = format!("{};", msg.iter().map(|s| hex::encode(s.as_bytes())).join(","));
+        self.ctl_channel.write_all(data.as_bytes()).await.unwrap();
+    }
+
+    async fn read_message(&mut self) -> Vec<String> {
+        let mut buf = Vec::new();
+        self.ctl_channel.read_until(b';', &mut buf).await.unwrap();
+        let buf = buf.strip_suffix(b";").unwrap();
+        buf.split(|c| *c == b',')
+            .map(|s| String::from_utf8(hex::decode(s).unwrap()).unwrap())
+            .collect()
+    }
+
+    pub async fn check_exit_clean(mut self) {
+        info!("waiting for puppet to exit");
+        self.write_message(&["EXIT"]).await;
+
+        let puppet_stopped = EventMatcher::ok()
+            .moniker_regex("realm_builder:.+/puppets:puppet")
+            .wait::<Stopped>(&mut self.events)
+            .await
+            .unwrap();
+        assert_eq!(
+            puppet_stopped.result().unwrap().status,
+            ExitStatus::Clean,
+            "puppet must exit cleanly"
+        );
+
+        self.realm.destroy().await.unwrap();
+    }
+}
diff --git a/src/starnix/tests/psi/src/tests.rs b/src/starnix/tests/psi/src/tests.rs
new file mode 100644
index 0000000..100c05d
--- /dev/null
+++ b/src/starnix/tests/psi/src/tests.rs
@@ -0,0 +1,125 @@
+// Copyright 2025 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+use fidl_fuchsia_starnix_psi::{
+    PsiProviderGetMemoryPressureStatsResponse, PsiProviderRequest, PsiStats,
+};
+use std::sync::Arc;
+use test_case::test_case;
+
+mod fake_psi_provider;
+use fake_psi_provider::*;
+mod puppet;
+use puppet::*;
+
+async fn start_puppet_with_fake_psi_provider() -> (PuppetInstance, Arc<FakePsiProvider>) {
+    // Start Starnix with our fake PsiProvider and expect its initial probe.
+    let fake_psi_provider = Arc::new(FakePsiProvider::new());
+    let puppet = fake_psi_provider
+        .clone()
+        .with_expected_request(
+            |request| {
+                // Before the puppet starts to run, the Starnix kernel will make an initial request
+                // to probe if the PsiProvider is actually connected. As it doesn't use the returned
+                // data, we can just reply with zeros.
+                let PsiProviderRequest::GetMemoryPressureStats { responder } = request else {
+                    panic!("Unexpected request received")
+                };
+                let zeros = PsiStats {
+                    avg10: Some(0.0),
+                    avg60: Some(0.0),
+                    avg300: Some(0.0),
+                    total: Some(0),
+                    ..Default::default()
+                };
+                let response = PsiProviderGetMemoryPressureStatsResponse {
+                    some: Some(zeros.clone()),
+                    full: Some(zeros.clone()),
+                    ..Default::default()
+                };
+                responder.send(Ok(&response)).unwrap();
+            },
+            async { PuppetInstance::new(Some(fake_psi_provider.clone())).await },
+        )
+        .await;
+
+    (puppet, fake_psi_provider)
+}
+
+#[fuchsia::test]
+async fn test_read_psi_memory_stats() {
+    let (mut puppet, fake_psi_provider) = start_puppet_with_fake_psi_provider().await;
+
+    fake_psi_provider
+        .with_expected_request(
+            |request| {
+                let PsiProviderRequest::GetMemoryPressureStats { responder } = request else {
+                    panic!("Unexpected request received")
+                };
+                let some = PsiStats {
+                    avg10: Some(0.08),
+                    avg60: Some(0.9),
+                    avg300: Some(1.0),
+                    total: Some(5678 * 1000),
+                    ..Default::default()
+                };
+                let full = PsiStats {
+                    avg10: Some(0.5),
+                    avg60: Some(0.6),
+                    avg300: Some(0.77),
+                    total: Some(1234 * 1000),
+                    ..Default::default()
+                };
+                let response = PsiProviderGetMemoryPressureStatsResponse {
+                    some: Some(some),
+                    full: Some(full),
+                    ..Default::default()
+                };
+                responder.send(Ok(&response)).unwrap();
+            },
+            async {
+                let fd = puppet.open("/proc/pressure/memory").await;
+                let contents = puppet.read_to_end(fd).await;
+                assert_eq!(
+                    contents,
+                    "some avg10=0.08 avg60=0.90 avg300=1.00 total=5678\n\
+                     full avg10=0.50 avg60=0.60 avg300=0.77 total=1234\n"
+                );
+                puppet.close(fd).await;
+            },
+        )
+        .await;
+
+    puppet.check_exit_clean().await;
+}
+
+// Test files that are just stubs too.
+#[test_case("cpu")]
+#[test_case("io")]
+#[fuchsia::test]
+async fn test_read_psi_stub_stats(kind: &str) {
+    let (mut puppet, _fake_psi_provider) = start_puppet_with_fake_psi_provider().await;
+
+    let fd = puppet.open(&format!("/proc/pressure/{kind}")).await;
+    let contents = puppet.read_to_end(fd).await;
+    assert_eq!(
+        contents,
+        "some avg10=0.00 avg60=0.00 avg300=0.00 total=0\n\
+         full avg10=0.00 avg60=0.00 avg300=0.00 total=0\n"
+    );
+    puppet.close(fd).await;
+
+    puppet.check_exit_clean().await;
+}
+
+// Verify that the pressure directory is not created if no PsiProvider is given.
+#[fuchsia::test]
+async fn test_psi_unavailable() {
+    // Start Starnix without a PsiProvider (which is optional in its manifest).
+    let mut puppet = PuppetInstance::new(None).await;
+
+    assert!(puppet.check_exists("/proc/pressure").await == false);
+
+    puppet.check_exit_clean().await;
+}
diff --git a/src/starnix/tests/syscalls/cpp/procfs_test.cc b/src/starnix/tests/syscalls/cpp/procfs_test.cc
index 3740618..b4338cb 100644
--- a/src/starnix/tests/syscalls/cpp/procfs_test.cc
+++ b/src/starnix/tests/syscalls/cpp/procfs_test.cc
@@ -558,19 +558,6 @@
   is_valid_uuid(uuid);
 }
 
-// Verify /proc/pressure/{cpu,io,memory} contains something reasonable.
-TEST_F(ProcfsTest, ProcPressure) {
-  for (auto path : {"/proc/pressure/cpu", "/proc/pressure/io", "/proc/pressure/memory"}) {
-    EXPECT_EQ(0, access(path, R_OK));
-    std::string content;
-    EXPECT_TRUE(files::ReadFileToString(path, &content));
-    // Some systems does not eport the `full` statistics for CPU.
-    EXPECT_THAT(content,
-                MatchesRegex("some avg10=[0-9.]+ avg60=[0-9.]+ avg300=[0-9.]+ total=[0-9.]+\n"
-                             "(full avg10=[0-9.]+ avg60=[0-9.]+ avg300=[0-9.]+ total=[0-9.]+\n)?"));
-  }
-}
-
 // Verify that /proc/zoneinfo contains something reasonable.
 TEST_F(ProcfsTest, ZoneInfo) {
   auto path = "/proc/zoneinfo";