Het artikel beschrijft het proces van het debuggen van een fatale crash in een Go-applicatie op 32-bit ARM embedded Linux-systemen. De foutmelding runtime: netpoll: eventfd ready for something unexpected wees op een probleem in het netpoll-mechanisme van Go.
De oorzaak: Er werd ontdekt dat er op 32-bit little-endian systemen een overlap (aliasing) ontstaat in het ev.Data-veld van de epoll_event struct. Go gebruikt dit veld voor zowel raw pointers naar netpollEventFd als tagged pointers voor pollDesc-objecten. Wanneer de teller van de tagged pointer (fdseq) een waarde bereikt die overeenkomt met het geheugenadres van netpollEventFd, verwart de runtime een socket fd met een event fd, wat leidt tot een crash.
De oplossing: De bug werd opgelost door de raw pointer te vervangen door een tagged nil pollDesc. Hierdoor wordt consequent gebruikgemaakt van tagged pointers, waardoor de aliasing verdwijnt. De bug was geslopen in Go 1.14 (2020) en is in 2026 definitief verholpen.
Op zoek naar een Go runtime-bug op 32-bit embedded systemen
Onlangs meldde een klant dat een in Go geschreven applicatie af en toe crasht op een van hun embedded Linux-systemen.
Inleiding
De crash was altijd dezelfde fatale fout met de volgende signatuur: runtime: netpoll: eventfd ready for 5 fatal error: runtime: netpoll: eventfd ready for something unexpected ...stack trace...
In eerste instantie gingen we ervan uit dat de applicatie zelf een bug bevatte en hersteld moest worden. Maar na een nadere inspectie van de fout leek het er meer op dat een interne aanname in het netpoll-mechanisme van Go niet langer opging.
De foutmelding is afkomstig uit netpoll() in src/runtime/netpoll_epoll.go:
if ev.Events != linux.EPOLLIN {
println("runtime: netpoll: eventfd ready for", ev.Events)
throw("runtime: netpoll: eventfd ready for something unexpected")
}
In dit codepad verwacht de netpoll-code dat EPOLLIN de enige actieve event is, maar er werd iets anders ontvangen. In ons geval was dat 5, wat staat voor EPOLLIN|EPOLLOUT. Waarom zou epoll plotseling meer rapporteren dan EPOLLIN als de code alleen om EPOLLIN vroeg?
Voordat we dieper doken, voerden we de foutmelding in een zoekmachine in, in de hoop dat iemand anders hetzelfde probleem al eens had meegemaakt. Dit leidde ons rechtstreeks naar een melding in de issue tracker van het Go-project: runtime: netpoll: eventfd ready for something unexpected.
De melding beschrijft exact de fatale fout die wij zagen. Bovendien gebeurde dit ook op een 32-bit ARM embedded Linux-systeem! De melders merkten ook op dat de crash voorkomt in applicaties die lange tijd draaien. Dat sloot aan bij de beschrijving van onze klant.
Bingo! De issue was sinds maart 2025 open en onopgelost. De Go-onderhouders hadden bovendien een eerdere poging om het probleem op te lossen afgewezen. Uit de reacties op de issue bleek dat de fatale fout alleen voorkwam op 32-bit ARM- en i386 Linux-systemen, ongeacht de kernelversie. Sommige kernels waren vrij oud, andere recent. Niemand rapporteerde dit op een x86_64- of arm64-systeem.
De uitdaging aangaan
Het probleem had meerdere melders maar geen oplossing, dus besloten we er zelf in te duiken. Op zijn minst konden we de Go-ontwikkelaars betere input geven.
In eerste instantie vermoedden we dat epoll zich anders gedroeg op 32-bit ARM of i386. Dit idee lieten we snel varen, aangezien epoll generieke kerncode in de kernel is. Waarom zou het alleen op 32-bit ARM of i386 een foutieve event-set teruggeven? Toch bleef het feit dat de fout alleen op 32-bit systemen optrad ons knagen.
Als volgende stap bekeken we het gebruik van epoll in de netpoll-code van Go. Met behulp van een LLM gingen we door src/runtime/netpoll_epoll.go, waarbij we ons richtten op 32-bit valkuilen zoals integer-conversies.
De review onthulde dat de netpoll-code van Go het data-veld van struct epoll_event gebruikt. Linux epoll kan een cookie van 8 bytes in de kernel opslaan en deze als onderdeel van het actieve event teruggeven aan de gebruikersruimte. Applicaties gebruiken deze cookie om metadata aan een event te koppelen, bijvoorbeeld om verschillende event-bronnen van elkaar te onderscheiden.
struct epoll_event {
__poll_t events; /* ev.Events in Go netpoll */
__u64 data; /* ev.Data in Go netpoll */
};
Diep in de Go runtime moet de hoofd-eventhandler weten of een event bij een event fd of een socket fd hoort. Dit wordt beslist door ev.Data te vergelijken met het adres van het interne event fd-object:
if *(**uintptr)(unsafe.Pointer(&ev.Data)) == &netpollEventFd {
...
}
Bij verder lezen in de code bleek dat ev.Data ofwel een raw pointer naar netpollEventFd bevat, of een tagged pointer naar een per-socket object, pollDesc. De pointer-tag is een teller, fdseq, die gerecyclede pollDesc-objecten van elkaar onderscheidt.
Tot hier was alles logisch. Het mengen van raw pointers en tagged pointers in hetzelfde veld leek ons echter verdacht. Hoe dit precies relateerde aan de crash was nog niet duidelijk. Pas toen we onderzochten hoe Go tagged pointers in het geheugen lay-out, werd de kern van het probleem zichtbaar.
Het "Aha-moment"
Op 32-bit platforms pakt de tagged pointer-logica van Go het volledige 32-bit adres en tot 32 tag-bits in een woord van 8 bytes. De tag gaat in de onderste 4 bytes en het adres in de bovenste 4 bytes.
Het opslaan van een tagged pointer met adres 0x00123456 en tag 0x12 vult ev.Data als volgt:
| ev.Data[0:4] | ev.Data[4:8] |
| fdseq (bijv. 0x00000012) | *pollDesc (bijv. 0x00123456) |
Het opslaan van de raw pointer daarentegen resulteert in de volgende inhoud van ev.Data:
| ev.Data[0:4] | ev.Data[4:8] |
| &netpollEventFd (bijv. 0x00123456) | 0 (ongeroerd) |
De onderste 4 bytes bevatten het adres van het object. De bovenste 4 bytes blijven ongewijzigd, aangezien een adres op een 32-bit systeem slechts 4 bytes lang is.
Dit onthult de wortel van het probleem. De vergelijking van ev.Data met &netpollEventFd evalueert alleen de onderste 4 bytes van ev.Data. De code cast ev.Data naar uintptr, wat 4 bytes is op 32-bit platforms. Hierdoor ontstaat er een overlap (aliasing) tussen het adres van het netpollEventFd-object en fdseq.
Zodra fdseq groot genoeg wordt om overeen te komen met &netpollEventFd, verwisselt de netpoll-logica een socket fd voor een event fd. Interne aannames storten in, waaronder de aanname dat het actieve event enkel EPOLLIN is.
Let op dat deze aliasing alleen kan gebeuren op 32-bit little-endian systemen. Op een 64-bit systeem beslaat de vergelijking altijd de volledige 8 bytes. Op een 32-bit big-endian systeem zou de vergelijking de bovenste 4 bytes lezen, waarin het adres staat.
fdseq moet in de miljoenen lopen voordat het overeenkomt met &netpollEventFd. Dat is waarom het probleem alleen optreedt in programma's die lang draaien en in de loop veel pollDesc-objecten aanmaken. Op een typisch 32-bit ARM Linux-systeem bevindt netpollEventFd zich in een read-only sectie binnen de eerste 3 MiB van de adresruimte, zoals de memory map van ons testprogramma laat zien:
$ pmap `pidof netpoll_test`
204: /opt/netpoll_test
00010000 2696K r-x-- netpoll_test
002c0000 2192K r---- netpoll_test
004f0000 180K rw--- netpoll_test
...
fdseq moet dus een waarde van ongeveer 3 miljoen bereiken voordat de crash kan optreden.
Een testgeval
We hebben ook een standalone testgeval voor het probleem gemaakt. Op onze testsystemen triggerde dit de crash binnen enkele minuten:
$ /tmp/repro.arm.system
netpoll eventfd-alias reproducer (GOOS=linux GOARCH=arm)
runtime.netpollEventFd is at address 0x223008
crash expected around cycle 2240520
cycle 2236988runtime: netpoll: eventfd ready for 4
fatal error: runtime: netpoll: eventfd ready for something unexpected
runtime stack:
...
De bug oplossen
We stelden een oplossing voor die verandert hoe netpoll event fd's en socket fd's van elkaar onderscheidt. In plaats van de raw pointer &netpollEventFd op te slaan, slaat de fix een nil pollDesc op als een tagged pointer. Wanneer het uitpakken van de tagged pointer nil oplevert, hoort het event bij de event fd; anders bij een socket fd.
Op deze manier bevat ev.Data altijd een tagged pointer en is de aliasing verdwenen. Een paar dagen later is onze fix samengevoegd (merged).
Samenvatting
Een Go-applicatie crashte sporadisch op een 32-bit ARM embedded Linux-systeem met een fatale netpoll-fout. De fout leek op een epoll-probleem, maar epoll werkte prima.
De Go runtime slaat zowel een raw pointer naar netpollEventFd als tagged pollDesc pointers op in het 8-byte ev.Data-veld. Op 32-bit little-endian systemen ontstaat er een overlap tussen de raw pointer en de fdseq tag. Zodra een langdurig programma miljoenen pollDesc-objecten heeft gerecycled, verwisselt netpoll een socket fd voor de event fd en crasht het systeem.
Onze fix slaat in plaats daarvan een tagged nil pollDesc op voor de event fd, wat de aliasing wegneemt. De bug is in 2020 met Go 1.14 in de Go runtime geslopen. Hij bleef onopgemerkt tot de eerste melding in maart 2025 en is uiteindelijk in 2026 opgelost.
We kunnen alleen speculeren, maar dit suggereert dat Google zelf geen 32-bit Go-programma's meer draait. Anders zouden ze de bug zelf allang hebben ontdekt.
We willen Frequentis AG bedanken voor het budget om dit probleem te analyseren en op te lossen.
Op zoek naar een Go runtime-bug op 32-bit embedded systemen
Onlangs meldde een klant dat een in Go geschreven applicatie af en toe crasht op een van hun embedded Linux-systemen.
Inleiding
De crash was altijd dezelfde fatale fout met de volgende signatuur: runtime: netpoll: eventfd ready for 5 fatal error: runtime: netpoll: eventfd ready for something unexpected ...stack trace...
In eerste instantie gingen we ervan uit dat de applicatie zelf een bug bevatte en hersteld moest worden. Maar na een nadere inspectie van de fout leek het er meer op dat een interne aanname in het netpoll-mechanisme van Go niet langer opging.
De foutmelding is afkomstig uit netpoll() in src/runtime/netpoll_epoll.go:
if ev.Events != linux.EPOLLIN {
println("runtime: netpoll: eventfd ready for", ev.Events)
throw("runtime: netpoll: eventfd ready for something unexpected")
}
In dit codepad verwacht de netpoll-code dat EPOLLIN de enige actieve event is, maar er werd iets anders ontvangen. In ons geval was dat 5, wat staat voor EPOLLIN|EPOLLOUT. Waarom zou epoll plotseling meer rapporteren dan EPOLLIN als de code alleen om EPOLLIN vroeg?
Voordat we dieper doken, voerden we de foutmelding in een zoekmachine in, in de hoop dat iemand anders hetzelfde probleem al eens had meegemaakt. Dit leidde ons rechtstreeks naar een melding in de issue tracker van het Go-project: runtime: netpoll: eventfd ready for something unexpected.
De melding beschrijft exact de fatale fout die wij zagen. Bovendien gebeurde dit ook op een 32-bit ARM embedded Linux-systeem! De melders merkten ook op dat de crash voorkomt in applicaties die lange tijd draaien. Dat sloot aan bij de beschrijving van onze klant.
Bingo! De issue was sinds maart 2025 open en onopgelost. De Go-onderhouders hadden bovendien een eerdere poging om het probleem op te lossen afgewezen. Uit de reacties op de issue bleek dat de fatale fout alleen voorkwam op 32-bit ARM- en i386 Linux-systemen, ongeacht de kernelversie. Sommige kernels waren vrij oud, andere recent. Niemand rapporteerde dit op een x86_64- of arm64-systeem.
De uitdaging aangaan
Het probleem had meerdere melders maar geen oplossing, dus besloten we er zelf in te duiken. Op zijn minst konden we de Go-ontwikkelaars betere input geven.
In eerste instantie vermoedden we dat epoll zich anders gedroeg op 32-bit ARM of i386. Dit idee lieten we snel varen, aangezien epoll generieke kerncode in de kernel is. Waarom zou het alleen op 32-bit ARM of i386 een foutieve event-set teruggeven? Toch bleef het feit dat de fout alleen op 32-bit systemen optrad ons knagen.
Als volgende stap bekeken we het gebruik van epoll in de netpoll-code van Go. Met behulp van een LLM gingen we door src/runtime/netpoll_epoll.go, waarbij we ons richtten op 32-bit valkuilen zoals integer-conversies.
De review onthulde dat de netpoll-code van Go het data-veld van struct epoll_event gebruikt. Linux epoll kan een cookie van 8 bytes in de kernel opslaan en deze als onderdeel van het actieve event teruggeven aan de gebruikersruimte. Applicaties gebruiken deze cookie om metadata aan een event te koppelen, bijvoorbeeld om verschillende event-bronnen van elkaar te onderscheiden.
struct epoll_event {
__poll_t events; /* ev.Events in Go netpoll */
__u64 data; /* ev.Data in Go netpoll */
};
Diep in de Go runtime moet de hoofd-eventhandler weten of een event bij een event fd of een socket fd hoort. Dit wordt beslist door ev.Data te vergelijken met het adres van het interne event fd-object:
if *(**uintptr)(unsafe.Pointer(&ev.Data)) == &netpollEventFd {
...
}
Bij verder lezen in de code bleek dat ev.Data ofwel een raw pointer naar netpollEventFd bevat, of een tagged pointer naar een per-socket object, pollDesc. De pointer-tag is een teller, fdseq, die gerecyclede pollDesc-objecten van elkaar onderscheidt.
Tot hier was alles logisch. Het mengen van raw pointers en tagged pointers in hetzelfde veld leek ons echter verdacht. Hoe dit precies relateerde aan de crash was nog niet duidelijk. Pas toen we onderzochten hoe Go tagged pointers in het geheugen lay-out, werd de kern van het probleem zichtbaar.
Het "Aha-moment"
Op 32-bit platforms pakt de tagged pointer-logica van Go het volledige 32-bit adres en tot 32 tag-bits in een woord van 8 bytes. De tag gaat in de onderste 4 bytes en het adres in de bovenste 4 bytes.
Het opslaan van een tagged pointer met adres 0x00123456 en tag 0x12 vult ev.Data als volgt:
| ev.Data[0:4] | ev.Data[4:8] |
| fdseq (bijv. 0x00000012) | *pollDesc (bijv. 0x00123456) |
Het opslaan van de raw pointer daarentegen resulteert in de volgende inhoud van ev.Data:
| ev.Data[0:4] | ev.Data[4:8] |
| &netpollEventFd (bijv. 0x00123456) | 0 (ongeroerd) |
De onderste 4 bytes bevatten het adres van het object. De bovenste 4 bytes blijven ongewijzigd, aangezien een adres op een 32-bit systeem slechts 4 bytes lang is.
Dit onthult de wortel van het probleem. De vergelijking van ev.Data met &netpollEventFd evalueert alleen de onderste 4 bytes van ev.Data. De code cast ev.Data naar uintptr, wat 4 bytes is op 32-bit platforms. Hierdoor ontstaat er een overlap (aliasing) tussen het adres van het netpollEventFd-object en fdseq.
Zodra fdseq groot genoeg wordt om overeen te komen met &netpollEventFd, verwisselt de netpoll-logica een socket fd voor een event fd. Interne aannames storten in, waaronder de aanname dat het actieve event enkel EPOLLIN is.
Let op dat deze aliasing alleen kan gebeuren op 32-bit little-endian systemen. Op een 64-bit systeem beslaat de vergelijking altijd de volledige 8 bytes. Op een 32-bit big-endian systeem zou de vergelijking de bovenste 4 bytes lezen, waarin het adres staat.
fdseq moet in de miljoenen lopen voordat het overeenkomt met &netpollEventFd. Dat is waarom het probleem alleen optreedt in programma's die lang draaien en in de loop veel pollDesc-objecten aanmaken. Op een typisch 32-bit ARM Linux-systeem bevindt netpollEventFd zich in een read-only sectie binnen de eerste 3 MiB van de adresruimte, zoals de memory map van ons testprogramma laat zien:
$ pmap `pidof netpoll_test`
204: /opt/netpoll_test
00010000 2696K r-x-- netpoll_test
002c0000 2192K r---- netpoll_test
004f0000 180K rw--- netpoll_test
...
fdseq moet dus een waarde van ongeveer 3 miljoen bereiken voordat de crash kan optreden.
Een testgeval
We hebben ook een standalone testgeval voor het probleem gemaakt. Op onze testsystemen triggerde dit de crash binnen enkele minuten:
$ /tmp/repro.arm.system
netpoll eventfd-alias reproducer (GOOS=linux GOARCH=arm)
runtime.netpollEventFd is at address 0x223008
crash expected around cycle 2240520
cycle 2236988runtime: netpoll: eventfd ready for 4
fatal error: runtime: netpoll: eventfd ready for something unexpected
runtime stack:
...
De bug oplossen
We stelden een oplossing voor die verandert hoe netpoll event fd's en socket fd's van elkaar onderscheidt. In plaats van de raw pointer &netpollEventFd op te slaan, slaat de fix een nil pollDesc op als een tagged pointer. Wanneer het uitpakken van de tagged pointer nil oplevert, hoort het event bij de event fd; anders bij een socket fd.
Op deze manier bevat ev.Data altijd een tagged pointer en is de aliasing verdwenen. Een paar dagen later is onze fix samengevoegd (merged).
Samenvatting
Een Go-applicatie crashte sporadisch op een 32-bit ARM embedded Linux-systeem met een fatale netpoll-fout. De fout leek op een epoll-probleem, maar epoll werkte prima.
De Go runtime slaat zowel een raw pointer naar netpollEventFd als tagged pollDesc pointers op in het 8-byte ev.Data-veld. Op 32-bit little-endian systemen ontstaat er een overlap tussen de raw pointer en de fdseq tag. Zodra een langdurig programma miljoenen pollDesc-objecten heeft gerecycled, verwisselt netpoll een socket fd voor de event fd en crasht het systeem.
Onze fix slaat in plaats daarvan een tagged nil pollDesc op voor de event fd, wat de aliasing wegneemt. De bug is in 2020 met Go 1.14 in de Go runtime geslopen. Hij bleef onopgemerkt tot de eerste melding in maart 2025 en is uiteindelijk in 2026 opgelost.
We kunnen alleen speculeren, maar dit suggereert dat Google zelf geen 32-bit Go-programma's meer draait. Anders zouden ze de bug zelf allang hebben ontdekt.
We willen Frequentis AG bedanken voor het budget om dit probleem te analyseren en op te lossen.