> Rendered: https://z29k.github.io/rescriptum/fr/development/traps · Docs index: https://z29k.github.io/rescriptum/fr/llms.txt

# Pièges déjà rencontrés

Chacun de ceux-ci a coûté du temps réel. Aucun n'est évident à la seule lecture du code.

## À l'exécution, pas à la compilation

**hyper panique si un timeout est défini sans timer.** `http1::Builder::header_read_timeout`
exige `.timer(TokioTimer::new())`. Omettez-le et **chaque** connexion panique à l'exécution —
cela ne casse pas la compilation. Les tests d'intégration l'ont attrapé ; des tests unitaires
n'auraient pas pu.

**`header_read_timeout` s'arrête à la fin des en-têtes.** hyper n'a pas de délai de lecture de
corps, donc un client qui promet un corps sans l'envoyer garerait une connexion indéfiniment.
Le `tokio::time::timeout` sur la connexion entière dans `connection()` est ce qui couvre cela.
**Les deux sont nécessaires ; aucun n'est redondant.**

**hyper émet les noms d'en-tête en minuscules.** C'est correct — ils sont insensibles à la
casse — donc affirmez sur une copie en minuscules. Voir `has_header` dans les tests
d'intégration.

## Performance

**`fs::metadata` par entrée de répertoire est un appel système `stat` chacun.**
`DirEntry::file_type()` revient gratuitement avec le `readdir` sur Unix ; seul un lien
symbolique a besoin du `stat` pour être résolu. Cela seul valait **65 % à 2 000 fichiers**,
avant que le cache ne soit ajouté.

**Éditer le *contenu* d'un fichier de groupe ne change aucun mtime de répertoire.** Seul
`RELOAD_BACKSTOP` (1 s) l'attrape, ce qui est pourquoi le filet n'est pas redondant avec la
vérification du mtime. Un test d'intégration le couvre.

**Un `Content-Length` aberrant doit être refusé depuis l'en-tête**, pas en laissant `Limited`
sauter après avoir tamponné un mégaoctet.

**Fermer sur un pair qui écrit encore jette la réponse qu'on vient d'écrire.** Le noyau
envoie un reset, et le reset détruit les octets non lus — le client voit donc une connexion
coupée, pas votre réponse. `shed()` avait exactement ce défaut : il écrivait son `503` et
fermait aussitôt, si bien que l'installateur à qui il essayait de dire *« réessaie »*
recevait un reset. Il draine maintenant brièvement d'abord, comme le faisait déjà le
`put()` de l'API d'administration. Un test au plafond de connexions l'épingle.

- **macOS autorise un processus non privilégié à lier le port UDP 69 ; Linux non.** Un test
  qui atteint l'adresse TFTP *par défaut* prend donc une branche différente sur chaque
  plateforme — `boot check` traite un port libre mais silencieux comme une note, et un port
  non liable comme un problème, ce qui est la bonne règle et exactement ce qui rend le test
  dépendant de la plateforme. Il passait en local et échouait en CI pour une raison sans
  rapport avec le changement. Tout test qui définit `RESCRIPTUM_BOOT_DIR` doit aussi définir
  `RESCRIPTUM_TFTP_ADDR=off`, sauf si la sonde *est* le sujet ; `tests/tftp.rs` couvre le
  port non liable sur un port haut.
- **Une branche développée entièrement hors ligne n'a jamais rencontré la CI.** Celle-ci a
  accumulé 57 commits avant son premier push, et le premier run a échoué sur deux choses
  qu'aucune exécution locale ne pouvait voir : un clippy cinq versions plus récent que la
  toolchain locale épinglée, et une permission de port propre à Linux. Poussez assez tôt
  pour le découvrir, ou attendez-vous à le découvrir tard.

## Sélection et formats

