Populate PrivateMemorySize64 on macOS
Project: dotnet/runtime | Language: C# | Concepts: api-design, error-handling, testing-strategy, observability | Difficulty: mid
Link: https://github.com/dotnet/runtime/pull/131244
The problem
On macOS, the Process.PrivateMemorySize64 property always returned 0. This was because the previous implementation used proc_pidinfo(PROC_PIDTASKALLINFO), which only provides virtual and resident memory sizes, not private memory. As a result, developers could not reliably observe or monitor the private memory usage of processes on macOS.
Why it's non-trivial
A naive fix might try to find a direct "private memory" field in the macOS APIs, but such a field does not exist. Instead, the closest available metric is the "physical memory footprint" (ri_phys_footprint), which is not a strict count of private pages but an accounting-based measurement (the same as shown in Activity Monitor's Memory column). Furthermore, retrieving this value requires careful handling of permissions and process lifetimes, as the system call can fail for various reasons (e.g., lack of permission, process exit).
The approach
This PR introduces a new helper to retrieve the physical memory footprint using proc_pid_rusage with RUSAGE_INFO_V3, and updates the process info population and public API documentation accordingly.
1. Add a helper to safely retrieve the physical memory footprint:
internal static unsafe bool TryGetProcessPhysicalFootprint(int pid, out ulong physicalFootprint)
{
// Negative PIDs are invalid
ArgumentOutOfRangeException.ThrowIfNegative(pid);
rusage_info_v3 info = default;
int result = proc_pid_rusage(pid, RUSAGE_INFO_V3, &info);
if (result < 0)
{
physicalFootprint = 0;
return false;
}
physicalFootprint = info.ri_phys_footprint;
return true;
}
This method attempts to retrieve the process's physical memory footprint. It returns false and sets the output to 0 if the call fails (e.g., due to permissions or process exit), matching the permission behavior of other process info calls.
2. Populate the process info with the physical footprint, clamping to long.MaxValue:
if (Interop.libproc.TryGetProcessPhysicalFootprint(pid, out ulong physicalFootprint))
{
procInfo.PrivateBytes = physicalFootprint > long.MaxValue ? long.MaxValue : (long)physicalFootprint;
}
This ensures that the value assigned to PrivateBytes never overflows or becomes negative, even if the OS returns a very large value.
3. Update property documentation to clarify platform differences and failure cases:
/// <summary>Gets the amount of private memory, in bytes, allocated for the associated process.</summary>
/// <remarks>
/// ... On macOS, this value is the process's physical memory footprint - an accounting-based measurement,
/// the same value shown in Activity Monitor's Memory column - rather than a strict count of unshared
/// pages. If the footprint can't be retrieved for the specified process - for example, if the caller
/// lacks permission to query a process owned by another user, or the process has since exited - this
/// property returns 0.
/// </remarks>
public long PrivateMemorySize64
=> GetProcessInfo().PrivateBytes;
This makes it clear to API consumers what the value represents on each platform, and under what conditions it may be zero.
4. Update and merge tests for both 32-bit and 64-bit properties, and ensure correct casting:
[ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))]
public void TestPrivateMemorySize64AndPrivateMemorySize()
{
CreateDefaultProcess();
Assert.InRange(_process.PrivateMemorySize64, 1, long.MaxValue);
#pragma warning disable CS0618
Assert.Equal(unchecked((int)_process.PrivateMemorySize64), _process.PrivateMemorySize);
#pragma warning restore CS0618
}
This test checks that the 64-bit value is positive and that the 32-bit obsolete property matches the lower bits of the 64-bit value, as expected.
From the review thread
Copilot:
Castingulong physicalFootprinttolongcan wrap into a negative value if the OS ever reports a value >long.MaxValue(or returns a sentinel via an unsigned field). SincePrivateBytesis alongand should never be negative, it’s safer to clamp tolong.MaxValuebefore assigning.
adamsitnik:
Please add XML docs to the public API here based on this existing doc and extend it with the description of Windows vs Linux vs macOS differences.
Takeaways
- When platform APIs do not provide a direct metric, use the closest available system metric and document the difference clearly for API consumers.
- Always handle potential overflows and type mismatches when bridging between native and managed representations, especially when the native type is unsigned and the managed type is signed.
- Tests should not only check for non-zero values but also verify that legacy/obsolete APIs remain consistent with their replacements, and should be merged where possible to reduce test overhead.
- Public API documentation should explicitly describe platform-specific behaviors and edge cases, including possible failure modes and fallback values.