Android VpnService: blockerad TUN-läsning mot ENOBUFS-skrivfel
En läsning som aldrig återvänder och en skrivning som misslyckas med ENOBUFS har motsatta orsaker. Mät det annonserade fönstret och paketlängden för att välja rätt åtgärd.
Två symtom dyker upp i samma VpnService-loop och båda får skulden för samma sak: en läsare som aldrig återvänder, och en skrivning som misslyckas med ENOBUFS. De är inte varianter av samma bugg. En blockerad läsning är oenighet om flödeskontroll med peeren; en misslyckad skrivning är ett paket som gränssnittet inte tar emot. Behandlar du dem som ett problem lägger du en dag på att justera buffertar som aldrig var inblandade. Uppdelningen nedan är hur jag avgör vilken halva av loopen jag instrumenterar först.
En loop, två oberoende felpunkter
Din tjänst läser ett IP-paket från TUN-filbeskrivningen, inspekterar det och skriver ett svar tillbaka till samma beskrivning. Allt som går sönder på den vägen visar sig vid ett av de två anropen, och därför rapporteras båda som en trasig tunnel. Anropen har olika kontrakt. En läsning väntar på att kärnan ger dig ett paket, och en skrivning ger kärnan ett paket som den får avvisa.
Läsningar är den enkla halvan att resonera om. En läsning som inte returnerar något väntar antingen på trafik som aldrig kommer, eller väntar för att peeren slutade skicka.
En blockerad läsning är oenighet om flödeskontroll
Läsning av en TUN-enhet blockerar tills ett paket är tillgängligt. Det är avsiktligt beteende, och därför ser en tråd parkerad i read normal ut på en ledig länk. Det blir en hängning när din egen TCP-stack fortsätter ta emot data från den lokala appen men aldrig annonserar ett fönster stort nog för att den externa peeren ska kunna skicka mer. Peeren fyller fönstret du annonserade, väntar på en ACK och en fönsteruppdatering, och stannar. Då har din läsare inget att returnera, och appen ser en anslutning som är öppen och tyst.
De vanliga orsakerna är en stack som skriver till socketen utan att följa det annonserade fönstret, och en som glömmer skicka en fönsteruppdatering efter att ha tömt sin buffert. Utifrån ser de identiska ut: byte går ut, inget kommer tillbaka, inget fel kastas. Att blockera läsartråden på parsning, DNS-uppslag eller disk-I/O ger samma form av symtom av en orelaterad orsak, så bekräfta att tråden faktiskt är inne i read innan du skyller på flödeskontrollen.
Kontrollera peerens sändningskö och fönstret den såg senast. Om peeren håller kvar data för att ditt fönster är noll, ligger åtgärden i din ACK-väg, inte i TUN-läsningen.
ENOBUFS är ett storleksproblem, inte buffertbrist
En skrivning som returnerar ENOBUFS på ett TUN-gränssnitt avvisar vanligtvis paketlängden. Gränssnittet har en MTU, ett större paket kan inte köas, och att försöka igen med samma paket misslyckas på samma sätt varje gång. Den determinismen är ledtråden. En verklig buffertbrist försvinner när trycket sjunker, medan en storleksavvisning inte bryr sig om hur länge du väntar.
TCP har en mekanism just för detta, och det är steget folk hoppar över: MSS-clampen. När du syntetiserar segment i användarutrymmet väljer du nyttolastens storlek, och att ta den från det lokala gränssnittets MTU i stället för tunnelns MTU skickar ut segment som tunneln inte kan bära. Begränsa till den minsta av de två. Clampen täcker bara TCP, så ett för stort UDP-datagram måste fragmenteras eller släppas av din kod.
Logga längden på varje paket du skriver under felsökningen. Längden som misslyckas är svaret, och den ändras inte mellan försök.
Tre mätningar som skiljer fallen
Du behöver ingen debugger för något av de två felen. Tre observationer räcker för att styra dig till rätt avsnitt, och de tar kortare tid än en ombyggnad.
| What you see | Where to look |
|---|---|
| Reader parked, peer send queue empty, last advertised window zero | Window updates in your ACK path |
| Write fails on one length and succeeds on a smaller one | MSS clamp and tunnel MTU |
| Both directions look fine, app still sees no data | Protected socket and routing loop |
Om den tredje raden stämmer är loopen själv okej och problemet sitter där tunneln inte kan se det: en oskyddad transport-socket som bär din egen VPN-trafik tillbaka in i tunneln, vilket vanligtvis visar sig som en anslutning som lyckas och sedan inte levererar något.
Reparera rörledningen som båda vägarna är beroende av
Inget symtom är värt att jaga innan gränssnittet är korrekt byggt. De builder-anrop som betyder något är adressen, rutten, MTU:n och samtycket, i den ordningen:
Builder()
.addAddress(10.8.0.2, 32)
.addRoute(0.0.0.0, 0)
.setMtu(1400)
.establish()Sätt MTU:n till tunnelns verkliga värde i stället för att låta den stå på det exemplet använde. Allt du skriver senare begränsas av det talet, och en avvikelse mellan MTU:n du sätter och MSS:en du begränsar till är det vanligaste sättet att återinföra skrivfelet efter en åtgärd. Skydda sedan socketen som bär trafik till själva VPN-servern. Utan protect routas dessa paket tillbaka in i din egen tunnel och anslutningen fullbordas aldrig, vilket ser ut som en blockerad läsning men är en routing-loop.
Samtycket kan inte kringgås. En tjänst som startas utan att prepare returnerar null får inget gränssnitt, och establish returnerar null i stället för att kasta, så kontrollera beskrivningen innan du startar loopen. VpnService-referensen är platsen för att bekräfta builder-kontraktet i stället för att kopiera ett exempel.
Verifiera en riktning i taget
Skicka ett UDP-eko genom tunneln och följ paketlängderna innan du rör TCP. Öppna sedan en handskakning och bekräfta att segmenten stannar under MSS:en du beräknade. Räkna paket i stället för anslutningar, så att en blockering syns som en lucka i räknaren i stället för en hängande socket. Om läsningarna stoppar efter en skur av skrivningar är fönstret den misstänkta; om skrivningarna misslyckas direkt är det längden.
Starta sedan om enheten och upprepa en gång, eftersom en VpnService som bara fungerar till den första nätverksändringen är ett routingproblem och inte ett flödeskontrollproblem.