**Un Mac qui édite le répertoire de réponses en SMB peut détourner la réponse d'une
machine.** macOS écrit un fichier AppleDouble `._<nom>` à côté d'un fichier dont le système
n'accepte pas les attributs étendus — `._proxmox.toml` a une extension *présente* dans la
liste. Avec un répertoire par identité, c'est pire que du temps des réponses à plat : c'est
un **second `.toml` dans un répertoire qui n'en accepte qu'un**, et il se trie *avant* le
vrai, si bien qu'une règle prenant le premier servirait un contenu binaire à toutes les
requêtes. La machine qu'on configurait reçoit alors une erreur d'analyse au lieu de sa
réponse. `.DS_Store` n'est inoffensif que par chance (son extension n'est pas dans la liste).
Le store fichiers ignore toute entrée dont le nom commence par `.` ; trouvé sur un vrai NAS,
pas en lisant quoi que ce soit.

**Normaliser un motif de sélecteur retire `*` et `?`** à moins d'utiliser `normalize_pattern`
— ce qui transforme chaque glob en littéral, en silence.

**Dans un format texte, un placeholder à l'intérieur d'un commentaire reste un
placeholder.** `Kind::Text` est une chaîne opaque, donc la substitution parcourt tout le
document — un `{{ mac }}` écrit dans un commentaire `#` pour *expliquer* le templating doit
quand même se résoudre, et fait échouer `check` exactement comme un vrai. Trouvé en
ajoutant les exemples travaillés `.ipxe` et `.cfg`.

**Un GET n'a pas de corps, donc la botte de foin est vide.** Les valeurs de query et les
segments de chemin doivent l'alimenter aussi, sinon un document nommé d'après une MAC ne peut
jamais répondre à une récupération de preseed ou de kickstart.

**quick-xml émet les références d'entité comme leurs propres événements.** Les ignorer soude
les fragments de texte alentour : `1 &lt; 2 &amp; 3` revenait en `123`.

**Des frères XML répétés ne sont pas toujours une liste.** S'ils portent un attribut
discriminant, ce sont une collection indexée ; les traiter comme une liste remplaçait chaque
`<component>` d'un unattend.xml par celui que la surcouche mentionnait.

**Deux documents ayant le même radical ne sont pas des doublons.** Un `put` antérieur
supprimait les autres formats d'un radical pour éviter « deux réponses pour une machine ».
C'était le mauvais modèle : ce sont les réponses de cette machine pour deux **systèmes
d'exploitation**.

**Filtrer les endpoints sur l'extension, pas sur le `Kind`.** `.ks` et `.preseed` sont tous
deux `Kind::Text` ; filtrer par famille laisserait un preseed répondre à `/rhel/ks`.

**Un alias doit être assez spécifique pour que personne ne l'atteigne par accident.** `seed` a
été retiré comme alias d'endpoint : `s=http://server/seed/` est une URL de seed NoCloud
ordinaire, et elle sert du YAML.

## L'API d'administration

**Lire le corps de la requête avant de la rejeter.** Répondre et fermer pendant que le client
écrit encore lui vaut un `ECONNRESET` au lieu de la réponse. `put()` draine d'abord, puis
valide l'identifiant.

**Les réponses d'administration doivent définir `Connection: close`.** Sans cela, chaque client
de test attendait l'expiration de la connexion — la suite prenait 30 s au lieu de 0,4 s — et la
coupure finale arrivait parfois comme un reset plutôt qu'un EOF propre.

**Les identifiants deviennent des noms de fichiers.** `export` et le store fichiers construisent
des chemins à partir d'identifiants de machine et de noms de groupe, donc `valid_id` est imposé
à la frontière de l'API **et** dans les deux stores.

## Tests

**`cargo test` ne reconstruit pas `target/debug/rescriptum`.** Une vérification manuelle contre
un binaire périmé a un jour « reproduit » un bug déjà corrigé. Reconstruisez avant de triturer
le binaire à la main.

**Les tests d'invalidation de cache doivent partager une seule instance d'`Answers`.** Un test
qui en construit une nouvelle à chaque appel contourne complètement le cache et ne prouve
silencieusement rien.

**Affirmez sur des valeurs parsées, pas sur la mise en forme.** Remplacer une table par un
scalaire laisse la décoration d'origine de la clé, donc la sortie peut se lire `value= 3` — du
TOML valide, un texte différent.

**Un patch `python`/`sed` qui « réussit » peut n'avoir rien matché.** Deux éditions dans
l'histoire de ce projet n'ont silencieusement rien fait et n'ont été attrapées qu'en vérifiant
le nombre de tests ensuite. Vérifiez que l'ancien texte a été trouvé avant d'écrire.

