Débogage de la corruption de la mémoire : Qui a écrit "2" dans ma pile ?

Il y a plusieurs semaines, nous avons reçu un rapport de bogue d'un client qui nous disait que son jeu se plantait lorsqu'il utilisait le backend de script IL2CPP. L'assurance qualité a vérifié le bogue et me l'a confié pour que je le corrige. Le projet était assez important (bien que loin des plus grands) ; il a pris 40 minutes à construire sur ma machine. Les instructions sur le rapport de bogue indiquaient : "Jouez au jeu pendant 5 à 10 minutes jusqu'à ce qu'il s'arrête. Après avoir suivi les instructions, j'ai observé un crash. J'ai lancé WinDbg, prêt à tout mettre en œuvre. Malheureusement, la trace de la pile était fausse :
0:049> k
# Child-SP RetAddr Call Site
00 00000022`e25feb10 00000000`00000010 0x00007ffa`00000102
0:050> u 0x00007ffa`00000102 L10
00007ffa`00000102 ?? ???
^ Memory access error in 'u 0x00007ffa`00000102 l10'
Manifestement, il a essayé d'exécuter une adresse mémoire non valide. Bien que la trace de la pile ait été corrompue, j'espérais que seule une partie de la pile avait été corrompue et que je serais en mesure de la reconstruire si je regardais le contenu de la mémoire après le registre du pointeur de pile. Cela m'a certainement donné une idée de la direction à prendre :
0:049> dps @rsp L200
...............
00000022`e25febd8 00007ffa`b1fdc65c ucrtbased!heap_alloc_dbg+0x1c [d:\th\minkernel\crts\ucrt\src\appcrt\heap\debug_heap.cpp @ 447]
00000022`e25febe0 00000000`00000004
00000022`e25febe8 00000022`00000001
00000022`e25febf0 00000022`00000000
00000022`e25febf8 00000000`00000000
00000022`e25fec00 00000022`e25fec30
00000022`e25fec08 00007ffa`99b3d3ab UnityPlayer!std::_Vector_alloc<std::_Vec_base_types<il2cpp::os::PollRequest,std::allocator<il2cpp::os::PollRequest> > >::_Get_data+0x2b [ c:\program files (x86)\microsoft visual studio 14.0\vc\include\vector @ 642]
00000022`e25fec10 00000022`e25ff458
00000022`e25fec18 cccccccc`cccccccc
00000022`e25fec20 cccccccc`cccccccc
00000022`e25fec28 00007ffa`b1fdf54c ucrtbased!_calloc_dbg+0x6c [d:\th\minkernel\crts\ucrt\src\appcrt\heap\debug_heap.cpp @ 511]
00000022`e25fec30 00000000`00000010
00000022`e25fec38 00007ffa`00000001
...............
00000022`e25fec58 00000000`00000010
00000022`e25fec60 00000022`e25feca0
00000022`e25fec68 00007ffa`b1fdb69e ucrtbased!calloc+0x2e [d:\th\minkernel\crts\ucrt\src\appcrt\heap\calloc.cpp @ 25]
00000022`e25fec70 00000000`00000001
00000022`e25fec78 00000000`00000010
00000022`e25fec80 cccccccc`00000001
00000022`e25fec88 00000000`00000000
00000022`e25fec90 00000022`00000000
00000022`e25fec98 cccccccc`cccccccc
00000022`e25feca0 00000022`e25ff3f0
00000022`e25feca8 00007ffa`99b3b646 UnityPlayer!il2cpp::os::SocketImpl::Poll+0x66 [ c:\users\tautvydas\builds\bin2\il2cppoutputproject\il2cpp\libil2cpp\os\win32\socketimpl.cpp @ 1429]
00000022`e25fecb0 00000000`00000001
00000022`e25fecb8 00000000`00000010
...............
00000022`e25ff3f0 00000022`e25ff420
00000022`e25ff3f8 00007ffa`99c1caf4 UnityPlayer!il2cpp::os::Socket::Poll+0x44 [ c:\users\tautvydas\builds\bin2\il2cppoutputproject\il2cpp\libil2cpp\os\socket.cpp @ 324]
00000022`e25ff400 00000022`e25ff458
00000022`e25ff408 cccccccc`ffffffff
00000022`e25ff410 00000022`e25ff5b4
00000022`e25ff418 00000022`e25ff594
00000022`e25ff420 00000022`e25ff7e0
00000022`e25ff428 00007ffa`99b585f8 UnityPlayer!il2cpp::vm::SocketPollingThread::RunLoop+0x268 [ c:\users\tautvydas\builds\bin2\il2cppoutputproject\il2cpp\libil2cpp\vm\threadpool.cpp @ 452]
00000022`e25ff430 00000022`e25ff458
00000022`e25ff438 00000000`ffffffff
...............
00000022`e25ff7d8 00000022`e25ff6b8
00000022`e25ff7e0 00000022`e25ff870
00000022`e25ff7e8 00007ffa`99b58d2c UnityPlayer!il2cpp::vm::SocketPollingThreadEntryPoint+0xec [ c:\users\tautvydas\builds\bin2\il2cppoutputproject\il2cpp\libil2cpp\vm\threadpool.cpp @ 524]
00000022`e25ff7f0 00007ffa`9da83610 UnityPlayer!il2cpp::vm::g_SocketPollingThread
00000022`e25ff7f8 00007ffa`99b57700 UnityPlayer!il2cpp::vm::FreeThreadHandle [ c:\users\tautvydas\builds\bin2\il2cppoutputproject\il2cpp\libil2cpp\vm\threadpool.cpp @ 488]
00000022`e25ff800 00000000`0000106c
00000022`e25ff808 cccccccc`cccccccc
00000022`e25ff810 00007ffa`9da83610 UnityPlayer!il2cpp::vm::g_SocketPollingThread
00000022`e25ff818 000001c4`1705f5c0
00000022`e25ff820 cccccccc`0000106c
...............
00000022`e25ff860 00005eaa`e9a6af86
00000022`e25ff868 cccccccc`cccccccc
00000022`e25ff870 00000022`e25ff8d0
00000022`e25ff878 00007ffa`99c63b52 UnityPlayer!il2cpp::os::Thread::RunWrapper+0xd2 [ c:\users\tautvydas\builds\bin2\il2cppoutputproject\il2cpp\libil2cpp\os\thread.cpp @ 106]
00000022`e25ff880 00007ffa`9da83610 UnityPlayer!il2cpp::vm::g_SocketPollingThread
00000022`e25ff888 00000000`00000018
00000022`e25ff890 cccccccc`cccccccc
...............
00000022`e25ff8a8 000001c4`15508c90
00000022`e25ff8b0 cccccccc`00000002
00000022`e25ff8b8 00007ffa`99b58c40 UnityPlayer!il2cpp::vm::SocketPollingThreadEntryPoint [ c:\users\tautvydas\builds\bin2\il2cppoutputproject\il2cpp\libil2cpp\vm\threadpool.cpp @ 494]
00000022`e25ff8c0 00007ffa`9da83610 UnityPlayer!il2cpp::vm::g_SocketPollingThread
00000022`e25ff8c8 000001c4`155a5890
00000022`e25ff8d0 00000022`e25ff920
00000022`e25ff8d8 00007ffa`99c19a14 UnityPlayer!il2cpp::os::ThreadStartWrapper+0x54 [ c:\users\tautvydas\builds\bin2\il2cppoutputproject\il2cpp\libil2cpp\os\win32\threadimpl.cpp @ 31]
00000022`e25ff8e0 000001c4`155a5890
...............
00000022`e25ff900 cccccccc`cccccccc
00000022`e25ff908 00007ffa`99c63a80 UnityPlayer!il2cpp::os::Thread::RunWrapper [ c:\users\tautvydas\builds\bin2\il2cppoutputproject\il2cpp\libil2cpp\os\thread.cpp @ 80]
00000022`e25ff910 000001c4`155a5890
...............
00000022`e25ff940 000001c4`1e0801b0
00000022`e25ff948 00007ffa`e6858102 KERNEL32!BaseThreadInitThunk+0x22
00000022`e25ff950 000001c4`1e0801b0
00000022`e25ff958 00000000`00000000
00000022`e25ff960 00000000`00000000
00000022`e25ff968 00000000`00000000
00000022`e25ff970 00007ffa`99c199c0 UnityPlayer!il2cpp::os::ThreadStartWrapper [ c:\users\tautvydas\builds\bin2\il2cppoutputproject\il2cpp\libil2cpp\os\win32\threadimpl.cpp @ 26]
00000022`e25ff978 00007ffa`e926c5b4 ntdll!RtlUserThreadStart+0x34
00000022`e25ff980 00007ffa`e68580e0 KERNEL32!BaseThreadInitThunk
Voici une reconstitution approximative du tracé de la pile :
00000022`e25febd8 00007ffa`b1fdc65c ucrtbased!heap_alloc_dbg+0x1c [...\appcrt\heap\debug_heap.cpp @ 447]
00000022`e25fec28 00007ffa`b1fdf54c ucrtbased!_calloc_dbg+0x6c [...\appcrt\heap\debug_heap.cpp @ 511]
00000022`e25fec68 00007ffa`b1fdb69e ucrtbased!calloc+0x2e [...\appcrt\heap\calloc.cpp @ 25]
00000022`e25feca8 00007ffa`99b3b646 UnityPlayer!il2cpp::os::SocketImpl::Poll+0x66 [...\libil2cpp\os\win32\socketimpl.cpp @ 1429]
00000022`e25ff3f8 00007ffa`99c1caf4 UnityPlayer!il2cpp::os::Socket::Poll+0x44 [...\libil2cpp\os\socket.cpp @ 324]
00000022`e25ff428 00007ffa`99b585f8 UnityPlayer!il2cpp::vm::SocketPollingThread::RunLoop+0x268 [...\libil2cpp\vm\threadpool.cpp @ 452]
00000022`e25ff7e8 00007ffa`99b58d2c UnityPlayer!il2cpp::vm::SocketPollingThreadEntryPoint+0xec [...\libil2cpp\vm\threadpool.cpp @ 524]
00000022`e25ff878 00007ffa`99c63b52 UnityPlayer!il2cpp::os::Thread::RunWrapper+0xd2 [...\libil2cpp\os\thread.cpp @ 106]
00000022`e25ff8d8 00007ffa`99c19a14 UnityPlayer!il2cpp::os::ThreadStartWrapper+0x54 [...\libil2cpp\os\win32\threadimpl.cpp @ 31]
00000022`e25ff948 00007ffa`e6858102 KERNEL32!BaseThreadInitThunk+0x22
00000022`e25ff978 00007ffa`e926c5b4 ntdll!RtlUserThreadStart+0x34
D'accord, je savais maintenant quel thread se plantait : c'était le thread de polling de socket du runtime IL2CPP. Sa responsabilité est d'indiquer aux autres threads quand leurs sockets sont prêts à envoyer ou à recevoir des données. Cela se passe comme suit : il y a une file d'attente FIFO dans laquelle les demandes d'interrogation des sockets sont placées par d'autres threads, le thread d'interrogation des sockets met ces demandes en file d'attente une par une, appelle la fonction select() et lorsque select() renvoie un résultat, il met en file d'attente dans le pool de threads le callback qui se trouvait dans la demande originale.
Quelqu'un corrompt donc gravement la pile. Afin de limiter la recherche, j'ai décidé de mettre "sentinelles de pile" sur la plupart des cadres de pile dans ce fil. Voici comment ma sentinelle de pile a été définie :

