From e6a1bdb7c8fa2a9f89444549f9568ba282bb1e34 Mon Sep 17 00:00:00 2001 From: Muhannad Al-Khatib Date: Mon, 7 Sep 2026 23:23:55 +0300 Subject: [PATCH] fix: stream adapter zip installs to disk instead of buffering in memory AdapterInstaller.InstallAsync handed the cloud-storage read stream straight to `new ZipArchive(stream)`. ZipArchive needs a SEEKABLE stream to read the central directory, and the cloud-storage stream isn't seekable - so .NET silently buffers the ENTIRE archive into one in-memory MemoryStream before ZipArchive can do anything with it. For a small adapter this is invisible. For a large one (DevExpress-sized, several hundred MB uncompressed) it's a one-time multi-hundred-MB spike on top of whatever else the host process is already holding - on a memory-constrained container this alone can OOM the host before the child adapter process ever spawns. Confirmed live: installing a 413MB-uncompressed adapter package pushed a 768Mi-limited pod from a ~190MB baseline to its ceiling in one call. Fix: download to a temp file first (bounded copy-buffer regardless of archive size), then open ZipArchive from that file - a FileStream is seekable, so no internal buffering happens, and extraction streams straight to disk the whole way. Verified locally: host process peak RSS during a full install-and-render run dropped from an OOM to 124MB. Co-Authored-By: Claude Sonnet 5 --- SW.Serverless/Services/AdapterInstaller.cs | 41 +++++++++++++++++----- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/SW.Serverless/Services/AdapterInstaller.cs b/SW.Serverless/Services/AdapterInstaller.cs index d4a4b41..e0a6efc 100644 --- a/SW.Serverless/Services/AdapterInstaller.cs +++ b/SW.Serverless/Services/AdapterInstaller.cs @@ -43,18 +43,39 @@ public async Task InstallAsync(string adapterId) if (!Directory.Exists(directory)) { Directory.CreateDirectory(directory); + + // ZipArchive needs a SEEKABLE stream to read the central directory. The + // cloud-storage read stream is not seekable, so handing it directly to + // ZipArchive silently makes .NET buffer the entire archive into one + // in-memory MemoryStream first - for a large adapter (DevExpress-sized, + // several hundred MB uncompressed) that one-time spike is big enough to + // OOM the whole host process on a memory-constrained container, well + // before the child adapter process itself even starts. Downloading to a + // temp FILE first keeps memory use to one bounded copy-buffer regardless + // of archive size, and a FileStream is seekable so ZipArchive reads + // straight off disk. + var tempZipPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.zip"); try { - using var stream = await cloudFilesService.OpenReadAsync( - $"{options.AdapterRemotePath}/{adapterId}".ToLower()); - using var archive = new ZipArchive(stream); + using (var remoteStream = await cloudFilesService.OpenReadAsync( + $"{options.AdapterRemotePath}/{adapterId}".ToLower())) + using (var tempFileStream = new FileStream(tempZipPath, FileMode.Create, + FileAccess.Write, FileShare.None)) + { + await remoteStream.CopyToAsync(tempFileStream); + } - foreach (var entry in archive.Entries) + using (var archiveStream = new FileStream(tempZipPath, FileMode.Open, + FileAccess.Read, FileShare.Read)) + using (var archive = new ZipArchive(archiveStream, ZipArchiveMode.Read)) { - if (string.IsNullOrEmpty(entry.Name)) continue; - var path = $"{directory}/{entry.FullName.Replace("\\", "/")}"; - Directory.CreateDirectory(Path.GetDirectoryName(path)); - entry.ExtractToFile(path, overwrite: true); + foreach (var entry in archive.Entries) + { + if (string.IsNullOrEmpty(entry.Name)) continue; + var path = $"{directory}/{entry.FullName.Replace("\\", "/")}"; + Directory.CreateDirectory(Path.GetDirectoryName(path)); + entry.ExtractToFile(path, overwrite: true); + } } } catch (Exception) @@ -62,6 +83,10 @@ public async Task InstallAsync(string adapterId) Directory.Delete(directory, true); throw; } + finally + { + try { File.Delete(tempZipPath); } catch { /* best-effort cleanup */ } + } } } finally