Skip to content

Commit a6caf65

Browse files
committed
Improve tests
1 parent afb72d0 commit a6caf65

File tree

6 files changed

+344
-54
lines changed

6 files changed

+344
-54
lines changed

.github/workflows/CI.yml

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ jobs:
5959
- uses: actions/setup-node@v4
6060
if: ${{ !matrix.settings.docker }}
6161
with:
62-
node-version: 20
62+
node-version: 24
6363
- name: Install
6464
uses: dtolnay/rust-toolchain@stable
6565
if: ${{ !matrix.settings.docker }}
@@ -161,18 +161,31 @@ jobs:
161161
if-no-files-found: error
162162

163163
test-macOS-windows-binding:
164-
name: Test bindings on ${{ matrix.settings.target }} - node@${{ matrix.node }}
164+
name: Test ${{ matrix.settings.target }} - node@${{ matrix.node }} + python@${{ matrix.python }}
165165
needs:
166166
- build
167167
strategy:
168168
fail-fast: false
169169
matrix:
170170
settings:
171-
- host: macos-latest
171+
- host: macos-13
172172
target: x86_64-apple-darwin
173+
architecture: x64
174+
- host: macos-latest
175+
target: aarch64-apple-darwin
176+
architecture: arm64
173177
node:
174178
- '18'
175179
- '20'
180+
- '22'
181+
- '24'
182+
python:
183+
- '3.8'
184+
- '3.9'
185+
- '3.10'
186+
- '3.11'
187+
- '3.12'
188+
- '3.13'
176189
runs-on: ${{ matrix.settings.host }}
177190
steps:
178191
- uses: actions/checkout@v4
@@ -187,8 +200,12 @@ jobs:
187200
- uses: actions/setup-node@v4
188201
with:
189202
node-version: ${{ matrix.node }}
203+
architecture: ${{ matrix.settings.architecture }}
190204
cache: pnpm
191-
architecture: x64
205+
- uses: actions/setup-python@v6
206+
with:
207+
python-version: ${{ matrix.python }}
208+
architecture: ${{ matrix.settings.architecture }}
192209
- run: pnpm install
193210
- uses: actions/download-artifact@v4
194211
with:
@@ -209,7 +226,7 @@ jobs:
209226
- run: pnpm test
210227

211228
test-linux-binding:
212-
name: Test bindings on ${{ matrix.settings.target }} - node@${{ matrix.node }}
229+
name: Test ${{ matrix.settings.target }} - node@${{ matrix.node }} + python@${{ matrix.python }}
213230
needs:
214231
- build
215232
strategy:
@@ -218,6 +235,7 @@ jobs:
218235
settings:
219236
- host: ubuntu-22.04
220237
target: x86_64-unknown-linux-gnu
238+
architecture: x64
221239
# Not supported yet.
222240
# - host: ubuntu-22.04
223241
# target: x86_64-unknown-linux-musl
@@ -229,6 +247,15 @@ jobs:
229247
node:
230248
- '18'
231249
- '20'
250+
- '22'
251+
- '24'
252+
python:
253+
- '3.8'
254+
- '3.9'
255+
- '3.10'
256+
- '3.11'
257+
- '3.12'
258+
- '3.13'
232259
runs-on: ${{ matrix.settings.host }}
233260
steps:
234261
- uses: actions/checkout@v4
@@ -239,7 +266,12 @@ jobs:
239266
uses: actions/setup-node@v4
240267
with:
241268
node-version: ${{ matrix.node }}
269+
architecture: ${{ matrix.settings.architecture }}
242270
cache: pnpm
271+
- uses: actions/setup-python@v6
272+
with:
273+
python-version: ${{ matrix.python }}
274+
architecture: ${{ matrix.settings.architecture }}
243275
- name: Install dependencies
244276
run: pnpm install
245277
- name: Fix soname

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ pyo3 = { version = "0.25.1", features = ["experimental-async"] }
3535
pyo3-async-runtimes = { version = "0.25.0", features = ["tokio-runtime"] }
3636
thiserror = "2.0.12"
3737
tokio = { version = "1.45.1", features = ["full"] }
38+
libc = "0.2"
3839

3940
[build-dependencies]
4041
napi-build = { version = "2", optional = true }

