Skip to main content

beamr/io/
resource.rs

1//! Heap resource terms for raw file descriptor ownership.
2//!
3//! `FdResource` stores one heap-owned strong `Arc<FdInner>` reference as a raw
4//! word. GC and process-exit cleanup must retain/release that raw reference
5//! explicitly because heap words are not Rust values and will not drop on their
6//! own.
7
8use std::os::fd::RawFd;
9use std::sync::Arc;
10use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
11
12use crate::io::ring::{CompletionRing, IoOp};
13use crate::term::Term;
14use crate::term::boxed::{BoxedHeader, BoxedTag};
15
16/// Payload words in an FdResource boxed object.
17pub const FD_RESOURCE_PAYLOAD_WORDS: usize = 1;
18/// Total heap words in an FdResource boxed object.
19pub const FD_RESOURCE_WORDS: usize = 1 + FD_RESOURCE_PAYLOAD_WORDS;
20
21/// File descriptor lifecycle state stored in [`FdInner`].
22#[derive(Copy, Clone, Debug, Eq, PartialEq)]
23#[repr(u8)]
24pub enum FdState {
25    Open = 0,
26    Closing = 1,
27    Closed = 2,
28}
29
30/// Socket receive mode for stream/datagram active delivery.
31#[derive(Copy, Clone, Debug, Eq, PartialEq)]
32#[repr(u8)]
33pub enum FdMode {
34    Passive = 0,
35    Active = 1,
36    ActiveOnce = 2,
37}
38
39impl FdMode {
40    fn from_u8(value: u8) -> Self {
41        match value {
42            1 => Self::Active,
43            2 => Self::ActiveOnce,
44            _ => Self::Passive,
45        }
46    }
47}
48
49impl FdState {
50    fn from_u8(value: u8) -> Self {
51        match value {
52            0 => Self::Open,
53            1 => Self::Closing,
54            _ => Self::Closed,
55        }
56    }
57}
58
59/// Reference-counted lifecycle manager for a raw file descriptor.
60#[derive(Debug)]
61pub struct FdInner {
62    fd: RawFd,
63    owner_pid: u64,
64    state: AtomicU8,
65    mode: AtomicU8,
66    controlling_process: AtomicU64,
67    current_offset: AtomicU64,
68}
69
70impl FdInner {
71    /// Creates a new open FD lifecycle manager.
72    pub fn new(fd: RawFd, owner_pid: u64) -> Self {
73        Self {
74            fd,
75            owner_pid,
76            state: AtomicU8::new(FdState::Open as u8),
77            mode: AtomicU8::new(FdMode::Passive as u8),
78            controlling_process: AtomicU64::new(owner_pid),
79            current_offset: AtomicU64::new(0),
80        }
81    }
82
83    /// Returns the managed raw file descriptor.
84    #[must_use]
85    pub fn fd(&self) -> RawFd {
86        self.fd
87    }
88
89    /// Returns the PID that owns this resource.
90    #[must_use]
91    pub fn owner_pid(&self) -> u64 {
92        self.owner_pid
93    }
94
95    /// Returns the current lifecycle state.
96    #[must_use]
97    pub fn state(&self) -> FdState {
98        FdState::from_u8(self.state.load(Ordering::Acquire))
99    }
100
101    /// Returns the active/passive receive mode.
102    #[must_use]
103    pub fn mode(&self) -> FdMode {
104        FdMode::from_u8(self.mode.load(Ordering::Acquire))
105    }
106
107    /// Set the active/passive receive mode.
108    pub fn set_mode(&self, mode: FdMode) {
109        self.mode.store(mode as u8, Ordering::Release);
110    }
111
112    /// Returns the process that receives active socket messages.
113    #[must_use]
114    pub fn controlling_process(&self) -> u64 {
115        self.controlling_process.load(Ordering::Acquire)
116    }
117
118    /// Set the process that receives active socket messages.
119    pub fn set_controlling_process(&self, pid: u64) {
120        self.controlling_process.store(pid, Ordering::Release);
121    }
122
123    /// Returns the tracked sequential file offset.
124    #[must_use]
125    pub fn current_offset(&self) -> u64 {
126        self.current_offset.load(Ordering::Acquire)
127    }
128
129    /// Sets the tracked sequential file offset.
130    pub fn set_current_offset(&self, offset: u64) {
131        self.current_offset.store(offset, Ordering::Release);
132    }
133
134    /// Advances the tracked sequential file offset by a completed byte count.
135    pub fn advance_current_offset(&self, bytes: usize) {
136        let delta = u64::try_from(bytes).unwrap_or(u64::MAX);
137        let _previous =
138            self.current_offset
139                .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| {
140                    Some(current.saturating_add(delta))
141                });
142    }
143
144    /// BIF-initiated async close. Returns true only for the transition that owns
145    /// the close operation.
146    pub fn explicit_close(&self, ring: &dyn CompletionRing) -> bool {
147        self.explicit_close_with_op_id(ring).is_some()
148    }
149
150    /// BIF-initiated async close that also returns the submitted operation id.
151    pub fn explicit_close_with_op_id(&self, ring: &dyn CompletionRing) -> Option<u64> {
152        if self.begin_close() {
153            Some(ring.submit(IoOp::Close { fd: self.fd }))
154        } else {
155            None
156        }
157    }
158
159    /// Complete an asynchronous close lifecycle after the completion ring reports success.
160    pub fn mark_closed(&self) {
161        self.state.store(FdState::Closed as u8, Ordering::Release);
162    }
163
164    /// Synchronous fallback close used when no completion ring is available.
165    pub fn close_synchronously(&self) -> bool {
166        if self.begin_close() {
167            self.close_fd_synchronously();
168            true
169        } else {
170            false
171        }
172    }
173
174    fn begin_close(&self) -> bool {
175        self.fd >= 0
176            && self
177                .state
178                .compare_exchange(
179                    FdState::Open as u8,
180                    FdState::Closing as u8,
181                    Ordering::AcqRel,
182                    Ordering::Acquire,
183                )
184                .is_ok()
185    }
186
187    fn close_fd_synchronously(&self) {
188        // SAFETY: this path owns the Open -> Closing transition for `self.fd`, so
189        // no other FdInner path in this process will close the same descriptor.
190        let _result = unsafe { libc::close(self.fd) };
191        self.state.store(FdState::Closed as u8, Ordering::Release);
192    }
193}
194
195impl Drop for FdInner {
196    fn drop(&mut self) {
197        if self.begin_close() {
198            self.close_fd_synchronously();
199        }
200    }
201}
202
203/// Borrowed accessor for an FdResource boxed term.
204#[derive(Copy, Clone, Debug)]
205pub struct FdResource {
206    ptr: *const u64,
207}
208
209impl FdResource {
210    /// Validates and builds an FdResource accessor.
211    pub fn new(term: Term) -> Option<Self> {
212        if !term.is_boxed() {
213            return None;
214        }
215        let ptr = term.heap_ptr()?;
216        // SAFETY: boxed terms point to a header word in caller-owned heap storage.
217        let header = unsafe { *ptr };
218        if BoxedHeader::tag(header) != Some(BoxedTag::FdResource) {
219            return None;
220        }
221        if BoxedHeader::size(header) != FD_RESOURCE_PAYLOAD_WORDS {
222            return None;
223        }
224        // SAFETY: validated FdResource layout has one payload word containing a
225        // raw Arc pointer. Reject null before exposing accessors.
226        if unsafe { *ptr.add(1) } == 0 {
227            return None;
228        }
229        Some(Self { ptr })
230    }
231
232    /// Returns the raw file descriptor.
233    #[must_use]
234    pub fn fd(self) -> RawFd {
235        self.inner_ref().fd()
236    }
237
238    /// Returns the active/passive receive mode.
239    #[must_use]
240    pub fn mode(self) -> FdMode {
241        self.inner_ref().mode()
242    }
243
244    /// Returns the owning process PID.
245    #[must_use]
246    pub fn owner_pid(self) -> u64 {
247        self.inner_ref().owner_pid()
248    }
249
250    /// Returns the current FD lifecycle state.
251    #[must_use]
252    pub fn state(self) -> FdState {
253        self.inner_ref().state()
254    }
255
256    /// Returns the process that receives active socket messages.
257    #[must_use]
258    pub fn controlling_process(self) -> u64 {
259        self.inner_ref().controlling_process()
260    }
261
262    /// Sets the active/passive receive mode.
263    pub fn set_mode(self, mode: FdMode) {
264        self.inner_ref().set_mode(mode);
265    }
266
267    /// Sets the process that receives active socket messages.
268    pub fn set_controlling_process(self, pid: u64) {
269        self.inner_ref().set_controlling_process(pid);
270    }
271
272    /// Clones the resource lifecycle Arc for use outside the heap term.
273    #[must_use]
274    pub fn inner(self) -> Arc<FdInner> {
275        clone_fd_inner_from_raw_word(self.arc_ptr_word())
276    }
277
278    fn inner_ref(self) -> &'static FdInner {
279        let ptr = self.arc_ptr_word() as *const FdInner;
280        // SAFETY: FdResource heap words own a strong `Arc<FdInner>` reference, so
281        // the pointed-to FdInner remains live while the heap object is live.
282        unsafe { &*ptr }
283    }
284
285    fn arc_ptr_word(self) -> u64 {
286        // SAFETY: validated FdResource payload word one stores the raw Arc ptr.
287        unsafe { *self.ptr.add(1) }
288    }
289}
290
291/// Writes an FdResource layout (`header, raw Arc<FdInner> pointer`) into `heap`.
292pub fn write_fd_resource(heap: &mut [u64], fd_inner: Arc<FdInner>) -> Option<Term> {
293    if heap.len() < FD_RESOURCE_WORDS {
294        return None;
295    }
296
297    heap[0] = BoxedHeader::new(BoxedTag::FdResource, FD_RESOURCE_PAYLOAD_WORDS);
298    heap[1] = Arc::into_raw(fd_inner) as u64;
299
300    Some(Term::boxed_ptr(heap.as_ptr()))
301}
302
303pub(crate) fn clone_fd_inner_from_raw_word(raw: u64) -> Arc<FdInner> {
304    let ptr = raw as *const FdInner;
305    // SAFETY: FdResource writers store pointers produced by `Arc::into_raw` for
306    // `Arc<FdInner>`. Reconstitute the heap-owned strong reference only long
307    // enough to clone it, then convert it back to raw so ownership remains in the
308    // heap word.
309    let arc = unsafe { Arc::from_raw(ptr) };
310    let cloned = Arc::clone(&arc);
311    let _raw = Arc::into_raw(arc);
312    cloned
313}
314
315pub(crate) fn retain_fd_inner_arc(ptr: *const u64) {
316    let raw = read_raw_word(ptr, 1);
317    let arc_ptr = raw as *const FdInner;
318    // SAFETY: FdResource word one stores a raw `Arc<FdInner>` pointer created by
319    // `Arc::into_raw`. Rebuild the source strong reference temporarily, clone it
320    // for the copied FdResource, then put both strong references back into raw
321    // form so the two heap objects own independent Arc counts.
322    let source = unsafe { Arc::from_raw(arc_ptr) };
323    let copied = Arc::clone(&source);
324    let _source_raw = Arc::into_raw(source);
325    let copied_raw = Arc::into_raw(copied);
326    write_raw_word(ptr, 1, copied_raw as u64);
327}
328
329pub(crate) fn release_fd_inner_arc(ptr: *const u64) {
330    let raw = read_raw_word(ptr, 1);
331    if raw == 0 {
332        return;
333    }
334    let arc_ptr = raw as *const FdInner;
335    // SAFETY: FdResource word one stores a raw `Arc<FdInner>` pointer created by
336    // `Arc::into_raw`. Rebuild exactly that heap-owned strong reference and let
337    // it drop to release this heap object's ownership of the FD lifecycle.
338    let _source = unsafe { Arc::from_raw(arc_ptr) };
339    write_raw_word(ptr, 1, 0);
340}
341
342pub(crate) fn close_owned_resource_at(ptr: *const u64, owner_pid: u64) {
343    let raw = read_raw_word(ptr, 1);
344    if raw == 0 {
345        return;
346    }
347    let inner = clone_fd_inner_from_raw_word(raw);
348    if inner.owner_pid() == owner_pid {
349        let _closed = inner.close_synchronously();
350    }
351}
352
353fn read_raw_word(ptr: *const u64, offset: usize) -> u64 {
354    // SAFETY: caller provides a live FdResource pointer and an offset within the
355    // fixed resource layout.
356    unsafe { *ptr.add(offset) }
357}
358
359fn write_raw_word(ptr: *const u64, offset: usize, value: u64) {
360    // SAFETY: callers only pass heap object pointers while they have exclusive
361    // access to the owning process or GC destination region.
362    unsafe { *(ptr as *mut u64).add(offset) = value }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use std::sync::Mutex;
369
370    use crate::io::ring::IoCompletion;
371
372    #[derive(Default)]
373    struct MockRing {
374        submitted: Mutex<Vec<IoOp>>,
375    }
376
377    impl CompletionRing for MockRing {
378        fn submit(&self, op: IoOp) -> u64 {
379            if let Ok(mut submitted) = self.submitted.lock() {
380                submitted.push(op);
381                submitted.len() as u64
382            } else {
383                0
384            }
385        }
386
387        fn poll_completions(&self, _timeout: std::time::Duration) -> Vec<IoCompletion> {
388            Vec::new()
389        }
390
391        fn pending_count(&self) -> usize {
392            self.submitted.lock().map(|ops| ops.len()).unwrap_or(0)
393        }
394
395        fn shutdown(&self) {}
396    }
397
398    fn pipe_read_fd() -> RawFd {
399        let mut fds = [0; 2];
400        // SAFETY: `fds` points to two valid RawFd slots for libc to initialize.
401        let rc = unsafe { libc::pipe(fds.as_mut_ptr()) };
402        assert_eq!(rc, 0);
403        // SAFETY: close the write end so tests only manage the read end.
404        let _closed = unsafe { libc::close(fds[1]) };
405        fds[0]
406    }
407
408    /// Captures the kernel identity (`st_dev`, `st_ino`) of an open descriptor.
409    /// Each `pipe(2)` instance has a unique inode, so this uniquely identifies
410    /// the descriptor independently of its fd *number*.
411    fn fd_identity(fd: RawFd) -> (libc::dev_t, libc::ino_t) {
412        // SAFETY: `fstat` fully writes the stat buffer on success; we zero-init
413        // it first so a failure path never reads uninitialized memory.
414        let mut st: libc::stat = unsafe { std::mem::zeroed() };
415        let rc = unsafe { libc::fstat(fd, &mut st) };
416        assert_eq!(rc, 0, "expected an open fd to fstat successfully");
417        (st.st_dev, st.st_ino)
418    }
419
420    /// Reuse-immune closure check: returns `true` iff the descriptor that had
421    /// identity `original` is now closed, even under cargo's parallel test
422    /// runner where another thread may concurrently recycle the freed fd number.
423    ///
424    /// - `fstat` fails (EBADF) -> the number is closed -> original closed.
425    /// - `fstat` succeeds with a *different* identity -> the number was recycled
426    ///   to a new file/pipe, which still proves the original was closed.
427    /// - `fstat` succeeds with the *same* identity -> genuinely still open.
428    fn fd_closed_since(fd: RawFd, original: (libc::dev_t, libc::ino_t)) -> bool {
429        // SAFETY: see `fd_identity`.
430        let mut st: libc::stat = unsafe { std::mem::zeroed() };
431        let rc = unsafe { libc::fstat(fd, &mut st) };
432        if rc != 0 {
433            return true;
434        }
435        (st.st_dev, st.st_ino) != original
436    }
437
438    #[test]
439    fn write_and_access_fd_resource_round_trips_fd_and_owner() {
440        let fd = pipe_read_fd();
441        let fd_id = fd_identity(fd);
442        let inner = Arc::new(FdInner::new(fd, 42));
443        let retained = Arc::clone(&inner);
444        let mut heap = [0_u64; FD_RESOURCE_WORDS];
445        let term = write_fd_resource(&mut heap, retained).expect("fd resource should fit");
446
447        assert_eq!(BoxedHeader::tag(heap[0]), Some(BoxedTag::FdResource));
448        assert_eq!(BoxedHeader::size(heap[0]), FD_RESOURCE_PAYLOAD_WORDS);
449        assert_ne!(heap[1], 0);
450        let resource = FdResource::new(term).expect("valid fd resource");
451        assert_eq!(resource.fd(), fd);
452        assert_eq!(resource.owner_pid(), 42);
453        assert_eq!(resource.state(), FdState::Open);
454        assert_eq!(resource.inner().current_offset(), 0);
455        assert_eq!(Arc::strong_count(&inner), 2);
456
457        release_fd_inner_arc(heap.as_ptr());
458        drop(inner);
459        assert!(fd_closed_since(fd, fd_id));
460    }
461
462    #[test]
463    fn write_fd_resource_rejects_too_small_heap_slice() {
464        let fd = pipe_read_fd();
465        let fd_id = fd_identity(fd);
466        let inner = Arc::new(FdInner::new(fd, 7));
467        let mut heap = [0_u64; 1];
468        assert!(write_fd_resource(&mut heap, inner).is_none());
469        assert!(fd_closed_since(fd, fd_id));
470    }
471
472    #[test]
473    fn fd_resource_accessor_rejects_invalid_tag_size_and_null_pointer() {
474        let mut heap = [0_u64; FD_RESOURCE_WORDS];
475        heap[0] = BoxedHeader::new(BoxedTag::Tuple, FD_RESOURCE_PAYLOAD_WORDS);
476        heap[1] = 1;
477        assert!(FdResource::new(Term::boxed_ptr(heap.as_ptr())).is_none());
478
479        heap[0] = BoxedHeader::new(BoxedTag::FdResource, 2);
480        assert!(FdResource::new(Term::boxed_ptr(heap.as_ptr())).is_none());
481
482        heap[0] = BoxedHeader::new(BoxedTag::FdResource, FD_RESOURCE_PAYLOAD_WORDS);
483        heap[1] = 0;
484        assert!(FdResource::new(Term::boxed_ptr(heap.as_ptr())).is_none());
485    }
486
487    #[test]
488    fn last_arc_drop_closes_fd() {
489        let fd = pipe_read_fd();
490        let fd_id = fd_identity(fd);
491        let inner = Arc::new(FdInner::new(fd, 1));
492        drop(inner);
493        assert!(fd_closed_since(fd, fd_id));
494    }
495
496    #[test]
497    fn explicit_close_then_drop_does_not_submit_or_close_twice() {
498        let fd = pipe_read_fd();
499        let inner = Arc::new(FdInner::new(fd, 1));
500        let ring = MockRing::default();
501
502        assert!(inner.explicit_close(&ring));
503        assert!(!inner.explicit_close(&ring));
504        assert_eq!(inner.state(), FdState::Closing);
505        let submitted_len = ring.submitted.lock().map(|ops| ops.len()).unwrap_or(0);
506        assert_eq!(submitted_len, 1);
507        drop(inner);
508
509        // The mock ring accepted ownership of the async close but does not execute
510        // it; close here so the test process does not leak the descriptor.
511        // SAFETY: the descriptor was not synchronously closed by Drop after the
512        // explicit close transitioned the state to Closing.
513        let _closed = unsafe { libc::close(fd) };
514    }
515}