Skip to content

fix(djpeg): reap the child process when decoding fails - #198

Merged
SmilyOrg merged 1 commit into
SmilyOrg:mainfrom
PaulEdwardsAP:fix-djpeg-zombie-leak
Oct 11, 2026
Merged

SmilyOrg merged 1 commit into
SmilyOrg:mainfrom
PaulEdwardsAP:fix-djpeg-zombie-leak

Conversation

@PaulEdwardsAP

Copy link
Copy Markdown
Contributor

Fixes #197.

The bug

Djpeg.run() only calls cmd.Wait() on the success path. When bmp.Decode(stdout) fails it returns immediately, so the child is never reaped:

img, err := bmp.Decode(stdout)

if err != nil {
    return nil, err          // <-- child never reaped
}

if err := cmd.Wait(); err != nil {   // only reached when decoding succeeded

So every file djpeg cannot decode leaks one zombie per attempt — and Get()'s two-pass resize calls run() twice, so up to two per file per index pass.

exec.CommandContext kills the process when the context is cancelled, but killing is not reaping; a Wait() is still required.

Why it bites harder than it looks

In a container the zombies accumulate until the cgroup pids limit is reached, after which every fork/exec inside the container fails. That includes ffmpeg, so video thumbnails silently stop being generated while photos keep working (cached or embedded-EXIF thumbnails). It therefore presents as "some videos have no thumbnail", which is a long way from a process leak.

Observed on a 167k-file library containing ~147 undecodable .jpg files: 1,547 zombie djpeg processes, pids.current pinned at the 1578 default, and pids.events recording 3,745 hits on the limit.

The change

cmd.Wait() is now called on every path.

Error precedence is deliberately unchanged — a decode error is still returned ahead of a non-zero exit status, so callers see exactly what they saw before. Only the reaping is new.

Test

TestRunReapsChildOnDecodeFailure counts this process's zombie children across 20 failed decodes of a non-JPEG file. On the previous code it fails with:

djpeg_test.go:84: leaked 20 zombie(s) over 20 failed decodes (before=0 after=20)

and passes with the fix. TestRunReapsChildOnSuccess covers the success path and encodes its own sample JPEG, so neither test needs fixtures. Both skip when djpeg is absent or off Linux (reaping is only observable via /proc).

Not affected

internal/io/ffmpeg/ffmpeg.go uses cmd.Output(), which waits internally — I checked, and it has no equivalent problem. (The issue speculated it might; it does not.)

run() returned as soon as bmp.Decode failed, skipping cmd.Wait(), so every file
djpeg could not decode left a zombie behind - one per attempt, and Get()'s
two-pass resize calls run() twice.

In a container these accumulate until the cgroup pids limit is reached, after
which every fork/exec inside the container fails. That includes ffmpeg, so video
thumbnails silently stop being generated while photos keep working from cached or
embedded-EXIF thumbnails - which makes it present as 'some videos have no
thumbnail' rather than as a process leak.

Observed on a 167k-file library with ~147 undecodable .jpg files: 1,547 zombie
djpeg processes, pids pinned at the 1578 default, 3,745 recorded hits on the
limit.

cmd.Wait() is now called on every path. Error precedence is unchanged: a decode
error is still reported ahead of a non-zero exit, which is what callers saw
before.

Adds a regression test that counts this process's zombie children across 20
failed decodes. It fails on the previous code with 'leaked 20 zombie(s)'.

Fixes SmilyOrg#197

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟢 Approval recommended

The focused fix correctly addresses the process leak and includes suitable regression coverage.

0 open findings

What changed in this PR

Fixes zombie djpeg processes caused by decode failures, preventing eventual PID exhaustion.

Changes:

  • Unconditionally waits for djpeg after decoding while preserving error precedence.
  • Adds Linux regression tests for failure and success paths.
File Description
internal/​io/​djpeg/​djpeg.go Reaps the child process on every decode path.
internal/​io/​djpeg/​djpeg_test.go Verifies child reaping through /proc.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@SmilyOrg
SmilyOrg merged commit d7374a9 into SmilyOrg:main Oct 11, 2026
4 checks passed
@SmilyOrg

Copy link
Copy Markdown
Owner

@PaulEdwardsAP thanks for your contribution!

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.

djpeg children are never reaped when bmp.Decode fails, exhausting the container PID limit

3 participants