Fix share-image memory leak (disk cache) and harden silicon rendering#1236
Merged
Conversation
The Vapor in-memory cache held every generated share PNG for 14 days, but MemoryCache only evicts lazily on access — so a share whose image is never re-fetched leaked forever. Combined with eager generation on every POST /shared_link, this grew the process heap until the 512MB OOM kill. - Generate the share image lazily on first GET (not on shared_link POST) and cache it on disk, streaming it back with req.fileio.streamFile. Heap no longer grows with the number of shares. - ShareImage now renders to a temp file and atomically moves it into place, so a partial/killed render is never served or cached, and the temp file is always cleaned up (previously the output PNG was leaked on disk). - Discard silicon's stdout/stderr via the null device instead of leaving unread Pipes — those could fill their ~64KB buffer and hang the process (and the request) forever. - Force-kill silicon after a 20s timeout so a hung render can't pin a request and its memory. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR aims to prevent occasional 512MB OOM kills by removing long-lived in-memory caching of generated share PNGs and hardening the silicon rendering path to avoid hangs and partial outputs.
Changes:
- Stop pre-rendering share images during
POST /shared_link; render lazily on firstGET /<id>.png. - Cache rendered PNGs on disk under
Cache/ShareImages/and stream them back instead of buffering in memory. - Harden
siliconrendering by writing to a temp file, discarding stdout/stderr, and enforcing a timeout with SIGKILL.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
Sources/App/routes.swift |
Switch share-image handling to a disk cache + streamed response, and remove eager pre-rendering on share creation. |
Sources/App/Controllers/ShareImage.swift |
Update share-image generation to render via silicon to a temp file with timeout and safer process IO handling. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+144
to
+148
| let directory = URL(fileURLWithPath: app.directory.workingDirectory) | ||
| .appendingPathComponent("Cache", isDirectory: true) | ||
| .appendingPathComponent("ShareImages", isDirectory: true) | ||
| try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) | ||
| return directory.appendingPathComponent("\(id).png") |
| // previous in-memory cache held every generated PNG for 14 days and, since | ||
| // Vapor's MemoryCache only evicts lazily on access, links whose image was | ||
| // never re-fetched leaked forever — the main driver of the OOM kills. | ||
| let cacheURL = shareImageCacheURL(req.application, id: id) |
| throw Abort(.notFound) | ||
| } | ||
| } | ||
| return req.fileio.streamFile(at: cacheURL.path) |
Comment on lines
15
to
+16
| @available(macOS 12.0.0, *) | ||
| static func generate(code: String) async throws -> Data? { | ||
| static func generate(code: String, to destination: URL) async throws { |
Comment on lines
+76
to
+77
| try? FileManager.default.removeItem(at: destination) | ||
| try FileManager.default.moveItem(at: tempURL, to: destination) |
Comment on lines
+162
to
+165
| } catch { | ||
| req.logger.error("Failed to generate share image: \(error)") | ||
| throw Abort(.notFound) | ||
| } |
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.
Addresses the occasional OOM kills of the web server at the 512MB limit.
Root cause
Share images (the OG PNGs at
/<id>.png) were stored in Vapor's in-memory cache with a 14-day TTL. ButMemoryCacheonly evicts lazily, ongetof that exact key (verified in the Vapor source — there is no background sweep). A share whose image is never re-fetched therefore leaks forever. On top of that,POST /shared_linkeagerly rendered and cached a PNG for every share, even ones never opened. Under sustained traffic the heap grew until the 512MB OOM.So 512MB isn't really "too low" — without the leak this app's baseline is well under that. Raising the limit would only delay the OOM.
Changes
GET /<id>.pngand cached on disk underCache/ShareImages/, then streamed back withreq.fileio.streamFile(no full-image buffer on the heap).POST /shared_linkno longer pre-renders. Heap no longer scales with the number of shares.ShareImagehardening (ShareImage.swift):Pipe()s that were never read; once silicon wrote past the ~64KB pipe buffer it would block, the termination handler would never fire, and the request + process would hang forever..pngon disk).Verification
swift buildpasses.--output, stdout/stderr to null) produces a valid PNG locally.Notes / follow-ups (not in this PR)
tmpReaper) would bound it.POST /runand/runner/*/runbuffer the full proxied request/response in memory (bounded by concurrency, not a steady leak) — left as-is.🤖 Generated with Claude Code