Come si collegano i componenti, quale ruolo svolge ciascuno e come i frame scorrono attraverso il sistema.
Topologia del sistema
CapDAG dispone i componenti su tre livelli. L’engine sta in cima ed emette le richieste di invocazione delle capacità. Le cartucce stanno in fondo ed eseguono quelle richieste. Tra i due, una rete di relay instrada i frame verso la destinazione corretta.
graph TD
Engine["Engine / esecutore del DAG"]
Switch["RelaySwitch<br/>Instrada le REQ per cap URN<br/>Instrada le continuazioni per RID<br/>Tiene traccia delle invocazioni peer"]
Engine --> Switch
subgraph Host0 ["Host 0"]
RM0["RelayMaster"] --> RS0["RelaySlave"]
RS0 --> PH0["CartridgeHostRuntime"]
PH0 --> P0A["Cartuccia A"]
PH0 --> P0B["Cartuccia B"]
end
subgraph Host1 ["Host 1"]
RM1["RelayMaster"] --> RS1["RelaySlave"]
RS1 --> PH1["CartridgeHostRuntime"]
PH1 --> P1A["Cartuccia C"]
end
subgraph Host2 ["Host 2"]
RM2["RelayMaster"] --> RS2["RelaySlave"]
RS2 --> PH2["CartridgeHostRuntime"]
PH2 --> P2A["Cartuccia D"]
PH2 --> P2B["Cartuccia E"]
end
Switch --> RM0
Switch --> RM1
Switch --> RM2
Ogni CartridgeHostRuntime gestisce uno o più processi di cartuccia. Le coppie RelayMaster/RelaySlave collegano ciascun host al RelaySwitch centrale tramite socket Unix. Lo switch aggrega tutte le capacità di tutti gli host e instrada le richieste verso quello corretto.
Tipi di connessione:
- Switch ↔ Master: socket Unix
- Master ↔ Slave: socket Unix (la stessa coppia)
- Host ↔ Cartuccia: pipe stdin/stdout
Sorgente: capdag-rs/src/bifaci/relay_switch.rs, relay.rs, host_runtime.rs, cartridge_runtime.rs.
Ruoli dei componenti
RelaySwitch
Il RelaySwitch è un multiplexer di instradamento che si trova tra l’engine e i relay master. Quando l’engine (o l’esecutore del DAG dell’orchestratore) deve invocare una capacità, lo switch decide a quale master inviare la richiesta.
L’instradamento avviene in due fasi:
-
Frame REQ: lo switch confronta il cap URN della richiesta con l’insieme di capacità di ciascun master usando
is_dispatchable(vedi Dispatch). Quando più master possono gestire una cap, lo switch sceglie la corrispondenza più specifica usando la funzione di ranking (vedi Ranking). Lo switch assegna alla richiesta un ID di instradamento (XID) e registra la mappatura, così che i frame di risposta possano ritrovare la strada del ritorno. -
Frame di continuazione (STREAM_START, CHUNK, STREAM_END, END, ERR, LOG): instradati in base alla coppia (XID, RID) registrata quando è stata inviata la REQ. Non avviene alcuna corrispondenza di cap, perché la tabella di instradamento conosce già la destinazione.
Lo switch usa la mutabilità interna (RwLock, AtomicU64, Mutex) affinché più esecuzioni di DAG possano instradare frame in modo concorrente senza detenere lock esclusivi.
Sorgente: capdag-rs/src/bifaci/relay_switch.rs.
RelaySlave e RelayMaster
Una coppia RelaySlave/RelayMaster forma un ponte trasparente per i frame su un socket Unix. Lo slave si trova nel processo host (ad es. un servizio XPC); il master si trova nel processo dell’engine, accanto al RelaySwitch.
Tutti i frame ordinari attraversano il relay invariati in entrambe le direzioni. Due tipi di frame vengono intercettati e non trapelano mai:
- RelayNotify (slave → master): lo slave lo invia per annunciare le capacità delle sue cartucce. Il master memorizza il manifest e i limiti. Lo switch li legge per costruire la propria tabella di instradamento.
- RelayState (master → slave): il master lo invia per fornire allo slave le risorse dell’host (percorsi dei modelli, disponibilità della GPU, ecc.). Lo slave memorizza il payload e lo rende disponibile al CartridgeHostRuntime.
Il relay applica inoltre un ReorderBuffer sul lato slave (direzione socket → locale) per garantire che i frame arrivino nell’ordine di sequenza nonostante possibili differenze di temporizzazione tra i master.
Sorgente: capdag-rs/src/bifaci/relay.rs.
CartridgeHostRuntime
Il CartridgeHostRuntime gestisce più processi di cartuccia all’interno di un singolo host. Si collega al relay slave da un lato e ai processi di cartuccia dall’altro, tramite pipe stdin/stdout.
Le sue responsabilità:
- Handshake: quando una cartuccia serve per la prima volta, l’host avvia il processo, esegue lo scambio HELLO per ottenere il manifest della cartuccia ed esegue la verifica dell’identità (vedi Handshake).
- Instradamento delle richieste: i frame REQ in arrivo vengono instradati alla cartuccia che gestisce il cap URN richiesto. L’avvio su richiesta fa sì che le cartucce partano solo quando le loro capacità vengono invocate per la prima volta.
- Instradamento delle continuazioni: i frame STREAM_START, CHUNK, STREAM_END, END ed ERR vengono instradati in base alla coppia (XID, RID) verso la cartuccia che sta gestendo la richiesta originale.
- Instradamento delle chiamate peer: quando una cartuccia invia una REQ (un’invocazione peer), l’host registra la cartuccia di origine, inoltra la richiesta al relay con lo XID appropriato e instrada la risposta di ritorno alla cartuccia richiedente.
- Monitoraggio dello stato di salute: ogni 30 secondi vengono inviate sonde di heartbeat. Se una cartuccia non risponde entro 10 secondi, viene contrassegnata come non in salute. Se un processo di cartuccia termina, l’host invia frame ERR per tutte le richieste in sospeso e contrassegna la cartuccia come morta.
Sorgente: capdag-rs/src/bifaci/host_runtime.rs.
CartridgeRuntime
Il CartridgeRuntime è la controparte sul lato della cartuccia. Legge i frame da stdin, li smista agli handler registrati in base al cap URN e scrive i frame di risposta su stdout.
Comportamenti principali:
- Rilevamento della modalità: se il processo viene lanciato con argomenti da riga di comando, il runtime funziona in modalità CLI (analizzando gli argomenti dalle definizioni delle cap). Se viene lanciato senza argomenti, funziona in modalità CBOR della cartuccia (leggendo i frame da stdin). Ciò permette allo stesso binario di servire sia come strumento CLI sia come cartuccia CapDAG.
- Registrazione degli handler: gli handler vengono registrati per cap URN. Ogni handler riceve un
InputStream(o unInputPackageper le cap con più argomenti), unOutputStreamper scrivere i dati di risposta e unPeerInvokerper chiamare altre cartucce. - Task di scrittura: un task tokio dedicato svuota il canale di output e scrive i frame su stdout. Questa separazione fa sì che il codice degli handler possa accodare frame senza bloccarsi sull’I/O.
- Richieste multiplexate: più richieste possono essere in corso in modo concorrente. Ogni richiesta ottiene un proprio task con i propri canali di input/output.
Sorgente: capdag-rs/src/bifaci/cartridge_runtime.rs.
InProcessCartridgeHost
L’InProcessCartridgeHost è un’alternativa al CartridgeHostRuntime che smista i frame direttamente a oggetti trait FrameHandler nello stesso processo, senza avviare binari separati. Si collega a un RelaySlave allo stesso modo e instrada i frame usando lo stesso protocollo, ma il codice degli handler viene eseguito come task asincroni anziché come processi esterni.
Viene usato per i test e per scenari embedded in cui il costo dell’avvio di processi non è necessario.
Sorgente: capdag-rs/src/bifaci/in_process_host.rs.
Flusso dei frame
Ciclo di vita di una richiesta
Un’invocazione completa di una capacità segue questo percorso:
sequenceDiagram
participant E as Engine
participant SW as RelaySwitch
participant RS as RelaySlave
participant PH as CartridgeHost
participant P as Cartuccia
E->>SW: REQ(cap)
Note over SW: assegna lo XID, corrispondenza cap → master
SW->>RS: REQ(cap, xid)
RS->>PH: REQ(cap, xid)
Note over PH: corrispondenza cap → cartuccia
PH->>P: REQ(cap, xid)
Note over P: l'handler viene eseguito
P-->>PH: STREAM_START
P-->>PH: CHUNK(data)
P-->>PH: CHUNK(data)
P-->>PH: STREAM_END
P-->>PH: END
PH-->>RS: (inoltrato)
RS-->>SW: (inoltrato)
SW-->>E: (inoltrato)
La REQ porta il cap URN nella chiave 10. Il RelaySwitch assegna uno XID (routing_id, chiave 11) e registra la mappatura (XID, RID) → destination master. Tutti i frame successivi di questa richiesta portano la stessa coppia (XID, RID) e seguono il percorso registrato.
I frame di risposta tornano indietro attraverso gli stessi componenti in ordine inverso. Il relay e lo switch sono trasparenti rispetto al payload; esaminano solo frame_type, id e routing_id per le decisioni di instradamento.
Flusso di un’invocazione peer
Quando la cartuccia A deve chiamare una capacità fornita dalla cartuccia B:
sequenceDiagram
participant PA as Cartuccia A
participant PHA as CartridgeHost A
participant RSA as RelaySlave A
participant SW as RelaySwitch
participant PB as Cartuccia B
PA->>PHA: REQ(cap), senza XID
Note over PHA: registra il RID in outgoing_rids
PHA->>RSA: REQ(cap)
RSA->>SW: REQ(cap)
Note over SW: assegna lo XID, registra il peer
SW->>PB: REQ(cap, xid)
PB-->>SW: frame di risposta
SW-->>RSA: risposta (per XID)
RSA-->>PHA: risposta
Note over PHA: instrada per RID alla cartuccia A
PHA-->>PA: PeerResponse
La cartuccia A invia una REQ senza XID, perché la cartuccia non conosce gli ID di instradamento. Il CartridgeHostRuntime registra il RID della richiesta in outgoing_rids e la inoltra. Il RelaySwitch assegna uno XID, registra la richiesta come invocazione peer in peer_requests e la instrada verso il master della cartuccia B. I frame di risposta tornano indietro lungo lo stesso percorso, con lo switch che usa la mappatura (XID, RID) per instradarli verso il master della cartuccia A.
Il CartridgeHostRuntime sul lato della cartuccia A instrada la risposta al canale PeerResponse della cartuccia A facendo corrispondere il RID di outgoing_rids.
Verifica dell’identità
Dopo l’handshake HELLO, l’host esegue la verifica dell’identità per dimostrare che l’intero stack di protocollo funziona prima di inviare traffico reale.
L’host invia una REQ per CAP_IDENTITY (cap:effect=none) con un nonce deterministico: Text("bifaci") codificato in CBOR, un valore di 7 byte. La richiesta segue lo schema di streaming standard: REQ → STREAM_START → CHUNK(nonce) → STREAM_END → END. L’handler di identità della cartuccia restituisce il nonce con lo stesso schema di streaming.
L’host confronta i byte restituiti con il nonce originale. Una discrepanza significa che lo stack di protocollo è rotto: la codifica dei frame, l’instradamento o lo smistamento agli handler ha un bug. Si tratta di un errore fatale; la connessione viene abbandonata.
Sorgente: capdag-rs/src/bifaci/io.rs (verify_identity, identity_nonce).
Versione del protocollo
La versione attuale del protocollo è 5, impostata nella costante PROTOCOL_VERSION (capdag-rs/src/bifaci/frame.rs). Ogni frame la porta nella chiave 0, e l’handshake HELLO la impone: un HELLO che dichiara qualsiasi altra versione è un fallimento definitivo dell’handshake (nessun funzionamento tra versioni diverse).
La versione 5 fornisce:
- streaming multiplexato: uno o più stream con nome (STREAM_START/CHUNK/STREAM_END) terminati da END, con i campi di integrità chunk_index/chunk_count/checksum e routing_id per un instradamento consapevole dei relay;
- controllo di flusso per stream basato su crediti (il frame Credit e il limite negoziato
initial_credit; vedi Controllo di flusso); - stream illimitati;
- metadati terminali su END (l’avanzamento finale viaggia nel frame terminale); e
- stato unificato per richiesta con conteggio degli scarti, e istantanee delle statistiche di protocollo; e
- più output per richiesta: una risposta ha uno stream per ciascun output su cui la sua cap risponde; lo stream di un output diverso da quello principale nomina l’output che è (chiave 21 del frame), e un output facoltativo può non aprire alcuno stream.
Modello di threading
Rust (tokio)
graph LR
subgraph Tokio Workers
RT["Task di lettura<br/>(stdin → dispatch)"]
WT["Task di scrittura<br/>(channel → stdout)"]
H1["Task handler 1"]
H2["Task handler 2"]
end
subgraph Blocking Pool
BL["spawn_blocking<br/>(caricamento del modello via FFI)"]
end
RT -->|instrada i frame| H1
RT -->|instrada i frame| H2
H1 -->|invia i frame| CH["canale mpsc"]
H2 -->|invia i frame| CH
BL -.->|ProgressSender| CH
CH --> WT
Il CartridgeRuntime per Rust usa un runtime tokio multi-thread. I componenti principali:
- Thread principale: chiama
tokio::runtime::Runtime::block_on()per avviare il ciclo di eventi asincrono. - Task di lettura: legge i frame da stdin e li smista ai task degli handler in base all’ID della richiesta.
- Task di scrittura: un task
tokio::spawnche riceve i frame da un canalempsce li scrive su stdout. Tutti gli handler inviano i frame attraverso questo canale anziché scrivere direttamente su stdout. Ciò serializza l’output e impedisce l’interfogliamento. - Task degli handler: un
tokio::spawnper ogni richiesta attiva. Ciascuno esegue il codice asincrono dell’handler registrato. - FFI bloccante: i caricamenti dei modelli e le altre chiamate bloccanti usano
tokio::task::spawn_blockingper essere eseguiti su un pool di thread dedicato. Ciò evita di bloccare i thread worker di tokio di cui hanno bisogno il task di scrittura e gli altri task asincroni. Vedi Avanzamento e logging per il meccanismo di keepalive che impedisce i timeout di attività durante il lavoro bloccante.
Swift (concorrenza strutturata)
Il CartridgeRuntime per Swift usa la concorrenza strutturata di Apple:
run(): blocca il thread chiamante usando unDispatchSemaphore, mentre unTaskinterno esegue il ciclo di eventi asincrono.Op.perform(): il metodoperform()di ciascun handler viene eseguito all’interno di unTaskcreato dadispatchOp(). Il wrapperdispatchOpsegnala il semaforo quando il task termina.- Keepalive: istanze di
Taskin background emettono frame di avanzamento a intervalli di 30 secondi durante le operazioni bloccanti. Il task di keepalive viene annullato quando il lavoro bloccante termina.
Il vincolo principale in Swift: dispatchOp() avvolge già Op.perform() in un Task + DispatchSemaphore.wait(). Annidare un altro Task + semaforo dentro perform() causerebbe un deadlock dell’executor asincrono, perché il semaforo esterno sta bloccando un thread su cui il Task interno ha bisogno di essere eseguito.