chillffi 0.3.0

A simple isolated dynamic FFI framework for Rust
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
use crate::errnoPolicy::globalReadErrno;
use crate::ffi::types::primitive::{Arg, FfiArg, FfiPrimitive};
use crate::ffi::types::primitive::Callback;
use crate::ffi::types::primitive::DynamicList;
use crate::ffi::types::primitive::Primitive;
use crate::ffi::types::{Type, Value};
use crate::ffi::callback::sendable::Sendable;
use serde::Serialize;
use std::sync::atomic::Ordering;
use std::sync::atomic::AtomicU64;
use std::cell::RefCell;
use std::path::PathBuf;
use crate::pathResolver::{PathResolver, resolveGlobal};
use std::cell::UnsafeCell;
use crate::ffi::allocatedMemory::AllocatedMemory;
use crate::ffi::errors::FFIError;
use crate::ffi::library::{sendRawRequest, nextLibraryId, registerLibrary, Library};
use crate::zygote::{ClonedZygote, FFIRequest, ZygoteGuard};
// =================================================================================================

/// Heavy stack or arena for temporary allocations within an [`ffi!`] scope.
struct HeavyStack
{
  /// Local path resolver for the current scope.
  pathResolver: Option<PathResolver>,
  /// Scope-level override for errno capture — see [`Scope::setReadErrno`].
  /// `None` means "no scope override, fall through to the global default".
  readErrno: Option<bool>
}

// =================================================================================================

/// Owner of HeavyStack. Created by the [`ffi!`] macro once per block (only if
/// the user requested Scope), lives and dies strictly with this block.
///
/// Is not published directly — access only through [`Scope<'g>.`].
#[doc(hidden)]
pub struct ScopeGuard
{
  /// Lazily initialized internal state of the scope.
  inner: UnsafeCell<Option<HeavyStack>>
}

impl ScopeGuard
{
  #[doc(hidden)]
  #[inline(always)]
  pub const fn new() -> Self
  {
    Self {
      inner: UnsafeCell::new(None)
    }
  }
}

thread_local!{
  /// Thread-local stack tracking active ScopeGuard pointers for the current thread.
  static ScopeStack: RefCell<Vec<*const ScopeGuard>> = const { RefCell::new(Vec::new()) };
}

/// Reads the innermost active scope's errno-capture override, if any was set
/// via [`Scope::setReadErrno`]. `None` if there's no active scope, or none
/// was set — same "peek the thread-local stack" shape as `resolveGlobal`,
/// just scoped instead of global, and without needing a live `Scope<'g>` handle
/// (used by [`crate::ffi::library::resolveReadErrno`] from `CallBuilder`, which
/// only has a `Library<'g>`, not the `Scope<'g>` that created it).
pub(super) fn currentScopeReadErrno() -> Option<bool>
{
  ScopeStack.with(|stack| {
    let guardPtr: *const ScopeGuard = *stack.borrow().last()?;
    // Safety: a pointer is only ever on ScopeStack while its ScopeGuard is
    // alive — pushed in Scope::new, popped in Scope::drop before the guard
    // itself can go out of scope.
    let slot: &Option<HeavyStack> = unsafe{ &*(*guardPtr).inner.get() };
    slot.as_ref()?.readErrno
  })
}

// =================================================================================================

/// A handle to the ScopeGuard of the current [`ffi!`]-block — borrows it for 'g.
///
/// That is precisely why [`AllocatedMemory<'g>`] and [`Library<'g>`] cannot leave
/// the block: the ScopeGuard, which they borrow, is dropped at the boundary of the
/// block, and this is checked by the compiler.
pub struct Scope<'g>
{
  guard: &'g ScopeGuard,
}

impl<'g> Scope<'g>
{
  // ===============================================================================================

