Introduction
Pyroxide moves work out of your application’s foreground path without asking you to deploy Redis, a worker daemon, or another service. Decorate a Python function, submit it, and receive a handle that you can wait for or await.
That simple task API can cross four different execution boundaries:
| Mode | What it is good at |
|---|---|
@task | Blocking I/O, background orchestration, and lightweight local work |
@task(isolated=True) | CPU-bound Python and process crash containment |
@wasm_task | Portable guest modules with memory and execution-time limits |
@dylib_task | GIL-free calls into trusted C-ABI libraries |
Start with Python. Use process isolation when Python needs another interpreter, WASM for portable guest code, or a compatible C ABI library written in C, Rust, Zig, or another language for a native hot path. Pyroxide keeps all four choices in one task system, without requiring a separate Python extension wrapper for supported native signatures.
Why use it?
An application often needs more than one kind of concurrency. A web handler may offload blocking work to a thread, a calculation may need another Python interpreter, and an extension point may need WASM isolation or an existing native library. Using a different framework for every boundary adds deployment and lifecycle work of its own.
Pyroxide keeps these jobs inside one bounded engine:
Python application
|
v
bounded broker and task registry
|
+-- worker threads: Python, WASM, trusted native code
|
+-- coordinator threads: isolated worker processes
The queue applies backpressure instead of growing forever. Results have an explicit lifetime. The engine reports statistics and has a defined shutdown path. These details matter once a convenient decorator becomes production infrastructure.
Pick the boundary, not a slogan
@task is the smallest boundary. On regular CPython, pure-Python code still
uses the GIL; on free-threaded CPython it may run across cores. Isolation adds
serialization and process cost, but supplies another interpreter and contains a
worker crash.
WASM guests receive no host imports from Pyroxide and run with configured memory and epoch-time limits. Native libraries run without the GIL but have full access to their host process. An isolated native task gains crash containment, not an OS security sandbox.
Choosing an execution mode turns these trade-offs into a short decision guide.
When Pyroxide is not the right tool
Pyroxide is embedded, local, and non-durable. Accepted work belongs to the application process and is lost if that process exits.
Use a durable queue such as Celery or RQ when jobs must survive application failure, retry durably, run on schedules, or move between hosts. Use a cluster runtime such as Ray or Dask for distributed compute. If a standard thread or process pool already meets the requirement, it may be the simpler choice.
Pyroxide is strongest when one application owns the work but needs more execution choices than a single pool provides.
Start here
- Install Pyroxide.
- Submit your first task.
- Choose the execution mode that matches the workload.
- Read Production operations before a broad rollout.
1.0.0rc1 is the compatibility preview for 1.0. Test representative payloads,
capacity limits, shutdown, and failure cases in a canary first.
Installation
Pyroxide is published on PyPI as pyro3 and imported as pyroxide. It requires
CPython 3.10 or newer.
python -m pip install pyro3
Then confirm the installed release:
python -c "import pyroxide; print(pyroxide.__version__)"
Wheels
Release wheels target common Linux, macOS, and Windows platforms. Pip uses a compatible wheel when one is available, so installing Pyroxide does not normally require a Rust toolchain.
Free-threaded CPython uses dedicated wheels. Regular CPython wheels use the stable ABI where the platform supports it.
Build from source
A source build requires Rust 1.86 or newer and maturin:
git clone https://github.com/emivvvvv/pyroxide.git
cd pyroxide
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -e '.[dev]'
maturin develop
pytest -q
On Windows, activate the environment with:
.venv\Scripts\activate
See CONTRIBUTING.md for the complete development workflow.
Optional compilers
Ordinary Python tasks, precompiled native libraries, and precompiled .wasm
files do not need local C, Zig, Rust-to-WASM, or Emscripten compilers.
Install those toolchains only if the application intentionally compiles trusted native or WASM source at runtime. The native plugin and WASM chapters list the supported compilation paths and their production risks.
Next: submit your first task.
Getting started
A Pyroxide task is a Python callable that returns a TaskHandle when you submit
it. The handle lets you inspect, wait for, await, cancel, or release that piece
of work.
Decorate and submit
from pyroxide import task
@task
def square(value: int) -> int:
return value * value
handle = square(12)
print(handle.status) # Pending, Running, or Completed
print(handle.result()) # 144
The decorated callable accepts one payload argument. Calling square(12) does
not run the function inline; it submits the payload and returns immediately.
result() waits for completion. By default it also consumes the task record, so
the handle should not be queried again.
Wait without consuming
Use wait() when you need the terminal status before reading the result:
handle = square(12)
status = handle.wait(timeout_sec=2)
result = handle.result()
wait() returns Completed or Failed. It raises TimeoutError if the
deadline expires and RuntimeError if the task was cancelled.
Use consume=False when more than one part of your code must inspect a finished
handle:
handle = square(12)
result = handle.result(consume=False)
print(handle.status)
handle.close()
close() releases a terminal record. If work is still running, it marks the
record for automatic release after completion. A context manager does the same
cleanup:
with square(12) as handle:
result = handle.result()
Await inside an event loop
Do not call blocking result() on an event-loop thread. Await the asynchronous
form:
result = await square(12).result_async(timeout_sec=2)
The result and exception semantics match result(). A handle supports only one
active asynchronous waiter. See Concurrency and asyncio
for a complete example.
Understand status
| Status | Meaning |
|---|---|
Pending | Accepted but not started |
Running | A worker started it |
Completed | A result is available |
Failed | Execution raised or trapped |
Cancelled | Work was cancelled before completion |
Cancellation depends on the execution boundary. Pending work can be cancelled; running in-process work cannot be safely interrupted. Read Task cancellation before relying on it for control flow.
Do more
- Choose threads, processes, WASM, or native execution.
- Submit related payloads as a batch.
- Run CPU-bound Python in an isolated process.
- Prepare the engine for production.
Shut Pyroxide down during application teardown:
import pyroxide
pyroxide.shutdown(wait=True, cancel_pending=False)
Shutdown is idempotent and irreversible in the current process. The default waits for accepted work to finish.
Choosing an execution mode
@task is the default for local background work. Move a workload to a stronger
boundary only when you need another interpreter, crash containment, portable
guest isolation, or a native ABI.
| Mode | Boundary | Best use | Main cost |
|---|---|---|---|
@task | Worker thread in the application | Blocking I/O and background orchestration | Pure Python uses the GIL on regular CPython |
@task(isolated=True) | Reused worker process | Another interpreter for CPU-bound Python and crash containment | Serialization, IPC, and process startup |
@wasm_task | Wasmtime guest in a worker thread | Portable, resource-limited guest modules | Guest ABI and data-copy cost |
@dylib_task or load_dylib() | Native call in a worker thread by default | Compatible native C-ABI libraries without a Python extension wrapper | Native memory-safety and host-process risk |
All four modes return a TaskHandle and support the same basic
submit-and-result workflow. Their failure and cancellation semantics differ.
Blocking or background Python
Use @task when work can safely run in the application process:
from pyroxide import task
@task
def fetch_report(report_id: int) -> bytes:
return read_report(report_id)
This is the lowest-overhead option. On regular CPython, the GIL still governs pure-Python execution. Free-threaded CPython may run Python tasks across cores, although an imported extension can re-enable the GIL.
CPU-bound Python or crash containment
Use @task(isolated=True) when regular CPython needs another interpreter for
CPU work. It also supplies process crash containment:
@task(isolated=True)
def calculate(limit: int) -> int:
return sum(i * i for i in range(limit))
Isolation is also useful when trusted native code might abort or segfault. The callable and its data must be serializable and importable by a fresh Python interpreter. Read Isolated worker processes.
Portable guest code
Use @wasm_task for a portable guest module that should receive no file, socket,
or environment imports from Pyroxide and should run with configured memory and
epoch-time limits.
The module must implement Pyroxide’s guest ABI. Treat WASM as an application-level isolation boundary, not an absolute security promise. Read WebAssembly execution.
Trusted native libraries
Use @dylib_task or load_dylib() to call a compatible .so, .dylib, or
.dll through a stable C ABI without holding the Python GIL or writing a
separate Python extension wrapper.
Native code has unrestricted access to its process. isolated=True can contain
a crash to a worker process, but it does not make the library safe or sandboxed.
Read Native shared-library plugins.
A quick decision
- Need ordinary local background work? Start with
@task. - Need multiple cores for Python on regular CPython? Use isolation.
- Need a portable, resource-bounded guest? Use WASM.
- Already have reviewed native code or need a C ABI? Use a native plugin.
- Need durable jobs or multiple hosts? Choose a different system; see Choosing the right tool.
Concurrency and asyncio
Use result_async() when an asyncio application needs a Pyroxide result without
blocking its event loop. Calling handle.result() on the event-loop thread
blocks every other coroutine until the task finishes.
import asyncio
from pyroxide import task
@task
def calculate(value: int) -> int:
return sum(i * i for i in range(value))
async def main() -> None:
handle = calculate(1_000_000)
result = await handle.result_async(timeout_sec=5)
print(result)
asyncio.run(main())
result_async() preserves the same result and exception behavior as result().
Timeout only stops waiting; it does not cancel the task.
The same pattern works in an asynchronous web handler without coupling the task to a web framework:
async def calculate_route(value: int) -> dict[str, int]:
result = await calculate(value).result_async(timeout_sec=5)
return {"result": result}
Completion notification
On Unix, Rust writes to a non-blocking completion pipe. A dedicated Python reader
thread scans registered futures and schedules completion on each owning event loop
with call_soon_threadsafe. It does not poll task status on a timer.
On Windows, Pyroxide waits on its native condition variable through asyncio’s default executor.
Applications may await different handles from different event loops. A task may
have only one active result_async() waiter; a second concurrent call raises
RuntimeError. A consuming result releases the task record.
See Getting started for handle lifetime and Task cancellation for the difference between a wait timeout and stopping work.
Batch submission and groups
Use .batch(payloads) to submit related inputs with one all-or-nothing
admission decision. Functions created by @task and @dylib_task expose the
helper directly.
from pyroxide import task
@task
def square(value: int) -> int:
return value * value
handles = square.batch([1, 2, 3, 4])
results = [handle.result() for handle in handles]
Batch admission reserves capacity for the entire input before creating task
records. If the queue cannot accept the whole batch before the queue timeout,
.batch() raises BufferError and accepts none of it. An empty input returns an
empty list.
Batching is an API and admission convenience. It does not promise one internal lock acquisition or a fixed speedup; measure it for your workload.
WASM batching is available on proxy methods:
from pyroxide import load_wasm
codec = load_wasm("codec")
handles = codec.run.batch([b"one", b"two"])
The @wasm_task decorator submits one payload at a time.
Task groups
group() manages existing handles and preserves their order.
from pyroxide import group
tasks = group(square.batch([1, 2, 3, 4]))
print(tasks.status)
print(tasks.result(consume=False))
print(tasks.status) # Completed
for handle in tasks.handles:
handle.close()
statusreportsFailedif any task failed, thenCancelled, thenCompleted; otherwise it reportsRunning.wait()waits sequentially for every handle.result()returns results in order.cancel()returnsTrueonly if every handle accepted cancellation.
The async context manager waits for all handles and groups failures. On Python
3.10, Pyroxide exposes a compatible fallback exception container with an
exceptions attribute; Python 3.11+ uses built-in ExceptionGroup.
Use individual handles when each item needs different admission or cancellation logic. See Production operations before choosing a batch size for a bounded queue.
Isolated worker processes
Use isolated=True when CPU-bound Python needs another interpreter on regular
CPython, or when trusted native code needs process crash containment.
from pyroxide import task
@task(isolated=True)
def calculate(value: int) -> int:
return sum(i * i for i in range(value))
print(calculate(1_000_000).result())
Isolation adds serialization, IPC, and possible cold-start cost. It is a deliberate boundary, not a default performance upgrade.
Importability contract
The callable and payload are serialized for a fresh interpreter. The callable must be defined at module scope in an importable module. Arguments and results must be pickleable.
Avoid:
- lambdas and closures;
- nested functions;
- definitions available only while a script is
__main__; and - process-local resources such as open sockets, locks, and database connections.
Create process-local resources inside the worker callable instead.
Pool behavior
- Workers are created lazily when isolated work arrives.
- At most
PYROXIDE_MAX_PROCESSEScoordinators and worker processes execute isolated work concurrently. - An idle worker may be reaped after
PYROXIDE_IDLE_TIMEOUT_SEC. PYROXIDE_MIN_WORKERSprotects that many already-created idle workers from reaping; it does not pre-create them.- A worker is recycled after
PYROXIDE_MAX_TASKS_PER_WORKER;0disables task-count recycling.
Small frames travel over a private local socket or named pipe. Large serialized
frames use shared memory when they meet PYROXIDE_SHM_THRESHOLD. This avoids
copying a large frame through the socket, but it is not end-to-end zero-copy:
Python objects are still serialized and copied into and out of shared memory.
The Unix socket directory is private to the user and created with mode 0700.
IPC frame and metadata lengths are checked before allocation.
Cancellation and crashes
Cancelling a running isolated task terminates its worker and reports cancellation only after the child is no longer alive. A crashed worker surfaces an error for the task; later work can use another worker.
Isolation is crash containment, not a security sandbox. A worker normally has the same user identity, filesystem visibility, and network access as the parent.
Pyroxide mitigates orphan workers when the parent disappears:
- macOS uses a process-exit event through
kqueue; - Linux and other Unix platforms poll the parent relationship;
- Windows polls the parent process handle.
Detection is best effort and may take roughly one polling interval on platforms without an event notification.
Fork safety
Do not initialize Pyroxide before calling fork(). An inherited engine contains
threads and synchronization state that cannot be used safely in the child.
Pyroxide detects this and raises ForkSafetyError. Initialize Pyroxide separately
after the fork, or use a spawn-based process model.
See Choosing an execution mode for alternatives and Production operations for capacity, recycling, and shutdown settings.
Task cancellation
Call cancel() when work is no longer useful, but design for the possibility
that it has already started. Whether Pyroxide can stop it depends on its state
and execution boundary.
| Task state and mode | cancel() | Outcome |
|---|---|---|
| Pending, any mode | True | Work is skipped; status becomes Cancelled |
| Running, isolated | True after termination | Worker process is terminated; status becomes Cancelled |
| Running, in-process Python | False | Callable continues and its real result is preserved |
| Running, in-process WASM or native | False | Guest or native call continues |
| Terminal | False | Status and result remain unchanged |
handle = task_function(payload)
if handle.cancel():
print("Cancellation took effect")
else:
print("The task may already be running or finished")
Calling result() on a cancelled task raises RuntimeError("Task cancelled").
Python threads, foreign native calls, and a running WASM invocation cannot be
interrupted safely at an arbitrary instruction. Use cooperative cancellation
inside your callable when you need finer control, or use isolated=True when
process termination is acceptable.
WASM execution timeouts are separate from user cancellation. They trap a guest after its epoch deadline; see WebAssembly.
A timeout passed to result() or result_async() also stops only the wait; it
does not cancel the task. See Getting started for handle
lifetime after a timeout.
Exceptions and tracebacks
Pyroxide keeps the background traceback when Python work fails, so you can
diagnose the original call site after the exception crosses a task boundary.
result() raises a RuntimeError containing the exception text and formatted
traceback.
from pyroxide import task
@task
def fail(_: object) -> None:
raise ValueError("invalid payload")
try:
fail(None).result()
except RuntimeError as error:
print(error)
The original exception object is not re-raised across every execution boundary; do not depend on catching its original Python type. Treat the reported traceback as diagnostic text.
WASM traps, native loader errors, IPC failures, and isolated-worker crashes are also surfaced as runtime errors with backend-specific context. A native crash in the main process cannot be converted into a Python exception; use process isolation for crash containment.
Use isolated workers when a native crash must not take down the main application, and inspect Production operations for failure and shutdown planning.
WebAssembly execution
Use WebAssembly when an application needs to run a portable guest module without giving it file, socket, or environment imports through Pyroxide. Wasmtime applies a memory limit and an epoch-time deadline to every invocation.
This is an application-level isolation boundary, not an absolute security promise. Validate inputs, keep Pyroxide and Wasmtime updated, run the host with least privilege, and test hostile modules against your own threat model.
Register and call a module
from pyroxide import register_wasm, wasm_task
with open("codec.wasm", "rb") as stream:
register_wasm("codec", stream.read())
@wasm_task("codec", "run")
def transform(payload: bytes) -> bytes:
pass
print(transform(b"data").result())
The decorated function is an interface declaration; the guest export performs
the work. Payloads may be bytes or UTF-8 str, and the result follows the
input representation.
Use a proxy when the module has several exports:
from pyroxide import load_wasm
codec = load_wasm("codec")
compressed = codec.compress(b"data").result()
restored = codec.decompress(compressed).result()
isolated=True adds a worker-process boundary. It usually adds overhead without
changing the imports available to the WASM guest.
Guest ABI
A callable core WebAssembly module exports:
memory
alloc(size: i32) -> i32
dealloc(ptr: i32, size: i32)
run(ptr: i32, size: i32) -> i64
The result packs the output pointer in the high 32 bits and output length in the
low 32 bits. A different exported function name may replace run.
For each call, Pyroxide:
- checks the input against the configured limit;
- allocates guest memory and copies the input;
- invokes the export with an epoch deadline;
- validates the returned pointer, length, range, and configured limit;
- copies the output to Python; and
- calls the guest deallocator.
Inputs and outputs are copied across the boundary. Pyroxide 1.0 does not expose WASI, the Component Model, custom host imports, shared-memory threads, or arbitrary typed calls.
Limits
| Setting | Default |
|---|---|
| Memory per invocation | 100 MiB |
| Execution deadline | 1000 ms |
| Epoch tick | 10 ms |
Set process-wide defaults:
import pyroxide
pyroxide.set_wasm_limits(
memory_limit_bytes=50 * 1024 * 1024,
timeout_ms=500,
)
Use a scoped override for tasks submitted in one thread:
with pyroxide.scoped(
wasm_memory_limit_bytes=10 * 1024 * 1024,
wasm_timeout_ms=100,
):
handle = transform(b"tenant input")
Programmatic global settings take precedence over environment settings. Memory
must be between 1 byte and 2**31 - 1; timeouts and tick intervals must be
positive.
An epoch deadline is not a real-time guarantee. Wasmtime observes it at an engine epoch check, so scheduler delay and tick granularity add latency.
Traps and debugging
Trap messages include WebAssembly function names when the module provides them.
Set WASMTIME_BACKTRACE_DETAILS=1 before the first module registration to add
source locations from guest DWARF data. Debug data increases module startup time
and memory use.
WASM execution deadlines are separate from TaskHandle.cancel(). A running
in-process guest cannot be user-cancelled safely, but it traps after its engine
deadline.
Proxies and type stubs
Dynamic proxies are convenient at runtime. For editor completion and deployments
that should not write files during startup, generate .py and .pyi helpers
ahead of time:
pyroxide build-stubs --scan --scan-dir . --out-dir generated
You can also request generation while loading a registered module:
codec = load_wasm("codec", generate_stubs=True)
That writes helpers in the current directory, so static generation is normally the better production workflow.
Runtime compilation
compile_wat_wasm, compile_c_wasm, compile_rust_wasm, and
compile_zig_wasm are development conveniences. The C, Rust, and Zig helpers
invoke local toolchains before Wasmtime runs the compiled guest.
Source compilation itself is not sandboxed: compilers, plugins, and build
scripts run with host permissions. Prefer reviewed .wasm artifacts in
production and set PYROXIDE_DISABLE_COMPILATION=1 when runtime compilation is
not required.
See Choosing an execution mode for boundary trade-offs and Production operations for global configuration.
Native shared-library plugins
Use a native plugin when you already have a reviewed C-ABI library, or when a
hot path belongs in compiled code. Pyroxide calls .so, .dylib, and .dll
exports on background threads without holding the Python GIL.
Choose a native workflow
- Load a reviewed precompiled
.so,.dylib, or.dll. - Use optional helpers to compile trusted C, Rust, or Zig source.
Both paths submit work through Pyroxide task handles and retain batching, async
results, and lifecycle controls. Both can opt into process crash containment
with isolated=True.
Native code has unrestricted access to its process. A bad pointer, wrong
signature, panic across the ABI, buffer overflow, abort(), or segmentation
fault can corrupt or terminate Python. Rust panic handling cannot catch hardware
faults or undefined behavior.
isolated=True can contain a crash to a worker process. It does not make native
code safe or turn the worker into a permission sandbox.
Register and call a precompiled library
Precompiled artifacts are the preferred production path:
from pyroxide import register_dylib, dylib_task
register_dylib("codec", "/opt/myapp/libcodec.so")
@dylib_task("codec")
def transform(payload: bytes) -> bytes:
pass
result = transform(b"data").result()
For process crash containment:
@dylib_task("codec", isolated=True)
def transform_safely(payload: bytes) -> bytes:
pass
The decorator declares the interface. The registered library export performs the work.
Byte-buffer ABI
The default export receives bytes and returns an owned byte buffer:
#include <stddef.h>
#include <stdint.h>
#include <stdlib.h>
uint8_t *pyroxide_plugin_run(
const uint8_t *input,
size_t input_len,
size_t *output_len
) {
uint8_t *output = malloc(input_len);
if (output == NULL && input_len != 0) return NULL;
for (size_t i = 0; i < input_len; i++) output[i] = input[i];
*output_len = input_len;
return output;
}
void pyroxide_plugin_free(uint8_t *output, size_t output_len) {
(void)output_len;
free(output);
}
The plugin must:
- export the exact C ABI used by the call;
- keep the allocation valid until the free callback;
- report the exact allocation length; and
- free memory with the allocator that created it.
Pyroxide rejects a reported output larger than
PYROXIDE_MAX_NATIVE_OUTPUT_BYTES, 64 MiB by default, before copying it. It
cannot prove that an arbitrary pointer or deallocator is valid. This ABI is a
trust boundary, not a memory-safety boundary.
Call several exports through a proxy
load_dylib() builds a proxy whose methods submit named symbols:
from pyroxide import load_dylib
codec = load_dylib("codec")
compressed = codec.compress(b"data").result()
restored = codec.decompress(compressed).result()
Each unresolved method uses the byte-buffer ABI. Use explicit primitive signatures when an export takes numbers:
math = load_dylib(
"/opt/myapp/libmath.so",
signatures={
"scale": {"args": ["i32", "f64"], "ret": "i32"},
},
)
print(math.scale(100, 1.5).result())
Primitive names are i32, i64, f32, and f64, with up to eight
arguments. The dispatcher supports a defined subset of their possible
combinations; see the exact signature matrix.
An unsupported combination raises RuntimeError. A declaration that is
accepted but does not match the real export is undefined behavior.
A library may expose metadata such as scale:i32,f64|i32 from
pyroxide_metadata(). When no explicit signatures are supplied,
load_dylib() uses that metadata. Metadata does not make an untrusted library
safe.
Generate proxy and stub files
Dynamic methods work at runtime but give an editor little type information.
Generate .py and .pyi helpers during development or packaging:
pyroxide build-stubs --scan --scan-dir . --out-dir generated
For a registered library, generation can also happen during loading:
codec = load_dylib("codec", generate_stubs=True)
That writes files in the current directory. Prefer the CLI for production builds so application startup does not modify the filesystem or trigger development reloaders.
Compile trusted source during development
Pyroxide can invoke installed Rust, C, or Zig toolchains, cache the resulting library, and register it:
from pyroxide import compile_c
path = compile_c("codec", trusted_c_source)
| Variable | Meaning | Default |
|---|---|---|
PYROXIDE_CACHE_DIR | Native compilation cache | ~/.pyroxide/cache |
PYROXIDE_COMPILER_TIMEOUT_SEC | Per-command timeout | 300 |
PYROXIDE_DISABLE_COMPILATION | Reject runtime compilation when 1 or true | disabled |
Runtime compilation executes the compiler and its output with host permissions.
Never pass tenant- or user-controlled source. Ship reviewed precompiled
artifacts when possible, and set PYROXIDE_DISABLE_COMPILATION=1 when the
application does not need compilation.
Missing toolchains raise CompilerNotFoundError. Compilation is serialized
across threads and processes, and cache publication is atomic.
Unregistering
unregister_dylib(name) prevents future lookup through that registration.
Ensure that no task is using the library before unregistering it. Unloading a
library while native code is executing is unsafe.
For a smaller primitive-only interface to an existing system library, see Reusing existing libraries with FFI. For deployment and security settings, see Production operations.
Reusing existing native libraries
Use load_dylib() when an application needs a small, reviewed primitive C-ABI
surface from an existing shared library. This avoids writing a wrapper for a
small number of numeric calls.
It is not a general C header parser and does not make an unsafe API safe.
import sys
from pyroxide import load_dylib
library = "libm.dylib" if sys.platform == "darwin" else "libm.so.6"
math = load_dylib(
library,
signatures={
"cos": {"args": ["f64"], "ret": "f64"},
"sin": {"args": ["f64"], "ret": "f64"},
},
)
print(math.cos(3.1415926535).result())
Supported primitive types
The primitive dispatcher supports:
| Pyroxide type | Typical native type | Python value |
|---|---|---|
i32 | Rust i32 or C int32_t | int |
u32 | Rust u32 or C uint32_t | int |
i64 | Rust i64 or C int64_t | int |
u64 | Rust u64 or C uint64_t | int |
isize | Rust isize or a matching signed pointer-width integer | int |
usize | Rust usize, C size_t, or a matching unsigned pointer-width integer | int |
f32 | Rust f32 or C float | float |
f64 | Rust f64 or C double | float |
usize and isize follow the pointer width of the running Python process and
loaded library. They are 64-bit in a 64-bit process and 32-bit in a 32-bit
process.
Do not use usize or isize as portable aliases for C unsigned long or
long. The width of those C types varies between platforms.
Return values must use one of the supported primitive types. The primitive
dispatcher does not support void.
Supported signature shapes
In the following table:
T,T1, andT2mean any supported primitive type.Rmeans any supported primitive return type.- A homogeneous shape repeats the same argument type in every position.
| Argument count | Supported argument shapes | Supported returns |
|---|---|---|
| 0 | No arguments | Any supported primitive |
| 1 | T | Any supported primitive |
| 2 | Every T1,T2 combination | Any supported primitive |
| 3 | Homogeneous T,T,T | Any supported primitive |
| 3 | i32,i32,f64; f64,f64,i32 | Any supported primitive |
| 4 | Homogeneous T,T,T,T | Any supported primitive |
| 4 | i32,i32,f64,f64 | Any supported primitive |
| 5-8 | Homogeneous arguments only | Any supported primitive |
Examples of supported signatures include:
|u64
u32|u32
u32,f64|usize
u64,u64|u64
usize,usize|usize
f32,f32,f32|f64
i32,i32,f64|u32
u64,u64,u64,u64|u64
i32,i32,i32,i32,i32,i32,i32,i32|i64
The zero-argument metadata form places no text before |:
current_counter:|u64
Support for a primitive type does not imply support for every high-arity combination. Other combinations raise an error similar to:
RuntimeError(
"Unsupported FFI signature mapping: "
"(u32, f64, usize) -> u64"
)
The declared signature must match the real export exactly.
Unsigned example
from pyroxide import load_dylib
counter = load_dylib(
"./libcounter.so",
signatures={
"read_counter": {
"args": [],
"ret": "u64",
},
"add_to_counter": {
"args": ["u64"],
"ret": "u64",
},
"combine_flags": {
"args": ["u32", "u32"],
"ret": "u32",
},
},
)
print(counter.read_counter().result())
print(counter.add_to_counter(4_000_000_000).result())
Python integers are range-checked against the declared primitive.
For example, a u32 argument accepts:
0 through 4294967295
Negative values and values outside the declared width are rejected rather than wrapped.
Safety rules
- Verify the library path and binary provenance.
- Declare the exact exported C ABI.
- Signedness is part of the signature. Do not declare
uint32_tasi32. - Calling-convention, width, signedness, or return-type mismatches are undefined behavior.
- Do not assume that C
longandunsigned longhave the same width on every platform. - The running Python process and loaded library must use compatible architectures and ABIs.
- Do not use this primitive interface for pointers, strings, structs, arrays, callbacks, output parameters, or ownership-bearing values.
- The default byte-buffer ABI requires a matching deallocator; see Native plugins.
- Use
isolated=Truefor crash containment, understanding that it is not a permission sandbox.
Platform library names differ and may not be present in minimal containers. Pin or package the libraries an application requires instead of depending on ambient system versions.
See Native shared-library plugins for byte buffers, generated proxies, runtime compilation, and the complete safety contract.
Production operations
Pyroxide removes a separate broker and worker service, but it does not remove operational decisions. Because the engine lives inside the application, capacity, shutdown, and native-code risk belong in the application’s design.
Start with a canary using representative payloads and failure cases.
Configuration
Set environment variables before importing pyroxide. Runtime engine settings
are process-global unless documented as scoped.
| Variable | Meaning | Default |
|---|---|---|
PYROXIDE_WORKERS | In-process worker threads | logical CPU count |
PYROXIDE_QUEUE_CAPACITY | Pending submissions across both queues | 10000 |
PYROXIDE_QUEUE_TIMEOUT_MS | Admission wait; 0 rejects immediately | 1000 |
PYROXIDE_MAX_PROCESSES | Concurrent isolated coordinators/processes | min(logical CPUs, 8) |
PYROXIDE_SHM_THRESHOLD | Serialized frame size that selects shared memory | 1048576 |
PYROXIDE_MAX_IPC_FRAME_BYTES | Maximum accepted IPC frame | 67108864 |
PYROXIDE_MAX_NATIVE_OUTPUT_BYTES | Maximum copied native byte-buffer result | 67108864 |
PYROXIDE_MAX_TASKS_PER_WORKER | Isolated tasks before recycle; 0 disables | 100 |
PYROXIDE_WORKER_STARTUP_TIMEOUT_SEC | Isolated worker startup timeout | 5 |
PYROXIDE_IDLE_TIMEOUT_SEC | Idle time before eligible worker reaping | 60 |
PYROXIDE_MIN_WORKERS | Existing idle workers protected from reaping | 0 |
PYROXIDE_WASM_MEMORY_LIMIT_BYTES | Per-invocation WASM memory limit | 104857600 |
PYROXIDE_WASM_TIMEOUT_MS | WASM epoch deadline | 1000 |
PYROXIDE_WASM_TICK_MS | Epoch increment interval | 10 |
PYROXIDE_CACHE_DIR | Native compiler cache | ~/.pyroxide/cache |
PYROXIDE_COMPILER_TIMEOUT_SEC | Per native compiler command timeout | 300 |
PYROXIDE_DISABLE_COMPILATION | Reject runtime source compilation | disabled |
Invalid integer engine settings fail during import instead of silently selecting
an unsafe value. PYROXIDE_MIN_WORKERS cannot exceed
PYROXIDE_MAX_PROCESSES. WASM memory cannot exceed 2**31 - 1 bytes.
Task-count recycling replaces an isolated worker synchronously, so the task
that crosses the limit pays process startup cost. Latency-sensitive services
can set PYROXIDE_MAX_TASKS_PER_WORKER=0 after validating that their workload
does not accumulate worker state or memory. Keep recycling enabled when bounding
long-lived worker growth matters more than that occasional pause.
Backpressure
Choose queue capacity from the maximum memory you can retain while work waits, not
only desired throughput. Submission raises BufferError after the queue timeout.
Batch admission requires room for the whole batch.
Application code should decide whether to retry, shed load, or return a service error. Unbounded retries defeat backpressure.
Metrics
import pyroxide
metrics = pyroxide.stats()
| Key | Meaning |
|---|---|
worker_count | Configured in-process worker threads |
max_processes | Maximum concurrent isolated workers |
queue_capacity | Pending admission capacity |
queued_tasks | Accepted tasks not yet taken by a worker |
running_tasks | Tasks currently executing |
active_tasks | Task records still retained by handles |
submitted_tasks | Lifetime accepted submissions |
rejected_tasks | Lifetime capacity/channel rejections |
completed_tasks | Lifetime successful completions |
failed_tasks | Lifetime failed completions |
cancelled_tasks | Lifetime effective cancellations |
Fields are read independently. During concurrent activity, stats() is an
approximate cross-field snapshot and may combine values from nearby moments;
use quiescent readings for drain or leak checks. If you require a linearizable
cross-field snapshot, open an issue with your use case.
Counters are process-local and reset on restart. Export them with labels supplied by your application; Pyroxide does not run a metrics server.
Shutdown
pyroxide.shutdown(wait=True, cancel_pending=False)
- The default stops admission, drains accepted work, and joins workers.
cancel_pending=Truecancels work that has not started. Running in-process work still completes; running isolated work is not automatically user-cancelled.wait=Falseinitiates shutdown and returns promptly.- A Pyroxide worker task cannot call
shutdown(wait=True)because shutdown would wait for that task. Usewait=Falseor shut down from another thread. - Shutdown is idempotent and irreversible in the process.
Set an application-level termination grace period long enough for the largest non-interruptible in-process task, or isolate work that must be forcibly stopped.
Process models
Initialize Pyroxide after process managers fork or preload application code. A
broker or WebAssembly runtime inherited across fork() raises
ForkSafetyError. Spawn-based child processes can initialize their own runtime
normally.
Do not recursively submit isolated Pyroxide tasks from an isolated worker. The worker executes its decorated Python callable directly to avoid a nested broker.
Security checklist
- Prefer precompiled WASM and native artifacts with provenance checks.
- Set
PYROXIDE_DISABLE_COMPILATION=1when runtime compilation is unused. - Treat native libraries as part of the trusted computing base.
- Do not describe process isolation as a permission sandbox.
- Bound request size below the IPC and WASM limits at the application boundary.
- Run the host service with least filesystem and network privilege.
- Track Pyroxide, Wasmtime, PyO3, and Rust security advisories.
See SECURITY.md for reporting and support policy.
Migrating from 0.10 to 1.0
1.0.0rc1 is the compatibility preview for 1.0. Test it before upgrading a
production service.
Runtime requirements
- Minimum Python is now 3.10.
- Source builds require Rust 1.86 or newer.
- Wheels use the CPython 3.10 stable ABI where supported; free-threaded CPython uses dedicated wheels.
Behavior changes
Cancellation
cancel() no longer reports success for running in-process work it cannot stop.
Pending work remains cancellable. Running isolated work is cancelled by
terminating its worker. Audit code that assumed True meant a Python thread,
native call, or WASM call had been interrupted.
Bounded admission
Queue capacity applies atomically before task records are created. Batch
submission is all-or-nothing. Handle BufferError and choose an explicit
PYROXIDE_QUEUE_TIMEOUT_MS for overload behavior.
Lifecycle and fork
Call pyroxide.shutdown() during teardown. The engine rejects new work after
shutdown and cannot restart in that process. Initialize after fork(); inherited
engines raise ForkSafetyError.
Isolated workers
Isolated concurrency is bounded by PYROXIDE_MAX_PROCESSES, defaulting to at most
eight. Workers are created lazily. PYROXIDE_MIN_WORKERS retains already-created
idle workers; it does not prewarm them.
Validation and limits
Invalid engine environment values now fail at import. IPC frames and WASM guest input/output ranges are checked before allocation or memory access. If an existing deployment relied on larger frames, set an intentional limit and validate memory capacity first.
Packaging
The license expression is now MIT OR Apache-2.0. The Coffeeware option was
removed. The package ships a py.typed marker.
Recommended rollout
- Run the test suite on
1.0.0rc1under your oldest and newest Python versions. - Load-test bounded admission and record rejection behavior.
- Exercise cancellation, worker crashes, fork behavior, and graceful shutdown.
- Canary one service instance and monitor queue, failure, memory, and latency metrics.
- Report RC compatibility problems before adopting final 1.0.
Benchmarking
Pyroxide does not publish a universal latency or speedup claim. Results change with hardware, OS, Python build, worker count, payload size, execution mode, compiler, and whether workers are warm.
The scripts under examples/benchmarks/ are evaluation tools, not product
guarantees.
What the evidence says
The saved studies support a narrow, useful product claim: Pyroxide gives one application several execution boundaries with competitive measured overhead. They do not show that it is the fastest choice for every task.
- Standard thread and process pools win some workloads.
- Pyroxide isolated stayed close to
ProcessPoolExecutorin the measured CPU cells while using the same task API as other Pyroxide modes. - In-process execution avoided the process-tree memory cost of process pools in the measured scenarios.
- Native and WASM results include different boundaries and must not be presented as scheduler-only speedups.
- Production sizing still requires the application’s own payloads, worker counts, platform, and failure cases.
The canonical summaries live in
benchmark_results/.
Fair-comparison rules
- Compare systems with the same durability and isolation semantics.
@taskis comparable to an in-process executor; Celery is a distributed, durable queue and answers a different problem. - Use identical work and input data. Do not compare compiled native work with interpreted Python and attribute the difference to scheduling.
- Fix and report worker counts, affinity, power mode, and dependency versions.
- Separate cold results from warm steady-state results. Process creation, JIT, module loading, and runtime compilation belong in cold-start measurements.
- Run enough repetitions and report distributions such as median and p95, not one best sample.
- Verify every result and report failures. A fast benchmark that skipped work is invalid.
- Save the command, configuration, platform metadata, and raw machine-readable output with any published number.
Recommended comparisons
| Question | Appropriate comparison |
|---|---|
| In-process Python scheduling | @task vs ThreadPoolExecutor |
| CPU-bound Python isolation | isolated tasks vs ProcessPoolExecutor or loky |
| Free-threaded Python | same Python function and worker count on CPython 3.14t |
| Native execution | same compiled algorithm through Pyroxide and a direct binding |
| WASM overhead | same module and ABI through comparable Wasmtime hosts |
| Large IPC payload | same serialization format, payload, warm pool, and process count |
Celery, RQ, and similar systems may be included to explain architectural cost, but their broker durability, retry, routing, and multi-host behavior must be enabled and disclosed. They are not direct substitutes for an embedded engine.
July 2026 reference run
The saved reference run used macOS 15.7.4 on an Apple M1 Pro with 8 physical cores. Each ranked cell used four workers and 30 fresh-process blocks. Values below are median complete-batch makespans; lower is better.
| CPython / batch | ThreadPool | Pyroxide threaded | ProcessPool | Pyroxide isolated |
|---|---|---|---|---|
| 3.14, 32 CPU tasks | 65.20 ms | 55.49 ms | 17.79 ms | 19.22 ms |
| 3.14t, 32 CPU tasks | 18.91 ms | 18.03 ms | 13.31 ms | 15.88 ms |
| 3.14, 1,000 trivial tasks | 6.29 ms | 18.25 ms | 157.46 ms | 52.35 ms |
The result is mixed, which is the useful conclusion:
- regular CPython still needs processes or independent interpreters for CPU-parallel Python;
- Pyroxide isolated was 8% slower than
ProcessPoolExecutorin the 3.14 CPU cell, not faster; - Pyroxide threaded used 33 MiB peak process-tree RSS in that cell, versus 147 MiB isolated and 154 MiB for the process pool;
- on 3.14t, Pyroxide threaded recorded a 4.7% lower median than the thread pool, but their bootstrap intervals overlapped; and
- the standard thread pool was about three times faster than Pyroxide threaded for the trivial-task batch.
The broader CPython 3.14 comparison used 100 CPU tasks:
| Backend | Median | Peak process-tree RSS |
|---|---|---|
| loky | 59.07 ms | 179 MiB |
ProcessPoolExecutor | 60.47 ms | 156 MiB |
InterpreterPoolExecutor | 65.97 ms | 74 MiB |
| joblib | 96.20 ms | 211 MiB |
| Pyroxide threaded | 171.14 ms | 34 MiB |
ThreadPoolExecutor | 182.83 ms | 30 MiB |
Across CPython 3.10-3.14, Pyroxide threaded was 12-14% faster than the thread pool for that same CPU batch, but both remained much slower than the process pool. This is scheduler efficiency under the GIL, not CPU parallelism.
The native/WASM boundary track used the same 1 KiB Rust workload. Direct PyO3,
nanobind, warmed CFFI, and ctypes calls measured 7.26, 7.38, 7.63, and 8.59 µs
respectively. A scheduled Pyroxide dylib call measured 22.56 µs; it includes
task submission and result handling, so it is not a direct-binding speed claim.
Warm Pyroxide WASM measured 47.57 µs versus 80.24 µs for the tested direct
wasmtime-py host. Cold Wasmtime compile, instantiate, and call measured
41.12 ms and is reported separately.
Distributed and durable systems were measured in separate tracks. In the single-node four-worker run, Ray processed 7,542 trivial tasks/s and 1,690 CPU tasks/s; Dask processed 685 and 658 tasks/s. Ray used about 963-978 MiB peak process-tree RSS versus 329-335 MiB for Dask. With Redis, late acknowledgement, JSON serialization, two workers, and result retrieval enabled, Celery processed 564 payload tasks/s and 251 CPU tasks/s; Dramatiq processed 248 and 93 tasks/s. These numbers compare each track’s operational cost and must not be ranked against the embedded executors.
The Odoo track produced one valid environment: pinned Odoo 19 on Python 3.13.
For eight ledger payloads, two workers, and 30 matched blocks, steady-state
median compute-only batch time was 60.37 ms inline, 31.77 ms with
ProcessPoolExecutor, and 30.85 ms with Pyroxide isolated. Their p95 values
were 61.59, 32.96, and 31.64 ms; Pyroxide’s maximum was 33.41 ms.
A separate run retained Pyroxide’s default 100-task worker lifetime. Its median was 31.02 ms, but synchronous worker replacement produced one 303.52 ms batch. Two earlier runs were invalidated because they claimed recycling was disabled while using that default. The controlled runs show stable steady-state performance and a predictable recycling latency cost; they do not show random Pyroxide stalls. This test excludes ORM extraction, writes, HTTP, and Odoo worker-process overhead.
Odoo 19 and pinned Odoo master (“Odoo 20 preview”) could not install their
official libsass==0.22.0 requirement on Python 3.14. No 3.14 Odoo timing was
published and no unpinned dependency was substituted.
RC1 reliability observation
The fixed-seed RC1 controller ran for five minutes with seed 1729 and recorded 301 ordered once-per-second samples. The first and last 60-sample windows had process-tree RSS medians of 87,916,544 and 88,948,736 bytes, with descriptor medians fixed at 9. The maximum observed child count was 1 against a configured bound of 2; the three intervening one-minute RSS medians were 88,662,016, 88,784,896, and 88,866,816 bytes, also with descriptor medians of 9.
The controller scheduled 300 representative scenario cycles and accepted new work in every one-second interval. Accepted totals at the minute boundaries were 783, 1,353, 1,928, 2,499, and 3,080. Terminal accounting was exact: the 3,080 accepted operations became 2,480 completions, 300 expected crash failures, and 300 expected cancellations; 300 saturation submissions were rejected. Peak engine gauges were 2 running, 4 queued, and 6 active tasks.
Isolated work succeeded after every deliberate crash and after both initial 100-task recycling boundaries. The two synchronous recycling operations took 68.861 ms and 69.144 ms, so the observed maximum recycling latency was 69.144 ms. Shutdown completed in 2.317 ms with no queued, running, or active tasks. All 3,080 terminal latencies were drained into sample records before the final sample, leaving its latency list empty.
Recycling remained correct and bounded in this observation, so RC1 retains the 100-task default and documents its synchronous replacement latency. This is a synthetic embedded-engine observation, not a hard leak threshold, an HTTP/Odoo service benchmark, or evidence of final long-duration stability. The configurable eight-hour final soak remains separate.
Canonical summaries, sample counts, environment metadata, native/WASM
boundaries, distributed tracks, and Odoo results are versioned in
benchmark_results/. Raw observations, logs, and invalid runs are generated
locally by the reproducible harness but are not committed. The measurements are
evidence for this machine and workload, not capacity-planning constants.
Running the local scripts
Build Pyroxide in the active environment first:
maturin develop
python examples/benchmarks/benchmark.py
python examples/benchmarks/benchmark_large_payload.py
PYROXIDE_WORKERS=8 PYROXIDE_MAX_PROCESSES=8 \
python examples/benchmarks/benchmark_vs_alternatives.py --workers 8
The comparison script exits instead of publishing mismatched results unless
both Pyroxide pool sizes equal --workers.
Optional comparisons require their own dependencies and interpreters. Record those exact versions in results. Do not copy numbers from this book into capacity plans; benchmark the deployed platform with representative task sizes and queue pressure.
The generic runner refuses the reliability manifest. A one-task throughput cell is not a duration-aware soak, so reliability evidence must come from the dedicated reliability harness. The RC profile declares a five-minute evidence run and a separately configurable eight-hour final soak.
Production evaluation
Performance is only one release criterion. A useful soak test also records:
- accepted and rejected submissions under bounded capacity;
- queued and running tasks;
- completion, failure, cancellation, and timeout behavior;
- RSS and file-descriptor growth;
- isolated worker churn and orphan cleanup;
- shutdown drain time; and
- p50, p95, and p99 end-to-end latency.
Use pyroxide.stats() for engine counters and your application telemetry for
request-level latency and correctness.
Choosing Pyroxide or another tool
Choose the execution and deployment model before comparing speed. A local executor, an embedded multi-mode engine, a durable queue, and a cluster runtime solve different problems.
| Need | Usually choose |
|---|---|
| One straightforward local thread or process pool | concurrent.futures |
| One embedded API for Python threads, isolated processes, WASM, and native libraries | Pyroxide |
| Durable jobs, retries, schedules, routing, or multi-host workers | Celery, RQ, Dramatiq, Temporal, or a managed queue |
| Distributed data or compute scheduling | Ray or Dask |
| One stable native algorithm known at build time | PyO3, nanobind, Cython, or another direct extension |
| A custom WASM host with its own imports and component model | Wasmtime or another dedicated host |
What Pyroxide combines
Pyroxide is useful when one application owns the work but not every task belongs behind the same boundary.
You can start a blocking operation on a worker thread, move CPU-bound Python to another interpreter, run a portable guest in Wasmtime, or call a reviewed native library without changing the caller’s basic submit-and-result flow. Bounded admission, batching, async results, cancellation rules, statistics, and shutdown apply across those modes.
That combination is the selling point. Pyroxide is not a distributed queue compressed into a Python extension.
Standard executors
ThreadPoolExecutor is mature, built into Python, and often the simplest choice
for blocking I/O. ProcessPoolExecutor has a broad serialization ecosystem and
is a strong CPU-bound baseline on regular CPython.
Use them when one pool and its future API are enough. Choose Pyroxide when the same application benefits from bounded task admission, integrated telemetry, or several execution modes behind one interface.
All process approaches pay serialization and IPC costs. Pyroxide’s isolated mode reuses lazily created workers and routes large serialized frames through shared memory. Python objects are still serialized; this is not end-to-end zero-copy.
Durable and distributed queues
Celery, RQ, Dramatiq, Temporal, and managed queue services are designed for work that outlives one application process. Depending on the system, they provide durability, retries, schedules, routing, monitoring, and workers on other hosts.
Pyroxide provides none of those durability guarantees. Its advantage is the opposite trade-off: no broker, no separate worker deployment, and no network hop for work that belongs to the current application.
No-op latency is not a fair way to rank these categories. The external systems do more operational work because they promise different failure semantics.
Cluster runtimes
Ray and Dask coordinate work and data across processes and machines. Choose them when cluster scheduling, distributed object/data handling, or elastic compute is part of the requirement.
Pyroxide stays inside one host application. It is a smaller fit for a web service, desktop tool, automation process, or plugin host that needs local execution choices without becoming a cluster.
Direct native extensions
PyO3, nanobind, Cython, and C/C++ extension modules are strong choices when the algorithm and Python API are known at build time. They offer tight typing, wheel-time validation, and direct-call overhead.
Pyroxide native plugins favor runtime registration, background scheduling, and a small C ABI. That flexibility brings ABI and trust risks. A scheduled native task should not be marketed as faster than a direct binding merely because the algorithm itself is compiled.
Dedicated WASM hosts
Pyroxide supplies a deliberately small guest ABI, no host imports, memory limits, and epoch deadlines. This is convenient when WASM is one execution mode inside a Python application.
Choose a dedicated Wasmtime host when you need WASI, the Component Model, custom imports, or a richer typed interface.
Ask these questions
- Must accepted work survive the application process? If yes, use a durable queue.
- Must work run across hosts? If yes, use a distributed queue or cluster runtime.
- Is one standard executor enough? If yes, keep the standard library.
- Does one application need several local execution boundaries with one lifecycle? That is where Pyroxide fits.
See Choosing an execution mode for Pyroxide’s internal choices and Benchmarking for measured comparisons.
Python API reference
This placeholder is replaced by the generated pdoc page during the documentation build.