Lorsqu'il est construit, il remplit le tampon avec "0xDD". Lors de sa destruction, il vérifiera que ces valeurs n'ont pas changé. Cela a incroyablement bien fonctionné : le jeu ne se plantait plus ! Il s'agit plutôt d'une affirmation :

Quelqu'un avait touché les parties intimes de ma sentinelle - et ce n'était certainement pas un ami. J'ai répété l'opération plusieurs fois, et le résultat a été le même : à chaque fois, c'est la valeur "2" qui a été écrite en premier dans le tampon. En regardant la vue de la mémoire, j'ai remarqué que ce que je voyais m'était familier :

Ce sont exactement les mêmes valeurs que nous avons vues dans la toute première trace de pile corrompue. Je me suis rendu compte que ce qui avait causé le crash plus tôt était également responsable de la corruption de la sentinelle de la pile. Au début, j'ai pensé qu'il s'agissait d'une sorte de débordement de mémoire tampon et que quelqu'un écrivait en dehors des limites de sa variable locale. J'ai donc commencé à placer ces sentinelles de pile de manière beaucoup plus agressive : avant presque chaque appel de fonction effectué par le thread. Cependant, les corruptions semblaient se produire à des moments aléatoires, et je n'ai pas été en mesure de trouver la cause de ces corruptions en utilisant cette méthode.
Je savais que la mémoire était toujours corrompue lorsque l'une de mes sentinelles était dans le champ d'application. Je devais en quelque sorte prendre en flagrant délit la chose qui le corrompt. J'ai pensé que la mémoire de la sentinelle de la pile ne serait lue que pendant la durée de vie de la sentinelle de la pile : J'appellerais VirtualProtect() dans le constructeur pour marquer les pages en lecture seule, et je l'appellerais à nouveau dans le destructeur pour les rendre accessibles en écriture :

