-
Notifications
You must be signed in to change notification settings - Fork 0
fix: resolve memory crash on large image imports by adding thumbnails and bounded concurrency #109
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b21e95b
c0a143d
0c587a0
b9c36c3
c78fcd8
2dc0e43
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,9 +1,12 @@ | ||||||||||||||
| package main | ||||||||||||||
|
|
||||||||||||||
| import ( | ||||||||||||||
| "bytes" | ||||||||||||||
| "context" | ||||||||||||||
| "crypto/rand" | ||||||||||||||
| "encoding/hex" | ||||||||||||||
| "image" | ||||||||||||||
| "image/jpeg" | ||||||||||||||
| "io" | ||||||||||||||
| "log" | ||||||||||||||
| "mime" | ||||||||||||||
|
|
@@ -15,8 +18,20 @@ import ( | |||||||||||||
| "strings" | ||||||||||||||
| "sync" | ||||||||||||||
| "time" | ||||||||||||||
|
|
||||||||||||||
| "github.com/rwcarlsen/goexif/exif" | ||||||||||||||
| "golang.org/x/image/draw" | ||||||||||||||
| ) | ||||||||||||||
|
|
||||||||||||||
| const maxImageTokens = 2000 | ||||||||||||||
|
|
||||||||||||||
| // fileOpenSem restricts the number of concurrent file opens for /api/image and /api/thumb | ||||||||||||||
| // to avoid hitting the OS file descriptor limit (e.g. 256 on macOS). | ||||||||||||||
| var fileOpenSem = make(chan struct{}, 100) | ||||||||||||||
|
|
||||||||||||||
| // thumbProcessSem restricts concurrent heavy image decoding/resizing. | ||||||||||||||
| var thumbProcessSem = make(chan struct{}, 4) | ||||||||||||||
|
|
||||||||||||||
| // ImageHandler provides HTTP endpoints to stream images and receive binary save data, | ||||||||||||||
| // avoiding the memory-intensive Base64 IPC transfer. | ||||||||||||||
| // It is registered as AssetServer Middleware (not Handler) so that it intercepts | ||||||||||||||
|
|
@@ -68,6 +83,8 @@ func (h *ImageHandler) Middleware(next http.Handler) http.Handler { | |||||||||||||
| switch r.URL.Path { | ||||||||||||||
| case "/api/image": | ||||||||||||||
| h.handleImage(w, r) | ||||||||||||||
| case "/api/thumb": | ||||||||||||||
| h.handleThumb(w, r) | ||||||||||||||
| case "/api/save": | ||||||||||||||
| h.handleSave(w, r) | ||||||||||||||
| default: | ||||||||||||||
|
|
@@ -101,11 +118,107 @@ func (h *ImageHandler) handleImage(w http.ResponseWriter, r *http.Request) { | |||||||||||||
| return | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| fileOpenSem <- struct{}{} | ||||||||||||||
| defer func() { <-fileOpenSem }() | ||||||||||||||
|
|
||||||||||||||
| // http.ServeFile handles Content-Type detection, Range requests, and streaming | ||||||||||||||
| // without loading the entire file into memory. | ||||||||||||||
| http.ServeFile(w, r, filePath) | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // handleThumb serves a lightweight thumbnail for the requested image token. | ||||||||||||||
| func (h *ImageHandler) handleThumb(w http.ResponseWriter, r *http.Request) { | ||||||||||||||
| if r.Method != http.MethodGet { | ||||||||||||||
| http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) | ||||||||||||||
| return | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| token := r.URL.Query().Get("token") | ||||||||||||||
| h.imgMu.RLock() | ||||||||||||||
| filePath := h.imageTokens[token] | ||||||||||||||
| h.imgMu.RUnlock() | ||||||||||||||
|
|
||||||||||||||
| if filePath == "" { | ||||||||||||||
| http.Error(w, "Token not found", http.StatusNotFound) | ||||||||||||||
| return | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| fileOpenSem <- struct{}{} | ||||||||||||||
| defer func() { <-fileOpenSem }() | ||||||||||||||
|
|
||||||||||||||
| f, err := os.Open(filePath) | ||||||||||||||
| if err != nil { | ||||||||||||||
| http.Error(w, "File not found", http.StatusNotFound) | ||||||||||||||
| return | ||||||||||||||
| } | ||||||||||||||
| defer f.Close() | ||||||||||||||
|
|
||||||||||||||
| // 1. Try to get EXIF thumbnail | ||||||||||||||
| x, err := exif.Decode(f) | ||||||||||||||
| if err == nil { | ||||||||||||||
| pic, err := x.JpegThumbnail() | ||||||||||||||
| if err == nil && len(pic) > 0 { | ||||||||||||||
| w.Header().Set("Content-Type", "image/jpeg") | ||||||||||||||
| w.Write(pic) | ||||||||||||||
| return | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // 2. No EXIF thumbnail, generate one on the fly safely | ||||||||||||||
| thumbProcessSem <- struct{}{} | ||||||||||||||
| defer func() { <-thumbProcessSem }() | ||||||||||||||
|
|
||||||||||||||
| // Reset file pointer | ||||||||||||||
| if _, err := f.Seek(0, io.SeekStart); err != nil { | ||||||||||||||
| http.Error(w, "Failed to seek file", http.StatusInternalServerError) | ||||||||||||||
| return | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // Decode high-res image | ||||||||||||||
| img, _, err := image.Decode(f) | ||||||||||||||
| if err != nil { | ||||||||||||||
| http.Error(w, "Failed to decode image", http.StatusInternalServerError) | ||||||||||||||
| return | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // Calculate thumbnail size (max 256x256) | ||||||||||||||
| bounds := img.Bounds() | ||||||||||||||
| w0, h0 := bounds.Dx(), bounds.Dy() | ||||||||||||||
| if w0 <= 0 || h0 <= 0 { | ||||||||||||||
| http.Error(w, "Invalid image dimensions", http.StatusInternalServerError) | ||||||||||||||
| return | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| var w1, h1 int | ||||||||||||||
| if w0 > h0 { | ||||||||||||||
| w1 = 256 | ||||||||||||||
| h1 = h0 * 256 / w0 | ||||||||||||||
| } else { | ||||||||||||||
| h1 = 256 | ||||||||||||||
| w1 = w0 * 256 / h0 | ||||||||||||||
| } | ||||||||||||||
| if w1 == 0 { | ||||||||||||||
| w1 = 1 | ||||||||||||||
| } | ||||||||||||||
| if h1 == 0 { | ||||||||||||||
| h1 = 1 | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| dst := image.NewRGBA(image.Rect(0, 0, w1, h1)) | ||||||||||||||
| // ApproxBiLinear is faster than CatmullRom and sufficient for a thumbnail | ||||||||||||||
| draw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, bounds, draw.Src, nil) | ||||||||||||||
|
|
||||||||||||||
| var buf bytes.Buffer | ||||||||||||||
| if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 70}); err != nil { | ||||||||||||||
| log.Printf("Failed to encode generated thumbnail: %v", err) | ||||||||||||||
| http.Error(w, "Failed to encode thumbnail", http.StatusInternalServerError) | ||||||||||||||
| return | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| w.Header().Set("Content-Type", "image/jpeg") | ||||||||||||||
| w.Write(buf.Bytes()) | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Line 162-164 では EXIF サムネイル書き込み時にエラーをログ出力していますが、こちらでは未処理です。ヘッダー送信後なので HTTP エラーは返せませんが、デバッグのためにログ出力を追加してください。 🔧 修正案 w.Header().Set("Content-Type", "image/jpeg")
- w.Write(buf.Bytes())
+ if _, err := w.Write(buf.Bytes()); err != nil {
+ log.Printf("Failed to write generated thumbnail: %v", err)
+ }
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // prepareSave is called from the IPC side (App.SaveImage) after the native save | ||||||||||||||
| // dialog completes. It generates a unique token, stores the save metadata, and | ||||||||||||||
| // returns the token. The frontend must include this token in the POST to /api/save. | ||||||||||||||
|
|
@@ -155,8 +268,8 @@ func (h *ImageHandler) registerImageToken(filePath string) string { | |||||||||||||
|
|
||||||||||||||
| token := generateToken() | ||||||||||||||
|
|
||||||||||||||
| // Optional: Limit size to prevent memory leaks if many images are opened | ||||||||||||||
| if len(h.imageTokens) >= 100 { | ||||||||||||||
| // Limit size to prevent memory leaks if many images are opened | ||||||||||||||
| if len(h.imageTokens) >= maxImageTokens { | ||||||||||||||
| // Evict the oldest entry (FIFO with registration-time refresh) to free space. | ||||||||||||||
| if len(h.imageTokenOrder) > 0 { | ||||||||||||||
| oldestToken := h.imageTokenOrder[0] | ||||||||||||||
|
|
||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.