Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build/instructions_template.rs
Original file line number Diff line number Diff line change
Expand Up @@ -603,7 +603,7 @@ enum SystemClauseType {
HttpAccept,
#[strum_discriminants(strum(props(Arity = "4", Name = "$http_answer")))]
HttpAnswer,
#[strum_discriminants(strum(props(Arity = "2", Name = "$load_foreign_lib")))]
#[strum_discriminants(strum(props(Arity = "3", Name = "$load_foreign_lib")))]
LoadForeignLib,
#[strum_discriminants(strum(props(Arity = "3", Name = "$foreign_call")))]
ForeignCall,
Expand Down
32 changes: 31 additions & 1 deletion src/ffi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ pub struct FunctionDefinition {
pub args: Vec<Atom>,
}

/// Symbol visibility scope for loaded libraries (Unix only)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RtldScope {
/// RTLD_LOCAL: Symbols not available to subsequently loaded libraries (default)
Local,
/// RTLD_GLOBAL: Symbols available for resolution by subsequently loaded libraries
Global,
}

#[derive(Debug)]
pub struct FunctionImpl {
cif: Cif,
Expand Down Expand Up @@ -676,9 +685,30 @@ impl ForeignFunctionTable {
&mut self,
library_name: &str,
functions: &Vec<FunctionDefinition>,
scope: RtldScope,
) -> Result<(), Box<dyn Error>> {
let mut ff_table: ForeignFunctionTable = Default::default();
let library = unsafe { Library::new(library_name) }?;

let library = unsafe {
#[cfg(unix)]
{
use libloading::os::unix;
let scope_flag = match scope {
RtldScope::Local => unix::RTLD_LOCAL,
RtldScope::Global => unix::RTLD_GLOBAL,
};
// Always use RTLD_LAZY (standard, faster loading)
let unix_lib = unix::Library::open(
Some(library_name),
unix::RTLD_LAZY | scope_flag
)?;
Library::from(unix_lib)
}
#[cfg(not(unix))]
{
Library::new(library_name)?
}
};
for function in functions {
let symbol_name: CString = CString::new(&*function.name.as_str())?;
let code_ptr: Symbol<*mut c_void> =
Expand Down
124 changes: 113 additions & 11 deletions src/lib/ffi.pl
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
:- module(ffi, [use_foreign_module/2, foreign_struct/2, with_locals/2, allocate/4, deallocate/3, read_ptr/3, array_type/3]).
:- module(ffi, [use_foreign_module/2, use_foreign_module/3, foreign_struct/2, with_locals/2, allocate/4, deallocate/3, read_ptr/3, array_type/3]).

/** Foreign Function Interface

Expand All @@ -7,9 +7,45 @@
and is very unsafe and should be used with care. FFI isn't the only way to communicate with
the outside world in Prolog: sockets, pipes and HTTP may be good enough for your use case.

The main predicate is `use_foreign_module/2`. It takes a library name (which depending on the
operating system could be a `.so`, `.dylib` or `.dll` file). and a list of functions. Each
function is defined by its name, a list of the type of the arguments, and the return argument.
The main predicate is `use_foreign_module/2` or `use_foreign_module/3` (with options).
It takes a library name (which depending on the operating system could be a `.so`, `.dylib`
or `.dll` file), a list of functions, and optionally a list of options. Each function is
defined by its name, a list of the type of the arguments, and the return argument.

## Library Loading Options

### Scope Option (POSIX only)

The scope option controls symbol visibility. Libraries are always loaded with lazy binding
(`RTLD_LAZY` - symbols resolved as needed).

- **`scope(local)`** (default): `RTLD_LOCAL` - Symbols not available to subsequently loaded
libraries. Prevents symbol pollution and conflicts. Use this for most libraries.

- **`scope(global)`**: `RTLD_GLOBAL` - Symbols available for resolution by subsequently loaded
libraries. Required for certain use cases:
- **Python C extensions**: When embedding Python, C extension modules (NumPy, SciPy, pandas,
standard library modules like `math`, `socket`, etc.) need to resolve symbols from libpython.
Without RTLD_GLOBAL, these imports fail with "undefined symbol" errors.
- **Plugin architectures**: Libraries that dynamically load plugins which depend on symbols
from the main library.

### Examples

```prolog
% Default (local scope with lazy binding)
use_foreign_module('/path/libmath.so', [...]).

% Python C library - needs global scope for C extensions
use_foreign_module('/path/libpython3.11.so', [...], [scope(global)]).

% Explicit local scope
use_foreign_module('/path/lib.so', [...], [scope(local)]).
```

**Note**: On Windows, the scope option has no effect as Windows uses a different library
loading model. Using `scope(global)` can cause symbol conflicts if multiple libraries export
the same symbol names - only use it when necessary.

For each function in the list a predicate of the same name is generated in the ffi module which
can then be used to call the native code.
Expand Down Expand Up @@ -86,7 +122,7 @@
And a new window should pop up!
*/

:- use_module(library(lists)).
:- use_module(library(lists), [member/2, maplist/2, append/2, length/2]).
:- use_module(library(error)).
:- use_module(library(format)).
:- use_module(library(dcgs)).
Expand All @@ -111,22 +147,88 @@

%% use_foreign_module(+LibName, +Predicates)
%
% - LibName the path to the shared library to load/bind
% - Predicates list of function definitions
% Load a foreign library with default options and register predicates.
% Uses POSIX defaults: scope(local) with lazy binding (RTLD_LAZY).
%
% @arg LibName The path to the shared library to load/bind (e.g. '/path/to/lib.so')
% @arg Predicates List of function definitions (functors of arity 2: Name(Args, ReturnType))
%
use_foreign_module(LibName, Predicates) :-
use_foreign_module(LibName, Predicates, []).

%% use_foreign_module(+LibName, +Predicates, +Options)
%
% Load a foreign library with specified options and register predicates.
%
% @arg LibName The path to the shared library to load/bind (e.g. '/path/to/lib.so')
% @arg Predicates List of function definitions (functors of arity 2: Name(Args, ReturnType))
% @arg Options List of loading options. Supported options:
% - scope(Scope): Symbol visibility - `local` (default) or `global`
%
% Each function definition is a functor of arity 2.
% The functor name is the name of the function to bind,
% the first argument is the list of arguments of the function,
% the second argument is the return type of the function.
%
% This will define a predicate in the ffi module with the defined name,
% for void and bool return type functions the arity will match the length of the arguments list,
% This will define a predicate in the ffi module with the defined name.
% For void and bool return type functions the arity will match the length of the arguments list,
% for other return types there will be an additional out parameter.
%
use_foreign_module(LibName, Predicates) :-
'$load_foreign_lib'(LibName, Predicates),
% Libraries are always loaded with lazy binding (RTLD_LAZY - symbols resolved as needed).
% Default scope is local (RTLD_LOCAL).
%
% Examples:
% ```
% % Default options (local scope, lazy binding)
% use_foreign_module('lib.so', [foo([int], void)]).
%
% % Python library - needs global scope for C extension modules
% use_foreign_module('/path/libpython3.11.so', [...], [scope(global)]).
%
% % Explicit local scope
% use_foreign_module('lib.so', [...], [scope(local)]).
% ```
%
use_foreign_module(LibName, Predicates, Options) :-
call_with_error_context(use_foreign_module_(LibName, Predicates, Options), predicate-use_foreign_module/3).

use_foreign_module_(LibName, Predicates, Options) :-
must_be(list, Predicates),
must_be(list, Options),
validate_ffi_options(Options, Scope),
'$load_foreign_lib'(LibName, Predicates, Scope),
maplist(assert_predicate, Predicates).

%% validate_ffi_options(+Options, -Scope)
%
% Validate FFI loading options and extract scope value.
% Throws domain_error for unknown or duplicate options.
% Default scope is 'local'.
%
validate_ffi_options(Options, Scope) :-
validate_ffi_options_(Options, [], local, Scope).

validate_ffi_options_([], _Seen, Scope, Scope).
validate_ffi_options_([Option|Rest], Seen, _CurrentScope, FinalScope) :-
( Option = scope(Value) ->
( member(scope, Seen) ->
domain_error(non_duplicate_options, scope, [])
; valid_scope(Value) ->
validate_ffi_options_(Rest, [scope|Seen], Value, FinalScope)
; domain_error(ffi_scope, Value, [])
)
; functor(Option, Name, 1) ->
domain_error(ffi_option, Name, [])
; domain_error(ffi_option, Option, [])
).

%% valid_scope(+Scope)
%
% Check if Scope is a valid scope value (local or global).
%
valid_scope(local).
valid_scope(global).

assert_predicate(PredicateDefinition) :-
PredicateDefinition =.. [Name, Inputs, void],
length(Inputs, NumInputs),
Expand Down
25 changes: 23 additions & 2 deletions src/machine/system_calls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4915,13 +4915,34 @@ impl Machine {
#[inline(always)]
pub(crate) fn load_foreign_lib(&mut self) -> CallResult {
fn stub_gen() -> MachineStub {
functor_stub(atom!("$load_foreign_lib"), 2)
functor_stub(atom!("$load_foreign_lib"), 3)
}

#[cfg(feature = "ffi")]
{
let library_name = self.deref_register(1);
let args_reg = self.deref_register(2);
let scope_reg = self.deref_register(3);

// Expect scope to be a simple atom: 'local' or 'global'
// Options validation is done on the Prolog side
let scope = read_heap_cell!(scope_reg,
(HeapCellValueTag::Atom, (name, arity)) => {
debug_assert_eq!(arity, 0);
match name {
atom!("global") => RtldScope::Global,
atom!("local") => RtldScope::Local,
_ => {
self.machine_st.fail = true;
return Ok(());
}
}
}
_ => {
self.machine_st.fail = true;
return Ok(());
}
);
if let Some(library_name) = self.machine_st.value_to_str_like(library_name) {
match self.machine_st.try_from_list(args_reg, stub_gen) {
Ok(addrs) => {
Expand Down Expand Up @@ -4955,7 +4976,7 @@ impl Machine {
}
if self
.foreign_function_table
.load_library(&library_name.as_str(), &functions)
.load_library(&library_name.as_str(), &functions, scope)
.is_err()
{
self.machine_st.fail = true;
Expand Down
3 changes: 3 additions & 0 deletions tests/scryer/cli/src_tests/ffi_options_duplicate.in/input.pl
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
:- use_module(library(ffi)).

test :- use_foreign_module("nonexistent.so", [], [scope(local), scope(global)]).
5 changes: 5 additions & 0 deletions tests/scryer/cli/src_tests/ffi_options_duplicate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
```trycmd
$ scryer-prolog -f --no-add-history input.pl -g test -g halt
test causes: error(domain_error(non_duplicate_options,scope),[predicate-use_foreign_module/3])

```
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
:- use_module(library(ffi)).

test :- use_foreign_module("nonexistent.so", [], [scope(invalid)]).
5 changes: 5 additions & 0 deletions tests/scryer/cli/src_tests/ffi_options_invalid_scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
```trycmd
$ scryer-prolog -f --no-add-history input.pl -g test -g halt
test causes: error(domain_error(ffi_scope,invalid),[predicate-use_foreign_module/3])

```
3 changes: 3 additions & 0 deletions tests/scryer/cli/src_tests/ffi_options_unknown.in/input.pl
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
:- use_module(library(ffi)).

test :- use_foreign_module("nonexistent.so", [], [unknown(value)]).
5 changes: 5 additions & 0 deletions tests/scryer/cli/src_tests/ffi_options_unknown.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
```trycmd
$ scryer-prolog -f --no-add-history input.pl -g test -g halt
test causes: error(domain_error(ffi_option,unknown),[predicate-use_foreign_module/3])

```