Een klant stuurde een eenvoudige observatie die precies bleek te kloppen: bestanden serveren vanaf de HTTP-server gebruikte veel geheugen. Als TsgcWebSocketHTTPServer een groot bestand vanuit DocumentRoot serveerde, werd het hele bestand voor elke verbinding in het RAM gelezen. Honderd clients die hetzelfde 1 GB-bestand downloadden, betekende ongeveer 100 GB RAM. Deze release lost dat op. Bestanden streamen nu vanaf schijf en het servergeheugen blijft vlak, ongeacht de bestandsgrootte en hoeveel clients er verbonden zijn. Het zit in sgcWebSockets 2026.6.0.
Het probleem: één volledige kopie per verbinding
Beide statische-bestandspaden, DoResponseHTTP voor HTTP/1.x en DoHTTP2DocumentRoot voor HTTP/2, bouwden de respons op dezelfde manier op. Ze maakten een TMemoryStream aan, riepen LoadFromFile aan en gaven die stream door aan de respons. LoadFromFile leest het hele bestand in één keer in het geheugen, dus de kosten per verzoek waren de volledige bestandsgrootte, en elke gelijktijdige download hield zijn eigen kopie vast.
// Old behaviour, simplified
oStream := TMemoryStream.Create;
oStream.LoadFromFile(vDocument); // entire file into RAM
AResponseInfo.ContentStream := oStream;
Daardoor is het piekgeheugen ruwweg file size × concurrent connections. Een handvol clients dat een video, een installatieprogramma, een schijfimage of een databaseback-up ophaalt is genoeg om een server in swapping of een out-of-memory-crash te duwen.
De vermenigvuldiger van traag lezen
Dezelfde klant beschreef een tweede scenario, en het is een echte aanval: veel verbindingen die elk de respons heel langzaam lezen, in de orde van één byte per seconde. Dit is een slow-read-aanval, een variant van Slowloris. Een client die zijn reads laat druppelen, houdt de verbinding heel lang open, en met de oude code hield elk van die verbindingen ook een volledige kopie van het bestand in het geheugen vast. Een paar duizend trage lezers van een bescheiden bestand konden gigabytes gegijzeld houden terwijl ze bijna niets overdroegen.
Hoe we het hebben gemeten
We bouwden een kleine testopstelling: een server met een DocumentRoot en een groot testbestand, plus een client met twee modi. Een "fast"-modus opent N gelijktijdige normale downloads, en een "slow"-modus opent M ruwe verbindingen die een geldig verzoek versturen en vervolgens de body lezen met één byte per seconde. We bemonsterden de working set van de server met een 100 MB-bestand, vóór de fix en erna.
| Scenario (100 MB-bestand) | Vóór fix | Na fix |
|---|---|---|
| 5 gelijktijdige downloads | ~511 MB | ~11 MB |
| 10 gelijktijdige downloads | ~1.012 MB | ~11 MB |
| 20 gelijktijdige downloads | ~1,7 GB | ~12 MB |
| 100 slow-read-verbindingen (1 byte/sec) | ~1,67 GB vlak vastgehouden | ~19 MB |
| Bestandsintegriteit | n.v.t. | SHA-256 byte-exact, correcte Content-Length |
Vóór de fix volgde het geheugen vrijwel exact de bestandsgrootte maal het aantal verbindingen. Na de fix blijft het in de tientallen megabytes, ongeacht hoe groot het bestand is of hoeveel clients er verbinden.
De fix: streamen vanaf schijf
Het bestand wordt nu geserveerd met een alleen-lezen, gedeelde TFileStream in plaats van een TMemoryStream. Niets anders in de pipeline hoefde te veranderen, omdat de schrijfkant al streamt: de HTTP/1.x-server verstuurt de content-stream in chunks van 32 KB, en de HTTP/2-server geeft die uit in DATA-frames van ongeveer 64 KB. De server leest dus een kleine chunk van schijf, verstuurt die en gaat verder, waardoor de geheugenkosten per verbinding dalen van het hele bestand naar een paar tientallen kilobytes.
// New behaviour, simplified
oStream := TFileStream.Create(vDocument, fmOpenRead or fmShareDenyWrite);
oStream.Position := 0;
AResponseInfo.ContentStream := oStream;
Content-Length is nog steeds correct, omdat die afkomstig is van TFileStream.Size, en de stream wordt na de respons exact zoals voorheen vrijgegeven. We hebben geverifieerd dat downloads byte-identiek zijn aan het bronbestand met een SHA-256-vergelijking, en dat de header de juiste lengte rapporteert. Er is geen API-wijziging en niets te configureren: DocumentRoot instellen werkt zoals het altijd deed, het buffert het bestand alleen niet langer.
oServer := TsgcWebSocketHTTPServer.Create(nil);
oServer.Port := 80;
oServer.DocumentRoot := 'C:\inetpub\files'; // large files now stream from disk
oServer.Active := True;
De .NET-editie erft de fix automatisch, omdat het serveren van DocumentRoot-bestanden draait in dezelfde gecompileerde kern die de Delphi-componenten gebruiken.
Trage lezers afremmen
Streamen vanaf schijf verwijdert de geheugenvermenigvuldiging, dus een trage lezer kan niet langer een heel bestand in het RAM vastpinnen. Wat overblijft is de verbinding zelf, en daarvoor is er een send-timeout. Options.WriteTimeOut stelt een deadline in op socket-writes, zodat een client die stopt met het accepteren van data wordt afgebroken in plaats van een serverthread voor altijd te blokkeren. In deze release werkt die timeout ook op Linux en andere POSIX-platforms, niet alleen op Windows. De POSIX-socket-API verwacht een timeval in plaats van een integer in milliseconden, en dat wordt nu correct afgehandeld.
oServer := TsgcWebSocketHTTPServer.Create(nil);
oServer.DocumentRoot := 'C:\inetpub\files';
oServer.Options.WriteTimeOut := 15000; // drop a client that stalls for 15s, Windows and POSIX
oServer.Active := True;
Voor een publieke server raden we aan drie hefbomen te combineren: stel Options.WriteTimeOut in zodat vastgelopen writes worden afgebroken, gebruik RateLimit.MaxConnectionsPerIP van de firewall om te beperken hoeveel verbindingen één adres kan openen, en overweeg een thread-pool-scheduler of de IOCP- en EPOLL-handlers zodat een vloed aan trage verbindingen niet elk een thread kan uitputten.
Upgraden
De streaming-fix is een drop-in. Zodra je bijwerkt, serveert DocumentRoot bestanden vanaf schijf met vlak geheugen, zonder codewijziging. De wijziging is geverifieerd over Delphi 7 tot en met 13, op de HTTP/1.x- en HTTP/2-paden, en de .NET-build pikt die op via de gedeelde kern. Als je grote statische bestanden serveert, is dit een betekenisvolle vermindering van het geheugengebruik en een nuttige stap tegen slow-read-misbruik.
Werk bij vanaf de sgcWebSockets-downloadpagina, of haal het op via GetIt of je geregistreerde account.
Vragen, feedback of hulp bij de migratie? Neem contact op, je krijgt een reactie van de mensen die de code hebben geschreven.
