# Threading Model

nanosynth uses multiple threads internally. This page documents which threads exist, what they do, and what is safe to call from where.

## Thread overview

| Thread | Created by | Purpose | Lifetime |
|--------|-----------|---------|----------|
| Main thread | User | SynthDef construction, Server API calls, interactive use | Entire process |
| Engine thread | `EmbeddedProcessProtocol.boot()` | Runs the embedded scsynth/supernova audio engine | Boot to quit (daemon) |
| Reply callback | Engine internals | Dispatches OSC replies from the engine back to Python | While engine is running |
| Clock thread | `Clock.start()` | Drives pattern scheduling via `time.monotonic()` | Start to stop (daemon) |
| Timer threads | `Player._tick()` | One-shot threads that release synth gates after sustain duration | Short-lived (daemon) |
| MIDI thread | `MidiIn()` | RtMidi's internal polling thread for MIDI input | While port is open |

All background threads are daemon threads -- they do not prevent process exit.

## Engine lifecycle

`EmbeddedProcessProtocol` manages the audio engine on a background thread. The lifecycle is a state machine:

```
OFFLINE --> BOOTING --> ONLINE --> QUITTING --> OFFLINE
```

Only one engine instance (World) can exist per process. This is enforced by a class-level `threading.Lock` (`_active_world_lock`) that is held for the entire lifetime of the engine:

```python
from nanosynth import Server, Options

# boot() acquires the lock, starts the engine, launches the daemon thread
with Server(Options(verbosity=0)) as server:
    # engine is ONLINE, daemon thread is blocked in world_wait_for_quit()
    server.synth("sine", frequency=440.0)
# quit() signals shutdown, joins daemon thread, releases the lock
```

The daemon thread blocks in a C++ call (`world_wait_for_quit`) until the engine shuts down. When shutdown occurs -- either from `quit()` or an engine panic -- the thread cleans up, transitions to `OFFLINE`, and signals an `exit_future` that callers can await.

`EmbeddedSupernovaProtocol` follows the same pattern but runs supernova's event loop on its daemon thread instead.

## Reply dispatch

When the engine sends an OSC response (e.g., `/done`, `/n_go`, `/b_info`), it invokes a reply callback from its internal UDP processing thread. `Server._dispatch_reply()` handles this:

1. Decodes the raw OSC bytes into an `OscMessage`

2. Acquires `_reply_lock` to snapshot the handler and waiter lists

3. Releases the lock

4. Invokes all persistent handlers registered via `server.on(address, callback)`

5. Signals all one-shot waiters registered via `server.wait_for_reply(address)`

Handler exceptions are caught and logged individually -- one failing handler does not prevent others from running.

```python
# Persistent handler -- runs on every /n_go message
def on_node_created(msg):
    print(f"Node {msg.contents[0]} created")

server.on("/n_go", on_node_created)

# One-shot waiter -- blocks until a specific reply arrives
reply = server.send_msg_sync("/b_query", 0, reply_address="/b_info")
```

Handlers run on the reply callback thread, not the main thread. Keep them fast. If a handler needs to interact with shared state, use explicit locking.

## Thread-local SynthDef builders

`SynthDefBuilder` uses a `threading.local()` stack to isolate concurrent graph construction. Each thread maintains its own stack of active builders:

```python
import threading
from nanosynth import SynthDefBuilder, SinOsc, Out

def build_in_thread(freq):
    with SynthDefBuilder(frequency=freq) as b:
        # This builder is only visible to this thread.
        # Other threads have their own independent stacks.
        Out.ar(bus=0, source=SinOsc.ar(frequency=b["frequency"]))
    return b.build(name=f"sine_{freq}")

threads = []
for freq in [220, 440, 880]:
    t = threading.Thread(target=build_in_thread, args=(freq,))
    threads.append(t)
    t.start()

for t in threads:
    t.join()
```

Each UGen is stamped with its builder's UUID at creation time. If a UGen from one builder is passed as input to a UGen in a different builder, a `SynthDefError` is raised. This prevents accidental cross-contamination between concurrent builds.

## Clock and Player scheduling

The `Clock` runs a daemon thread that drives pattern playback. Its main loop:

1. Acquires `_lock`, copies the player list

2. For each player whose scheduled time has arrived, calls `player._tick(now)`

3. Sleeps until the next scheduled event (adaptive wake time via `_stop_event.wait(timeout=...)`)

`Player._tick()` pulls the next event from its pattern, creates a synth via `server.synth()`, and -- for gated envelopes -- spawns a `threading.Timer` daemon thread to send `gate=0.0` after the sustain duration:

```python
import time
from nanosynth import Server, Clock, Player, Pbind, Pseq

with Server() as server:
    synthdef.send(server)
    time.sleep(0.1)

    clock = Clock(bpm=120)
    # Player._tick() runs on the clock thread
    # Gate release runs on short-lived Timer threads
    player = Player(server, Pbind(name="sine", dur=Pseq([0.25, 0.5], repeats=4)))
    clock.add(player)
    clock.start()

    time.sleep(4.0)
    clock.stop()
```

The `Clock._lock` protects mutation of the player list (add/remove). Player state itself (`_stopped`, `_next_time`) uses simple attribute writes that are atomic under CPython's GIL. `Player.stop()` can be called from the main thread while the clock thread is ticking -- the `_stopped` flag is checked at the top of each tick.

## What is thread-safe

| Operation | Safe from any thread? | Notes |
|-----------|----------------------|-------|
| `server.synth()`, `server.free()`, `server.set()` | Yes | Delegates to `send_packet()`, a synchronous C call |
| `server.send_msg()`, `server.send_msg_sync()` | Yes | Same as above; `send_msg_sync` blocks the calling thread |
| `server.on()`, `server.off()` | Yes | Protected by `_reply_lock` |
| `server.wait_for_reply()` | Yes | Uses `threading.Event`, blocks calling thread |
| `SynthDefBuilder` context manager | Yes | Thread-local stacks, UUID scope checking |
| `clock.add()`, `clock.remove()` | Yes | Protected by `Clock._lock` |
| `player.stop()` | Yes | Atomic flag write |
| `node.set()`, `node.free()` | Yes | Delegates to `server.set()` / `server.free()` |
| `NodeProxy` source swap | No | No internal locking; use from one thread |
| `Ndef` registry access | No | Global dict, no locking |
| MIDI handler registration | No | Not locked; register before starting playback |
