API guide / 05

Streaming

Consume generators and progress from Swift.

Source: API guide ↗. Package: 0.6.0-duplex.8.4.

Use streaming when Python produces values over time: token generation, progress updates, sensor data, long transforms, search results, or incremental parsing.

All pool streaming APIs return CancellableStream, a custom AsyncSequence that sends a cooperative cancel signal when the consumer stops iterating.

Value Streams

swift
let stream: CancellableStream<Int> = try await pool.evalStream("range(10)")

for try await value in stream {
    print(value)
}

For a module function:

swift
let tokens: CancellableStream<String> = try await pool.invokeStream(
    module: "my_inference",
    function: "generate_tokens",
    args: [.python("Write a haiku")]
)

For a method on a remote object:

swift
let model = try await pool.invokeOwned(
    module: "my_inference",
    function: "load_model",
    args: [.python("/models/model.bin")]
)

let tokens: CancellableStream<String> = try await pool.methodStream(
    handle: model,
    name: "stream",
    args: [.python("Hello")]
)

Decode Tiers

Each stream verb has two public forms:

Form Use
T: PythonConvertible Python yields pickle-compatible values that map directly to Swift
decode: (Data) async throws -> T You want to decode the raw pickled payload into your own type

The custom decoder receives raw pickled data. Do not pass it directly to JSONDecoder. Use a decoder appropriate to the actual wire value, or first return a Python JSON string through a typed CancellableStream<String> and then decode that string in your application.

StreamOptions

StreamOptions carries per-stream timeout, worker affinity, and channel capacity.

swift
let stream: CancellableStream<Int> = try await pool.evalStream(
    "slow_generator()",
    options: .longRunning(timeout: 1800)
)

let pinned: CancellableStream<Double> = try await pool.methodStream(
    handle: model,
    name: "predict_stream",
    options: .pinned(worker: 0)
)

let custom = StreamOptions(
    timeout: 120,
    workerAffinity: 0,
    channelCapacity: 8
)

WorkerContext stream helpers are already pinned to the context worker.

Stopping a Stream

Breaking out of the loop cancels the stream promptly.

swift
let stream: CancellableStream<Double> = try await pool.evalStream("""
import itertools
itertools.count(0.0, 0.1)
""")

for try await value in stream {
    if value >= 1.0 {
        break
    }
}

Python generators can cooperate with cancellation:

python
from swift_bridge import check_cancel

def generate():
    for item in expensive_source():
        check_cancel()
        yield item

check_cancel() raises KeyboardInterrupt when the Swift consumer has stopped iterating. Outside an active stream it is a no-op.

For code without checkpoints, use a timeout and then replace the worker if the workload stays wedged:

swift
try await pool.respawnWorker(0, reason: .userInitiated, force: true)

C extensions blocked in syscalls may not stop until the syscall returns. Design long-running Python iterators with checkpoints when you need graceful cancel.

Timeouts and Keepalive

swift
let ipc = IPCConfiguration(
    receiveTimeout: 30,
    streamKeepaliveInterval: 5,
    respawnOnTimeout: true
)

receiveTimeout is the maximum time the pool waits for stream activity. streamKeepaliveInterval lets the worker prove the stream is alive even when no user value is ready. With respawnOnTimeout, a timed-out stream can replace the wedged worker so later work starts fresh.

For long-running but healthy streams, prefer StreamOptions.longRunning(timeout:) or a custom timeout rather than disabling timeouts globally.

Timeout cleanup is stream-scoped. When a stream times out, the runtime aborts that stream and drains stale frames through the stream's demux channel before returning, so a per-call timeout override does not fall through to the pool-wide receive timeout.

Progress Events

Use swift_bridge.progress in Python to report semantic progress.

python
from swift_bridge import progress

def train():
    progress("loading data")
    data = load_data()
    for epoch in range(10):
        train_one_epoch(data, epoch)
        progress(f"epoch {epoch + 1}/10")
        yield epoch

