test(writes): count every write, drop the stack attribution (#40) #44
No reviewers
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#44
Loading…
Reference in a new issue
No description provided.
Delete branch "issue-40-count-every-write"
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?
Fixes #40
Ce qui change
__tests__/writes.test.jsn'attribuait une écriture à l'API que si une frame d'un fichier runtime se trouvait sur la pile synchrone. Une fonctionnode:fspassée telle quelle à un mécanisme asynchrone s'exécute sans cette frame, et l'écriture passait sur un chemin pourtant exercé.node:fsetchild_processcomptent maintenant tout appel, sans condition de pile et sans drapeau.real.writeFileSync,real.rmSync,real.renameSync). Aucune fenêtre où les espions ne regardent pas, contrairement au drapeaupausedproposé dans le commentaire 17331 de la PR #39.REPORTS_DIRdevient un renommage :rmSync(dir, { recursive: true })appelle en interne lefs.rmdirSyncpublic, donc l'espion, même depuis l'original capturé.captureStack()etfromRuntimeFile()sont supprimés ;isRuntimeFile()reste pour la purge du cache.writes.test.jsdeCLAUDE.mdréécrits. La phrase deREADME.md(section Bind-mounts) et le gotcha « L'API n'est plus read-only » restent exacts, non modifiés.Aucun code runtime ne change : pas de redéploiement après le merge.
Preuve par mutation
Chaque mutation est appliquée à
index.jsdepuis une copie de sauvegarde, puiswrites.test.jsest lancé dans sa version demain(avant) et dans celle de cette branche (après). Nombre de cas rouges sur 11 :process.nextTick(() => writeFileSync(tmp, "x"))danshandleDefenseursprocess.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)agents-map.jsoncorrompu mis en quarantaine parrenameSyncserveJsonFilerecrée le fichier absentlogRejectionajoute àauth.logexecFileSync("touch", …)dans/defenseursrequire("node:fs/promises").writeFileaprès la réponse de/reports/scansopenSync(…, O_WRONLY | O_CREAT)dans/defenseurs/statecreateWriteStream(…, { flags: "a" })dans/hostssetImmediateaprèsres.enddans/hosts${HOSTS_DIR}-backup.jsonwriteFileSync(new URL("file://…"))sousREPORTS_DIRappendFileSync)findLatestReportForAgentrecrée le dossier manquant/data/defenseurs/last-404REPORTS_DIR/..fs.promises.appendFilesansawaitagents-map.jsoncorrompu copié à côtéserveJsonFilerecrée le fichier absent parsetImmediatespawnSync("sh", ["-c", "touch …"])dans/defenseurs/stateexec("touch …")dansGET /hostssetIntervalde 60 s posé au chargementLes revues de la PR #39 décrivent R, V et C en une ligne, sans code : ces mutations sont réécrites d'après leur description, pas recopiées. R5 à R8, R10 et R4 donnent 2 rouges ici contre 1 dans la revue 17233, écrite avant l'ajout du cas dégradé. R8b n'est décrite nulle part dans les deux revues et n'a pas été rejouée. La mutation m5 de la PR #39 (attribution morte) n'a plus d'objet.
index.jsrestauré depuis la copie, identique octet pour octet,git diffvide.Vérification
writes.test.js: 15 passes sur 15 en boucle.npm test: 355/355 (compte inchangé).sh scripts/boot-from-copy.sh: PASS.bash test-curl.sh: 45 passed, 0 failed, lancé sansFORCE_COLOR. Avec leFORCE_COLOR=3du terminal de cette session, 8 cas échouent, surmaincomme ici :node -e 'console.log(<nombre>)'colore sa sortie. Sans rapport avec cette PR, la CI n'a pas cette variable ; traité avec #41.🤖 Generated with Claude Code
Verdict : APPROVE
Résumé. Le correctif fait ce qu'il annonce : les espions
node:fsetchild_processcomptent tout appel, et le trou de l'issue #40 (fonction passée telle quelle à un mécanisme asynchrone) est fermé. Suite et mutations rejouées par le relecteur sur une copie hors dépôt de la tête5912f08(git archive), Node v22.22.0 ; CI de la tête verte (CI / verification (pull_request): success).Mutations rejouées
Mutation insérée en tête de
handleDefenseursdansindex.js,writes.test.jslancé dans sa version demain(avant) puis dans celle de la branche (après). Cas rouges sur 11 :process.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)Mutations du relecteur, absentes du tableau de la PR :
setImmediate(writeFileSync, tmp, "x")queueMicrotask(writeFileSync.bind(null, tmp, "x"))setTimeout(fs.writeFile, 0, tmp, "x", cb)process.nextTick(child_process.execFileSync, "touch", [tmp])res.on("close", child_process.exec.bind(null, "touch …"))Promise.resolve().then(fs.promises.appendFile.bind(null, tmp, "x"))setTimeout(writeFileSync, 200, tmp, "x")N4 et N5 montrent que le même trou existait côté
child_processet qu'il est fermé aussi.index.jsrestauré, identique octet pour octet à la sauvegarde après chaque série.Mutations côté test, pour les deux affirmations du corps de la PR :
real.rmSync(REPORTS_DIR, { recursive: true })à la place du renommage : 1 rouge,rmdirSync(…/defenseurs/reports)dans les violations. L'affirmation «rmSyncrécursif appelle lefs.rmdirSyncpublic » est exacte, le renommage est nécessaire.fs.*(espionné) au lieu dereal.*: 1 rouge, 6 violations. Les fonctions capturées sont nécessaires, pas décoratives.Vérification
writes.test.js: 15 passes sur 15 en boucle, aucune écriture parasite du lanceur.npm test: 355/355, compte deCLAUDE.mdinchangé et exact.sh scripts/boot-from-copy.sh: PASS.test-curl.shnon rejoué (aucun code runtime touché, étape verte en CI).captureStack/fromRuntimeFiledans le dépôt ; la phrase duREADME.md(ligne 160) reste exacte.pull_refde la PR #44 avecref_action: "closes", l'issue se fermera au merge. Commit au format conventionnel,Fixes #40aussi dans le message.skip/only, aucun code runtime modifié.Suggestions (non bloquantes)
WRITE_APISne couvre pasmkdtempDisposableSync/fs.promises.mkdtempDisposable(Node 24). Sans objet tant que l'image restenode:22-alpine; à ajouter le jour d'une montée de version.realdit « Only single-call functions belong here » : un test-épingle sur les clés derealn'apporterait rien, la mutation T1 montre que l'erreur se signale seule (cas rouge immédiat).