## Empaqueter pour DSM

**Un script shell qui marche sur macOS n'est pas un script qui marche en CI.** Deux cas
trouvés en faisant tourner les harnais dans un conteneur Linux plutôt qu'en leur faisant
confiance : `stat -f '%Lp'` est le drapeau de format sur BSD et *statut du système de
fichiers* sur GNU — où il **réussit**, en déversant des informations d'overlayfs dans une
variable censée contenir un mode de fichier, si bien que le repli ne se déclenche jamais.
Demander d'abord à GNU (`stat -c '%a' || stat -f '%Lp'`), qui échoue proprement sur macOS.
Et `shasum` est un script Perl qu'une Debian minimale n'a pas : `sha256sum` vient de
coreutils et existe partout sur Linux. Les runners Ubuntu ont les deux, ce qui est
exactement la façon dont un script pareil part cassé chez tous les autres.

**musl 1.2 ne peut pas tourner sur les noyaux ARMv7 de Synology, et le symptôme ne nomme
rien.** Ces noyaux sont des 3.10 et répondent `EINVAL` aux appels *time64* ; musl ne se
replie sur les appels 32 bits que sur `ENOSYS`, donc `clock_gettime`, `clock_nanosleep` et le
futex temporisé échouent tous. Le binaire s'installe, répond à `--version`, puis panique à
`time.rs:131` avec `Os { code: 22, kind: InvalidInput }` dès qu'il veut un horodatage — ce
qui ressemble à un problème d'ABI ou de noyau trop vieux, et n'est ni l'un ni l'autre. La
cible armv7 est en glibc avec un plancher 2.17 pour cette raison ; les cibles 64 bits n'ont
pas le clivage time32/time64 et ne sont pas concernées. Prouvé par une sonde C de dix lignes
sur la machine, pas en lisant quoi que ce soit.

**`SYNOPKG_PKGDEST` vaut `/volume1/@appstore/<paquet>`, pas `/var/packages/<paquet>/target`.**
Le second est un lien vers le premier, donc `dirname "$SYNOPKG_PKGDEST"` donne
`/volume1/@appstore` et tout ce qu'on y accroche — `etc/`, `var/`, `shares/` — atterrit là où
rien ne lit. La racine du paquet est un chemin fixe. Ça coûte un service qui s'installe
parfaitement et ne démarre jamais, et un harnais sur faux arbre ne peut pas l'attraper : dans
un arbre qu'on a construit soi-même, `dirname` tombe juste par construction.

**`$SYNOPKG_TEMP_UPGRADE_FOLDER` survit à la mise à jour qui l'a créé.** Une installation
*neuve* qui le lit y trouve la configuration d'une installation que l'utilisateur a
supprimée, et la restaure en silence — jetons compris. La restauration doit exiger
`SYNOPKG_PKG_STATUS = UPGRADE`.

**`etc/` et `var/` survivent à une désinstallation.** Ce sont des liens vers
`/volume1/@appconf/<pkg>` et `/volume1/@appdata/<pkg>`, que DSM conserve. Le fichier
d'environnement, jetons inclus, reste donc sur le volume après la disparition du paquet — ce
que la documentation doit dire, et qui fait échouer le tour *suivant* d'un banc qui ne les
efface pas, pour des raisons appartenant au précédent.

**Un compte DSM portant le nom de l'utilisateur du paquet est détruit avec lui.** Le
`username` de `conf/privilege` crée un utilisateur système à l'installation ; un
administrateur du même nom est masqué par lui puis supprimé à la désinstallation.

**Le répertoire du pare-feu est `/usr/local/etc/services.d/`** — au pluriel. Le guide
développeur dit `service.d`, qui n'existe pas. Le worker `port-config` acquiert **après
`postinst`**, donc le port de l'assistant atteint bien l'entrée pare-feu dès l'installation.

**`port-config` et `usr-local-linker` acquièrent quand le paquet est *activé*,** pas quand
`postinst` tourne : vérifiés plus tôt, ils sont toujours absents.

**L'unité générée n'a pas de `Restart=`** — `Type=oneshot`, `RemainAfterExit=yes`,
`TimeoutStartSec=3600`. DSM ne relance pas le processus s'il meurt.

