diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 4bba881..cc18f75 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -37,13 +37,22 @@ jobs: - host: macos-latest target: x86_64-apple-darwin build: pnpm run build --target x86_64-apple-darwin + - host: macos-latest + target: aarch64-apple-darwin + build: pnpm run build --target aarch64-apple-darwin - host: ubuntu-latest target: x86_64-unknown-linux-gnu docker: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian build: pnpm run build --target x86_64-unknown-linux-gnu - - host: macos-latest - target: aarch64-apple-darwin - build: pnpm run build --target aarch64-apple-darwin + # Need to deal with this build failure before we can enable arm64 Linux builds. + # Probably also should be public first so we can use the arm runners to test it anyway. + # error: PYO3_CROSS_PYTHON_VERSION or an abi3-py3* feature must be specified when cross-compiling and PYO3_CROSS_LIB_DIR is not set. + # help: see the PyO3 user guide for more information: https://pyo3.rs/v0.25.1/building-and-distribution.html#cross-compiling + # + # - host: ubuntu-latest + # target: aarch64-unknown-linux-gnu + # docker: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-aarch64 + # build: pnpm run build --target aarch64-unknown-linux-gnu name: stable - ${{ matrix.settings.target }} - node@20 runs-on: ${{ matrix.settings.host }} steps: @@ -59,15 +68,13 @@ jobs: - uses: actions/setup-node@v4 if: ${{ !matrix.settings.docker }} with: - node-version: 20 - - name: Install - uses: dtolnay/rust-toolchain@stable + node-version: 24 + - uses: dtolnay/rust-toolchain@stable if: ${{ !matrix.settings.docker }} with: toolchain: stable targets: ${{ matrix.settings.target }} - - name: Cache cargo - uses: actions/cache@v4 + - uses: actions/cache@v4 with: path: | ~/.cargo/registry/index/ @@ -161,18 +168,32 @@ jobs: if-no-files-found: error test-macOS-windows-binding: - name: Test bindings on ${{ matrix.settings.target }} - node@${{ matrix.node }} + name: Test ${{ matrix.settings.target }} - node@${{ matrix.node }} + python@${{ matrix.python }} needs: - build strategy: fail-fast: false matrix: settings: - - host: macos-latest + - host: macos-13 target: x86_64-apple-darwin + architecture: x64 + - host: macos-latest + target: aarch64-apple-darwin + architecture: arm64 node: - '18' - '20' + - '22' + - '24' + python: + - '3.8' + - '3.9' + - '3.10' + - '3.11' + - '3.12' + - '3.13' + # - '3.14-rc' runs-on: ${{ matrix.settings.host }} steps: - uses: actions/checkout@v4 @@ -187,8 +208,12 @@ jobs: - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} + architecture: ${{ matrix.settings.architecture }} cache: pnpm - architecture: x64 + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python }} + architecture: ${{ matrix.settings.architecture }} - run: pnpm install - uses: actions/download-artifact@v4 with: @@ -209,7 +234,7 @@ jobs: - run: pnpm test test-linux-binding: - name: Test bindings on ${{ matrix.settings.target }} - node@${{ matrix.node }} + name: Test ${{ matrix.settings.target }} - node@${{ matrix.node }} + python@${{ matrix.python }} needs: - build strategy: @@ -218,6 +243,7 @@ jobs: settings: - host: ubuntu-22.04 target: x86_64-unknown-linux-gnu + architecture: x64 # Not supported yet. # - host: ubuntu-22.04 # target: x86_64-unknown-linux-musl @@ -229,6 +255,16 @@ jobs: node: - '18' - '20' + - '22' + - '24' + python: + - '3.8' + - '3.9' + - '3.10' + - '3.11' + - '3.12' + - '3.13' + # - '3.14-rc' runs-on: ${{ matrix.settings.host }} steps: - uses: actions/checkout@v4 @@ -239,7 +275,12 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} + architecture: ${{ matrix.settings.architecture }} cache: pnpm + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python }} + architecture: ${{ matrix.settings.architecture }} - name: Install dependencies run: pnpm install - name: Fix soname diff --git a/Cargo.lock b/Cargo.lock index c8c82a7..dfa9263 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -762,6 +762,7 @@ dependencies = [ "bytes", "http-handler", "http-rewriter", + "libc", "napi", "napi-build", "napi-derive", diff --git a/Cargo.toml b/Cargo.toml index 66910a1..ea607d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ pyo3 = { version = "0.25.1", features = ["experimental-async"] } pyo3-async-runtimes = { version = "0.25.0", features = ["tokio-runtime"] } thiserror = "2.0.12" tokio = { version = "1.45.1", features = ["full"] } +libc = "0.2" [build-dependencies] napi-build = { version = "2", optional = true } diff --git a/npm/linux-arm64-gnu/README.md b/npm/linux-arm64-gnu/README.md new file mode 100644 index 0000000..11637e7 --- /dev/null +++ b/npm/linux-arm64-gnu/README.md @@ -0,0 +1,3 @@ +# `python-node-linux-arm64-gnu` + +This is the **aarch64-unknown-linux-gnu** binary for `python-node` diff --git a/npm/linux-arm64-gnu/package.json b/npm/linux-arm64-gnu/package.json new file mode 100644 index 0000000..26a0b47 --- /dev/null +++ b/npm/linux-arm64-gnu/package.json @@ -0,0 +1,26 @@ +{ + "name": "python-node-linux-arm64-gnu", + "version": "0.0.0", + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ], + "main": "python-node.linux-arm64-gnu.node", + "files": [ + "python-node.linux-arm64-gnu.node", + "fix-python-soname.js", + "fix-python-soname.wasm" + ], + "scripts": { + "postinstall": "node fix-python-soname.js" + }, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "libc": [ + "glibc" + ] +} diff --git a/src/asgi/lifespan.rs b/src/asgi/lifespan.rs index 554f267..cccb3ec 100644 --- a/src/asgi/lifespan.rs +++ b/src/asgi/lifespan.rs @@ -160,4 +160,168 @@ mod tests { assert_eq!(message, LifespanSendMessage::LifespanStartupComplete); }); } + + #[test] + fn test_lifespan_send_message_from_pyobject_error_cases() { + Python::with_gil(|py| { + // Test missing 'type' key + let dict = PyDict::new(py); + let result: Result = dict.extract(); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("Missing 'type' key") + ); + + // Test unknown message type + let dict = PyDict::new(py); + dict.set_item("type", "unknown.message.type").unwrap(); + let result: Result = dict.extract(); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("Unknown Lifespan send message type") + ); + + // Test non-dict object + let list = py.eval(c"[]", None, None).unwrap(); + let result: Result = list.extract(); + assert!(result.is_err()); + + // Test invalid type value (not string) + let dict = PyDict::new(py); + dict.set_item("type", 123).unwrap(); + let result: Result = dict.extract(); + assert!(result.is_err()); + }); + } + + #[test] + fn test_lifespan_send_message_traits() { + // Test Debug trait + let msg1 = LifespanSendMessage::LifespanStartupComplete; + let msg2 = LifespanSendMessage::LifespanShutdownComplete; + + let debug1 = format!("{:?}", msg1); + let debug2 = format!("{:?}", msg2); + assert!(debug1.contains("LifespanStartupComplete")); + assert!(debug2.contains("LifespanShutdownComplete")); + + // Test Clone + let cloned1 = msg1.clone(); + let cloned2 = msg2.clone(); + + // Test PartialEq and Eq + assert_eq!(msg1, cloned1); + assert_eq!(msg2, cloned2); + assert_ne!(msg1, msg2); + + // Test Hash + use std::collections::HashSet; + let mut set = HashSet::new(); + set.insert(msg1); + set.insert(cloned1); // Should not increase size due to equality + set.insert(msg2); + assert_eq!(set.len(), 2); // Only unique messages + } + + #[test] + fn test_lifespan_receive_message_traits() { + // Test all the derive traits for LifespanReceiveMessage + let msg1 = LifespanReceiveMessage::LifespanStartup; + let msg2 = LifespanReceiveMessage::LifespanShutdown; + + // Test Debug + let debug1 = format!("{:?}", msg1); + let debug2 = format!("{:?}", msg2); + assert!(debug1.contains("LifespanStartup")); + assert!(debug2.contains("LifespanShutdown")); + + // Test Clone and Copy + let cloned1 = msg1.clone(); + let copied1 = msg1; + + // Test PartialEq and Eq + assert_eq!(msg1, cloned1); + assert_eq!(msg1, copied1); + assert_ne!(msg1, msg2); + + // Test Hash + use std::collections::HashSet; + let mut set = HashSet::new(); + set.insert(msg1); + set.insert(copied1); // Should not increase size due to equality + set.insert(msg2); + assert_eq!(set.len(), 2); // Only unique messages + } + + #[test] + fn test_lifespan_scope_with_populated_state() { + Python::with_gil(|py| { + // Create a state dictionary with some data + let state_dict = PyDict::new(py); + state_dict.set_item("initialized", true).unwrap(); + state_dict.set_item("counter", 42).unwrap(); + + let lifespan_scope = LifespanScope { + state: Some(state_dict.unbind()), + }; + + let py_obj = lifespan_scope.into_pyobject(py).unwrap(); + + // Verify the scope structure + assert_eq!( + dict_extract!(py_obj, "type", String), + "lifespan".to_string() + ); + + // Verify ASGI info is present + let asgi_info = dict_get!(py_obj, "asgi"); + let asgi_dict = asgi_info.downcast::().unwrap(); + assert_eq!( + asgi_dict + .get_item("version") + .unwrap() + .unwrap() + .extract::() + .unwrap(), + "3.0" + ); + assert_eq!( + asgi_dict + .get_item("spec_version") + .unwrap() + .unwrap() + .extract::() + .unwrap(), + "2.0" + ); + + // Verify state is preserved + let state_obj = dict_get!(py_obj, "state"); + let state_dict = state_obj.downcast::().unwrap(); + assert_eq!( + state_dict + .get_item("initialized") + .unwrap() + .unwrap() + .extract::() + .unwrap(), + true + ); + assert_eq!( + state_dict + .get_item("counter") + .unwrap() + .unwrap() + .extract::() + .unwrap(), + 42 + ); + }); + } } diff --git a/src/asgi/mod.rs b/src/asgi/mod.rs index 59d83ae..7b0e3f0 100644 --- a/src/asgi/mod.rs +++ b/src/asgi/mod.rs @@ -7,12 +7,7 @@ use std::{ }; #[cfg(target_os = "linux")] -use std::os::raw::c_void; - -#[cfg(target_os = "linux")] -unsafe extern "C" { - fn dlopen(filename: *const i8, flag: i32) -> *mut c_void; -} +use std::{ffi::CStr, mem}; use bytes::BytesMut; use http_handler::{Handler, Request, RequestExt, Response, extensions::DocumentRoot}; @@ -288,54 +283,38 @@ impl Handler for Asgi { } } -/// Load Python library with RTLD_GLOBAL on Linux to make symbols available +/// Load Python library with RTLD_GLOBAL on Linux to expose interpreter symbols #[cfg(target_os = "linux")] fn ensure_python_symbols_global() { - unsafe { - // Try to find the system Python library dynamically - use std::process::Command; - - // First try to find the Python library using find command - if let Ok(output) = Command::new("find") - .args(&[ - "/usr/lib", - "/usr/lib64", - "/usr/local/lib", - "-name", - "libpython3*.so.*", - "-type", - "f", - ]) - .output() + // Only perform the promotion once per process + static GLOBALIZE_ONCE: OnceLock<()> = OnceLock::new(); + + GLOBALIZE_ONCE.get_or_init(|| unsafe { + let mut info: libc::Dl_info = mem::zeroed(); + if libc::dladdr(pyo3::ffi::Py_Initialize as *const _, &mut info) == 0 + || info.dli_fname.is_null() { - let output_str = String::from_utf8_lossy(&output.stdout); - for lib_path in output_str.lines() { - if let Ok(lib_cstring) = CString::new(lib_path) { - let handle = dlopen(lib_cstring.as_ptr(), RTLD_NOW | RTLD_GLOBAL); - if !handle.is_null() { - // Successfully loaded Python library with RTLD_GLOBAL - return; - } - } - } + eprintln!("unable to locate libpython for RTLD_GLOBAL promotion"); + return; } - const RTLD_GLOBAL: i32 = 0x100; - const RTLD_NOW: i32 = 0x2; - - // Fallback to trying common library names if find command fails - // Try a range of Python versions (3.9 to 3.100 should cover future versions) - for minor in 9..=100 { - let lib_name = format!("libpython3.{}.so.1.0\0", minor); - let handle = dlopen(lib_name.as_ptr() as *const i8, RTLD_NOW | RTLD_GLOBAL); - if !handle.is_null() { - // Successfully loaded Python library with RTLD_GLOBAL - return; + let path_cstr = CStr::from_ptr(info.dli_fname); + let path_str = path_cstr.to_string_lossy(); + + // Clear any prior dlerror state before attempting to reopen + libc::dlerror(); + + let handle = libc::dlopen(info.dli_fname, libc::RTLD_NOW | libc::RTLD_GLOBAL); + if handle.is_null() { + let error = libc::dlerror(); + if !error.is_null() { + let msg = CStr::from_ptr(error).to_string_lossy(); + eprintln!("dlopen({path_str}) failed with RTLD_GLOBAL: {msg}",); + } else { + eprintln!("dlopen({path_str}) returned null without dlerror",); } } - - eprintln!("Failed to locate system Python library"); - } + }); } /// Find all Python site-packages directories in a virtual environment diff --git a/src/lib.rs b/src/lib.rs index 8289a96..7e592a5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ #![warn(clippy::dbg_macro, clippy::print_stdout)] #![warn(missing_docs)] +use std::ffi::c_char; #[cfg(feature = "napi-support")] use std::sync::Arc; @@ -95,7 +96,7 @@ impl FromNapiValue for PythonHandlerTarget { check_status!(sys::napi_get_value_string_utf8( env, napi_val, - buffer.as_mut_ptr() as *mut i8, + buffer.as_mut_ptr() as *mut c_char, length + 1, &mut length )) @@ -142,7 +143,7 @@ impl ToNapiValue for PythonHandlerTarget { unsafe { check_status!(sys::napi_create_string_utf8( env, - full_str.as_ptr() as *const i8, + full_str.as_ptr() as *const c_char, full_str.len() as isize, &mut result )) @@ -365,3 +366,115 @@ impl Task for PythonRequestTask { Ok(Into::::into(output)) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_python_handler_target_try_from_str_valid() { + let target = PythonHandlerTarget::try_from("main:app").unwrap(); + assert_eq!(target.file, "main"); + assert_eq!(target.function, "app"); + + let target = PythonHandlerTarget::try_from("my_module:my_function").unwrap(); + assert_eq!(target.file, "my_module"); + assert_eq!(target.function, "my_function"); + } + + #[test] + fn test_python_handler_target_try_from_str_invalid() { + // No colon + let result = PythonHandlerTarget::try_from("invalid"); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + "Invalid format, expected \"file:function\"" + ); + + // Multiple colons + let result = PythonHandlerTarget::try_from("too:many:colons"); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + "Invalid format, expected \"file:function\"" + ); + + // Empty string + let result = PythonHandlerTarget::try_from(""); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + "Invalid format, expected \"file:function\"" + ); + + // Only colon - this actually succeeds with empty file and function + // The current implementation allows this: ":" -> file="", function="" + let result = PythonHandlerTarget::try_from(":"); + assert!(result.is_ok()); + let target = result.unwrap(); + assert_eq!(target.file, ""); + assert_eq!(target.function, ""); + + // Test with empty parts in different ways + let result = PythonHandlerTarget::try_from(":function"); + assert!(result.is_ok()); + let target = result.unwrap(); + assert_eq!(target.file, ""); + assert_eq!(target.function, "function"); + + let result = PythonHandlerTarget::try_from("file:"); + assert!(result.is_ok()); + let target = result.unwrap(); + assert_eq!(target.file, "file"); + assert_eq!(target.function, ""); + } + + #[test] + fn test_python_handler_target_from_string_conversion() { + let target = PythonHandlerTarget { + file: "test_module".to_string(), + function: "test_function".to_string(), + }; + let result: String = target.into(); + assert_eq!(result, "test_module:test_function"); + } + + #[test] + fn test_python_handler_target_default() { + let target = PythonHandlerTarget::default(); + assert_eq!(target.file, "main"); + assert_eq!(target.function, "app"); + } + + #[test] + fn test_python_handler_target_debug_clone_eq_hash() { + let target1 = PythonHandlerTarget { + file: "test".to_string(), + function: "app".to_string(), + }; + let target2 = target1.clone(); + + // Test Debug + let debug_str = format!("{:?}", target1); + assert!(debug_str.contains("test")); + assert!(debug_str.contains("app")); + + // Test Clone and PartialEq + assert_eq!(target1, target2); + + // Test inequality + let target3 = PythonHandlerTarget { + file: "different".to_string(), + function: "app".to_string(), + }; + assert_ne!(target1, target3); + + // Test Hash by putting in a HashSet + use std::collections::HashSet; + let mut set = HashSet::new(); + set.insert(target1); + set.insert(target2); // Should not increase size due to equality + assert_eq!(set.len(), 1); + } +}