Skip to content
Merged
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 Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
name = "benchset"
# Generated from [project].version in pyproject.toml by scripts/version.py.
version = "0.0.7"
version = "0.0.8"
edition = "2021"
rust-version = "1.74"
build = "native/build.rs"
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -442,7 +442,7 @@ pinned release toolchain described under [Publishing](#publishing).

```toml
[dependencies]
benchset = "0.0.7"
benchset = "0.0.8"
# For changes from a local source checkout, use:
# benchset = { path = "../benchset" }
```
Expand Down Expand Up @@ -608,7 +608,7 @@ the same repository. Rust is not required by the consumer.
For a Swift manifest, add:

```swift
.package(url: "https://github.com/mutating/benchset.git", .exact("0.0.7"))
.package(url: "https://github.com/mutating/benchset.git", .exact("0.0.8"))
```

Add `.product(name: "Benchset", package: "benchset")` to your target's
Expand Down
65 changes: 60 additions & 5 deletions benchset/_native.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@

import _ctypes
import ctypes
import signal
import sys
from _ctypes import CFuncPtr
from functools import lru_cache
from os.path import abspath
from pathlib import Path
from threading import current_thread, main_thread
from types import FrameType
from typing import TYPE_CHECKING, Any, Callable, List, NoReturn, Optional, Tuple, cast

from benchset._metadata import DATA, canonical_name
Expand Down Expand Up @@ -37,7 +40,7 @@ def _load_library(self, name: str, mode: int, handle: int, winmode: Optional[int


@lru_cache(maxsize=1)
def _load_library(path: str) -> _CDLL:
def _open_library(path: str) -> _CDLL:
"""Open before constructing ctypes types, rolling back handles that cannot be adopted."""
if _AsDouble is None:
return cast(_CDLL, cast(Any, ctypes.cdll)._dlltype(path))
Expand All @@ -53,13 +56,65 @@ def _load_library(path: str) -> _CDLL:
factory = cast(Any, ctypes.cdll)._dlltype
return cast(_CDLL, (_Library if factory is _CDLL else factory)(path, handle=handle))
except BaseException:
if sys.platform == 'win32':
_ctypes.FreeLibrary(handle)
else:
_ctypes.dlclose(handle)
_close_library(handle)
raise


def _close_library(handle: int) -> None:
if sys.platform == 'win32':
_ctypes.FreeLibrary(handle)
else:
_ctypes.dlclose(handle)


def _shared_library(path: str) -> _CDLL:
"""Discard surplus opens from concurrent/reentrant misses for the fixed native path."""
library = _open_library(path)
shared = _open_library(path)
if library is not shared and _AsDouble is not None:
_close_library(library._handle)
return shared


def _load_library(path: str) -> _CDLL:
"""Defer Python SIGINT delivery until the opened image has a cached owner.

A try/finally cannot protect a C call's return before Python assigns its
handle, or a return before lru_cache publishes its value. Keep the original
policy and originating frames, delivering signals after either publication
or rollback. Python dispatches signal handlers only on the main thread.
"""
handler = signal.getsignal(signal.SIGINT)
if current_thread() is not main_thread() or not callable(handler):
return _shared_library(path)
pending: List[Tuple[int, Optional[FrameType]]] = []

def defer(signum: int, frame: Optional[FrameType]) -> None:
pending.append((signum, frame))

try:
signal.signal(signal.SIGINT, defer)
except ValueError: # Subinterpreters cannot install or receive Python signal handlers.
return _shared_library(path)
try:
return _shared_library(path)
finally:
try:
signal.signal(signal.SIGINT, handler)
while pending:
arguments = pending.pop(0)
try:
callback = signal.getsignal(signal.SIGINT)
if callable(callback):
callback(*arguments)
elif callback != signal.SIG_IGN:
signal.raise_signal(signal.SIGINT)
finally:
del arguments
finally:
pending.clear() # Frames can refer back to this queue, even with cyclic GC disabled.


class _Functions:
"""Own independent address-bound functions without per-call ctypes cycles."""

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ backend-path = ["."]

[project]
name = "benchset"
version = "0.0.7"
version = "0.0.8"
authors = [
{ name="Evgeniy Blinov", email="zheni-b@yandex.ru" },
]
Expand Down
10 changes: 9 additions & 1 deletion tests/test_cli_loading_signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ def test_cli_sigint_during_native_loading_cancels_reports_and_releases_before_re
makes a worker-side loader wait for observed cancellation without sleeps.
Two calls on the same runner check restoration, joined workers and exact
native handle release independently of Thread's own completion bookkeeping.
Acknowledge cancellation only after the runner's flag is committed, so a
free-threaded loader cannot resume between notification and cancellation.
"""
runner = BenchmarkRunner()
cancelled = Event()
Expand Down Expand Up @@ -80,9 +82,15 @@ def free(native: Native, handle: int, *arguments: Any) -> None:
released.append(identity)
original_free(native, handle, *arguments)

def acknowledge() -> None:
if not cancelled.is_set():
committed = runner._active is not None and runner._active.cancelled
cancelled.set() # Always release the worker, including a broken fixture's assertion path.
assert committed, 'the loader was acknowledged before cancellation was committed'

def cancel() -> None:
cancelled.set()
original_cancel()
acknowledge()

class Worker(Thread):
def __init__(self, *arguments: Any, **options: Any) -> None:
Expand Down
12 changes: 6 additions & 6 deletions tests/test_loader_failures.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@


def _clear_standard_loader() -> None:
loader = getattr(_native, '_load_library', None)
loader = getattr(_native, '_open_library', None)
if loader is not None:
loader.cache_clear()

Expand Down Expand Up @@ -90,7 +90,7 @@ def test_standard_loader_preflights_and_adopts_the_open_handle(monkeypatch: pyte
secure Windows absolute-path defaults. This is a new private bridge check;
HEAD had no cached loader helper.
"""
loader = getattr(_native, '_load_library', None)
loader = getattr(_native, '_open_library', None)
if loader is None:
pytest.skip('HEAD has no standard loader helper')
loader.cache_clear()
Expand Down Expand Up @@ -176,7 +176,7 @@ def initialize(library: Any, name: str, *_args: Any, **kwargs: Any) -> None:
requested = 'subdir/../adoption-probe.dll'
expected_path = os.path.abspath(requested) if platform == 'win32' else requested # noqa: PTH100 — Match CDLL.
expected_mode = 0x1100 if platform == 'win32' else ctypes.DEFAULT_MODE
library = _native._load_library.__wrapped__(requested)
library = _native._open_library.__wrapped__(requested)
assert calls == [('open', expected_path, expected_mode, 701), ('wrapper', expected_path, 701)]
assert library._handle == 701
assert library._name == expected_path
Expand Down Expand Up @@ -224,7 +224,7 @@ def test_standard_loader_keeps_platform_modes_and_normalizes_windows_path(
ctypes' legacy alias reproduces modern Windows: the raw platform opener
must still work without changing ownership, paths or search flags.
"""
loader = getattr(_native, '_load_library', None)
loader = getattr(_native, '_open_library', None)
if loader is None:
pytest.skip('HEAD has no standard loader helper')
calls: List[Tuple[str, str, object]] = []
Expand Down Expand Up @@ -268,7 +268,7 @@ def test_failed_wrapper_adoption_closes_only_its_open_handle_and_retries( # noq
own handle, preserve the original error and remain uncached. Recovery then
adopts and caches its handle without closing the successful image.
"""
loader = getattr(_native, '_load_library', None)
loader = getattr(_native, '_open_library', None)
if loader is None:
pytest.skip('HEAD has no pre-opened wrapper adoption helper')
loader.cache_clear()
Expand Down Expand Up @@ -341,7 +341,7 @@ def test_failed_raw_open_never_constructs_or_closes_a_wrapper(
_dlopen returned a real handle, while a recovered successful image remains
open. Both close entry points are intercepted on every host.
"""
loader = getattr(_native, '_load_library', None)
loader = getattr(_native, '_open_library', None)
if loader is None:
pytest.skip('HEAD has no pre-opened wrapper adoption helper')
loader.cache_clear()
Expand Down
Loading
Loading