Repository: denoland/deno
Pull request: fix(ext/node): add missing `process.constrainedMemory()` API

## Summary

- Extracts the cgroup v1/v2 parsing logic used by `process.constrainedMemory()` into a shared public `cgroup` module in `ext/node/ops/process.rs`
- Reuses the shared parser from `cli/lib/worker.rs` (V8 heap limit configuration) instead of duplicating it
- Moves the cgroup parsing unit tests to the new shared module
- Enables `parallel/test-process-constrained-memory.js` test

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Changed files:
filename: cli/lib/worker.rs
patch: @@ -155,6 +155,9 @@ pub fn create_isolate_create_params<TSys: DenoLibSys>(
 
 #[cfg(any(target_os = "android", target_os = "linux"))]
 mod linux {
+  use deno_runtime::deno_node::ops::process::cgroup::CgroupVersion;
+  use deno_runtime::deno_node::ops::process::cgroup::parse_self_cgroup;
+
   /// Get memory limit with cgroup (either v1 or v2) taken into account.
   pub(super) fn get_memory_limit<TSys: crate::sys::DenoLibSys>(
     sys: &TSys,
@@ -195,104 +198,6 @@ mod linux {
 
     limit.or(system_total_memory)
   }
-
-  enum CgroupVersion<'a> {
-    V1 { cgroup_relpath: &'a str },
-    V2 { cgroup_relpath: &'a str },
-    None,
-  }
-
-  fn parse_self_cgroup(self_cgroup_content: &str) -> CgroupVersion<'_> {
-    // Initialize the cgroup version as None. This will be updated based on the parsed lines.
-    let mut cgroup_version = CgroupVersion::None;
-
-    // Iterate through each line in the cgroup content. Each line represents a cgroup entry.
-    for line in self_cgroup_content.lines() {
-      // Split the line into parts using ":" as the delimiter. The format is typically:
-      // "<hierarchy_id>:<subsystems>:<cgroup_path>"
-      let split = line.split(":").collect::<Vec<_>>();
-
-      match &split[..] {
-        // If the line specifies "memory" as the subsystem, it indicates cgroup v1 is used
-        // for memory management. Extract the relative path and update the cgroup version.
-        [_, "memory", cgroup_v1_relpath] => {
-          cgroup_version = CgroupVersion::V1 {
-            cgroup_relpath: cgroup_v1_relpath
-              .strip_prefix("/")
-              .unwrap_or(cgroup_v1_relpath),
-          };
-          // Break early since v1 explicitly manages memory, and no further checks are needed.
-          break;
-        }
-        // If the line starts with "0::", it indicates cgroup v2 is used. However, in hybrid
-        // mode, memory might still be managed by v1. Continue checking other lines to confirm.
-        ["0", "", cgroup_v2_relpath] => {
-          cgroup_version = CgroupVersion::V2 {
-            cgroup_relpath: cgroup_v2_relpath
-              .strip_prefix("/")
-              .unwrap_or(cgroup_v2_relpath),
-          };
-        }
-        _ => {}
-      }
-    }
-
-    cgroup_version
-  }
-
-  #[test]
-  fn test_parse_self_cgroup_v2() {
-    let self_cgroup = "0::/user.slice/user-1000.slice/session-3.scope";
-    let cgroup_version = parse_self_cgroup(self_cgroup);
-    assert!(matches!(
-      cgroup_version,
-      CgroupVersion::V2 { cgroup_relpath } if cgroup_relpath == "user.slice/user-1000.slice/session-3.scope"
-    ));
-  }
-
-  #[test]
-  fn test_parse_self_cgroup_hybrid() {
-    let self_cgroup = r#"12:rdma:/
-11:blkio:/user.slice
-10:devices:/user.slice
-9:cpu,cpuacct:/user.slice
-8:pids:/user.slice/user-1000.slice/session-3.scope
-7:memory:/user.slice/user-1000.slice/session-3.scope
-6:perf_event:/
-5:freezer:/
-4:net_cls,net_prio:/
-3:hugetlb:/
-2:cpuset:/
-1:name=systemd:/user.slice/user-1000.slice/session-3.scope
-0::/user.slice/user-1000.slice/session-3.scope
-"#;
-    let cgroup_version = parse_self_cgroup(self_cgroup);
-    assert!(matches!(
-      cgroup_version,
-      CgroupVersion::V1 { cgroup_relpath } if cgroup_relpath == "user.slice/user-1000.slice/session-3.scope"
-    ));
-  }
-
-  #[test]
-  fn test_parse_self_cgroup_v1() {
-    let self_cgroup = r#"11:hugetlb:/
-10:pids:/user.slice/user-1000.slice
-9:perf_event:/
-8:devices:/user.slice
-7:net_cls,net_prio:/
-6:memory:/
-5:blkio:/
-4:cpuset:/
-3:cpu,cpuacct:/
-2:freezer:/
-1:name=systemd:/user.slice/user-1000.slice/session-2.scope
-"#;
-    let cgroup_version = parse_self_cgroup(self_cgroup);
-    assert!(matches!(
-      cgroup_version,
-      CgroupVersion::V1 { cgroup_relpath } if cgroup_relpath.is_empty()
-    ));
-  }
 }
 
 #[derive(Debug, thiserror::Error, deno_error::JsError)]

filename: ext/node/lib.rs
patch: @@ -289,6 +289,7 @@ deno_core::extension!(deno_node,
     ops::process::op_node_process_setgid,
     ops::process::op_node_process_setuid,
     ops::process::op_process_abort,
+    ops::process::op_node_process_constrained_memory<TSys>,
     ops::node_cli_parser::op_node_translate_cli_args,
     ops::tls::op_get_root_certificates,
     ops::tls::op_set_default_ca_certificates,

filename: ext/node/ops/process.rs
patch: @@ -14,6 +14,8 @@ use nix::unistd::Uid;
 #[cfg(unix)]
 use nix::unistd::User;
 
+use crate::ExtNodeSys;
+
 #[derive(Debug, thiserror::Error, deno_error::JsError)]
 pub enum ProcessError {
   #[class(inherit)]
@@ -259,3 +261,151 @@ pub fn op_node_process_setuid(
 ) -> Result<(), ProcessError> {
   Err(ProcessError::NotSupported)
 }
+
+/// Returns the cgroup-constrained memory limit, or 0 if unconstrained.
+/// This matches Node.js `process.constrainedMemory()` semantics.
+#[op2(fast)]
+#[number]
+pub fn op_node_process_constrained_memory<TSys: ExtNodeSys + 'static>(
+  state: &mut OpState,
+) -> u64 {
+  #[cfg(any(target_os = "android", target_os = "linux"))]
+  {
+    let sys = state.borrow::<TSys>();
+    cgroup::cgroup_memory_limit(sys).unwrap_or(0)
+  }
+  #[cfg(not(any(target_os = "android", target_os = "linux")))]
+  {
+    let _ = state;
+    0
+  }
+}
+
+#[cfg(any(target_os = "android", target_os = "linux"))]
+pub mod cgroup {
+  pub enum CgroupVersion<'a> {
+    V1 { cgroup_relpath: &'a str },
+    V2 { cgroup_relpath: &'a str },
+    None,
+  }
+
+  pub fn parse_self_cgroup(self_cgroup_content: &str) -> CgroupVersion<'_> {
+    let mut cgroup_version = CgroupVersion::None;
+
+    for line in self_cgroup_content.lines() {
+      let split = line.split(":").collect::<Vec<_>>();
+
+      match &split[..] {
+        // cgroup v1 memory controller — takes priority, break immediately
+        [_, "memory", cgroup_v1_relpath] => {
+          cgroup_version = CgroupVersion::V1 {
+            cgroup_relpath: cgroup_v1_relpath
+              .strip_prefix("/")
+              .unwrap_or(cgroup_v1_relpath),
+          };
+          break;
+        }
+        // cgroup v2 (but keep looking for v1 memory in hybrid mode)
+        ["0", "", cgroup_v2_relpath] => {
+          cgroup_version = CgroupVersion::V2 {
+            cgroup_relpath: cgroup_v2_relpath
+              .strip_prefix("/")
+              .unwrap_or(cgroup_v2_relpath),
+          };
+        }
+        _ => {}
+      }
+    }
+
+    cgroup_version
+  }
+
+  /// Read the cgroup memory limit from the filesystem.
+  /// Returns `None` if cgroup info cannot be read or parsed.
+  pub fn cgroup_memory_limit<TSys: sys_traits::FsRead>(
+    sys: &TSys,
+  ) -> Option<u64> {
+    let self_cgroup = sys.fs_read_to_string("/proc/self/cgroup").ok()?;
+
+    match parse_self_cgroup(&self_cgroup) {
+      CgroupVersion::V1 { cgroup_relpath } => {
+        let limit_path = std::path::Path::new("/sys/fs/cgroup/memory")
+          .join(cgroup_relpath)
+          .join("memory.limit_in_bytes");
+        sys
+          .fs_read_to_string(limit_path)
+          .ok()
+          .and_then(|s| s.trim().parse::<u64>().ok())
+      }
+      CgroupVersion::V2 { cgroup_relpath } => {
+        let limit_path = std::path::Path::new("/sys/fs/cgroup")
+          .join(cgroup_relpath)
+          .join("memory.max");
+        sys
+          .fs_read_to_string(limit_path)
+          .ok()
+          .and_then(|s| s.trim().parse::<u64>().ok())
+      }
+      CgroupVersion::None => None,
+    }
+  }
+
+  #[cfg(test)]
+  mod tests {
+    use super::*;
+
+    #[test]
+    fn test_parse_self_cgroup_v2() {
+      let self_cgroup = "0::/user.slice/user-1000.slice/session-3.scope";
+      let cgroup_version = parse_self_cgroup(self_cgroup);
+      assert!(matches!(
+        cgroup_version,
+        CgroupVersion::V2 { cgroup_relpath } if cgroup_relpath == "user.slice/user-1000.slice/session-3.scope"
+      ));
+    }
+
+    #[test]
+    fn test_parse_self_cgroup_hybrid() {
+      let self_cgroup = r#"12:rdma:/
+11:blkio:/user.slice
+10:devices:/user.slice
+9:cpu,cpuacct:/user.slice
+8:pids:/user.slice/user-1000.slice/session-3.scope
+7:memory:/user.slice/user-1000.slice/session-3.scope
+6:perf_event:/
+5:freezer:/
+4:net_cls,net_prio:/
+3:hugetlb:/
+2:cpuset:/
+1:name=systemd:/user.slice/user-1000.slice/session-3.scope
+0::/user.slice/user-1000.slice/session-3.scope
+"#;
+      let cgroup_version = parse_self_cgroup(self_cgroup);
+      assert!(matches!(
+        cgroup_version,
+        CgroupVersion::V1 { cgroup_relpath } if cgroup_relpath == "user.slice/user-1000.slice/session-3.scope"
+      ));
+    }
+
+    #[test]
+    fn test_parse_self_cgroup_v1() {
+      let self_cgroup = r#"11:hugetlb:/
+10:pids:/user.slice/user-1000.slice
+9:perf_event:/
+8:devices:/user.slice
+7:net_cls,net_prio:/
+6:memory:/
+5:blkio:/
+4:cpuset:/
+3:cpu,cpuacct:/
+2:freezer:/
+1:name=systemd:/user.slice/user-1000.slice/session-2.scope
+"#;
+      let cgroup_version = parse_self_cgroup(self_cgroup);
+      assert!(matches!(
+        cgroup_version,
+        CgroupVersion::V1 { cgroup_relpath } if cgroup_relpath.is_empty()
+      ));
+    }
+  }
+}

filename: ext/node/polyfills/process.ts
patch: @@ -11,6 +11,7 @@ import {
   op_getegid,
   op_geteuid,
   op_node_load_env_file,
+  op_node_process_constrained_memory,
   op_node_process_kill,
   op_node_process_setegid,
   op_node_process_seteuid,
@@ -350,6 +351,10 @@ export function availableMemory(): number {
   return Deno.systemMemoryInfo().available;
 }
 
+export function constrainedMemory(): number {
+  return op_node_process_constrained_memory();
+}
+
 // Returns a negative error code than can be recognized by errnoException
 function _kill(pid: number, sig: number): number {
   const maybeMapErrno = (res: number) =>
@@ -817,6 +822,7 @@ process.kill = kill;
 
 process.memoryUsage = memoryUsage;
 process.availableMemory = availableMemory;
+process.constrainedMemory = constrainedMemory;
 
 /** https://nodejs.org/api/process.html#process_process_stderr */
 process.stderr = stderr;

filename: tests/node_compat/config.jsonc
patch: @@ -1044,6 +1044,7 @@
     "parallel/test-process-binding-internalbinding-allowlist.js": {},
     "parallel/test-process-binding.js": {},
     "parallel/test-process-config.js": {},
+    "parallel/test-process-constrained-memory.js": {},
     "parallel/test-process-cpuUsage.js": {},
     "parallel/test-process-default.js": {},
     "parallel/test-process-dlopen-undefined-exports.js": {},