  #[doc(hidden)]
  #[inline(always)]
  pub fn new(guard: &'g ScopeGuard) -> Self
  {
    ScopeStack.with(|s| s.borrow_mut().push(guard as *const ScopeGuard));
    Self { guard }
  }

  // ===============================================================================================

  /// Adds a directory to the local search path of the scope.
  pub fn addSearchPath(&self, path: impl Into<PathBuf>) -> ()
  {
    let slot: &mut Option<HeavyStack> = unsafe{ &mut *self.guard.inner.get() };
    slot.get_or_insert_with(|| HeavyStack{ pathResolver: None, readErrno: None })
      .pathResolver.get_or_insert_with(PathResolver::default)
      .addPath(path);
  }

  // ===============================================================================================

  /// Overrides errno capture for every call made through this scope — see
  /// [`FFIRequest::Call`]'s `readErrno` field for what capture actually means.
  /// A per-call override (`.errno()`/`.noErrno()` on [`CallBuilder`](crate::ffi::library::CallBuilder))
  /// still takes priority over this; this in turn takes priority over the
  /// global default set via [`crate::errnoPolicy::setGlobalReadErrno`].
  pub fn setReadErrno(&self, enabled: bool) -> ()
  {
    let slot: &mut Option<HeavyStack> = unsafe{ &mut *self.guard.inner.get() };
    slot.get_or_insert_with(|| HeavyStack{ pathResolver: None, readErrno: None })
      .readErrno = Some(enabled);
  }

  // ===============================================================================================

  /// Loads a dynamic library and binds the handle to this scope — the same
  /// model as [`Scope::alloc`] / [`AllocatedMemory<'g>`].
  ///
  /// Resolution order: this scope's local search path, then the global search path, 
  /// then the raw path as given.
  pub fn load(&self, libraryPath: &str) -> Result<Library<'g>, FFIError>
  {
    let slot: &Option<HeavyStack> = unsafe{ &*self.guard.inner.get() };
    let resolved: String = slot.as_ref()
      .and_then(|s| s.pathResolver.as_ref())
      .and_then(|r| r.resolve(libraryPath))
      .or_else(|| resolveGlobal(libraryPath))
      .unwrap_or_else(|| libraryPath.to_string());

    let libraryId: usize = nextLibraryId();
    registerLibrary(libraryId, &resolved);
    Ok(Library::new(libraryId, resolved))
  }

  // ===============================================================================================

  /// Allocates `length` bytes in the clone's heap.
  pub fn alloc(&self, length: usize) -> Result<AllocatedMemory<'g>, FFIError>
  {
    let stack: &mut Option<HeavyStack> = unsafe{ &mut *self.guard.inner.get() };

    // Initialization of the heavy stack happens only on the first call to alloc()
    if stack.is_none() {
      *stack = Some(HeavyStack{
        pathResolver: None,
        readErrno: None
      });
    }

    // Memory allocation through zigot
    match sendRawRequest(FFIRequest::Alloc { length })? {
      Value::Pointer(address) => Ok(AllocatedMemory::new(address, length)),
      _ => Err(FFIError::Other("Alloc did not return a pointer".to_string())),
    }
  }

  /// Allocates enough zygote heap memory to hold a dynamically-shaped C
  /// struct with the given field layout. 
  ///
  /// Unlike [`Scope::alloc`], the byte size isn't supplied by the caller 
  /// — there's no Rust type to run `size_of` on for a shape that only
  /// exists as C source, so guessing it by hand is exactly how 
  /// `malloc(sizeof(struct ...))` bugs happen on a new target.
  ///
  /// It's resolved on the clone side instead, by the same
  /// ABI-aware layout math [`Scope::readDynamicStruct`]/
  /// [`Scope::writeDynamicStruct`] already use.
  pub fn allocStruct(&self, fields: &[Type]) -> Result<AllocatedMemory<'g>, FFIError>
  {
    let stack: &mut Option<HeavyStack> = unsafe{ &mut *self.guard.inner.get() };

    if stack.is_none() {
      *stack = Some(HeavyStack{
        pathResolver: None,
        readErrno: None
      });
    }

    match sendRawRequest(FFIRequest::AllocDynamicStruct { fields: fields.to_vec() })? {
      Value::Struct(parts) if parts.len() == 2 => match (&parts[0], &parts[1]) {
        (Value::Pointer(address), Value::Usize(size)) => Ok(AllocatedMemory::new(*address, *size)),
        _ => Err(FFIError::Other("AllocDynamicStruct returned an unexpected shape".to_string())),
      },
      _ => Err(FFIError::Other("AllocDynamicStruct did not return a pointer+size pair".to_string())),
    }
  }

  /// Allocates `length` bytes in the clone's heap with the specified `alignment`.
  ///
  /// Uses `posix_memalign` under the hood, so `alignment` must be a power of 2
  /// and at least the size of a `void*` (typically 8 bytes on 64-bit systems).
  ///
  /// This is essential for SIMD types like `__m128` that require 16/32/64-byte alignment,
  /// which regular `malloc` (and therefore `alloc`) does not guarantee.
  pub fn allocAligned(&self, length: usize, alignment: usize) -> Result<AllocatedMemory<'g>, FFIError>
  {
    let stack: &mut Option<HeavyStack> = unsafe{ &mut *self.guard.inner.get() };

    // Initialization of the heavy stack happens only on the first call to alloc()
    if stack.is_none() {
      *stack = Some(HeavyStack{
        pathResolver: None,
        readErrno: None
      });
    }

    // Memory allocation through zygote with alignment
    match sendRawRequest(FFIRequest::AllocAligned { length, alignment })? {
      Value::Pointer(address) => Ok(AllocatedMemory::new(address, length)),
      _ => Err(FFIError::Other("AllocAligned did not return a pointer".to_string())),
    }
  }

  /// Frees memory previously obtained via `alloc` (or a C-side allocator).
  #[inline]
  pub fn free(pointer: impl Into<usize>) -> Result<(), FFIError>
  {
    sendRawRequest(FFIRequest::Free {
      pointer: pointer.into()
    })?;
    Ok(())
  }

  /// Reads `length` bytes at `pointer` from the clone's memory.
  #[inline]
  pub fn readMemory(pointer: impl Into<usize>, length: usize) -> Result<Vec<u8>, FFIError>
  {
    let value: Value = sendRawRequest(FFIRequest::ReadMemory {
      pointer: pointer.into(),
      length,
    })?;
    value.try_into()
  }

  /// Writes data from [`Value`] into the clone's memory at `pointer`.
  pub fn writeMemory(pointer: impl Into<usize>, value: impl FfiArg) -> Result<(), FFIError>
  {
    sendRawRequest(FFIRequest::WriteMemory {
      pointer: pointer.into(),
      value: value.intoFfiValue().0,
    })?;
    Ok(())
  }

  // ===============================================================================================

  /// Reads a dynamically typed C structure from the pointer 
  /// and returns it as a `DynamicStruct`.
  pub fn readDynamicStruct(
    pointer: impl Into<usize>,
    fields: &[Type],
  ) -> Result<DynamicList, FFIError>
  {
    match sendRawRequest(FFIRequest::ReadDynamicStruct {
      pointer: pointer.into(),
      fields: fields.to_vec(),
    })? {
      Value::Struct(values) => Ok(DynamicList::fromValues(values)),
      other => Err(FFIError::Other(format!(
        "ReadDynamicStruct: expected Value::Struct, got {:?}",
        other
      ))),
    }
  }

  /// Writes `values` into a dynamically-typed C struct at `pointer`.
  pub fn writeDynamicStruct(
    pointer: impl Into<usize>,
    fields: &[Type],
    values: Vec<Arg>
  ) -> Result<(), FFIError>
  {
    let values: Vec<Value> = values.into_iter().map(|a: Arg| a.0).collect();
    sendRawRequest(FFIRequest::WriteDynamicStruct {
      pointer: pointer.into(), fields: fields.to_vec(), values
    })?;
    Ok(())
  }

  // ===============================================================================================

  /// Calls a raw function pointer directly — no `dlopen`/`dlsym`, the address
  /// is already known. Typical source: a pointer *returned* by a previous
  /// call (C ABI functions returning function pointers exist — e.g. libc's
  /// `signal()` both takes and returns one), or read out of a dispatch table
  /// via `readMemory`.
  pub fn callPointer<T: FfiPrimitive>(
    &self,
    pointer: impl Into<usize>,
    args: Vec<Arg>
  ) -> Result<T, FFIError>
  {
    self.callPointerImpl(pointer, args, None)
  }

  /// Fire-and-forget variant of `callPointer` — mirrors `Library::callv`.
  #[inline]
  pub fn callvPointer(
    &self,
    pointer: impl Into<usize>,
    args: Vec<Arg>
  ) -> Result<(), FFIError>
  {
    self.callPointer::<()>(pointer, args)
  }

  /// Same as `callPointer`, but forces errno capture for this specific call —
  /// no builder to chain `.errno()` onto, since `callPointer` skips `CallBuilder`
  /// entirely. Read it back via [`Scope::lastErrno`].
  #[inline]
  pub fn callPointerErrno<T: FfiPrimitive>(
    &self,
    pointer: impl Into<usize>,
    args: Vec<Arg>
  ) -> Result<T, FFIError>
  {
    self.callPointerImpl(pointer, args, Some(true))
  }

  /// Shared implementation: resolves the effective `readErrno` flag (explicit
  /// override, else scope, else global — same order as `CallBuilder::result`)
  /// and sends the request.
  fn callPointerImpl<T: FfiPrimitive>(
    &self,
    pointer: impl Into<usize>,
    args: Vec<Arg>,
    readErrno: Option<bool>
  ) -> Result<T, FFIError>
  {
    let readErrno: bool = readErrno.unwrap_or_else(|| currentScopeReadErrno().unwrap_or_else(globalReadErrno));
    let args: Vec<Value> = args.into_iter().map(|a: Arg| a.0).collect();
    let raw: Value = sendRawRequest(FFIRequest::CallPointer {
      pointer: pointer.into(),
      args,
      resultType: T::TypeTag,
      readErrno
    })?;
    T::fromFfiValue(Arg(raw))
  }

  /// Returns the errno captured by the most recent call on this thread, if
  /// that call's effective policy asked for it (see `setReadErrno`,
  /// `CallBuilder::errno`, [`crate::errnoPolicy::setGlobalReadErrno`]) —
  /// `None` otherwise, including right after any non-call operation.
  #[inline]
  pub fn lastErrno() -> Option<i32>
  {
    crate::ffi::library::lastErrno()
  }

  // ===============================================================================================

  /// Registers a closure built with [`callback!`] as an FFI-callable function
  /// (e.g. a `qsort` comparator). Capture is explicit at the macro call site,
  /// this method only ships the already-built closure to the clone:
  pub fn callback<State: Serialize + Send, Output: Primitive>(
    &self,
    f: Sendable<State, Output>
  ) -> Callback
  {
    static nextID: AtomicU64 = AtomicU64::new(1);
    let id: u64 = nextID.fetch_add(1, Ordering::SeqCst);

    sendRawRequest(FFIRequest::RegisterCallback {
      id,
      bytes: f.encode().expect("encode callback"),
      argTypes: f.argTypes,
      returnType: f.returnType
    }).expect("register callback failed");

    Callback(id)
  }

  // ===============================================================================================
}

impl<'g> Drop for Scope<'g>
{
  fn drop(&mut self) -> () { ScopeStack.with(|s| { s.borrow_mut().pop(); }); }
}

// =================================================================================================

/// RAII owner of an isolated FFI execution context.
///
/// Created by [`FFIScope::enter`]. Holds:
/// - a cloned zygote process and its IPC socket — any FFI operation sent
///   through this thread's stack lands in this clone,
/// - a [`ScopeGuard`] that anchors the `'g` lifetime of [`Scope`],
///   [`AllocatedMemory<'g>`] and [`Library<'g>`].
///
/// On drop: the zygote is killed, the guard is released, and any
/// [`AllocatedMemory<'g>`] / [`Library<'g>`] still alive is *not* freed
/// automatically (their own `Drop` runs only while `'g` is still valid — by
/// construction it has been, because we are now at the end of the borrow).
///
/// todo Requires consideration; this should theoretically not be possible:
///  If you need to keep an allocation past the scope, extract its raw address
///  via [`AllocatedMemory::address`] before the scope ends.
///
/// This is the non-macro entry point. It exists for use cases where the
/// boundaries of the FFI block are not known at compile time — a JIT, an
/// interpreter, or code generated from another language.
pub struct FFIScope
{
  /// RAII handle that keeps the cloned zygote on the thread-local stack
  /// for as long as we are alive.
  _zygote: ZygoteGuard,
  /// Backing storage for the `'g` lifetime borrowed by [`Scope`],
  /// [`AllocatedMemory<'g>`] and [`Library<'g>`].
  guard: ScopeGuard,
}

impl FFIScope
{
  /// Enters a new isolated FFI context: forks a fresh zygote clone and
  /// prepares a scope guard. Returns an error if the global zygote has
  /// not been initialized or if the fork / IPC handshake fails.
  pub fn enter() -> Result<Self, FFIError>
  {
    let zygote: ClonedZygote = ClonedZygote::getMeClone()
      .map_err(|e| FFIError::Other(format!("failed to acquire zygote clone: {}", e)))?;
    let _zygote: ZygoteGuard = ZygoteGuard::enter(zygote);
    let guard: ScopeGuard = ScopeGuard::new();
    Ok(Self { _zygote, guard })
  }