Value-only *Stream APIs drop progress frames. Use *Events APIs to receive both values and progress.

swift
let events: CancellableStream<StreamEvent<Int>> = try await pool.evalEvents("train()")

for try await event in events {
    switch event {
    case .value(let epoch):
        print("completed epoch \(epoch)")
    case .progress(_, let hint):
        print(hint ?? "working")
    }
}

The event-stream entry points are:

Verb API
Eval code evalEvents(_:bindings:options:)
Module function invokeEvents(module:function:args:kwargs:options:)
Handle method methodEvents(handle:name:args:kwargs:options:)

Each also has a custom decode: form.

Ordering Contract

For *Events, progress and values are delivered in the order Python produced them. If Python calls progress("a"), yields 1, calls progress("b"), and yields 2, Swift observes those four events in that order.

Streaming From a Specific Worker

swift
let worker = pool.worker(1)
let stream: CancellableStream<String> = try await worker.invokeStream(
    module: "my_jobs",
    function: "tail_logs",
    args: [.python("/tmp/job.log")],
    timeout: 300
)

Worker contexts are useful when the stream depends on a handle or Python state that exists on a specific worker.

Out-of-Band Streaming

evalStream / invokeStream / methodStream send each chunk over the worker's main IPC socket. For the duration of the stream, that socket is held by the streaming command — any other eval / invoke on the same worker has to wait its turn.

When you need a long-lived stream that does not tie up the worker, ask the runtime for an opaque ManagedOutputBuffer. The runtime selects and owns the transport; the public API does not expose mapping names, ring headers, socket framing, or isolation-specific endpoints.

swift
let output = try await pool.startOutputStream(
    generatorCode: "(json.dumps(frame) for frame in frames())",
    worker: 0,
    capacity: 65_536
)

while !output.isFinished {
    let chunk = output.readAvailable()
    if !chunk.isEmpty { handle(chunk) }
    try await Task.sleep(nanoseconds: 10_000_000)
}

generatorCode must be a Python expression that evaluates to an iterable of str or bytes. It can reference names installed in that worker's persistent namespace. The worker's main command channel remains available for regular calls while output is produced.

Polling Pattern

swift
let output = try await pool.startOutputStream(
    generatorCode: "telemetry(n=200)",
    worker: 0,
    capacity: 64 * 1024
)

var lineBuffer = Data()
while !output.isFinished || !lineBuffer.isEmpty {
    let chunk = output.readAvailable()
    if !chunk.isEmpty {
        lineBuffer.append(chunk)
        while let nl = lineBuffer.firstIndex(of: 0x0A) {
            let line = Data(lineBuffer[..<nl])
            lineBuffer.removeSubrange(...nl)
            handle(line) // parse one framed message
        }
    }
    try await Task.sleep(nanoseconds: 10_000_000) // ~10 ms
}

To stop early, call output.cancel(). Inspect output.failed after completion when the application needs to distinguish a producer failure.

When to Reach for OOB Streaming vs evalStream

Need Use
Bounded iteration where the worker has nothing else to do evalStream / invokeStream / methodStream
Long-lived telemetry, log tail, or progress feed startOutputStream + ManagedOutputBuffer
Same generator in a different isolation provider The same startOutputStream call; the runtime selects the route
Main commands while a long-running output stream is active Regular pool calls plus ManagedOutputBuffer polling

Common Pitfalls

Issue Fix
Progress never appears Use evalEvents / invokeEvents / methodEvents, not value-only streams
Stream times out while doing real work Increase stream timeout or keep the default keepalive enabled
Cancel does not stop a tight loop Add swift_bridge.check_cancel() checkpoints or use forced respawn
A stream returns huge objects Yield smaller chunks or use shared memory handles
UI updates arrive too fast Coalesce stream events before publishing to the main actor
evalStream blocks other work on the same worker Move the long-lived producer to startOutputStream
Output consumer misses the last bytes Drain readAvailable() once more after isFinished is true