Skip to content

Fix share-image memory leak (disk cache) and harden silicon rendering#1236

Merged
kishikawakatsumi merged 1 commit into
masterfrom
fix/web-memory-stability
Jun 11, 2026
Merged

Fix share-image memory leak (disk cache) and harden silicon rendering#1236
kishikawakatsumi merged 1 commit into
masterfrom
fix/web-memory-stability

Conversation

@kishikawakatsumi

Copy link
Copy Markdown
Member

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. But MemoryCache only evicts lazily, on get of 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_link eagerly 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

  • Disk cache instead of heap. The share image is generated lazily on the first GET /<id>.png and cached on disk under Cache/ShareImages/, then streamed back with req.fileio.streamFile (no full-image buffer on the heap). POST /shared_link no longer pre-renders. Heap no longer scales with the number of shares.
  • ShareImage hardening (ShareImage.swift):
    • Render to a temp file and atomically move into place — a crashed/killed render never leaves a partial image to be served or cached.
    • Discard silicon's stdout/stderr via the null device. The previous code attached 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.
    • 20s timeout → SIGKILL so a hung render can't pin a request thread and its (font-rendering) memory.
    • Temp file always cleaned up (the old code leaked the output .png on disk).

Verification

  • swift build passes.
  • Confirmed the exact silicon invocation (stdin code → --output, stdout/stderr to null) produces a valid PNG locally.

Notes / follow-ups (not in this PR)

  • The disk cache currently has no age-based cleanup. PNGs are small and disk ≫ the 512MB RAM cap, so it's far less urgent than the heap leak, but a periodic prune (or tmpReaper) would bound it.
  • POST /run and /runner/*/run buffer the full proxied request/response in memory (bounded by concurrency, not a steady leak) — left as-is.

🤖 Generated with Claude Code

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>
Copilot AI review requested due to automatic review settings June 11, 2026 21:01

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 first GET /<id>.png.
  • Cache rendered PNGs on disk under Cache/ShareImages/ and stream them back instead of buffering in memory.
  • Harden silicon rendering 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 thread Sources/App/routes.swift
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")
Comment thread Sources/App/routes.swift
// 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)
Comment thread Sources/App/routes.swift
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 thread Sources/App/routes.swift
Comment on lines +162 to +165
} catch {
req.logger.error("Failed to generate share image: \(error)")
throw Abort(.notFound)
}
@kishikawakatsumi kishikawakatsumi merged commit 412086f into master Jun 11, 2026
2 checks passed
@kishikawakatsumi kishikawakatsumi deleted the fix/web-memory-stability branch June 11, 2026 21:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants