Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

ModeWhat it is good at
@taskBlocking I/O, background orchestration, and lightweight local work
@task(isolated=True)CPU-bound Python and process crash containment
@wasm_taskPortable guest modules with memory and execution-time limits
@dylib_taskGIL-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

  1. Install Pyroxide.
  2. Submit your first task.
  3. Choose the execution mode that matches the workload.
  4. 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

StatusMeaning
PendingAccepted but not started
RunningA worker started it
CompletedA result is available
FailedExecution raised or trapped
CancelledWork 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

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.

ModeBoundaryBest useMain cost
@taskWorker thread in the applicationBlocking I/O and background orchestrationPure Python uses the GIL on regular CPython
@task(isolated=True)Reused worker processAnother interpreter for CPU-bound Python and crash containmentSerialization, IPC, and process startup
@wasm_taskWasmtime guest in a worker threadPortable, resource-limited guest modulesGuest ABI and data-copy cost
@dylib_task or load_dylib()Native call in a worker thread by defaultCompatible native C-ABI libraries without a Python extension wrapperNative 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()
  • status reports Failed if any task failed, then Cancelled, then Completed; otherwise it reports Running.
  • wait() waits sequentially for every handle.
  • result() returns results in order.
  • cancel() returns True only 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_PROCESSES coordinators and worker processes execute isolated work concurrently.
  • An idle worker may be reaped after PYROXIDE_IDLE_TIMEOUT_SEC.
  • PYROXIDE_MIN_WORKERS protects that many already-created idle workers from reaping; it does not pre-create them.
  • A worker is recycled after PYROXIDE_MAX_TASKS_PER_WORKER; 0 disables 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 modecancel()Outcome
Pending, any modeTrueWork is skipped; status becomes Cancelled
Running, isolatedTrue after terminationWorker process is terminated; status becomes Cancelled
Running, in-process PythonFalseCallable continues and its real result is preserved
Running, in-process WASM or nativeFalseGuest or native call continues
TerminalFalseStatus 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:

  1. checks the input against the configured limit;
  2. allocates guest memory and copies the input;
  3. invokes the export with an epoch deadline;
  4. validates the returned pointer, length, range, and configured limit;
  5. copies the output to Python; and
  6. 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

SettingDefault
Memory per invocation100 MiB
Execution deadline1000 ms
Epoch tick10 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

  1. Load a reviewed precompiled .so, .dylib, or .dll.
  2. 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)
VariableMeaningDefault
PYROXIDE_CACHE_DIRNative compilation cache~/.pyroxide/cache
PYROXIDE_COMPILER_TIMEOUT_SECPer-command timeout300
PYROXIDE_DISABLE_COMPILATIONReject runtime compilation when 1 or truedisabled

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 typeTypical native typePython value
i32Rust i32 or C int32_tint
u32Rust u32 or C uint32_tint
i64Rust i64 or C int64_tint
u64Rust u64 or C uint64_tint
isizeRust isize or a matching signed pointer-width integerint
usizeRust usize, C size_t, or a matching unsigned pointer-width integerint
f32Rust f32 or C floatfloat
f64Rust f64 or C doublefloat

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, and T2 mean any supported primitive type.
  • R means any supported primitive return type.
  • A homogeneous shape repeats the same argument type in every position.
Argument countSupported argument shapesSupported returns
0No argumentsAny supported primitive
1TAny supported primitive
2Every T1,T2 combinationAny supported primitive
3Homogeneous T,T,TAny supported primitive
3i32,i32,f64; f64,f64,i32Any supported primitive
4Homogeneous T,T,T,TAny supported primitive
4i32,i32,f64,f64Any supported primitive
5-8Homogeneous arguments onlyAny 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_t as i32.
  • Calling-convention, width, signedness, or return-type mismatches are undefined behavior.
  • Do not assume that C long and unsigned long have 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=True for 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.