À ma grande surprise, il était toujours corrompu ! Et le message dans le journal de débogage était :
La mémoire a été corrompue à 0xd046ffeea8. Il était en lecture seule lorsqu'il a été corrompu.
CrashingGame.exe a déclenché un point d'arrêt.
Cela m'a mis la puce à l'oreille. Quelqu'un avait corrompu la mémoire soit pendant qu'elle était en lecture seule, soit juste avant que je la mette en lecture seule. Comme je n'ai constaté aucune violation d'accès, j'ai supposé qu'il s'agissait de la seconde hypothèse et j'ai donc modifié le code pour vérifier si le contenu de la mémoire changeait juste après la définition de mes valeurs magiques :

Ma théorie a été vérifiée :
La mémoire a été corrompue à 0x79b3bfea78.
CrashingGame.exe a déclenché un point d'arrêt.
C'est à ce moment-là que j'ai réfléchi : "Il doit s'agir d'un autre fil qui corrompt ma pile. Il doit l'être. C'est vrai ? DROIT ?". La seule façon dont je savais comment procéder pour enquêter sur ce problème était d'utiliser des points d'arrêt pour les données (mémoire) afin d'attraper le contrevenant. Malheureusement, sur x86, vous ne pouvez surveiller que quatre emplacements de mémoire à la fois, ce qui signifie que je peux surveiller 32 octets au maximum, alors que la zone qui avait été corrompue était de 16 Ko. Je devais en quelque sorte déterminer où placer les points d'arrêt. J'ai commencé à observer des schémas de corruption. Au début, il semblait qu'ils étaient aléatoires, mais ce n'était qu'une illusion due à la nature de l'ASLR: chaque fois que je redémarrais le jeu, il plaçait la pile à une adresse mémoire aléatoire, de sorte que le lieu de la corruption différait naturellement. Cependant, lorsque j'ai compris cela, j'ai cessé de redémarrer le jeu à chaque fois que la mémoire était corrompue et j'ai simplement poursuivi l'exécution. J'ai ainsi découvert que l'adresse de la mémoire corrompue était toujours constante pour une session de débogage donnée. En d'autres termes, une fois qu'il a été corrompu une fois, il sera toujours corrompu exactement à la même adresse mémoire tant que je ne mettrai pas fin au programme :
La mémoire a été corrompue à 0x90445febd8.
CrashingGame.exe a déclenché un point d'arrêt.
La mémoire a été corrompue à 0x90445febd8.
CrashingGame.exe a déclenché un point d'arrêt.
J'ai placé un point d'arrêt de données sur cette adresse mémoire et j'ai observé qu'il continuait à s'interrompre chaque fois que je le plaçais à la valeur magique de 0xDD. Je me suis dit que cela allait prendre un certain temps, mais Visual Studio me permet en fait de définir une condition sur ce point d'arrêt : ne l'interrompre que si la valeur de cette adresse mémoire est 2 :

