Espions d’écriture aveugles aux fonctions passées en callback (revue PR #39) #40
Labels
No labels
autopilot:pending-human
source:analyste
source:defenseur
source:human
source:medic
status:approved
status:blocked
status:in-progress
status:needs-clarification
status:needs-fix
status:ready
status:review
status:triage
type:bug
type:feature
type:infra
type:refactor
type:schema
type:security
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: maximus/vps-health-api#40
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Constat non bloquant de la seconde revue de la PR #39 (commentaire 17331), à ne pas perdre avec la PR fermée. Analyse du 2026-10-01 : prémisse rejouée et confirmée, variante arrêtée.
Contexte
__tests__/writes.test.jsn'attribue une écriture à l'API que si une frame d'un fichier runtime se trouve sur la pile synchrone au moment de l'appel (fromRuntimeFile(captureStack())). Une fonctionnode:fspassée telle quelle à un mécanisme asynchrone s'exécute sans cette frame, et l'écriture passe sur un chemin pourtant exercé (GET /defenseursnominal) :handleDefenseursprocess.nextTick(() => writeFileSync(tmp, "x"))process.nextTick(writeFileSync, tmp, "x")res.on("finish", writeFileSync.bind(null, tmp, "x"))new fs.WriteStream(tmp).end("x")Promise.resolve(tmp).then(fs.promises.rm)Ces chiffres ont été rejoués le 2026-10-01 sur une copie jetable de
main(198e1cf, Node 22.22.0) : G0 à 2 rouges, G1 à G4 à 0.Ces formes sont rares dans ce dépôt. Mais
CLAUDE.md(gotcha « L'API n'est plus read-only » et parenthèse « Limites » de la ligne dewrites.test.js) etREADME.md(section Bind-mounts, vers la ligne 161) affirment que toute écriture parnode:fsviseHOSTS_DIRsur les chemins exercés : G1 à G3 le contredisent.Décision : originaux capturés, sans drapeau
pausedLes espions comptent tout appel, sans attribution par pile. Les retouches de fixtures du cas dégradé passent par les vraies fonctions
node:fs, capturées au chargement du fichier de test, avant la pose des espions (const real = { writeFileSync: fs.writeFileSync, ... }).Pourquoi pas le drapeau
pauseddu commentaire 17331 : il fonctionne (rejoué, G1 à G4 à 2 rouges, 15/15 passes), mais il rend les espions aveugles pendant la fenêtre des retouches, qui suit immédiatement les 13 requêtes rejetées. Une écriture différée par l'API qui tomberait dans cette fenêtre ne serait pas comptée. Avec les originaux capturés, il n'y a aucune fenêtre.Piège vérifié :
fs.rmSync(dir, { recursive: true })appelle en interne la fonction publiquefs.rmdirSync, donc l'espion, même quandrmSyncest l'original capturé. La variante rougit alors sans mutation (rmdirSync(object),rmdirSync(<tmp>/defenseurs/reports)). La suppression récursive deREPORTS_DIRest donc à remplacer par unreal.renameSync(REPORTS_DIR, <tmpDir>/reports-moved-away): un seul appel, le dossier disparaît de son chemin, etafterEachnettoietmpDir.Variante ainsi écrite, rejouée sur la copie : 11/11 vert sans mutation, G0 à G4 à 2 rouges chacun, 15/15 passes de
writes.test.js, suite complète à 355/355.Travail à faire
writeFileSync,rmSyncetrenameSyncréels en tête dewrites.test.js, avant tout espion.fromRuntimeFile(captureStack())des espionsnode:fsetchild_process: tout appel est compté.rmSyncrécursif deREPORTS_DIRpar un renommage.captureStack()etfromRuntimeFile(), devenus morts.isRuntimeFile()reste, pour la purge du cache. Corriger son commentaire (« Used both to attribute a call... »).rmSyncrécursif à côté de la capture des originaux.CLAUDE.md(ligne dewrites.test.jset gotcha « L'API n'est plus read-only ») et vérifier que la phrase deREADME.mdreste exacte.Fichiers concernés
__tests__/writes.test.js: espions (l. 127 à 157), helpers de pile (l. 39 à 64), retouches du cas dégradé (l. 391 à 401), en-tête (l. 8 à 32).CLAUDE.md: lignewrites.test.jsde la section Tests, gotcha « L'API n'est plus read-only ».README.md: section Bind-mounts, relecture seulement.index.jsetmetrics.js: aucun changement, cibles des mutations uniquement.Surface de test
__tests__/writes.test.js(11 cas), le seul touché. Le nombre de cas ne change pas (355 au total).node:fsdans le worker pendant un test, le test rougit au lieu de passer en silence. C'est le bon sens d'échec.Critères d'acceptation
node:fsetchild_processcomptent tout appel, sans condition de pile et sans drapeau. Les seules écritures qui leur échappent sont les retouches de fixtures, faites par des originaux capturés avant la pose.writes.test.jsstable : 15 passes de suite en boucle, CI verte.CLAUDE.mdréécrits : l'attribution par pile disparaît ; restent les limites « chemin qu'aucun cas n'emprunte » et « écriture différée au-delà de 20 ms ».captureStack()etfromRuntimeFile()absents du fichier.Complexité estimée
Simple. Un fichier de test, une quinzaine de lignes, plus la documentation. L'essentiel du temps va au rejeu des mutations R, V et C de la PR #39.
Variante écartée : drapeau paused (diff du commentaire 17331)
maximus referenced this issue2026-10-02 00:13:35 +00:00