VariableMeaningDefault
PYROXIDE_WORKERSIn-process worker threadslogical CPU count
PYROXIDE_QUEUE_CAPACITYPending submissions across both queues10000
PYROXIDE_QUEUE_TIMEOUT_MSAdmission wait; 0 rejects immediately1000
PYROXIDE_MAX_PROCESSESConcurrent isolated coordinators/processesmin(logical CPUs, 8)
PYROXIDE_SHM_THRESHOLDSerialized frame size that selects shared memory1048576
PYROXIDE_MAX_IPC_FRAME_BYTESMaximum accepted IPC frame67108864
PYROXIDE_MAX_NATIVE_OUTPUT_BYTESMaximum copied native byte-buffer result67108864
PYROXIDE_MAX_TASKS_PER_WORKERIsolated tasks before recycle; 0 disables100
PYROXIDE_WORKER_STARTUP_TIMEOUT_SECIsolated worker startup timeout5
PYROXIDE_IDLE_TIMEOUT_SECIdle time before eligible worker reaping60
PYROXIDE_MIN_WORKERSExisting idle workers protected from reaping0
PYROXIDE_WASM_MEMORY_LIMIT_BYTESPer-invocation WASM memory limit104857600
PYROXIDE_WASM_TIMEOUT_MSWASM epoch deadline1000
PYROXIDE_WASM_TICK_MSEpoch increment interval10
PYROXIDE_CACHE_DIRNative compiler cache~/.pyroxide/cache
PYROXIDE_COMPILER_TIMEOUT_SECPer native compiler command timeout300
PYROXIDE_DISABLE_COMPILATIONReject runtime source compilationdisabled

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()
KeyMeaning
worker_countConfigured in-process worker threads
max_processesMaximum concurrent isolated workers
queue_capacityPending admission capacity
queued_tasksAccepted tasks not yet taken by a worker
running_tasksTasks currently executing
active_tasksTask records still retained by handles
submitted_tasksLifetime accepted submissions
rejected_tasksLifetime capacity/channel rejections
completed_tasksLifetime successful completions
failed_tasksLifetime failed completions
cancelled_tasksLifetime 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=True cancels work that has not started. Running in-process work still completes; running isolated work is not automatically user-cancelled.
  • wait=False initiates shutdown and returns promptly.
  • A Pyroxide worker task cannot call shutdown(wait=True) because shutdown would wait for that task. Use wait=False or 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=1 when 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.

  1. Run the test suite on 1.0.0rc1 under your oldest and newest Python versions.
  2. Load-test bounded admission and record rejection behavior.
  3. Exercise cancellation, worker crashes, fork behavior, and graceful shutdown.
  4. Canary one service instance and monitor queue, failure, memory, and latency metrics.
  5. 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 ProcessPoolExecutor in 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

  1. Compare systems with the same durability and isolation semantics. @task is comparable to an in-process executor; Celery is a distributed, durable queue and answers a different problem.
  2. Use identical work and input data. Do not compare compiled native work with interpreted Python and attribute the difference to scheduling.
  3. Fix and report worker counts, affinity, power mode, and dependency versions.
  4. Separate cold results from warm steady-state results. Process creation, JIT, module loading, and runtime compilation belong in cold-start measurements.
  5. Run enough repetitions and report distributions such as median and p95, not one best sample.
  6. Verify every result and report failures. A fast benchmark that skipped work is invalid.
  7. Save the command, configuration, platform metadata, and raw machine-readable output with any published number.
QuestionAppropriate comparison
In-process Python scheduling@task vs ThreadPoolExecutor
CPU-bound Python isolationisolated tasks vs ProcessPoolExecutor or loky
Free-threaded Pythonsame Python function and worker count on CPython 3.14t
Native executionsame compiled algorithm through Pyroxide and a direct binding
WASM overheadsame module and ABI through comparable Wasmtime hosts
Large IPC payloadsame 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 / batchThreadPoolPyroxide threadedProcessPoolPyroxide isolated
3.14, 32 CPU tasks65.20 ms55.49 ms17.79 ms19.22 ms
3.14t, 32 CPU tasks18.91 ms18.03 ms13.31 ms15.88 ms
3.14, 1,000 trivial tasks6.29 ms18.25 ms157.46 ms52.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 ProcessPoolExecutor in 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:

BackendMedianPeak process-tree RSS
loky59.07 ms179 MiB
ProcessPoolExecutor60.47 ms156 MiB
InterpreterPoolExecutor65.97 ms74 MiB
joblib96.20 ms211 MiB
Pyroxide threaded171.14 ms34 MiB
ThreadPoolExecutor182.83 ms30 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.

NeedUsually choose
One straightforward local thread or process poolconcurrent.futures
One embedded API for Python threads, isolated processes, WASM, and native librariesPyroxide
Durable jobs, retries, schedules, routing, or multi-host workersCelery, RQ, Dramatiq, Temporal, or a managed queue
Distributed data or compute schedulingRay or Dask
One stable native algorithm known at build timePyO3, nanobind, Cython, or another direct extension
A custom WASM host with its own imports and component modelWasmtime 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

  1. Must accepted work survive the application process? If yes, use a durable queue.
  2. Must work run across hosts? If yes, use a distributed queue or cluster runtime.
  3. Is one standard executor enough? If yes, keep the standard library.
  4. 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

Generated Pyroxide API

This placeholder is replaced by the generated pdoc page during the documentation build.