# Audit des bugs — `@mostajs/media-mcu` 0.1.0 (module parqué)

**Auteur** : Dr Hamid MADANI <drmdh@msn.com>
**Date** : 2026-06-07
**Statut** : AUDIT (DEVRULES §9 livrable #02) — préalable à la reprise (cf. `DESIGN-ROLES-DIRECTION.md`)
**Méthode** : lecture du code (`src/lib/server.ts`, `ffmpeg.ts`, `sdp-helpers.ts`, `api/index.ts`, `types/index.ts`), `tsc` (build ✅, 0 erreur), `test-scripts/` **vide** (aucun test). Bugs = **runtime/logique** (non détectés par le compilateur).
**Focus signalé** : erreurs à l'exécution au niveau **track (produce/SDP)** et **connexion (DTLS/transport)**.

> **MAJ remédiation — 0.2.0 (2026-06-07)** : **les 11 bugs B1→B11 sont traités** (✅). B2 = compositing N→1 implémenté (single/grid xstack/pip overlay + amix, `switchLayout` à chaud). B7 = réservation de ports ; B10 = pool de workers ; B11 = `removeInput`/`outputUrl` corrigés (+ bonus mapLayout). Build ✅, **11/11 tests** (`test-scripts/mcu-unit.test.mjs`). Reste : test d'intégration réel WHIP→ffmpeg + TOCTOU inter-process (acceptée). Détail : `CHANGELOG.md` 0.2.0.

---

## 0. Statut consolidé des bugs (B1 → B11) — 0.2.0

| # | Sév. | Zone | Bug (résumé) | Statut | Correctif (fichier) |
|---|---|---|---|---|---|
| B1 | 🔴 | connexion | transport d'ingest mis en cache → double `connect()` | ✅ corrigé | transport par source WHIP (`server.ts`) |
| B2 | 🔴 | compositing | N→1 (mixage/layouts) non implémenté | ✅ implémenté | `buildFilterComplex` grid/pip/single + amix (`ffmpeg.ts`) |
| B3 | 🔴 | finalisation | `stop()` n'attend pas ffmpeg → fichier tronqué | ✅ corrigé | attente `exit` + SIGKILL 3 s (`ffmpeg.ts`) |
| B4 | 🟠 | track | `encodings:[]` si pas de SSRC | ✅ corrigé | fallback `[{}]` (`sdp-helpers.ts`) |
| B5 | 🟠 | connexion | rôle DTLS codé en dur | ✅ corrigé | dérivé de `a=setup` + answer cohérent (`sdp-helpers.ts`) |
| B6 | 🟠 | track multi | 2ᵉ producer ignoré après démarrage | ✅ corrigé | restart du pipeline avec tous les producers (`server.ts`) |
| B7 | 🟠 | ports | allocation TOCTOU | ✅ atténué | registre de ports réservés intra-process + release (`server.ts`) |
| B8 | 🟠 | A/V | `-use_wallclock` + `-c copy` | ✅ corrigé | wallclock seulement si ré-encodage (`ffmpeg.ts`) |
| B9 | 🟡 | cleanup | timers keyframe non annulés | ✅ corrigé | `_kfTimers` annulés au teardown/close (`server.ts`) |
| B10 | 🟡 | scalabilité | 1 seul worker mediasoup | ✅ corrigé | pool round-robin `min(cpus,4)` / opt `workers` (`server.ts`) |
| B11 | 🟡 | API | `inputRemove` ferme la session ; `outputUrl` = chemin | ✅ corrigé | `removeInput` ciblé + URL servable `publicBaseUrl` (`api/index.ts`) |

> **+ bonus** : mismatch de layout (types `grid-2x2`/`pip-bottom-right`… ↔ moteur `grid`/`pip`) corrigé via `mapLayout()`.
> **Tests** : **12/12** — 11 unitaires (SDP/filtres/rôles) + **1 intégration réel** (RTP VP8 ffmpeg → mediasoup → record `.webm` finalisé ; `test-scripts/mcu-integration.test.mjs`, cmds `RUN-TESTS.md`).
> **Reste** : chemin navigateur WHIP/DTLS non testable headless (couvert unitairement) ; TOCTOU inter-process acceptée.

> *Le tableau §1 ci-dessous est l'audit d'origine (avant correctifs).*

## 1. Synthèse (par sévérité)

| # | Sévérité | Zone | Bug |
|---|---|---|---|
| B1 | 🔴 Bloquant | **connexion** | `createIngressTransport()` met en cache le transport → un 2ᵉ WHIP rappelle `transport.connect()` → mediasoup lève « connect() already called » |
| B2 | 🔴 Structurel | track/compositing | **Le N→1 (mixage/layouts) n'existe pas** : single-publisher recording uniquement (`addInput`/`removeInput` throw, `switchLayout` no-op, **aucun `-filter_complex`** ffmpeg) |
| B3 | 🔴 Données | finalisation | `stop(graceful)` : `if (child.killed) return resolve()` résout **immédiatement** après `kill('SIGINT')` (Node met `killed=true` aussitôt) → on n'attend PAS la fin de ffmpeg → **fichier tronqué/corrompu** (moov/cluster non flush) |
| B4 | 🟠 Sérieux | **track** | `encodings: primarySsrc ? [{ssrc}] : []` → si l'offre n'a pas d'`a=ssrc` exploitable, `produce()` reçoit `encodings: []` → RTP non rattaché au producer → **aucun média ne flue** vers ffmpeg |
| B5 | 🟠 Sérieux | **connexion** | DTLS role **codé en dur** `role: 'client'` dans `parseWhipOffer` → ignore le `a=setup` réellement offert ; casse si le publisher offre `setup:passive`/`active` |
| B6 | 🟠 Sérieux | track multi | `startRecording()` : `if (this.ffmpeg) return` → un 2ᵉ producer/publisher arrivé après le démarrage de ffmpeg **n'est jamais enregistré** |
| B7 | 🟠 Concurrence | ports | `allocateFreePort()` = **TOCTOU** : bind+close pour tester, puis ffmpeg bind plus tard → fenêtre de course → collision de ports entre sessions concurrentes |
| B8 | 🟠 A/V | ffmpeg | `-use_wallclock_as_timestamps 1` **+** `-c copy` (webm) → conflit de timestamps → désync A/V ou fichier illisible |
| B9 | 🟡 Fuite | cleanup | `requestKeyFrame` rejoués via `setTimeout(1s/2s/4s)` non annulés à `close()` → tirent sur des consumers fermés (catchés mais réfs retenues) |
| B10 | 🟡 Scalabilité | worker | **1 seul `createWorker`** (mediasoup) ; `cpus` importé mais inutilisé (`void cpus`) → pas de pool multi-cœur → goulot CPU pour du transcoding MCU |
| B11 | 🟡 Robustesse | API | `inputRemove` appelle `closeSession()` (ferme TOUTE la session pour retirer 1 input) ; `outputUrl` renvoie un chemin disque, pas une URL servable |

---

## 2. Détail des bugs « connexion »

### B1 🔴 — transport d'ingest mis en cache → double `connect()`
`server.ts:138-147` : `createIngressTransport()` fait `if (this.ingressTransport) return this.ingressTransport`.
`api/index.ts:85-89` : chaque WHIP fait `transport.connect({ dtlsParameters })`.
→ Au **2ᵉ** appel (reconnexion, renégociation, ou 2ᵉ source), on récupère le transport **déjà connecté** et on rappelle `connect()` → mediasoup : *« connect() already called »* → réponse **400 « DTLS connect »**. C'est l'erreur de **connexion** typique observée.
**Fix** : un WebRtcTransport **par WHIP/source** (ne pas mettre en cache un transport partagé), ou détecter `dtlsState !== 'new'` et sauter le `connect()`.

### B5 🟠 — rôle DTLS codé en dur (`role: 'client'`)
`sdp-helpers.ts:106` : `dtlsParameters: { role: 'client', … }` quel que soit le `a=setup` de l'offre.
→ Fonctionne tant que le publisher offre `setup:actpass` (cas courant), **casse** s'il offre `setup:active`/`passive`. Le rôle remote doit être **dérivé du `a=setup`** (`active`→remote client, `passive`→remote server, `actpass`→on choisit). Côté réponse, `setup:'passive'` (server.ts answer) doit rester cohérent.
**Fix** : lire `m.setup`/session `setup`, mapper en `role` (`active→'client'`, `passive→'server'`, `actpass→'client'`).

---

## 3. Détail des bugs « track »

### B4 🟠 — `encodings` vide si pas de SSRC
`sdp-helpers.ts:80-99` : détection du `primarySsrc` via `a=ssrc … cname`. Si absent (offre sans ssrc, ou parse différent), `encodings: []`.
→ `transport.produce()` (`api/index.ts:108`) accepte mais le RTP entrant n'est **pas rattaché** → ffmpeg ne reçoit rien → `output.failed` / fichier vide.
**Fix** : exiger/repli d'un ssrc (générer un ssrc déterministe ou refuser la section avec un message clair) ; logguer quand `encodings` est vide.

### B6 🟠 — un 2ᵉ producer après démarrage ffmpeg est ignoré
`server.ts:161-162` : `startRecording()` retourne si `this.ffmpeg` existe. `api/index.ts:129` lance `startRecording()` dès le 1ᵉʳ WHIP.
→ Si audio et vidéo arrivent en **deux** négociations, ou un 2ᵉ publisher, ffmpeg est déjà lancé avec les seuls producers initiaux → le reste est **perdu**. (Lié à B1 : un seul transport.)
**Fix** : modèle multi-source réel (cf. B2) ; tant que single-source, garantir que **tous** les producers d'une offre BUNDLE sont créés **avant** `startRecording`, et interdire les ajouts ultérieurs proprement.

### B2 🔴 — le compositing N→1 n'est pas implémenté (cœur du MCU)
`server.ts:4-5` (« v0.1 : 1 publisher solo recording »), `addInput`/`removeInput` **throw** (`server.ts:263-271`), `switchLayout` **no-op** (`273-276`), et `ffmpeg.ts` **n'a aucun `-filter_complex`** (xstack/overlay/scale) — il fait `-c copy` d'un seul flux.
→ Le module **n'effectue pas** ce que promet le README (« compose N flux WebRTC en 1 flux composite », grid 2×2 / focus / PiP). C'est la **dette principale** : MCU ≈ enregistreur mono-source aujourd'hui.
**Fix** : implémenter le graphe ffmpeg `-filter_complex` (xstack pour grid, overlay pour PiP, scale par tuile) alimenté par N PlainTransports ; câbler `addInput`/`removeInput`/`switchLayout` (redémarrage ou reconfig du graphe). C'est le chantier de `DESIGN-ROLES-DIRECTION.md` (program-bus).

---

## 4. Détail des autres bugs

### B3 🔴 — `stop(graceful)` n'attend pas ffmpeg → fichier corrompu
`ffmpeg.ts:147-155` :
```js
child.kill('SIGINT')
await new Promise((resolve) => {
  if (child.killed) return resolve()   // ← child.killed === true JUSTE après kill() → résout tout de suite
  const t = setTimeout(() => { child.kill('SIGKILL'); resolve() }, 3000)
  child.once('exit', () => { clearTimeout(t); resolve() })
})
```
`child.killed` passe à `true` **dès l'appel** `kill()` (cela signifie « un signal a été envoyé », **pas** « le process est mort »). La garde résout donc **immédiatement**, sans attendre l'`exit` → `close()` enchaîne `stat()` et ferme les transports **avant** que ffmpeg ait flush le container → **moov atom / dernier cluster manquant → fichier illisible**, ou `output.failed` (taille ≤ 1024).
**Fix** : supprimer la garde `if (child.killed)` ; attendre l'`exit` (avec timeout SIGKILL). Tester `child.exitCode !== null` au lieu de `child.killed`.

### B7 🟠 — allocation de port TOCTOU
`server.ts:316-338` : `isPortPairFree` bind puis **close** les sockets ; ffmpeg bind ces ports **plus tard** (après spawn). Entre-temps un autre process/session peut prendre le port.
**Fix** : garder les sockets bindés et les passer à ffmpeg (`fd`), ou un registre de ports en mémoire + plage par session, ou retries sur échec de bind ffmpeg.

### B8 🟠 — timestamps webm
`ffmpeg.ts:114-124` : `-use_wallclock_as_timestamps 1` avec `-c copy` (webm). Le wallclock réécrit les PTS alors que `copy` conserve le flux → incohérence.
**Fix** : pour `copy`, ne pas forcer wallclock (ou re-mux avec `-vsync`/`-af aresample=async`), ou re-encoder.

### B9/B10/B11 🟡 — voir tableau §1.

---

## 5. Recommandations (ordre de remise en route)

1. **B3** (finalisation) puis **B1** (transport) puis **B4/B5** (track/DTLS) → rend le **mono-source fiable** (record d'1 publisher de bout en bout).
2. Ajouter une **suite de tests** `test-scripts/` (actuellement vide) : SDP parse/answer (unitaire, sans mediasoup), allocation de ports, `stop()` attend l'exit. Tests d'intégration WHIP→ffmpeg en option (lourds, nécessitent mediasoup-worker + ffmpeg).
3. **B7/B8** (ports, timestamps) → fiabilité.
4. **B2** (compositing N→1) = vraie itération v0.2 : `-filter_complex` + multi-PlainTransport + `addInput/removeInput/switchLayout`, puis la capacité **program-bus / rôles** de `DESIGN-ROLES-DIRECTION.md`.
5. **B10** (pool de workers) + **B11** (API) → scalabilité/propreté.

> Verdict : le module **build** et le **mono-source** est presque fonctionnel, mais bloqué par **B1** (connexion au 2ᵉ appel) et **B3** (fichier non finalisé) ; la **promesse N→1 (B2)** reste à écrire. Aucun test ne verrouille le comportement aujourd'hui.

---

*Audit — Dr Hamid MADANI <drmdh@msn.com> — 2026-06-07.*
