In questa sezione

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:

  1. 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.

  2. 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 un InputPackage per le cap con più argomenti), un OutputStream per scrivere i dati di risposta e un PeerInvoker per 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::spawn che riceve i frame da un canale mpsc e 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::spawn per 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_blocking per 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 un DispatchSemaphore, mentre un Task interno esegue il ciclo di eventi asincrono.
  • Op.perform(): il metodo perform() di ciascun handler viene eseguito all’interno di un Task creato da dispatchOp(). Il wrapper dispatchOp segnala il semaforo quando il task termina.
  • Keepalive: istanze di Task in 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.