src/asgi/lifespan.rs

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,4 +160,168 @@ mod tests {
160160
assert_eq!(message, LifespanSendMessage::LifespanStartupComplete);
161161
});
162162
}
163+
164+
#[test]
165+
fn test_lifespan_send_message_from_pyobject_error_cases() {
166+
Python::with_gil(|py| {
167+
// Test missing 'type' key
168+
let dict = PyDict::new(py);
169+
let result: Result<LifespanSendMessage, _> = dict.extract();
170+
assert!(result.is_err());
171+
assert!(
172+
result
173+
.unwrap_err()
174+
.to_string()
175+
.contains("Missing 'type' key")
176+
);
177+
178+
// Test unknown message type
179+
let dict = PyDict::new(py);
180+
dict.set_item("type", "unknown.message.type").unwrap();
181+
let result: Result<LifespanSendMessage, _> = dict.extract();
182+
assert!(result.is_err());
183+
assert!(
184+
result
185+
.unwrap_err()
186+
.to_string()
187+
.contains("Unknown Lifespan send message type")
188+
);
189+
190+
// Test non-dict object
191+
let list = py.eval(c"[]", None, None).unwrap();
192+
let result: Result<LifespanSendMessage, _> = list.extract();
193+
assert!(result.is_err());
194+
195+
// Test invalid type value (not string)
196+
let dict = PyDict::new(py);
197+
dict.set_item("type", 123).unwrap();
198+
let result: Result<LifespanSendMessage, _> = dict.extract();
199+
assert!(result.is_err());
200+
});
201+
}
202+
203+
#[test]
204+
fn test_lifespan_send_message_traits() {
205+
// Test Debug trait
206+
let msg1 = LifespanSendMessage::LifespanStartupComplete;
207+
let msg2 = LifespanSendMessage::LifespanShutdownComplete;
208+
209+
let debug1 = format!("{:?}", msg1);
210+
let debug2 = format!("{:?}", msg2);
211+
assert!(debug1.contains("LifespanStartupComplete"));
212+
assert!(debug2.contains("LifespanShutdownComplete"));
213+
214+
// Test Clone
215+
let cloned1 = msg1.clone();
216+
let cloned2 = msg2.clone();
217+
218+
// Test PartialEq and Eq
219+
assert_eq!(msg1, cloned1);
220+
assert_eq!(msg2, cloned2);
221+
assert_ne!(msg1, msg2);
222+
223+
// Test Hash
224+
use std::collections::HashSet;
225+
let mut set = HashSet::new();
226+
set.insert(msg1);
227+
set.insert(cloned1); // Should not increase size due to equality
228+
set.insert(msg2);
229+
assert_eq!(set.len(), 2); // Only unique messages
230+
}
231+
232+
#[test]
233+
fn test_lifespan_receive_message_traits() {
234+
// Test all the derive traits for LifespanReceiveMessage
235+
let msg1 = LifespanReceiveMessage::LifespanStartup;
236+
let msg2 = LifespanReceiveMessage::LifespanShutdown;
237+
238+
// Test Debug
239+
let debug1 = format!("{:?}", msg1);
240+
let debug2 = format!("{:?}", msg2);
241+
assert!(debug1.contains("LifespanStartup"));
242+
assert!(debug2.contains("LifespanShutdown"));
243+
244+
// Test Clone and Copy
245+
let cloned1 = msg1.clone();
246+
let copied1 = msg1;
247+
248+
// Test PartialEq and Eq
249+
assert_eq!(msg1, cloned1);
250+
assert_eq!(msg1, copied1);
251+
assert_ne!(msg1, msg2);
252+
253+
// Test Hash
254+
use std::collections::HashSet;
255+
let mut set = HashSet::new();
256+
set.insert(msg1);
257+
set.insert(copied1); // Should not increase size due to equality
258+
set.insert(msg2);
259+
assert_eq!(set.len(), 2); // Only unique messages
260+
}
261+
262+
#[test]
263+
fn test_lifespan_scope_with_populated_state() {
264+
Python::with_gil(|py| {
265+
// Create a state dictionary with some data
266+
let state_dict = PyDict::new(py);
267+
state_dict.set_item("initialized", true).unwrap();
268+
state_dict.set_item("counter", 42).unwrap();
269+
270+
let lifespan_scope = LifespanScope {
271+
state: Some(state_dict.unbind()),
272+
};
273+
274+
let py_obj = lifespan_scope.into_pyobject(py).unwrap();
275+
276+
// Verify the scope structure
277+
assert_eq!(
278+
dict_extract!(py_obj, "type", String),
279+
"lifespan".to_string()
280+
);
281+
282+
// Verify ASGI info is present
283+
let asgi_info = dict_get!(py_obj, "asgi");
284+
let asgi_dict = asgi_info.downcast::<PyDict>().unwrap();
285+
assert_eq!(
286+
asgi_dict
287+
.get_item("version")
288+
.unwrap()
289+
.unwrap()
290+
.extract::<String>()
291+
.unwrap(),
292+
"3.0"
293+
);
294+
assert_eq!(
295+
asgi_dict
296+
.get_item("spec_version")
297+
.unwrap()
298+
.unwrap()
299+
.extract::<String>()
300+
.unwrap(),
301+
"2.0"
302+
);
303+
304+
// Verify state is preserved
305+
let state_obj = dict_get!(py_obj, "state");
306+
let state_dict = state_obj.downcast::<PyDict>().unwrap();
307+
assert_eq!(
308+
state_dict
309+
.get_item("initialized")
310+
.unwrap()
311+
.unwrap()
312+
.extract::<bool>()
313+
.unwrap(),
314+
true
315+
);
316+
assert_eq!(
317+
state_dict
318+
.get_item("counter")
319+
.unwrap()
320+
.unwrap()
321+
.extract::<i32>()
322+
.unwrap(),
323+
42
324+
);
325+
});
326+
}
163327
}