Une minute plus tard, ce point d'arrêt est enfin atteint. J'en suis arrivé là après trois jours de débogage. Cela allait être mon triomphe. "J'ai enfin réussi à vous mettre au tapis", ai-je proclamé. C'est du moins ce que je pensais avec optimisme :

J'ai regardé le débogueur avec incrédulité alors que mon esprit se remplissait de plus de questions que de réponses : "Qu'est-ce que c'est ? Comment cela est-il possible ? Suis-je en train de devenir fou ?". J'ai décidé d'examiner le démontage :

En effet, il modifiait cet emplacement de mémoire. Mais il écrivait 0xDD, et non 0x02 ! L'examen de la fenêtre de mémoire a révélé que toute la région était déjà corrompue :

Alors que j'étais prêt à me taper la tête contre le mur, j'ai appelé mon collègue et lui ai demandé de vérifier si je n'avais pas manqué quelque chose d'évident. Nous avons examiné ensemble le code de débogage et nous n'avons rien trouvé qui puisse, même de loin, provoquer une telle bizarrerie. J'ai ensuite pris du recul et j'ai essayé d'imaginer ce qui pouvait causer l'interruption du débogueur en pensant que le code fixait la valeur à "2". J'ai imaginé la chaîne d'événements hypothétique suivante :
1. mov byte ptr [rax], 0DDh modifie l'emplacement mémoire, l'unité centrale interrompt l'exécution pour permettre au débogueur d'inspecter l'état du programme.
2. La mémoire est altérée par quelque chose
3. Le débogueur inspecte l'adresse mémoire, trouve "2" à l'intérieur et pense que c'est ce qui a changé.
Alors, qu'est-ce qui peut modifier le contenu de la mémoire pendant que le programme est gelé par un débogueur ? Pour autant que je sache, il y a deux cas de figure : soit c'est un autre processus qui le fait, soit c'est le noyau du système d'exploitation. Pour étudier l'un ou l'autre de ces cas, un débogueur conventionnel ne fonctionnera pas. Entrez dans la zone de débogage du noyau.
Il est surprenant de constater que la configuration du débogage du noyau est extrêmement simple sous Windows. Vous aurez besoin de deux machines : celle sur laquelle le débogueur fonctionnera et celle sur laquelle vous déboguerez. Ouvrez une invite de commande élevée sur la machine que vous allez déboguer, et tapez ceci :