  /// Borrows a [`Scope`] handle tied to this `FFIScope`'s lifetime.
  ///
  /// All [`AllocatedMemory<'g>`] / [`Library<'g>`] values obtained through this
  /// handle are freed (via their own `Drop`) no later than when this `FFIScope`
  /// is dropped — the compiler enforces that statically through `'g`.
  pub fn scope(&self) -> Scope<'_>
  {
    Scope::new(&self.guard)
  }
}

// =================================================================================================

// todo
//  In general, this is not entirely correct, scope is not used here. But protection that it is
//  used only inside a scope should be present. Therefore, this should be fixed.

/// Calls a raw function pointer through a [`Scope`].
#[macro_export]
macro_rules! callPointer
{
  ($scope:expr, $pointer:expr $(, $args:expr)* $(,)?) => {
    $scope.callPointer($pointer, vec![$($crate::ffi::types::primitive::Arg::from($args)),*])
  };
}

/// Fire-and-forget variant of [`callPointer!`] — mirrors [`callv!`].
#[macro_export]
macro_rules! callvPointer
{
  ($scope:expr, $pointer:expr $(, $args:expr)* $(,)?) => {
    $scope.callvPointer($pointer, vec![$($crate::ffi::types::primitive::Arg::from($args)),*])
  };
}

// =================================================================================================

#[cfg(test)]
mod tests
{
  use crate::ffi;
  use crate::ffi::allocatedMemory::AllocatedMemory;
  use crate::ffi::types::Type;
  use crate::ffi::types::primitive::{Pointer, Arg, DynamicList};
  use crate::ffi::errors::FFIError;
  use crate::ffi::library::Library;
  use crate::ffi::scope::Scope;
  use crate::ffi::scope::FFIScope;
  // ===============================================================================================

