- Hubris est un OS où des tâches isolées communiquent via IPC, et son 13e appel système,
REPLY_FAULT, permet à un serveur de mettre fin à une requête client invalide par une fault plutôt qu’avec une valeur d’erreur - Côté client, l’IPC ressemble à un appel de fonction, mais comme les tâches sont compilées séparément, le compilateur ne peut pas empêcher à lui seul un code d’opération erroné, des octets impossibles à interpréter ou une mémoire prêtée inadaptée
- Dans un programme Hubris normal, ces erreurs sont presque inexistantes grâce à la configuration de build et au code Rust généré ; imposer
Result<T, IpcError>etunwrap()à chaque appel augmenterait donc la taille du code et le coût à l’exécution - Le noyau tue immédiatement une tâche qui viole les préconditions d’un appel système, sans code d’erreur, et
REPLY_FAULTétend cette même politique de fail-fast jusqu’aux réponses serveur - Cette conception révèle vite les usages erronés d’API, mais complique fortement les fuzz tests ou les tâches de chaos qui envoient des IPC et appels système aléatoires, car elles redémarrent presque aussitôt
Hubris IPC et la place de REPLY_FAULT
- Hubris repose sur un petit noyau de type application indépendante, tandis que l’essentiel du code — pilotes, logique applicative, pile réseau — vit dans des tâches isolées compilées séparément
- La communication entre tâches passe par des appels système IPC implémentés par le noyau
RECV: récupère le message reçu de plus haute priorité, ou bloque jusqu’à l’arrivée d’un messageSEND: suspend l’appelant, transfère le message et le contrôle à la tâche réceptrice, puis attend une réponseREPLY: envoie une réponse à la tâche qui a précédemment exécutéSEND, afin qu’elle puisse reprendre
- Dans Hubris, client et serveur ne sont pas des identités fixes, mais des rôles joués par les tâches
- Une tâche qui utilise
SENDjoue le rôle de client - Une tâche qui utilise
RECVetREPLYjoue le rôle de serveur - Une même tâche peut être serveur pour certaines tâches et cliente pour d’autres
- Une tâche qui utilise
Les erreurs que le compilateur ne voit pas aux frontières entre tâches
- Dans un appel de fonction classique, le compilateur et l’éditeur de liens garantissent en grande partie les types et la cible de l’appel
- Si une fonction Rust attend un argument
String, le compilateur empêche l’appelant de lui passer unbool - Une confusion de cible, comme appeler
fire_missilesau lieu depet_cat, ne se produit généralement pas
- Si une fonction Rust attend un argument
- L’IPC de Hubris traverse les frontières entre tâches, et comme chaque tâche est compilée comme un programme distinct, le compilateur ne peut pas vérifier directement l’ensemble des relations IPC
- Les erreurs qu’un serveur IPC peut rencontrer se rangent principalement en trois catégories
- Un code d’opération qui ne correspond pas à l’interface, par exemple recevoir « operation number 48 » pour une interface qui ne définit que deux opérations
- Un bloc d’octets impossible à interpréter au lieu du type de message attendu, ou un message trop court ou trop long
- L’absence de la mémoire prêtée nécessaire, ou la réception d’une mémoire en lecture seule alors qu’une mémoire inscriptible est requise
Pourquoi ne pas imposer la gestion d’erreur aux programmes normaux
- Dans un programme Hubris normal, tout est fait pour que ces erreurs IPC n’arrivent pas
- Les connexions entre tâches sont définies par la configuration du système de build, ce qui rend les confusions difficiles
- Les clients construisent et envoient l’IPC à l’aide de code Rust généré
- Les serveurs traitent eux aussi les résultats via un code Rust généré séparément
- Si chaque opération IPC retournait
Result<T, IpcError>, un programme normal devrait ajouter desunwrap()pour des erreurs qu’il ne rencontrera en pratique jamaisunwrap()a un coût significatif en taille de code- Il faut aussi payer à l’exécution le coût de vérifications d’erreurs qui ne surviendront pas
- Mettre des
unwrap()ou despanic!dans le code généré centraliserait l’emplacement des panic et réduirait l’impact sur la taille du code, mais laisserait inchangé le coût à l’exécution - Pour prendre en charge un code d’erreur universel, toutes les opérations devraient suivre la même règle d’encodage des erreurs
- Toutes les opérations devraient pouvoir retourner une erreur
- Toutes les opérations devraient encoder cette erreur de la même manière
- Même les opérations qui ne peuvent pas échouer devraient être représentées comme faillibles
- Dans les firmwares basés sur Hubris, on a justement continué à trouver des opérations qui, en pratique, ne peuvent pas échouer ; la configuration d’une broche GPIO en est un exemple
La politique agressive de fault dans le noyau Hubris
- De nombreux systèmes d’exploitation renvoient un code d’erreur ou laissent une chance de traiter une exception ou un signal, même si les préconditions d’un appel système sont violées
- Sous Unix, appeler
closesur un descripteur de fichier non ouvert renvoie un code d’erreur - Passer un pointeur nul à
openà la place d’un chemin renvoie lui aussi un code d’erreur
- Sous Unix, appeler
- Hubris détruit immédiatement la tâche qui enfreint les préconditions d’un appel système
- La tâche ne peut plus exécuter d’instructions
- Elle n’a aucune possibilité de récupération ou de reprise
- La tâche superviseur de l’application est notifiée de la fault et supprime généralement la tâche avant de la redémarrer
- Les faults produites par le noyau sont des faults synthétiques
- Elles ressemblent aux faults matérielles générées par le CPU, comme un déréférencement de pointeur nul ou une division par zéro
- Les faults matérielles proviennent d’une violation des règles de l’architecture processeur, les faults synthétiques d’une violation des règles du noyau
- Par exemple, un appel
SENDdont l’index de la tâche réceptrice sort du périmètre de l’application, ou dont le pointeur de message vise une mémoire sans droit d’accès, provoque une fault synthétique - Hubris n’autorise pas de faults récupérables ni reprenables
- Qu’elle soit matérielle ou synthétique, une tâche qui reçoit une fault passe à l’état mort
- Ce choix vise à éviter des modes d’échec subtils et à simplifier le raisonnement sur le système
Comment un serveur répond à un client par une fault
REPLY_FAULTest l’appel système qui permet à un serveur d’envoyer au client une fault au lieu d’une réponse normale- Le flux normal avec
REPLYse déroule ainsi- Quand un client exécute
SEND, le noyau marque la tâche cliente comme « waiting to send » vis-à-vis de la tâche réceptrice - Quand la tâche réceptrice exécute
RECV, ce client passe à l’état « waiting for reply » - Quand le serveur appelle
REPLY, le client redevient exécutable
- Quand un client exécute
REPLY_FAULTressemble àREPLY, mais au lieu de transmettre un message et de rendre la tâche à nouveau exécutable, il transmet une fault et fait passer la tâche à l’état mort- Un serveur ne peut pas tuer n’importe quelle tâche arbitrairement
REPLY_FAULTne peut être utilisé que sur une tâche que ce serveur a reçue viaRECVet à laquelle il n’a pas encore répondu avecREPLY- Il ne fonctionne donc que pour un client en attente de la réponse de ce serveur précis
- Hubris utilise
REPLY_FAULTpour traiter les erreurs suivantes- code d’opération invalide
- message corrompu, tronqué ou dénué de sens
- client n’ayant pas envoyé le bon type de mémoire prêtée
Erreurs applicatives et expérience fail-fast
REPLY_FAULTpeut servir non seulement pour les erreurs de format IPC, mais aussi pour des erreurs propres à l’application- La pile IP de Hubris attribue les ports IP statiquement aux tâches
- Si une tâche essaie de manipuler le port IP d’une autre, la pile IP lui envoie une fault
- Cette approche réduit le traitement d’erreurs « théoriques » qui ne devraient jamais se produire en pratique et met rapidement en évidence les usages incorrects pendant le développement
- D’une manière analogue au modèle où une violation des préconditions d’un appel de fonction Rust déclenche généralement un
panic!,REPLY_FAULTdevient un moyen pour un serveur de déclencher unpanic!inter-processus chez le client - Le client n’a pas besoin d’embarquer du code spécifique ni de coopérer pour que cela fonctionne
Orientation sécurité et limites pour les tests
- Eliza Weissman décrit Hubris comme « agressivement hostile aux programmes malveillants »
- Une tentative d’exploitation commence souvent par une erreur d’API ou un mauvais usage, si bien qu’un système qui efface l’état d’un composant fautif peut être plus difficile à exploiter
- Cette hypothèse n’a pas encore été testée
- Le texte inclut un appel à contact pour toute personne intéressée par des tentatives d’exploitation de Hubris
- L’inconvénient observé est que le système est très difficile à fuzz tester
- Une petite tâche de chaos générant des IPC et appels système aléatoires a bien été implémentée, mais elle se fait réinitialiser presque immédiatement quoi qu’elle fasse
- Pour devenir utile, elle doit fonder ses décisions sur un compteur d’uptime du système qui varie de façon observable à chaque démarrage
REPLY_FAULTfournit aussi aux serveurs un moyen de tuer aléatoirement des clients pour forcer du chaos, mais cette option n’a pas encore été évaluée complètement- Comme une tâche Hubris classique ne génère pas volontairement des messages IPC invalides à l’exécution, elle peut généralement s’exécuter sans jamais avoir conscience de l’existence de
REPLY_FAULT
1 commentaires
Avis sur Hacker News
REPLY_FAULT semble bien adapté quand le système est petit et étroitement intégré, et que les applications sont elles aussi principalement écrites par les personnes qui ont conçu l’ensemble du système
Mais du point de vue d’un développeur d’application, relier du code tiers à travers un modèle IPC où un autre service peut à tout moment envoyer une pilule mortelle instantanée à mon processus a l’air assez effrayant
Je ne fais pas suffisamment confiance aux autres développeurs d’applications pour ça. Le monde est rempli de mauvais conducteurs et de processus en arrière-plan écrits par des développeurs sous pression de leur management, qui pourraient bien parsemer leur code de REPLY_FAULT par défaut potentiellement inappropriés s’ils peuvent juste rentrer chez eux avant 20 h
Tous les prérequis n’étaient pas faciles à comprendre, et ils pouvaient varier selon l’appareil ou la version de l’OS
Le périmètre a toujours tendance à s’étendre, mais on n’aura probablement pas envie de pousser dans une tâche Hubris d’un contrôleur embarqué un travail qu’il vaudrait mieux traiter sur l’hôte
Si le serveur dit « ce client est en tort », le noyau tue ce client. L’essentiel, c’est que les deux ne se sont pas compris
Et cela peut aussi être différent de ce à quoi on pense quand on dit « processus ». Dans Hubris, les threads partagent tous le même espace d’adressage
REPLY_FAULT se propage-t-il en cascade ? Par exemple, si A fait un
SENDà B et attend, puis B fait unSENDà C et attend, si C renvoieREPLY_FAULT, est-ce que A meurt aussi avec B ?Si ce n’est pas le cas, il suffit à une tâche malveillante de déléguer l’expérimentation à une tâche auxiliaire. À l’inverse, si c’est le cas, l’ensemble paraît assez fragile, même si je ne connais pas très bien Hubris
En plus, si
SENDpeut être circulaire ou réciproque, une tâche pourrait aussi se tuer elle-même par erreur. Dans un cas comme B → A → B, cela pourrait même créer une incitation à ne pas utiliser REPLY_FAULTSi un serveur peut renvoyer l’erreur au client, c’est pour la fiabilité, pas pour la sécurité. L’idée est que les erreurs viennent de bugs, pas d’attaques intentionnelles, et la réaction extrême du noyau aide les développeurs à trouver le problème le plus vite possible
Bien sûr, il y a un recoupement avec la sécurité, et cela peut servir de défense de secours utile quand un processus tente de faire quelque chose qu’il ne devrait pas faire
Hubris et son débogueur Humility sont des technologies dans lesquelles j’aimerais vraiment creuser si j’avais le temps, ou une mission qui l’exigeait. Malheureusement, ce n’est pas possible pour le moment
Dans un système où une seule équipe écrit tout le code, il est intéressant de voir qu’éjecter un client de son orbite simplement parce qu’il a regardé de travers peut accélérer l’itération du développement
Je me suis endormi en lisant sur les effets algébriques, puis j’ai lu ce billet ce matin, et c’est amusant. En le tordant légèrement, c’est un noyau qui permet au serveur d’exécuter des effets que le client ne peut pas gérer
La réutilisation et la composition du code deviendraient sans doute bien plus difficiles, mais le modèle d’exécution serait beaucoup plus simple. Pour un système embarqué statique, c’est clairement un compromis pertinent. Et si on a besoin de réutilisation, on peut toujours vendoriser une tâche et la modifier
Au contraire, Unix comporte trop d’erreurs qu’on peut ignorer, et je pense personnellement qu’une bonne partie d’entre elles auraient dû déclencher des signaux fatals. La qualité globale des logiciels s’en serait probablement trouvée bien meilleure
Par exemple, appeler
close()sur un descripteur de fichier invalide n’est pas une erreur fatale, donc c’est souvent ignoré. Mais en pratique, c’est très dangereux, surtout dans les applications multithread. La plupart du temps, fermer le mauvais descripteur de fichier échoue sans gravité, mais dans 1 % des cas, cela ferme une socket de logs, un fichier de verrouillage de base de données ou une connexion IPC sans rapport. Et c’est ainsi qu’on fabrique ces logiciels instables que tout le monde détesteCela me rappelle la réplique d’Errand of Mercy : « Vous découvrirez qu’il existe diverses règles et réglementations. Elles seront affichées. La violation de la moindre d’entre elles est punie de mort »
Il faudrait en faire une RFC du 1er avril pour HTTP
Je propose HTTP 499 « Shame on you. ». Un client qui reçoit un 499 devrait, peut-être seulement pour les requêtes commencées avec un en-tête spécifique du genre
Strict: true, terminer la tâche qui a émis cette requête d’une manière propre au langageCela atteint parfaitement cet équilibre de « mais qu’est-ce que c’est que ça… et pourtant, en fait, c’est pas mal ? » que l’on ressent ici
Lecture très divertissante, et cette approche à supervisor unique ressemble à la manière dont, dans une ancienne startup, nous structurions l’application pour tout faire en unwrap
Cela m’a aussi rappelé l’un de mes billets préférés : https://medium.com/@mattklein123/crash-early-and-crash-often...
Je me demande si c’est vraiment trop agressif
Sous Linux, il est impossible de faire planter directement un autre programme qui communique uniquement via des sockets, sauf en lui envoyant des données invalides sur le socket
En revanche, il est tout à fait possible de le tuer. Tout ce qui tourne en root peut tuer autre chose, et même redémarrer pour faire tomber l’ensemble du système
C’est un peu plus difficile et moins courant, mais au moins dans les conteneurs, les privilèges root sont fréquents. Bien sûr, il y a les cgroups qui imposent davantage de limites, mais l’idée générale reste la même
C’est aussi un peu différent de la sagesse habituelle selon laquelle il faut « être libéral dans ce que l’on reçoit et conservateur dans ce que l’on envoie ». Cela dit, c’est peut-être une idée davantage liée aux systèmes réseau
Malgré tout, il est peut-être inévitable qu’un système soit libéral dans ce qu’il accepte. Sinon, comment modifier légèrement une API sans casser les programmes existants ?
Si j’ai bien compris, il n’autorise pas non plus de nouveaux types de processus à l’exécution. Tous les exécutables possibles doivent être définis à la compilation
À propos du passage disant qu’« il n’existe aucun moyen de corriger le problème et de reprendre la tâche. C’était un choix délibéré pour éviter des modes de défaillance subtils et simplifier le raisonnement sur le système », cela me rappelle la célèbre formule d’Einstein : « aussi simple que possible, mais pas plus simple »
Cette conception semble enfreindre la seconde partie. Je ne m’intéresse pas à des environnements d’exploitation incapables de tolérer le moindre désordre du monde réel, et je vois mal quels domaines commercialement viables accepteraient cela
Au final, l’idée est de revenir au système d’init pour réessayer en boucle ? Mais par quel mécanisme peut-on comprendre la fault survenue afin de réessayer de manière plus pertinente ?
Quoi qu’il en soit, j’applaudis la pureté de la conviction
En pratique, comme Cliff l’a décrit en détail sur le blog, REPLY_FAULT était au départ une fonctionnalité que nous pensions peut-être trop agressive, mais l’expérience acquise en construisant, déployant et, pour être francs, en déboguant le système nous a convaincus qu’elle ne rendrait pas notre système capricieusement fragile, mais au contraire plus robuste
On peut voir davantage cette manière de penser et sa mise en pratique dans [0] et [1]
[0] https://www.mattkeeter.com/blog/2024-03-25-packing/
[1] https://cliffle.com/blog/who-killed-the-network-switch/
Même sur des projets hobby, j’ai souvent vu un bus I2C se bloquer lorsqu’un seul bit du protocole se déréglait, au point de faire tomber tout le système ; je trouve donc cette conception assez inspirante
Si j’ai bien compris, il s’agit de cas d’erreur déjà connus, c’est-à-dire non pas d’erreurs gérées, mais de désalignements de protocole et de choses qui ne devraient absolument jamais arriver
Comme d’autres commentaires l’ont aussi relevé, c’est un OS conçu pour un objectif précis. De la même manière qu’on ne construirait pas une UI en Erlang, Hubris semble bien adapté à l’espace qu’il occupe
La cause peut être un bug, une attaque ou du matériel corrompu ; dans tous les cas, il ne faut pas continuer. L’appelant a un problème grave, et poursuivre ne ferait qu’aggraver les dégâts
Cela ressemble un peu à la philosophie « let it crash » d’Erlang/OTP. Erlang est utilisé dans pas mal de matériel mission-critical et est réputé pour sa fiabilité, donc en pratique ce n’est peut-être pas un défaut si rédhibitoire
Il a été écrit pour tourner profondément à l’intérieur des baies de serveurs 0xide
Sur le passage disant qu’« une tentative d’exploitation se manifeste souvent d’abord comme une erreur ou un mauvais usage de l’API, donc un système qui efface l’état du composant fautif à chaque mauvais comportement devrait être plus difficile à exploiter », ici on vérifie en quelque sorte plus strictement ce que l’application accepte
Il y a donc un avantage de sécurité, mais il est différent de celui auquel on pense spontanément. Il ne s’agit pas de briser la progression de l’attaquant pour le faire reculer, mais de faire en sorte que certains états erronés précis, qui auparavant pouvaient être enchaînés vers un état invalide plus utile, ne fonctionnent plus
L’attaquant cherchera alors probablement autre chose au lieu d’insister dans cette voie