Příznak nepřiřazeno nesmí být hodnota, kterou lze legitimně přiřadit
Jádro BSD přiděluje unixovým socketům inode čísla líně, až když se na ně někdo zeptá přes fstat. Kód v uipc_sense() vypadá v jádru takhle:
if (unp->unp_ino == 0)
unp->unp_ino = unp_ino++;
Globální čítač unp_ino startuje na nule a použitý je postfixový inkrement. První socket tedy dostane nulu, uloží si nulu, a při příštím dotazu se podmínka znovu vyhodnotí jako pravdivá, protože nula znamená nepřiřazeno. První socket v systému mění svoje inode číslo při každém dotazu.
Sentinel, který se překrývá s platnou hodnotou
Chyba není v líné inicializaci. Ta je v pořádku a je běžná. Chyba je v tom, že příznak stavu se skrývá v téže proměnné jako hodnota a obě sady se překrývají právě v jednom bodě.
Prefixový inkrement ++unp_ino by začal jedničkou a obor platných hodnot by se s nulou nepotkal. Rozdíl je jeden znak. Dopad je, že u jednoho konkrétního socketu ztratí identifikátor svou jedinou vlastnost, kvůli které existuje, totiž stabilitu.
Obecný tvar té chyby je starší než ten kód. Kdykoli použiješ hodnotu z oboru jako značku pro nepřítomnost hodnoty, existuje vstup, pro který nedokážeš rozlišit prázdno od obsahu. Nula pro chybějící číslo, prázdný řetězec pro chybějící jméno, minus jedna pro chybějící index. Sentinel patří mimo obor, nebo vedle něj jako samostatný příznak.
Proč to nikdo čtyřicet let neviděl
Chyba se projeví jen u prvního socketu po startu a jen tehdy, když se stejného deskriptoru zeptáš na inode dvakrát a čísla porovnáš. Většina kódu se ptá jednou a nic neporovnává.
Autor článku na ni narazil při vlastní implementaci TTY v uživatelském prostoru. Potřeboval rozlišit dva konce socketu, které se přes dup() tvářily stejně, a proto si cachoval původní inode číslo. fstat mu vrátil pokaždé něco jiného a selhání bylo deterministické při prvním bootu. Právě ta determinističnost vedla k nálezu, protože náhodná chyba by se svedla na časování.
Historie je dohledatelná. V květnu 1985 přišel požadavek, aby fstat fungoval i na unixových socketech. V prosinci téhož roku přibyl mechanismus cachování i s tou chybou. V roce 2001 se kód zdědil do XNU a s ním do Mac OS X, odkud vede přímá linie do dnešního macOS a iOS.
Komentář, který přiznává, že jde o atrapu
Původní commit to popsal jako vymyšlení inode čísel a zařízení pro ty naivní. To je čitelné přiznání, že funkce nebyla považovaná za skutečné rozhraní, jen za něco, co uspokojí volajícího.
Takhle označené místo je mimořádně spolehlivý ukazatel. Kde autor sám napsal, že hodnota je předstíraná nebo prozatímní, tam nikdy neproběhla úvaha o okrajových případech a zároveň na to od té doby něco začalo spoléhat. Rozdíl mezi rokem 1985 a dneškem je jen v tom, kolik vrstev se mezitím navrstvilo.
Co si z toho odnést
Při každé líné inicializaci si polož jedinou otázku: může být výchozí hodnota proměnné zároveň legitimním výsledkem. Pokud ano, máš chybu bez ohledu na to, jak vzácně se projeví. Řešením je čítač začínající za sentinelem, samostatný boolean, nebo typ, který prázdno modeluje mimo obor.
Z druhé strany to dává návod na hledání. Když v cizím kódu narazíš na komentář, který přiznává, že něco je zjednodušení pro nenáročné volající, podívej se, co na tom mezitím začalo stát. Takové místo nebylo nikdy revidované, protože se tváří jako vědomé rozhodnutí.
A třetí věc, praktická: deterministické selhání při prvním spuštění po bootu není výjimka v pořadí operací, ale skoro vždy stav, který se inicializuje jednou a jinak než později.