Callbacks let Python code running in a worker call Swift. Use them for host services, progress decisions, scoring functions, cancellation policy, streaming data sources, or any operation that must stay in Swift while Python controls the loop.
Pool callbacks are registered on PythonProcessPool and reinstalled when a
worker respawns.
Synchronous Callbacks
let registration = try await pool.registerCallback(name: "add") {
@Sendable (a: Int, b: Int) -> Int in
a + b
}
let value: Int = try await pool.evalResult("""
import swift_bridge
swift_bridge.call("add", 3, 7)
""")
Keep registration alive for as long as Python should be able to call it.
CallbackRegistration unregisters when it is deallocated.
Typed callback overloads support one through four arguments. Async callbacks
also include a zero-argument typed overload, and reentrant callbacks support
WorkerCallbackContext plus zero through four arguments. Use a single
payload/envelope value beyond four fields, or the raw [Any] form for dynamic
argument lists:
let anyArgs = try await pool.registerCallback(name: "describe") {
@Sendable (args: [Any]) -> Any in
"received \(args.count) args"
}
Use simple JSON-compatible values for cross-process callbacks: numbers, strings, booleans, and arrays of those values. For large data, pass a handle or file path and let Python fetch the data where it already lives.
Reentrant Callbacks
Use a reentrant callback when Swift needs to call back into the same worker that triggered the callback.
let registration = try await pool.registerReentrantCallback(name: "objective") {
@Sendable (ctx: WorkerCallbackContext, params: [Double]) -> Double in
let baseline: Double = try ctx.evalResult("current_baseline_score()")
return baseline + penalty(params)
}
WorkerCallbackContext gives you same-worker access:
| API | Use |
|---|---|
workerID |
Identify the worker that called Swift |
eval |
Run code and keep a handle descriptor on that worker |
evalResult |
Run code and return a Swift value |
release(id:) |
Release a handle created through the context |
sendNestedCommand |
Low-level escape hatch for custom integrations |
Reentrant callbacks avoid deadlocks by routing nested work through the worker's callback-safe path.
Do not call WorkerProcess.sendCommand() directly from a callback handler for
the same worker. That callback-stack reentry is rejected immediately as
PythonWorkerError.reentrantCallback; use WorkerCallbackContext for nested
same-worker work. The guard is scoped to callback-stack reentry, so unrelated
top-level commands to the same worker may still proceed while a callback is
active.
Raw Callbacks
Use raw callbacks when you want to own the JSON envelope instead of using a typed overload. The input is a JSON array of positional arguments; return a JSON array containing exactly one result value.
let registration = try await pool.registerRawCallback(name: "uppercase_json") {
@Sendable data in
let args = try JSONSerialization.jsonObject(with: data) as? [Any] ?? []
let object = args.first as? [String: Any]
let text = object?["text"] as? String ?? ""
return try JSONSerialization.data(
withJSONObject: [["text": text.uppercased()]]
)
}
Raw reentrant callbacks receive WorkerCallbackContext as a second argument.
Async Callbacks
Use async callbacks when the Swift handler needs to await host-side work while
Python receives a concurrent.futures.Future.
let registration = try await pool.registerAsyncCallback(name: "approve") {
@Sendable (payload: String) async throws -> String in
try await permissionCoordinator.resolve(payload)
}
Python side:
import swift_bridge
future = swift_bridge.call_async("approve", payload)
raw = future.result(timeout=300)
Async callbacks are a ProcessPool protocol-v5 feature. call_async can also
invoke sync callbacks and wrap their result in a Future. Calling an async
callback through swift_bridge.call(...) fails with guidance to use
swift_bridge.call_async; calling a streaming callback through call_async
fails with guidance to use swift_bridge.call_stream.
Streaming Callbacks
Streaming callbacks let Swift provide an iterator to Python.
let registration = try await pool.registerStreamingCallback(name: "numbers") {
@Sendable (count: Int) throws -> StreamingCallbackIterator in
StreamingCallbackIterator(bufferCapacity: 8) { yield in
for i in 0..<count {
let data = try JSONSerialization.data(withJSONObject: i)
try yield(data)
}
}
}
Python side:
import swift_bridge
for item in swift_bridge.call_stream("numbers", 100):
print(item)
Iterator options:
| Initializer | Behavior |
|---|---|
StreamingCallbackIterator(produce:) |
Eager producer; simple but can buffer all output |
StreamingCallbackIterator(bufferCapacity:produce:) |
Bounded producer; yield can throw on cancellation |
Prefer the bounded initializer for unbounded or large streams.
Python swift_bridge
Inside worker Python code:
import swift_bridge
swift_bridge.call(name, *args)
swift_bridge.call_async(name, *args)
swift_bridge.call_stream(name, *args)
swift_bridge.is_registered(name)
swift_bridge.registered_names()
swift_bridge.progress("optional hint")
swift_bridge.check_cancel()
progress and check_cancel are covered in
Chapter 5. They are useful inside Python generators even
when no Swift callback is registered.
ProcessPool callbacks accept positional arguments only. call and
call_async reject keyword arguments instead of silently dropping them;
call_stream is positional by definition.
Error Propagation
| Swift side | Python side |
|---|---|
| Callback throws | Python receives a runtime error |
| Callback name missing | Python receives a key error |
| Argument conversion fails | Python receives a type error |
| Reentrant Python call fails | Error propagates through the callback |
Design callback errors as part of your API. If Python can recover, throw clear messages and catch them in Python.
Callback Lifetime
Store registrations in the owning service:
actor HostBridge {
private let pool: PythonProcessPool
private var registrations: [CallbackRegistration] = []
init(pool: PythonProcessPool) {
self.pool = pool
}
func install() async throws {
let log = try await pool.registerCallback(name: "host_log") {
@Sendable (message: String) -> Bool in
print(message)
return true
}
registrations.append(log)
}
}
Explicit removal is also available:
try await pool.unregisterCallback(name: "host_log")
unregisterCallback(name:) removes sync, async, and streaming registrations for
that name.
Observability
When a worker dies with callbacks in flight, pool.events() emits
.callbackOrphaned. This is diagnostic information; the original pool command
still fails through its normal error path.
Task {
for await event in pool.events() {
if case .callbackOrphaned(let workerID, _, let name, let kind, _) = event {
logger.warning("callback \(name) on worker \(workerID) orphaned: \(String(describing: kind))")
}
}
}
Use this to annotate logs, cancel dependent UI work, or explain why a callback never returned.
Common Pitfalls
| Issue | Fix |
|---|---|
| Callback stops working unexpectedly | Keep the returned CallbackRegistration alive |
| Large payloads make callbacks slow | Pass handles, file paths, or shared memory instead |
| Callback needs same-worker Python state | Use registerReentrantCallback |
| Swift callback needs to await host work | Use registerAsyncCallback and swift_bridge.call_async |
| Python iterator should stop when consumer leaves | Use bounded StreamingCallbackIterator and handle thrown cancellation |
| Callback errors are hard to debug | Include stable callback names and log .callbackOrphaned events |