**`postinst` tourne aussi à une mise à jour, et il tourne *avant* `postupgrade`.** Donc
« le fichier d'environnement est absent » n'est pas la même question que « c'est une
installation neuve » : sur une mise à jour où `etc/` n'a pas survécu, y écrire les valeurs
par défaut détruit le port et les jetons de l'utilisateur avant que la restauration ne
tourne. `postinst` consulte `$SYNOPKG_TEMP_UPGRADE_FOLDER` avant de décider. Trouvé en
simulant ce cas précis, pas en lisant la séquence documentée.

**Les `preuninst`/`postuninst` de l'ancienne version tournent pendant une mise à jour.**
Tout ce qu'ils ont de destructeur tourne donc à chaque mise à jour — et le **premier `.spk`
publié** est celui dont les scripts de désinstallation tourneront pendant la première mise
à jour de tout le monde. Ils ne peuvent pas être corrigés après coup.

**`status` qui renvoie `1` veut dire « planté, pidfile resté »**, pas « arrêté ». Un paquet
proprement arrêté, c'est `3`. Renvoyer `1` dit à Package Center que le service est mort.

**`prestart` tourne au boot**, et DSM l'appelle que vous l'ayez écrit ou non —
`precheckstartstop` vaut `"yes"` par défaut. Un `case` qui sort non-zéro sur un verbe
inconnu empêche le paquet de démarrer après un reboot pour toujours, avec un symptôme
(« marche à la main, jamais après un reboot ») qui ressemble à tout sauf à un bras de `case`
manquant.

**Les scripts de cycle de vie ne sont pas root.** `run-as: package` les gouverne, pas
seulement le service — donc un chown hors de l'arbre du paquet, ou `synopkghelper`, échoue,
possiblement en silence.

**`data-share` tourne au *démarrage* du paquet, pas à l'installation**, donc rien dans
`postinst` ne peut supposer que le dossier partagé existe. Et un nom d'utilisateur qui ne
correspond pas à sa liste de permissions crée le partage et l'accorde à personne, sans un
mot.

**Une strophe logrotate sans `copytruncate` arrête silencieusement la journalisation** :
`log::init` ouvre le fichier une fois et ne le rouvre jamais, donc une rotation déplace
l'inode sous un serveur qui continue d'écrire dans un fichier sans nom.

**Un `.spk` dont le tar externe est gzippé est rejeté** avec « invalid file format » et rien
de plus. Idem pour un qui embarque des membres `._` de macOS. `check-spk.sh` vérifie les
deux.

**Il existe exactement une route vers le port 69 sur DSM 7, et c'est `setcap`.** Les quatre
ont été essayées sur une machine 7.2.2 le 2026-08-27, parce que l'affirmation « DSM 7
n'autorise pas un paquet non signé à tourner en root » traînait dans `CLAUDE.md` depuis un
moment **sans mesure derrière** — vraie, mais par chance.

| Route | Résultat |
|---|---|
| `"defaults": {"run-as": "root"}` dans `conf/privilege` | **refusée** — erreur `synopkg` **319**, `invalid package privilege content`, `stage: install_failed` |
| `"ctrl-script": [{"action":"start","run-as":"root"}]` — la forme qu'utilisent les paquets *de Synology* (FileStation, QuickConnect et StorageManager tous les trois) | **refusée**, même erreur 319 |
| `cap_net_bind_service` embarquée en attribut étendu `security.capability` dans `package.tgz` | s'installe très bien — le format pax interne est accepté — mais **Package Center supprime l'attribut**, et `getcap` revient vide |
| `setcap cap_net_bind_service=+ep` sur le binaire installé, en root, après l'installation | **fonctionne** ; le paquet ouvre alors `udp/69` sous son propre utilisateur non privilégié, à côté de 8000 et 8001 |

`net.ipv4.ip_unprivileged_port_start` n'existe pas sur ce noyau, donc cette route est
fermée aussi. `/volume1` est en btrfs avec `nodev` mais **pas** `nosuid`, donc les capacités
de fichier y fonctionnent bien, et `/usr/bin/setcap` existe en mode `0700`.