  /// Checks explicit memory release via [`Scope::free`].
  #[test]
  fn free() -> ()
  {
    ffi!(|scope| {
      let libc: Library = scope.load("libc.so.6")?;
      let ptr: Pointer = libc.call("malloc").arg::<usize>(16).result()?;

      Scope::free(ptr)?;
      Ok(())
    }).expect("Scope::free failed");
  }

  /// Checks reading memory allocated by C via [`Scope::readMemory`].
  #[test]
  fn readMemory() -> ()
  {
    let bytes: Vec<u8> = ffi!(|scope| {
      let libc: Library = scope.load("libc.so.6")?;
      let ptr: Pointer = libc.call("malloc").arg::<usize>(8).result()?;

      libc.call("memset")
        .arg(ptr)
        .arg::<i32>(0xAB)
        .arg::<usize>(8)
        .void()?;

      let readBytes: Vec<u8> = Scope::readMemory(ptr, 8)?;

      Scope::free(ptr)?;
      Ok(readBytes)
    }).expect("Scope::readMemory failed");

    assert_eq!(bytes, vec![0xABu8; 8]);
  }

  /// Checks writing memory via [`Scope::writeMemory`] and reading it back through C.
  #[test]
  fn writeMemory() -> ()
  {
    let len: usize = ffi!(|scope| {
      let libc: Library = scope.load("libc.so.6")?;
      let ptr: Pointer = libc.call("malloc").arg::<usize>(32).result()?;

      Scope::writeMemory(ptr, c"hello")?;

      let result: usize = libc.call("strlen").arg(ptr).result()?;

      Scope::free(ptr)?;
      Ok(result)
    }).expect("Scope::writeMemory failed");

    assert!(matches!(len, 5));
  }

