Un client nous a écrit avec une observation simple qui s'est révélée parfaitement exacte : servir des fichiers depuis le serveur HTTP consommait beaucoup de mémoire. Si TsgcWebSocketHTTPServer servait un fichier volumineux depuis DocumentRoot, le fichier entier était lu en RAM pour chaque connexion. Cent clients téléchargeant le même fichier de 1 GB représentaient environ 100 GB de RAM. Cette version corrige cela. Les fichiers sont désormais diffusés en flux depuis le disque et la mémoire du serveur reste stable quelle que soit la taille du fichier et le nombre de clients connectés. Cela arrive dans sgcWebSockets 2026.6.0.
Le problème : une copie complète par connexion
Les deux chemins de fichiers statiques, DoResponseHTTP pour HTTP/1.x et DoHTTP2DocumentRoot pour HTTP/2, construisaient la réponse de la même manière. Ils créaient un TMemoryStream, appelaient LoadFromFile, et transmettaient ce flux à la réponse. LoadFromFile lit le fichier entier en mémoire en une seule fois, de sorte que le coût par requête correspondait à la taille complète du fichier, et chaque téléchargement simultané détenait sa propre copie.
// Old behaviour, simplified
oStream := TMemoryStream.Create;
oStream.LoadFromFile(vDocument); // entire file into RAM
AResponseInfo.ContentStream := oStream;
Cela rend la mémoire maximale à peu près égale à taille du fichier × connexions simultanées. Une poignée de clients téléchargeant une vidéo, un installateur, une image disque ou une sauvegarde de base de données suffit à pousser un serveur vers le swap ou un plantage par manque de mémoire.
Le multiplicateur de lecture lente
Le même client a décrit un second scénario, et il s'agit d'une véritable attaque : de nombreuses connexions qui lisent chacune la réponse très lentement, de l'ordre d'un octet par seconde. C'est une attaque de lecture lente, une variante de Slowloris. Un client qui distille ses lectures maintient la connexion ouverte très longtemps, et avec l'ancien code chacune de ces connexions immobilisait aussi une copie complète du fichier en mémoire. Quelques milliers de lecteurs lents d'un fichier de taille modeste pouvaient retenir en otage des gigaoctets tout en ne transférant presque rien.
Comment nous l'avons mesuré
Nous avons construit un petit banc d'essai : un serveur avec un DocumentRoot et un gros fichier de test, ainsi qu'un client avec deux modes. Un mode « rapide » ouvre N téléchargements normaux simultanés, et un mode « lent » ouvre M connexions brutes qui envoient une requête valide puis lisent le corps à un octet par seconde. Nous avons échantillonné le working set du serveur avec un fichier de 100 MB, avant et après le correctif.
| Scénario (fichier de 100 MB) | Avant le correctif | Après le correctif |
|---|---|---|
| 5 téléchargements simultanés | ~511 MB | ~11 MB |
| 10 téléchargements simultanés | ~1 012 MB | ~11 MB |
| 20 téléchargements simultanés | ~1,7 GB | ~12 MB |
| 100 connexions à lecture lente (1 byte/sec) | ~1,67 GB maintenus stables | ~19 MB |
| Intégrité du fichier | s.o. | SHA-256 identique à l'octet près, Content-Length correct |
Avant le correctif, la mémoire suivait presque exactement la taille du fichier multipliée par le nombre de connexions. Après le correctif, elle reste dans les dizaines de mégaoctets quelle que soit la taille du fichier ou le nombre de clients connectés.
Le correctif : diffuser en flux depuis le disque
Le fichier est désormais servi avec un TFileStream partagé en lecture seule au lieu d'un TMemoryStream. Rien d'autre dans le pipeline n'a dû changer, car le côté écriture diffuse déjà en flux : le serveur HTTP/1.x envoie le flux de contenu par blocs de 32 KB, et le serveur HTTP/2 l'émet en trames DATA d'environ 64 KB. Le serveur lit donc un petit bloc depuis le disque, l'envoie, et passe au suivant, ce qui réduit le coût mémoire par connexion du fichier entier à quelques dizaines de kilooctets.
// New behaviour, simplified
oStream := TFileStream.Create(vDocument, fmOpenRead or fmShareDenyWrite);
oStream.Position := 0;
AResponseInfo.ContentStream := oStream;
Content-Length est toujours correct, car il provient de TFileStream.Size, et le flux est libéré après la réponse exactement comme auparavant. Nous avons vérifié que les téléchargements sont identiques à l'octet près au fichier source avec une comparaison SHA-256, et que l'en-tête indique la bonne longueur. Il n'y a aucune modification d'API ni rien à configurer : définir DocumentRoot fonctionne comme cela a toujours été le cas, sauf qu'il ne met plus le fichier en mémoire tampon.
oServer := TsgcWebSocketHTTPServer.Create(nil);
oServer.Port := 80;
oServer.DocumentRoot := 'C:\inetpub\files'; // large files now stream from disk
oServer.Active := True;
L'édition .NET hérite automatiquement du correctif, car le service de fichiers de son DocumentRoot s'exécute dans le même noyau compilé que celui utilisé par les composants Delphi.
Ralentir les lecteurs lents
La diffusion en flux depuis le disque supprime l'amplification mémoire, de sorte qu'un lecteur lent ne peut plus immobiliser un fichier entier en RAM. Ce qui reste, c'est la connexion elle-même, et pour cela il existe un délai d'expiration d'envoi. Options.WriteTimeOut fixe une échéance sur les écritures de socket, de sorte qu'un client qui cesse d'accepter des données est abandonné au lieu de bloquer un thread du serveur indéfiniment. Dans cette version, ce délai fonctionne aussi sur Linux et les autres plateformes POSIX, pas seulement sur Windows. L'API de socket POSIX attend un timeval plutôt qu'un entier en millisecondes, et cela est désormais géré correctement.
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;
Pour un serveur public, nous recommandons de combiner trois leviers : définir Options.WriteTimeOut pour que les écritures bloquées soient abandonnées, utiliser RateLimit.MaxConnectionsPerIP du pare-feu pour limiter le nombre de connexions qu'une seule adresse peut ouvrir, et envisager un ordonnanceur à pool de threads ou les gestionnaires IOCP et EPOLL afin qu'un flot de connexions lentes ne puisse pas épuiser un thread chacune.
Mise à niveau
Le correctif de diffusion en flux est immédiat. Dès que vous mettez à jour, DocumentRoot sert les fichiers depuis le disque avec une mémoire stable, sans aucune modification de code. Le changement a été vérifié de Delphi 7 à 13, sur les chemins HTTP/1.x et HTTP/2, et la build .NET en bénéficie via le noyau partagé. Si vous servez de gros fichiers statiques, il s'agit d'une réduction significative de la mémoire et d'une étape utile contre les abus de lecture lente.
Mettez à jour depuis la page de téléchargement de sgcWebSockets, ou récupérez-le via GetIt ou votre compte enregistré.
Des questions, des retours ou besoin d'aide pour la migration ? Contactez-nous, vous recevrez une réponse des personnes qui ont écrit le code.
