Repository navigation
feat: spool large request bodies to disk and map them - #459
Open
martin-rueegg wants to merge 1 commit into
Open
martin-rueegg wants to merge 1 commit into
martin-rueegg wants to merge 1 commit into
Conversation
A request body was read whole with ReadAll, which grows its slice by reallocation, so a 1.7 GB PDF took more than 6 GB of heap before libvips saw any of it, and a container with a memory limit was killed. A body larger than 4 MiB is now written to a temporary file, unlinked at once, and mapped read-only. libvips still gets a buffer, so bimg is unchanged, but its pages are backed by the file and can be reclaimed. The mapping is released once the response is written. A multipart part that net/http has already written to disk is mapped the same way. -spool-threshold sets the size above which a body is spooled; 0 spools every body. -tmp-dir sets where the files go, by default the system's temporary directory. Smaller bodies are read into memory as before. libvips' operation cache is switched off. It keys a loader's result by the address of the image buffer, and a later buffer at the same address could be served a result that is not its own. That holds for buffers freed by the garbage collector as well, and a mapping makes it likelier. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
martin-rueegg
marked this pull request as ready for review
October 14, 2026 13:45
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Imaginary reads a request body whole with
ioutil.ReadAll, which grows its slice by reallocation:the heap reaches 2–5 × the body before libvips sees any of it. With a memory limit, as in Nextcloud
AIO's Imaginary container, large images and PDFs get the process OOM-killed.
This PR writes a body larger than 4 MiB (configurable) to a temporary file and maps it read-only. libvips still
gets a
[]byte, so bimg is unchanged, but the pages are backed by the file and can be reclaimed bythe kernel.
Changes
readSpooled(spool.go):-spool-threshold), it reads into memory as before.crash), and maps it with
PROT_READ/MAP_SHARED.ReleasingImageSource: an optional interface for sources whose buffer must be released.imageControllerreleases it once the response is written.GetImagestill exists, and returns acopy.
net/httphas already written to disk (larger than 64 MiB) is mappedinstead of read back into memory.
-spool-threshold <bytes>: a new flag for the size above which a body is spooled. Thedefault is 4 MiB; 0 spools every body, and a negative value is refused at start.
-tmp-dir <path>: a new flag for where the temporary files go. It defaults to the system'stemporary directory.
-mountis unchanged on purpose:SIGBUS and takes the whole server down.
ReadFilesizes its buffer fromstat, so it costs about 1× the file, notReadAll's 2–5×.VipsCacheSetMax(0),VipsCacheSetMaxMem(0)):a result that is not its own.
vips_cache_drop_all) is not an option. In libvips 8.18it frees the cache's hash table, after which every cached operation leaks (GLib-CRITICAL
hash_table != NULL). We measured about 60 MB per request.mmap_windows.goreads the file instead of mapping it.Measurements
Setup: Nextcloud AIO's Imaginary image (Alpine 3.24, libvips 8.18.2), with only the binary
rebuilt from this branch. Memory limit 2500 MB, 2 CPUs, the temporary directory on ZFS, and a
container restart before every request.
The figures are the peak of anonymous memory during the request (mapped file pages not included)
and the time:
fitfit→autorotate→fitfitfit→autorotate→fitautorotate→fitfitfitfit→autorotate→fitautorotate→fitconvert→fitFurther results:
noise, one run per cell.
Not in this PR:
-max-allowed-sizeis not applied to bodies. It limits the URL source today, and applying ithere would change its meaning.
Tests
spool_test.go:go vet ./...is clean, andgo test ./...passes ingolang:1.27.1-alpine3.24with libvips 8.18.Windows build, and run on Windows. The full suite passes, the new tests included, through
mmap_windows.goand its fallback.vips/vips7compat.h, which isn'tthere. For the test build, a stand-in header gave it an empty
im__print_all, the only functionbimg uses from it. That is bimg's matter, not this PR's.
Context
This came out of Nextcloud previews of large archive files. The Nextcloud side, which shrinks before
rotating, is nextcloud/server#65483. Nextcloud AIO builds Imaginary from this repository at
6a274b4.Written with AI assistance (Claude), which did the measurements, the code and the tests. They were
reviewed and the PR submitted by the author.