  // ===============================================================================================

  /// Checks that [`Scope::allocStruct`] resolves the correct ABI-aware byte
  /// size for a shape mixing a 4-byte field with an 8-byte pointer field —
  /// on x86_64 that's 16 bytes (4 + 4 padding + 8), not the naively summed 12.
  #[test]
  fn allocStructResolvesLayoutSize() -> ()
  {
    let length: usize = ffi!(|scope| {
      let mem: AllocatedMemory = scope.allocStruct(&[Type::I32, Type::Pointer])?;
      Ok(mem.length())
    }).expect("Scope::allocStruct failed");

    assert_eq!(length, 16);
  }

  /// Round-trips a struct entirely on the Rust side — [`Scope::allocStruct`]
  /// sizes it, [`Scope::writeDynamicStruct`] fills it in from plain Rust
  /// values (via `Arg`, never naming the crate-private `Value`), and
  /// [`Scope::readDynamicStruct`] reads it back.
  #[test]
  fn writeThenReadDynamicStruct() -> ()
  {
    let shape: Vec<Type> = vec![Type::I32, Type::I64];

    let (a, b): (i32, i64) = ffi!(|scope| {
      let mem: AllocatedMemory = scope.allocStruct(&shape)?;

      Scope::writeDynamicStruct(mem.address(), &shape, vec![
        Arg::from(7i32),
        Arg::from(9_000_000_000i64),
      ])?;

      let fields: DynamicList = Scope::readDynamicStruct(mem.address(), &shape)?;
      Ok((fields.get(0)?, fields.get(1)?))
    }).expect("writeDynamicStruct/readDynamicStruct roundtrip failed");

    assert_eq!((a, b), (7, 9_000_000_000));
  }

