Skip to content

fix(compress): start the snap's gs; no pipe stalls, no Windows console - #194

Merged
nelsonduarte merged 2 commits into
mainfrom
fix/snap-ghostscript-lib
Oct 10, 2026
Merged

nelsonduarte merged 2 commits into
mainfrom
fix/snap-ghostscript-lib

Conversation

@nelsonduarte

Copy link
Copy Markdown
Owner

Summary

Compress in the snap silently skips its Ghostscript pass: the snap's gs
9.55.0 (Ubuntu 22.04 debs, built with --disable-compile-inits) looks for
gs_init.ps under the compiled-in /usr/share/ghostscript/9.55.0, which
inside the snap resolves against core22, where no Ghostscript data is
installed. gs exits 1 and _compress_pdf drops the pass without a message.

  • app/_gs_paths.py (new, no Qt): probes gs --version (5 s timeout,
    cached per binary) and, when <prefix>/share/ghostscript/<version>/ Resource/Init exists, passes a copy of the environment with that
    directory and its sibling lib appended to GS_LIB (a user's
    GS_LIB stays first; os.environ is untouched). The directory is
    the binary's own version, because gs_init.ps aborts on a mismatch.
    A failed probe is logged at WARNING.
  • _compress_pdf: gs stdout/stderr go to DEVNULL instead of an
    unread PIPE. With a working gs, a malformed PDF filled the pipe and
    blocked gs until the 120 s deadline; the native gs 9.55.0 of Ubuntu
    22.04 stalled the same way before this change.
  • Windows: gs and the probe start with CREATE_NO_WINDOW, so the
    windowed build no longer opens a console window per compress run.

Verification

  • Snap rev 43 over a core22 rootfs (emulated in a container, real
    _compress_pdf, 54 MB 3-page PDF): gs exits 0 at all three levels;
    final size, old -> new: recommended 3826561 -> 559297 bytes, low
    33898872 -> 12556343 bytes; extreme keeps the smaller PyMuPDF result.
  • Malformed 400-page PDF in the snap: about 120 s and rc -15 with PIPE,
    about a second and rc 0 with DEVNULL, same final result.
  • Fedora 44, Arch, Debian 13, Ubuntu 22.04/24.04/26.04 and Windows
    (gswin64c 10.05.0): same final sizes as before. macOS not measured.
  • Windows console: a visible console window on 3 of 3 runs before,
    none after.
  • Tests: 37 new tests; mutation testing (45 mutants) on Windows and
    Linux, the only survivor is equivalent on Windows (; join where
    os.pathsep is already ;). Full suite on Windows: 846 passed,
    2 skipped.

Not exercised: real snapd confinement, macOS/Homebrew.

🤖 Generated with Claude Code

The snap stages the Ubuntu 22.04 ghostscript debs, which are built
with --disable-compile-inits. Their gs looks for gs_init.ps under the
compiled-in /usr/share/ghostscript/9.55.0, which inside the snap
resolves against core22, where no Ghostscript data is installed.
Measured with rev 43 on a core22 rootfs: gs exits 1 with "Can't find
initialization file gs_init.ps" and _compress_pdf drops the
Ghostscript pass without a message.

Add app/_gs_paths.py, with no Qt imports. It reads the version from
"gs --version" (5 s timeout, result cached per binary path) and, when
<prefix>/share/ghostscript/<version>/Resource/Init exists under the
binary's prefix, returns a copy of the environment with that
directory and the sibling lib, when present, appended to GS_LIB. A
GS_LIB the user set stays first, and os.environ is not modified. The
directory is the one named after the binary's version because
gs_init.ps aborts on a version mismatch. Without a
<prefix>/share/ghostscript directory no probe runs and Popen gets
env=None, as before.

A failed probe (an exception, a non-zero exit, or output that is not
a dotted version) is logged at WARNING and stays cached for the
session, so a gs that hangs on --version costs one 5 s wait per
session instead of one per compress run. That wait comes before the
loop that polls for Cancel, so on the first compress Cancel can go
unanswered for about 5 s; this is documented, not fixed.

The probe and GS_LIB apply to any POSIX gs with a
<prefix>/share/ghostscript directory, not only to the snap. Measured:
on Fedora 44 and Arch that directory has no version subdirectory, so
the probe runs and env stays None; on Debian 13 and Ubuntu 22.04,
24.04 and 26.04 the GS_LIB entries repeat the compiled-in search path
and the result is the same; on Windows there is no share directory
and no probe. macOS (Homebrew) was not measured.