**Le root sur DSM 7 est conditionné au fait d'être un paquet *Synology*, et
`libsynopkg.so.1` le dit noir sur blanc.** Lire ses chaînes sur une machine 7.2.2 transforme
la mesure ci-dessus en explication. Un paquet qui ne passe pas le contrôle de signature
(`verifyPackageSignature` vit dans la même bibliothèque) se voit refuser tout ceci :

```
Failed to pass privilege check, ctrl-script and executable section should not exist
Failed to pass privilege check, defaults should be provided and defaults.run-as should be package
Failed to pass privilege check, join-groupname should not contains admin group
Failed to pass privilege check, tool capabilities should not exist
Failed to pass privilege check, tool user should be package
Failed to pass privilege check, non-synology package should not use privilege migration
```

D'où le fait que FileStation, StorageManager, QuickConnect et SecureSignIn portent tous
`"ctrl-script": [{"action": "start", "run-as": "root"}]` dans leur propre `conf/privilege`
et que nous ne le pouvons pas : la forme est légale, c'est la signature qui la rend légale
*pour eux*.

**La ligne la plus importante est `tool capabilities should not exist`.** Le format de
privilège de DSM a un champ `capabilities` natif — documenté comme
`"capabilities": "cap_chown,cap_net_raw"` sur une entrée `tool` depuis 7.0-40656, et
`SYNOPackageTool::Privilege::ChangeCapabilities` est bien là dans la bibliothèque. Un paquet
signé déclare `cap_net_bind_service` et n'a jamais besoin de `setcap`. **Le mécanisme que
nous voulons existe, est documenté, et nous est fermé.**

