duende_platform/
memory.rs

1//! Memory management for daemon processes.
2//!
3//! # DT-007: Swap Deadlock Prevention
4//!
5//! This module provides memory locking functionality to prevent swap deadlock
6//! for daemons that serve as swap devices (e.g., trueno-ublk).
7//!
8//! ## The Problem
9//!
10//! When a daemon serves as a swap device, a deadlock can occur:
11//! 1. Kernel needs to swap pages OUT to the daemon's device
12//! 2. Daemon needs memory to process I/O request
13//! 3. Kernel tries to swap out daemon's pages to free memory
14//! 4. Swap goes to the same daemon → waiting for itself → DEADLOCK
15//!
16//! ## Evidence
17//!
18//! Kernel log from 2026-01-06 stress test:
19//! ```text
20//! INFO: task trueno-ublk:59497 blocked for more than 122 seconds.
21//! task:trueno-ublk state:D (uninterruptible sleep)
22//! __swap_writepage+0x111/0x1a0
23//! swap_writepage+0x5f/0xe0
24//! ```
25//!
26//! ## Solution
27//!
28//! Use `mlockall(MCL_CURRENT | MCL_FUTURE)` to pin all daemon memory,
29//! preventing the daemon itself from being swapped out.
30
31use std::io;
32
33use crate::{PlatformError, Result};
34
35/// Result of memory locking operation.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum MlockResult {
38    /// Memory successfully locked.
39    Success,
40    /// mlock() not requested (lock_memory = false).
41    Disabled,
42    /// mlock() failed but daemon continues (non-fatal).
43    Failed(i32),
44}
45
46/// Lock all current and future memory allocations to prevent swapping.
47///
48/// This is CRITICAL for swap device daemons to prevent deadlock.
49///
50/// # Arguments
51///
52/// * `required` - If true, returns an error on failure. If false, logs warning and continues.
53///
54/// # Returns
55///
56/// - `Ok(MlockResult::Success)` if memory was locked successfully
57/// - `Ok(MlockResult::Failed(errno))` if mlockall() failed and `required` is false
58/// - `Err(...)` if mlockall() failed and `required` is true
59///
60/// # Platform Support
61///
62/// - **Linux**: Full support via `mlockall()`
63/// - **macOS**: Limited support (requires entitlements)
64/// - **Others**: Returns `MlockResult::Disabled`
65///
66/// # Capability Requirements
67///
68/// Requires one of:
69/// - `CAP_IPC_LOCK` capability
70/// - Root privileges
71/// - Sufficient `RLIMIT_MEMLOCK` limit
72///
73/// # Errors
74/// Returns `PlatformError::Resource` if `required` is true and mlockall fails.
75#[cfg(target_os = "linux")]
76#[allow(unsafe_code)]
77pub fn lock_daemon_memory(required: bool) -> Result<MlockResult> {
78    use tracing::{info, warn};
79
80    info!("Locking daemon memory to prevent swap deadlock (DT-007)...");
81
82    // MCL_CURRENT: Lock all pages currently mapped
83    // MCL_FUTURE: Lock all pages that become mapped in the future
84    // SAFETY: mlockall is a well-defined syscall. It affects only the current process.
85    let result = unsafe { libc::mlockall(libc::MCL_CURRENT | libc::MCL_FUTURE) };
86
87    if result == 0 {
88        info!("Memory locked successfully - daemon pages will not be swapped");
89        Ok(MlockResult::Success)
90    } else {
91        let errno = io::Error::last_os_error().raw_os_error().unwrap_or(-1);
92        let err_msg = match errno {
93            libc::ENOMEM => "insufficient memory or resource limits (check RLIMIT_MEMLOCK)",
94            libc::EPERM => "insufficient privileges (need CAP_IPC_LOCK or root)",
95            libc::EINVAL => "invalid flags",
96            _ => "unknown error",
97        };
98
99        if required {
100            Err(PlatformError::Resource(format!(
101                "mlockall() failed: {} (errno={}). \
102                 Cannot safely run as swap device without mlock(). \
103                 Either run as root, add CAP_IPC_LOCK, or set lock_memory_required=false",
104                err_msg, errno
105            )))
106        } else {
107            warn!(
108                "mlockall() failed: {} (errno={}). \
109                 Daemon may deadlock under memory pressure when used as swap device. \
110                 Set lock_memory_required=true to make this fatal.",
111                err_msg, errno
112            );
113            Ok(MlockResult::Failed(errno))
114        }
115    }
116}
117
118/// macOS implementation (limited support).
119#[cfg(target_os = "macos")]
120#[allow(unsafe_code)]
121pub fn lock_daemon_memory(required: bool) -> Result<MlockResult> {
122    use tracing::{info, warn};
123
124    info!("Attempting memory lock on macOS...");
125
126    // macOS supports mlockall but requires entitlements for full functionality
127    // SAFETY: mlockall is a well-defined syscall
128    let result = unsafe { libc::mlockall(libc::MCL_CURRENT | libc::MCL_FUTURE) };
129
130    if result == 0 {
131        info!("Memory locked successfully on macOS");
132        Ok(MlockResult::Success)
133    } else {
134        let errno = io::Error::last_os_error().raw_os_error().unwrap_or(-1);
135        let err_msg = match errno {
136            libc::ENOMEM => "insufficient memory or resource limits",
137            libc::EPERM => {
138                "insufficient privileges (may need com.apple.security.cs.allow-jit entitlement)"
139            }
140            libc::EINVAL => "invalid flags",
141            libc::EAGAIN => "system resources temporarily unavailable",
142            _ => "unknown error",
143        };
144
145        if required {
146            Err(PlatformError::Resource(format!(
147                "mlockall() failed on macOS: {} (errno={})",
148                err_msg, errno
149            )))
150        } else {
151            warn!("mlockall() failed on macOS: {} (errno={})", err_msg, errno);
152            Ok(MlockResult::Failed(errno))
153        }
154    }
155}
156
157/// Non-Unix platforms: memory locking not supported.
158#[cfg(not(any(target_os = "linux", target_os = "macos")))]
159pub fn lock_daemon_memory(_required: bool) -> Result<MlockResult> {
160    use tracing::debug;
161    debug!("Memory locking not supported on this platform");
162    Ok(MlockResult::Disabled)
163}
164
165/// Check if memory is currently locked.
166///
167/// Reads `/proc/self/status` on Linux to check the `VmLck` field.
168#[cfg(target_os = "linux")]
169pub fn is_memory_locked() -> bool {
170    if let Ok(status) = std::fs::read_to_string("/proc/self/status") {
171        for line in status.lines() {
172            if line.starts_with("VmLck:") {
173                let parts: Vec<&str> = line.split_whitespace().collect();
174                if parts.len() >= 2 && let Ok(kb) = parts[1].parse::<u64>() {
175                    return kb > 0;
176                }
177            }
178        }
179    }
180    false
181}
182
183/// Check if memory is locked (non-Linux fallback).
184#[cfg(not(target_os = "linux"))]
185pub fn is_memory_locked() -> bool {
186    // No easy way to check on other platforms
187    false
188}
189
190/// Unlock all memory (for cleanup/testing).
191///
192/// Note: This is rarely needed in production since process exit releases all locks.
193///
194/// # Errors
195/// Returns `PlatformError::Resource` if munlockall fails.
196#[cfg(any(target_os = "linux", target_os = "macos"))]
197#[allow(unsafe_code)]
198pub fn unlock_daemon_memory() -> Result<()> {
199    // SAFETY: munlockall is a well-defined syscall
200    let result = unsafe { libc::munlockall() };
201    if result == 0 {
202        Ok(())
203    } else {
204        Err(PlatformError::Resource("munlockall() failed".to_string()))
205    }
206}
207
208/// Unlock memory (non-Unix fallback).
209#[cfg(not(any(target_os = "linux", target_os = "macos")))]
210pub fn unlock_daemon_memory() -> Result<()> {
211    Ok(())
212}
213
214/// Apply memory-related resource configuration.
215///
216/// This is a convenience function for daemons to call during initialization.
217/// It reads the `ResourceConfig` and applies memory locking if configured.
218///
219/// # Example
220///
221/// ```rust,ignore
222/// use duende_core::ResourceConfig;
223/// use duende_platform::apply_memory_config;
224///
225/// fn daemon_init(config: &ResourceConfig) -> Result<()> {
226///     apply_memory_config(config)?;
227///     // ... rest of initialization
228///     Ok(())
229/// }
230/// ```
231///
232/// # Errors
233///
234/// Returns an error if `lock_memory` is true, `lock_memory_required` is true,
235/// and mlock() fails.
236pub fn apply_memory_config(config: &duende_core::ResourceConfig) -> Result<()> {
237    if config.lock_memory {
238        let result = lock_daemon_memory(config.lock_memory_required)?;
239        tracing::info!("Memory lock result: {:?}", result);
240    } else {
241        tracing::debug!("Memory locking disabled (lock_memory=false)");
242    }
243    Ok(())
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use duende_core::ResourceConfig;
250
251    #[test]
252    fn test_mlock_result_variants() {
253        // Test all variants can be constructed and compared
254        let success = MlockResult::Success;
255        let disabled = MlockResult::Disabled;
256        let failed = MlockResult::Failed(1);
257
258        assert_eq!(success, MlockResult::Success);
259        assert_eq!(disabled, MlockResult::Disabled);
260        assert_eq!(failed, MlockResult::Failed(1));
261        assert_ne!(success, disabled);
262        assert_ne!(success, failed);
263
264        // Test Debug impl
265        let _ = format!("{:?}", success);
266        let _ = format!("{:?}", disabled);
267        let _ = format!("{:?}", failed);
268
269        // Test Clone and Copy
270        let cloned = success;
271        assert_eq!(cloned, success);
272    }
273
274    #[test]
275    fn test_mlock_disabled_when_not_required() {
276        // This test should not fail even without privileges
277        // when required=false
278        let result = lock_daemon_memory(false);
279        assert!(result.is_ok());
280        // Result should be Success or Failed, but not an error
281        let mlock_result = result.expect("should succeed");
282        assert!(matches!(
283            mlock_result,
284            MlockResult::Success | MlockResult::Failed(_) | MlockResult::Disabled
285        ));
286    }
287
288    #[test]
289    fn test_is_memory_locked_returns_bool() {
290        // Just verify it doesn't panic
291        let _ = is_memory_locked();
292    }
293
294    #[test]
295    fn test_unlock_daemon_memory() {
296        // Should not panic or error (even without prior lock)
297        let result = unlock_daemon_memory();
298        // On most systems this will succeed (nop or actual unlock)
299        let _ = result; // May fail on non-Unix, that's ok
300    }
301
302    #[test]
303    fn test_apply_memory_config_disabled() {
304        let config = ResourceConfig {
305            lock_memory: false,
306            lock_memory_required: false,
307            ..ResourceConfig::default()
308        };
309
310        let result = apply_memory_config(&config);
311        assert!(result.is_ok());
312    }
313
314    #[test]
315    fn test_apply_memory_config_enabled_not_required() {
316        let config = ResourceConfig {
317            lock_memory: true,
318            lock_memory_required: false,
319            ..ResourceConfig::default()
320        };
321
322        let result = apply_memory_config(&config);
323        // Should succeed (even if mlock fails, since required=false)
324        assert!(result.is_ok());
325    }
326
327    #[test]
328    #[cfg(target_os = "linux")]
329    fn test_mlock_with_privileges() {
330        // This test may pass or fail depending on system configuration
331        // In CI/unprivileged environments, it should fail gracefully
332        let result = lock_daemon_memory(false);
333        assert!(result.is_ok());
334
335        match result.expect("mlock result") {
336            MlockResult::Success => {
337                // mlockall() succeeded. Note: VmLck in /proc/self/status may
338                // still be 0 for minimal test processes since only resident
339                // pages are counted. We verify the syscall succeeded, not that
340                // pages are locked (which depends on memory pressure).
341                // Clean up
342                let _ = unlock_daemon_memory();
343            }
344            MlockResult::Failed(errno) => {
345                // Expected in unprivileged environments
346                assert!(
347                    errno == libc::EPERM || errno == libc::ENOMEM,
348                    "Unexpected errno: {}",
349                    errno
350                );
351            }
352            MlockResult::Disabled => {
353                panic!("Should not be disabled on Linux");
354            }
355        }
356    }
357
358    #[test]
359    #[cfg(target_os = "linux")]
360    fn test_mlock_required_may_fail() {
361        // When required=true, mlock might return error if no privileges
362        let result = lock_daemon_memory(true);
363        // Either succeeds (with privileges) or fails (without)
364        match result {
365            Ok(MlockResult::Success) => {
366                // Has privileges, clean up
367                let _ = unlock_daemon_memory();
368            }
369            Err(_) => {
370                // Expected without CAP_IPC_LOCK
371            }
372            Ok(MlockResult::Failed(_)) => {
373                panic!("Should not return Failed when required=true");
374            }
375            Ok(MlockResult::Disabled) => {
376                panic!("Should not be disabled on Linux");
377            }
378        }
379    }
380}