GS_LIB through env= rather than -I: gs 10.06.0 lists -I entries ahead
of GS_LIB in its search path, so -I would put the derived entries
ahead of a user's GS_LIB.

Send the gs child's stdout and stderr to DEVNULL instead of PIPE.
_compress_pdf polls the child and reads neither pipe, so a gs that
writes more than a pipe buffer blocks until the 120 s deadline
terminates it, and a working gs makes that reachable in the snap. On
one malformed 400-page PDF the snap's gs 9.55.0 wrote 137600 bytes to
stdout and none to stderr when drained. With PIPE it stopped with
64976 bytes in the 65536-byte pipe, _compress_pdf took about 120 s and
gs ended with rc -15. Before GS_LIB, gs exited 1 at once; with DEVNULL
it exited 0. Both runs finished within about a second. The gs output
was not the result in any of the three: _compress_pdf keeps it only
when gs exits 0, and with DEVNULL it (187468 bytes) was not the
smallest. The result was 161585 -> 65124 bytes. With PIPE the native gs
9.55.0 of Ubuntu 22.04 also stalled for 120 s on this PDF. On the same
PDF gs 10.06.0 and gswin64c 10.05.0 wrote 0 bytes to either stream.

On Windows, start the gs child and the version probe with
CREATE_NO_WINDOW. gswin64c is a console program and the windowed
build has no console to share with it. Measured with gswin64c
10.05.0, running _compress_pdf under pythonw: without the flag every
run opened a visible console window (Windows Terminal, the default
terminal on the test machine): 3 of 3 with the old code, 3 of 3 with
DEVNULL but without the flag. With it, none (3 of 3), with gs exiting
0 and the same final size. The probe does not run on that install.

Measured with the real _compress_pdf on one 3-page PDF with 3000 px
images and non-embedded Helvetica and Times-Roman:
- snap rev 43 on core22: gs exits 0 at extreme (grayscale),
  recommended and low, embeds substitute fonts and keeps the text.
  Final size, old code -> this change: 3826561 -> 559297 bytes
  (recommended) and 33898872 -> 12556343 bytes (low). At extreme the
  PyMuPDF pass result (153514 bytes) is still the smallest and is
  kept. The same gs output and final sizes with DEVNULL as with PIPE.
- /usr/bin/gs 10.06.0 on Ubuntu 26.04: same argv. The env differs
  only by GS_LIB, set to the first two entries of the compiled-in
  search path. With SOURCE_DATE_EPOCH set, the gs output matches the
  old code's except for the trailer /ID, which also differs between
  two runs of the old code. With DEVNULL, the gs output and final
  sizes match the old code's at all three levels.
- gswin64c 10.05.0 on Windows: no share directory, no probe,
  env=None and the same argv. At all three levels the final sizes
  match the old code's, and so does the gs output except for its
  dates, document UUID and trailer /ID: gswin64c 10.05.0 ignores
  SOURCE_DATE_EPOCH, and those also differ between two runs of the
  old code.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

Deploying pdfapps with  Cloudflare Pages  Cloudflare Pages

Latest commit: 3981818
Status: ✅  Deploy successful!
Preview URL: https://348bdab9.pdfapps.pages.dev
Branch Preview URL: https://fix-snap-ghostscript-lib.pdfapps.pages.dev

View logs

Comment thread tests/test_compress_gs_pipes.py Fixed
Comment thread tests/test_compress_gs_pipes.py Fixed
CodeQL (py/uninitialized-local-variable) flagged `before` and `after`
in test_gs_pass_finishes_when_gs_floods_stdout_and_stderr as possibly
used before assignment: they are bound inside the try, and the except
for CancelledError only calls pytest.fail, which CodeQL does not know
always raises. The alerts are false positives, but unresolved review
comments block the merge.

Moving `assert after < before` into an else on the try makes the flow
explicit: it runs only when _compress_pdf returned. What the test
discriminates is unchanged; it still fails when gs gets a PIPE on
stdout, stderr or both, and the AST differs only in where that
assert sits.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@nelsonduarte
nelsonduarte merged commit ed7d852 into main Oct 10, 2026
5 checks passed
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