src/asgi/mod.rs

Lines changed: 26 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,7 @@ use std::{
77
};
88

99
#[cfg(target_os = "linux")]
10-
use std::os::raw::c_void;
11-
12-
#[cfg(target_os = "linux")]
13-
unsafe extern "C" {
14-
fn dlopen(filename: *const i8, flag: i32) -> *mut c_void;
15-
}
10+
use std::{ffi::CStr, mem};
1611

1712
use bytes::BytesMut;
1813
use http_handler::{Handler, Request, RequestExt, Response, extensions::DocumentRoot};
@@ -288,54 +283,38 @@ impl Handler for Asgi {
288283
}
289284
}
290285

291-
/// Load Python library with RTLD_GLOBAL on Linux to make symbols available
286+
/// Load Python library with RTLD_GLOBAL on Linux to expose interpreter symbols
292287
#[cfg(target_os = "linux")]
293288
fn ensure_python_symbols_global() {
294-
unsafe {
295-
// Try to find the system Python library dynamically
296-
use std::process::Command;
297-
298-
// First try to find the Python library using find command
299-
if let Ok(output) = Command::new("find")
300-
.args(&[
301-
"/usr/lib",
302-
"/usr/lib64",
303-
"/usr/local/lib",
304-
"-name",
305-
"libpython3*.so.*",
306-
"-type",
307-
"f",
308-
])
309-
.output()
289+
// Only perform the promotion once per process
290+
static GLOBALIZE_ONCE: OnceLock<()> = OnceLock::new();
291+
292+
GLOBALIZE_ONCE.get_or_init(|| unsafe {
293+
let mut info: libc::Dl_info = mem::zeroed();
294+
if libc::dladdr(pyo3::ffi::Py_Initialize as *const _, &mut info) == 0
295+
|| info.dli_fname.is_null()
310296
{
311-
let output_str = String::from_utf8_lossy(&output.stdout);
312-
for lib_path in output_str.lines() {
313-
if let Ok(lib_cstring) = CString::new(lib_path) {
314-
let handle = dlopen(lib_cstring.as_ptr(), RTLD_NOW | RTLD_GLOBAL);
315-
if !handle.is_null() {
316-
// Successfully loaded Python library with RTLD_GLOBAL
317-
return;
318-
}
319-
}
320-
}
297+
eprintln!("unable to locate libpython for RTLD_GLOBAL promotion");
298+
return;
321299
}
322300

323-
const RTLD_GLOBAL: i32 = 0x100;
324-
const RTLD_NOW: i32 = 0x2;
325-
326-
// Fallback to trying common library names if find command fails
327-
// Try a range of Python versions (3.9 to 3.100 should cover future versions)
328-
for minor in 9..=100 {
329-
let lib_name = format!("libpython3.{}.so.1.0\0", minor);
330-
let handle = dlopen(lib_name.as_ptr() as *const i8, RTLD_NOW | RTLD_GLOBAL);
331-
if !handle.is_null() {
332-
// Successfully loaded Python library with RTLD_GLOBAL
333-
return;
301+
let path_cstr = CStr::from_ptr(info.dli_fname);
302+
let path_str = path_cstr.to_string_lossy();
303+
304+
// Clear any prior dlerror state before attempting to reopen
305+
libc::dlerror();
306+
307+
let handle = libc::dlopen(info.dli_fname, libc::RTLD_NOW | libc::RTLD_GLOBAL);
308+
if handle.is_null() {
309+
let error = libc::dlerror();
310+
if !error.is_null() {
311+
let msg = CStr::from_ptr(error).to_string_lossy();
312+
eprintln!("dlopen({path_str}) failed with RTLD_GLOBAL: {msg}",);
313+
} else {
314+
eprintln!("dlopen({path_str}) returned null without dlerror",);
334315
}
335316
}
336-
337-
eprintln!("Failed to locate system Python library");
338-
}
317+
});
339318
}
340319

341320
/// Find all Python site-packages directories in a virtual environment

0 commit comments

Comments
 (0)