Host IP est l'adresse IP de la machine sur laquelle tourne le débogueur. Il utilisera le port spécifié pour la connexion au débogueur. Il peut être compris entre 49152 et 65535. Après avoir appuyé sur la touche "Entrée" de la deuxième commande, vous obtiendrez une clé secrète (tronquée dans l'image) qui vous servira de mot de passe lorsque vous connecterez le débogueur. Une fois ces étapes terminées, redémarrez l'ordinateur.
Sur l'autre ordinateur, ouvrez WinDbg, cliquez sur File -> Kernel Debug et entrez le port et la clé.

Si tout se passe bien, vous pourrez interrompre l'exécution en appuyant sur Debug -> Break. Si cela fonctionne, l'ordinateur "debugee" se fige. Saisissez "g" pour poursuivre l'exécution.
J'ai démarré le jeu et j'ai attendu qu'il se casse une fois pour pouvoir trouver l'adresse à laquelle la mémoire est corrompue :
La mémoire a été corrompue à 0x49d05fedd8.
CrashingGame.exe a déclenché un point d'arrêt.
Très bien, maintenant que je connaissais l'adresse où placer un point d'arrêt de données, je devais configurer mon débogueur de noyau pour qu'il le place effectivement :
kd> !process 0 0
PROCESS ffffe00167228080
SessionId: 1 Cid: 26b8 Peb: 49cceca000 ParentCid: 03d8
DirBase: 1ae5e3000 ObjectTable: ffffc00186220d80 HandleCount:
Image: CrashingGame.exe
kd> .process /i ffffe00167228080
You need to continue execution (press 'g' ) for the context
to be switched. When the debugger breaks in again, you will be in
the new process context.
kd> g
Break instruction exception - code 80000003 (first chance)
nt!DbgBreakPointWithStatus:
fffff801`7534beb0 cc int 3
kd> .process
Implicit process is now ffffe001`66e9e080
kd> .reload /f
kd> ba w 1 0x00000049D05FEDD8 ".if (@@c++(*(char*)0x00000049D05FEDD8 == 2)) { k } .else { gc }"
Au bout d'un certain temps, le point d'arrêt est effectivement atteint...
# Child-SP RetAddr Call Site
00 ffffd000`23c1e980 fffff801`7527dc64 nt!IopCompleteRequest+0xef
01 ffffd000`23c1ea70 fffff801`75349953 nt!KiDeliverApc+0x134
02 ffffd000`23c1eb00 00007ffd`7e08b4bd nt!KiApcInterrupt+0xc3
03 00000049`d05fad50 cccccccc`cccccccc UnityPlayer!StackSentinel::StackSentinel+0x4d [...\libil2cpp\utils\memory.cpp @ 21]
D'accord, qu'est-ce qui se passe ici ? La sentinelle règle joyeusement ses valeurs magiques, puis il y a une interruption matérielle, qui appelle alors une routine de finalisation, et qui écrit "2" dans ma pile. Wow. Ok, pour une raison ou une autre, le noyau de Windows corrompt ma mémoire. Mais pourquoi?
J'ai d'abord pensé qu'il s'agissait d'un appel à une API Windows et de la transmission d'arguments non valides. J'ai donc revu tout le code du thread d'interrogation des sockets et j'ai constaté que le seul appel système que nous appelions était la fonction select(). Je suis allé sur MSDN et j'ai passé une heure à relire la documentation sur select() et à revérifier si nous faisions tout correctement. Pour autant que je sache, il n'y avait pas grand-chose à faire de mal avec, et il n'y avait certainement pas d'endroit dans la documentation où il était dit "si vous lui passez ce paramètre, nous écrirons 2 dans votre pile". Nous avions l'impression de faire tout ce qu'il fallait.
À court d'idées, j'ai décidé d'entrer dans la fonction select() à l'aide d'un débogueur, de la désassembler et de comprendre comment elle fonctionne. Cela m'a pris quelques heures, mais j'y suis parvenu. Il semble que la fonction select() soit une enveloppe pour la fonction WSPSelect(), qui ressemble à peu près à ceci :
auto completionEvent = TlsGetValue(MSAFD_SockTlsSlot);
/* setting up some state
…
*/
IO_STATUS_BLOCK statusBlock;
auto result = NtDeviceIoControlFile(networkDeviceHandle, completionEvent, nullptr, nullptr, &statusBlock, 0x12024,
buffer, bufferLength, buffer, bufferLength);
if (result == STATUS_PENDING)
WaitForSingleObjectEx(completionEvent, INFINITE, TRUE);
/* convert result and return it
...
*/
La partie importante ici est l'appel à NtDeviceIoControlFile(), le fait qu'il passe sa variable locale statusBlock comme paramètre out, et enfin le fait qu'il attende que l'événement soit signalé à l'aide d'un alertable wait. Jusqu'à présent, tout va bien : elle appelle une fonction du noyau, qui renvoie STATUS_PENDING si elle ne peut pas exécuter la demande immédiatement. Dans ce cas, WSPSelect() attend que l'événement soit défini. Une fois que NtDeviceIoControlFile() a terminé, il écrit le résultat dans la variable statusBlock et définit ensuite l'événement. L'attente se termine et WSPSelect() revient.
La structure IO_STATUS_BLOCK se présente comme suit :
typedef struct _IO_STATUS_BLOCK
{
union
{
NTSTATUS Status;
PVOID Pointer;
};
ULONG_PTR Information;
} IO_STATUS_BLOCK, *PIO_STATUS_BLOCK;
Sur 64 bits, cette structure a une longueur de 16 octets. J'ai remarqué que cette structure semble correspondre à mon modèle de corruption de la mémoire : les 4 premiers octets sont corrompus (NTSTATUS a une longueur de 4 octets), puis 4 octets sont sautés (padding/espace pour PVOID) et enfin 8 autres octets sont corrompus. Si c'est bien ce qui a été écrit dans ma mémoire, les quatre premiers octets contiendront l'état du résultat. Les 4 premiers octets de corruption étaient toujours 0x00000102. Et il se trouve que c'est le code d'erreur pour... STATUS_TIMEOUT! Ce serait une bonne théorie, si seulement WSPSelect() n'attendait pas que NtDeviceIOControlFile() se termine. Mais c'est ce qui s'est passé.
Après avoir compris le fonctionnement de la fonction select(), j'ai décidé d'examiner le fonctionnement global du fil d'attente de la socket. C'est alors que j'ai été frappé comme une tonne de briques.
Lorsqu'un autre thread pousse une socket à être traitée par le thread d'interrogation de la socket, ce dernier appelle select() sur cette fonction. Comme select() est un appel bloquant, lorsqu'une autre socket est ajoutée à la file d'attente du thread d'interrogation des sockets, il faut interrompre select() pour que la nouvelle socket soit traitée le plus rapidement possible. Comment fonctionne la fonction interrupt select() ? Apparemment, nous avons utilisé QueueUserAPC() pour exécuter une procédure asynchrone alors que select() était bloqué... et nous avons levé une exception ! Cela a déroulé la pile, nous a fait exécuter un peu plus de code, et à un moment donné dans le futur, le noyau a terminé le travail et a écrit le résultat dans la variable locale statusBlock (qui n'existait plus à ce moment-là). S'il arrivait qu'une adresse de retour soit touchée sur la pile, nous nous planterions.
La solution est assez simple : au lieu d'utiliser QueueUserAPC(), nous créons maintenant une socket loopback à laquelle nous envoyons un octet à chaque fois que nous devons interrompre select(). Ce chemin est utilisé depuis longtemps sur les plates-formes POSIX et l'est désormais également sous Windows. La correction de ce bug est disponible dans Unity 5.3.4p1.
Il s'agit d'un de ces insectes qui vous empêchent de dormir. Il m'a fallu 5 jours pour le résoudre, et c'est probablement l'un des bogues les plus difficiles que j'ai eu à examiner et à corriger. Leçon apprise, les amis : ne lancez pas d'exceptions à partir de procédures asynchrones si vous êtes à l'intérieur d'un appel système !