  // ===============================================================================================

  /// Checks that `Scope::setReadErrno(true)` makes a plain `.result()` call
  /// (no `.errno()` on the call itself) surface errno via `Scope::lastErrno()`.
  #[test]
  fn scopeDefaultEnablesErrno() -> ()
  {
    let errno: Option<i32> = ffi!(|scope| {
      scope.setReadErrno(true);
      let libc: Library = scope.load("libc.so.6")?;
      let fd: i32 =
        libc.call("open")
          .arg(c"/no/such/chillffi/scope/path")
          .arg::<i32>(0 /* O_RDONLY */)
          .result()?; // relies on the scope default, not .errno()
      assert_eq!(fd, -1);
      Ok(Scope::lastErrno())
    }).expect("scope errno default test failed");

    assert_eq!(errno, Some(libc::ENOENT));
  }

  // ===============================================================================================

  /// Checks that [`Scope::allocAligned`] allocates memory with the requested alignment.
  #[test]
  fn allocAligned() -> ()
  {
    ffi!(|scope| {
      // 16-byte alignment (typical for SSE)
      let mem16: AllocatedMemory = scope.allocAligned(64, 16)?;
      assert_eq!(mem16.address() % 16, 0, "16-byte alignment not met");

      // 32-byte alignment (typical for AVX)
      let mem32: AllocatedMemory = scope.allocAligned(64, 32)?;
      assert_eq!(mem32.address() % 32, 0, "32-byte alignment not met");

      // 64-byte alignment (typical for cache lines or AVX-512)
      let mem64: AllocatedMemory = scope.allocAligned(64, 64)?;
      assert_eq!(mem64.address() % 64, 0, "64-byte alignment not met");

      Ok(())
    }).expect("allocAligned failed");
  }

  // ===============================================================================================

  /// Verify that a single [`FFIScope`] can be reused across multiple FFI operations.
  ///
  /// All operations use the same zygote and scope. The test also verifies that
  /// [`AllocatedMemory`] and [`Library`] remains tied to the scope lifetime.
  #[test]
  fn scopeRetention() -> ()
  {
    // Direct RAII form: hold the scope ourselves and run several ops through it.
    let (r1, r2): (f64, i32) = (|| -> Result<_, FFIError>
    {
      let ffiScope: FFIScope = FFIScope::enter()?;
      let scope: Scope<'_> = ffiScope.scope();

      // 1. load libm, call sqrt(9.0) — shares the same zygote.
      let libm: Library = scope.load("libm.so.6")?;
      let r1: f64 = libm.call("sqrt").arg::<f64>(9.0).result()?;

      // 2. load libc, call abs(-7) on the SAME zygote clone.
      let libc: Library = scope.load("libc.so.6")?;
      let _abs: i32 = libc.call("abs").arg::<i32>(-7).result()?;

      // 3. scope.alloc + writeMemory + strlen — AllocatedMemory<'g> is
      // bounded by `ffiScope` (via `scope`), proving the 'g lifetime is real.
      let mem: AllocatedMemory = scope.alloc(32)?;
      Scope::writeMemory(mem.address(), c"retained")?;
      let r2: usize = libc.call("strlen").arg(mem.address()).result()?;

      // mem drops here, sends Free, fine.
      Ok((r1, r2 as i32))
    })().expect("FFIScope flow failed");

    //
    assert!((r1 - 3.0).abs() < f64::EPSILON, "sqrt(9) != 3, got {}", r1);
    assert_eq!(r2, 8, "strlen(\"retained\") != 8, got {}", r2);
  }

  // ===============================================================================================
}

// =================================================================================================