Le guide développeur de Synology énonce la règle sans détour : *« If you are developing a
package with root privilege, you are not able to install that package unless it is signed
by synology. »* C'est donc **leur** signature, pas celle d'un éditeur tiers de confiance —
ce qui tranche ce que la chaîne de la bibliothèque laissait ouvert. SynoCommunity a heurté
le même mur ([spksrc#4170](https://github.com/SynoCommunity/spksrc/issues/4170),
[#4215](https://github.com/SynoCommunity/spksrc/issues/4215)).

Il existe un contournement documenté, et ce **n'est pas une voie de distribution** : un
*jeton de développement*. On génère `debug.dat` depuis Centre d'assistance → Services
d'assistance, on l'envoie à Synology, on reçoit un jeton signé, on le dépose dans
`/var/packages/syno_dev_token`. Il n'est valable **que sur le NAS qui a produit le
`debug.dat`** : livrer ainsi voudrait dire que chaque utilisateur fasse un aller-retour avec
Synology avant de pouvoir installer. Un `setcap` est une commande locale, et c'est
strictement mieux pour lui.

Conclusion, tranchée et non provisoire : **le `setcap` manuel est le prix de ne pas être
signé par Synology, et aucun changement d'empaquetage ne l'enlève.** Si le paquet est un
jour signé, l'étape manuelle et la tâche au démarrage sont remplacées par trois lignes dans
`conf/privilege`.

**`setcap` fonctionne aussi sur un DS416j, et ce n'était pas acquis.** Les quatre routes
vers le port 69 ont été mesurées sur une VM 7.2.2, qui est en x86_64 avec `/volume1` en
btrfs monté `nodev` mais pas `nosuid` — or un volume monté `nosuid` fait ignorer les
capacités de fichier par le noyau, ce qui aurait fermé la dernière route ouverte sur la
seule machine pour laquelle ce projet existe. Mesuré sur le DS416j (ARMv7, `armada38x`) :
la capacité tient, le paquet ouvre `udp/69` sous son utilisateur non privilégié, et
`boot check` rapporte `0.0.0.0:69 handed over ipxe-arm64.efi` — une vraie requête de
lecture à laquelle on a répondu avec de vraies données.

**La capacité appartient au fichier, donc une mise à jour la perd.** Une nouvelle version
remplace le binaire et la capacité part avec l'ancien — d'où la tâche au démarrage du
Planificateur de tâches documentée par le paquet plutôt qu'une commande unique, et d'où le
fait qu'un bind TFTP raté ne soit pas fatal : quand il l'était, cette mise à jour coupait
aussi le point d'entrée des réponses.

**Lier n'est pas un contrôle de santé, et cela prouve le contraire de ce qu'on croit.** Un
bind qui *réussit* sur le port TFTP signifie que personne n'écoute — l'état dégradé, pas
l'état sain — et un bind qui échoue ne distingue pas ce serveur d'un autre service qui
squatterait le port, puisque les deux donnent `AddrInUse`. `boot check` envoie donc une
vraie requête de lecture et rapporte ce qu'obtiendrait une machine. Sa première version
annonçait « already in use — that is this server, if it is running » et un test avec un
squatteur sur le port a montré tout de suite que c'était une supposition.

**Un nouveau réglage n'atteint jamais une installation qui existe déjà**, sauf si quelque
chose l'y met. Le fichier d'environnement vivant n'est écrit que s'il est absent — ce qui
est correct, une mise à jour ne doit jamais remplacer le port et les jetons de quelqu'un
par des valeurs par défaut — mais à lui seul cela rend une nouvelle fonctionnalité
invisible pour toute installation antérieure. Le boot media est arrivé avec les dossiers
créés, les chargeurs déposés et 69/udp enregistré au pare-feu, et `RESCRIPTUM_BOOT_DIR`
jamais posé : `boot check` répondait *« boot assets are off »* sur un DS416j où tout le
reste était en place. Comme `etc/` survit à une désinstallation, même désinstaller et
réinstaller n'y change rien. Le `.env.example` n'aide pas : rien n'oblige personne à le
lire.

`postinst` ajoute désormais les clés dont le fichier vivant **n'a jamais entendu parler**,
sans toucher à ce qui est présent. **Une clé commentée compte comme présente**, et c'est là
la propriété de sûreté : c'est ainsi qu'un exploitant dit « celle-là je la connais et je
n'en veux pas ». Supprimer une ligne veut dire « jamais entendu parler » et la fait
revenir ; la commenter veut dire non, et c'est respecté.

## L'application de bureau DSM

Huit choses, mesurées sur une machine virtuelle DSM 7.2.2 et sur un DS416j en 7.1.1, et
aucune dans le guide du développeur.

**Un défaut calculé à l'exécution doit l'être aussi dans `settings()`.** Le panneau rend le
défaut d'une variable comme valeur du champ ; un défaut qui n'existe que là où le serveur
le consomme s'affiche donc en case vide — pendant que le serveur, lui, tourne sur une
adresse qu'il a déduite et jamais montrée. `RESCRIPTUM_PUBLIC_HOST` est parti comme ça :
l'exploitant n'avait aucun moyen de voir vers quelle adresse ses machines seraient
envoyées, sinon en lisant le journal de démarrage. Deux entrées de `KNOWN` sont dans ce
cas, et toutes deux ont leur branche dans `settings()` : le nombre de threads et l'hôte
public. Une troisième demanderait le même traitement, et rien dans le typage ne le dit.

**Un CGI sous `/webman/3rdparty/<pkg>/` tourne sous le propriétaire du script.** Pas en
`http`, et pas en root — sous celui qui possède le fichier. DSM attribue l'arborescence d'un
paquet à l'utilisateur du paquet : le backend de l'application tourne donc en `rescriptum` et
peut lire le fichier d'environnement en `0600` qu'il possède, ce qui est toute la raison pour
laquelle la configuration reste modifiable pendant que le serveur est arrêté. Prouvé en
attribuant le même script de deux façons et en regardant `id` changer. Un script resté
possédé par root, lui, **tourne bien** en root là-bas : n'en laissez pas traîner.

**Ce chemin n'est pas authentifié par DSM.** Une requête non authentifiée atteint le script
et reçoit `200`. Ce qui garde le CGI d'un paquet, c'est le paquet qui l'a écrit — ici
`authenticate.cgi` plus un contrôle `administrators`, et en perdre un serait silencieux.

**`su` dans un CGI bloque la requête.** Sans `</dev/null`, il hérite du stdin du CGI — un
tube venant du serveur web que rien ne fermera —, y lit, et ne revient jamais. La page d'état
s'arrêtait simplement en plein milieu. Puis, une fois cela corrigé, il échouait quand même
avec « Permission denied », un processus non root ne pouvant devenir personne. Les deux
étaient du travail perdu : le script *est* déjà l'utilisateur en question, donc un simple
`test -r` était la réponse depuis le début.

**Le framework qu'un paquet peut utiliser, c'est la machine qui le décide, pas le guide de
Synology.** DSM 7.2 embarque un framework Vue et le guide actuel ne documente que celui-là.
Le DS416j plafonne en DSM 7.1.1, où `Vue` n'existe pas : une application bâtie dessus
s'installe et donne à cette machine une icône qui n'ouvre rien. ExtJS est présent sur les
deux (7.1.1 et 7.2.2 mesurés), d'où une seule application au lieu de deux.

**L'exemple ExtJS du guide ne tourne pas.** Il déclare les classes avec `Ext.define` et
enchaîne avec `callParent` ; face à `SYNO.SDS.AppInstance` cela lève
`Cannot read properties of null (reading 'apply')` avant que la fenêtre n'apparaisse. C'est
**ExtJS 3.4.1** avec une couche `Ext.define` par-dessus : utiliser `Ext.define` pour la
déclaration — le lanceur de DSM trouve la classe ainsi, et `superclass` est bien posé — puis
appeler `MaClasse.superclass.constructor.call(this, config)` plutôt que `callParent`.

**La barre des tâches de DSM appelle `getWindowTitle()` sur la fenêtre.** Sans titre, elle
lève une exception depuis le propre code de DSM, et l'application ne s'ouvre pas du tout —
avec une trace qui accuse Synology et pas vous.

**Ne jamais nommer une méthode `show`.** `Ext.Window.prototype.show()` est ce que DSM appelle
pour afficher la fenêtre : un `show(which)` ajouté pour changer d'onglet l'a écrasé
silencieusement. La fenêtre était construite, mise en page, capable de rendre une miniature
correcte dans l'aperçu de la barre des tâches — et n'apparaissait jamais. **Rien ne levait
d'exception**, sur aucune des deux versions de DSM, et c'est ce qui a coûté cher : trouvé en
bisectant depuis l'exemple minimal du guide. Tout ce qu'on ajoute à ce prototype partage
l'espace de noms de chaque méthode d'`Ext.Window`, qui est vaste.

**`fieldLabel` est dessiné par la mise en page « form », pas par le champ.** Un
`syno_displayfield` dans un `Ext.Panel` ordinaire affiche sa valeur et perd son libellé sans
rien dire, ce qui transformait la page d'état en colonne de valeurs nues. Il faut
`SYNO.ux.FormPanel`, ou `layout: 'form'`.

**Builds reproductibles et caches de navigateur ne s'entendent pas, et c'est le navigateur
qui gagne.** `make-spk.sh` fixe le mtime de chaque fichier empaqueté pour que les mêmes
entrées produisent un `.spk` identique octet pour octet. nginx en fait un
`Last-Modified: 2019` sans `Cache-Control`, et la fraîcheur heuristique d'un navigateur est
un dixième de l'âge apparent du fichier — des années. Un paquet mis à jour a continué de
faire tourner l'**ancien** JavaScript contre le nouveau backend, malgré une réinstallation et
un rechargement forcé. Le fichier de l'application porte donc le numéro de version dans son
nom, et tout ce qu'elle va chercher elle-même porte `?v=` ; `check-spk.sh` vérifie que le nom
bouge toujours.
deux.

## Changements de comportement à retenir

**Les documents de réponse doivent maintenant être valides.** Avant la fusion, ils étaient
servis comme des octets opaques, donc un document malformé atteignait l'installateur ;
maintenant c'est un `500` avec l'erreur de parsing dans le log. C'est le meilleur échec, mais
*c'est* un changement de comportement — des fixtures écrites en pseudo-YAML ont cessé de
fonctionner à ce moment-là.

**`{{ machine }}` n'est lié que si un document machine a matché.** Une machine revendiquée par
les `members` d'un groupe, sans document à elle, se résout avec `machine: None` — donc
`{{ machine }}` dans un groupe échoue pour exactement les membres qu'il devait couvrir.
Utilisez là un fait de requête comme `{{ mac }}`.
