Skip to content

Commit 59c73e6

Browse files
vandricclaude
andcommitted
Replace shell-based process runner with open3_safe
Replaces the bash pipe + system timeout binary approach in ExternalProcess#run with Open3Safe.capture3_safe, which provides proper stdout/stderr capture, timeout handling, and RSS-based memory limiting without spawning a shell. - Add open3_safe gem dependency - Propagate max_rss: keyword arg through public API and all extractors - Convert env strings to Hashes for Open3 compatibility - Remove 2>&1 redirects (stderr now captured separately) - Duplicate/blank-line filtering preserved in the new implementation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d49fa34 commit 59c73e6

12 files changed

Lines changed: 210 additions & 77 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
*.gem
22
.DS_Store
3+
.idea

CLAUDE.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Commands
6+
7+
```bash
8+
# Install dependencies (open3_safe is fetched from GitHub at a pinned commit ref — see Gemfile)
9+
bundle install
10+
11+
# Run all tests
12+
bundle exec rake test
13+
14+
# Run a single test file
15+
bundle exec ruby test/unit/test_extract_text.rb
16+
17+
# Build and install the gem locally
18+
bundle exec rake gem:install
19+
```
20+
21+
## External Dependencies
22+
23+
The following system tools must be installed for full functionality:
24+
- `gm` (GraphicsMagick) — image extraction and OCR pre-processing
25+
- `pdftotext`, `pdfinfo`, `pdftk` — text extraction and PDF metadata
26+
- `tesseract` — OCR (with optional `osd` language pack for orientation detection)
27+
- `java` + JODConverter (vendored in `vendor/`) — non-PDF document conversion
28+
- `open3_safe` gem — pinned to a specific GitHub commit ref in `Gemfile`; `Gemfile.lock` must be regenerated after changing the ref
29+
30+
## Architecture
31+
32+
`lib/docsplit.rb` is the public API entry point. It defines the `Docsplit` module, checks `PATH` for dependencies at load time, and delegates to extractor classes.
33+
34+
**Extractor classes** (`lib/docsplit/`):
35+
- `TextExtractor` — extracts text via `pdftotext`, falls back to Tesseract OCR for pages below `MIN_TEXT_PER_PAGE` (100 bytes)
36+
- `ImageExtractor` — rasterizes PDF pages via GraphicsMagick (`gm convert`/`gm mogrify`)
37+
- `PdfExtractor` — converts non-PDF documents to PDF using LibreOffice or JODConverter (Java)
38+
- `InfoExtractor` — parses `pdfinfo` output for metadata
39+
- `PageExtractor` — bursts PDFs into single-page PDFs via `pdftk`/`pdftailor`
40+
41+
**`ExternalProcess` module** (`external_process.rb`) is mixed into extractor classes. Its `run` method wraps `Open3Safe.capture3_safe` to execute subprocesses with:
42+
- timeout (SIGTERM → SIGKILL after 5s)
43+
- optional RSS memory limit via `max_rss:`
44+
- stdout+stderr merged, blank lines and consecutive duplicate lines filtered (guards against memory bloat from corrupt PDFs — silverfin/issues/1998)
45+
46+
**Timeout-aware public API**: `extract_text_with_timeouts` and `extract_images_with_timeouts` accept `timeout` (overall) and `item_timeout` (per page/file); `extract_pdf_with_timeout` accepts only `timeout`. RSS caps are not part of the public API — each extractor hardcodes its own `MAX_RSS` constant (`TextExtractor`/`ImageExtractor`: 512 MiB, `TextExtractor::TESSERACT_MAX_RSS`: 1 GiB, `PdfExtractor`: 2 GiB) and passes it into `run(..., max_rss:)` internally. The plain `extract_*` variants have no timeouts.
47+
48+
## Test Structure
49+
50+
Tests live in `test/unit/`, use Minitest, and write output to `test/output/` (cleaned up in `teardown`). Fixtures are in `test/fixtures/` — a mix of PDFs, Office docs, and edge-case files (encrypted, unicode, spaces/quotes in filenames).

Gemfile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
source 'https://rubygems.org'
2+
3+
gemspec
4+
5+
gem "open3_safe", github: "Silverfin-Engineering/open3_safe", ref: "5badbe14e94bd01d4420fc13ee9a1f129d78b182"
6+
gem "minitest", "~> 6.0"

Gemfile.lock

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
GIT
2+
remote: https://github.com/Silverfin-Engineering/open3_safe.git
3+
revision: 5badbe14e94bd01d4420fc13ee9a1f129d78b182
4+
ref: 5badbe14e94bd01d4420fc13ee9a1f129d78b182
5+
specs:
6+
open3_safe (0.1.0)
7+
get_process_mem
8+
9+
PATH
10+
remote: .
11+
specs:
12+
docsplit (0.7.6)
13+
open3_safe
14+
15+
GEM
16+
remote: https://rubygems.org/
17+
specs:
18+
bigdecimal (4.1.1)
19+
drb (2.2.3)
20+
ffi (1.17.4)
21+
ffi (1.17.4-aarch64-linux-gnu)
22+
ffi (1.17.4-aarch64-linux-musl)
23+
ffi (1.17.4-arm-linux-gnu)
24+
ffi (1.17.4-arm-linux-musl)
25+
ffi (1.17.4-arm64-darwin)
26+
ffi (1.17.4-x86-linux-gnu)
27+
ffi (1.17.4-x86-linux-musl)
28+
ffi (1.17.4-x86_64-darwin)
29+
ffi (1.17.4-x86_64-linux-gnu)
30+
ffi (1.17.4-x86_64-linux-musl)
31+
get_process_mem (1.0.0)
32+
bigdecimal (>= 2.0)
33+
ffi (~> 1.0)
34+
minitest (6.0.4)
35+
drb (~> 2.0)
36+
prism (~> 1.5)
37+
prism (1.9.0)
38+
39+
PLATFORMS
40+
aarch64-linux-gnu
41+
aarch64-linux-musl
42+
arm-linux-gnu
43+
arm-linux-musl
44+
arm64-darwin
45+
ruby
46+
x86-linux-gnu
47+
x86-linux-musl
48+
x86_64-darwin
49+
x86_64-linux-gnu
50+
x86_64-linux-musl
51+
52+
DEPENDENCIES
53+
docsplit!
54+
minitest (~> 6.0)
55+
open3_safe!
56+
57+
BUNDLED WITH
58+
2.6.6

docsplit.gemspec

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,6 @@ Gem::Specification.new do |s|
2222

2323
s.files = Dir['build/**/*', 'lib/**/*', 'bin/*', 'vendor/**/*',
2424
'docsplit.gemspec', 'LICENSE', 'README']
25+
26+
s.add_dependency 'open3_safe'
2527
end

lib/docsplit.rb

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
require 'fileutils'
33
require 'shellwords'
44

5+
# Loaded ahead of the module body so the load-time OSD probe can use
6+
# ExternalProcess.run instead of a raw backtick.
7+
require File.expand_path(File.dirname(__FILE__) + '/docsplit/external_process')
8+
59
# The Docsplit module delegates to the Java PDF extractors.
610
module Docsplit
711

@@ -21,6 +25,13 @@ module Docsplit
2125
PEMRISSIONS_PATTERN = /(?<=\().+?(?=\))/
2226
DEFAULT_PERMISSION = {"print"=>true, "copy"=>true, "change"=>true, "addNotes"=>true}
2327

28+
# Raise an ExtractionFailed exception when the PDF is encrypted, or otherwise
29+
# broke.
30+
class ExtractionFailed < StandardError; end
31+
32+
# Raise an TimeoutError when running external tool timeouts.
33+
class TimeoutError < StandardError; end
34+
2435
# Check for all dependencies, and note their absence.
2536
dirs = ENV['PATH'].split(File::PATH_SEPARATOR)
2637
DEPENDENCIES.each_key do |dep|
@@ -34,18 +45,11 @@ module Docsplit
3445

3546
# if tesseract is found check for the osd plugin so that we can do orientation independent OCR.
3647
if DEPENDENCIES[:tesseract]
37-
# osd will be listed in tesseract --listlangs
38-
val = %x[ #{'tesseract --list-langs'} 2>&1 >/dev/null ]
48+
# osd will be listed in tesseract --list-langs
49+
val = ExternalProcess.run("tesseract --list-langs") rescue ""
3950
DEPENDENCIES[:osd] = true if val =~ /\bosd\b/
4051
end
4152

42-
# Raise an ExtractionFailed exception when the PDF is encrypted, or otherwise
43-
# broke.
44-
class ExtractionFailed < StandardError; end
45-
46-
# Raise an TimeoutError when running external tool timeouts.
47-
class TimeoutError < StandardError; end
48-
4953
# Use the ExtractPages Java class to burst a PDF into single pages.
5054
def self.extract_pages(pdfs, opts={})
5155
pdfs = ensure_pdfs(pdfs)
@@ -144,7 +148,6 @@ def self.normalize_value(value)
144148

145149
end
146150

147-
require "#{Docsplit::ROOT}/lib/docsplit/external_process"
148151
require "#{Docsplit::ROOT}/lib/docsplit/image_extractor"
149152
require "#{Docsplit::ROOT}/lib/docsplit/transparent_pdfs"
150153
require "#{Docsplit::ROOT}/lib/docsplit/text_extractor"

lib/docsplit/external_process.rb

Lines changed: 39 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,46 @@
1+
require 'shellwords'
2+
require 'open3_safe'
3+
14
module Docsplit
25
module ExternalProcess
6+
extend self
7+
8+
# Seconds to wait after SIGTERM before escalating to SIGKILL.
9+
KILL_AFTER = 5
10+
11+
# Default RSS byte cap applied when no max_rss is supplied (or nil is passed) — 512 MiB.
12+
DEFAULT_MAX_RSS = 512 * 1024 * 1024
13+
314
# Run an external process and raise an exception if it fails.
4-
def run(command, env = "", timeout = nil)
5-
# If a corrupt PDF is parsed, it generates an infinite amount of identical warnings (with blank lines in between).
6-
# By filtering these we avoid memory bloat when the executing process tries to capture stdout. The timeout makes
7-
# sure we exit at some point.
8-
#
9-
# - See https://github.com/GetSilverfin/silverfin/issues/1998
10-
# - Add timeout so a stuck process doesn't block our Ruby process forever
11-
# - Remove blank lines
12-
# - Remove duplicate lines
13-
run_command = "#{env} #{timeout_prefix(timeout)} #{command} | grep -v \"^$\" | uniq"
14-
15-
# - Run through bash so we can use PIPESTATUS
16-
# - Use PIPESTATUS to return the exit status of #{command} instead of `uniq`
17-
result = `bash -c '#{run_command}; exit ${PIPESTATUS[0]}'`.chomp
18-
exit_code = $?.exitstatus
19-
20-
raise TimeoutError, run_command if exit_code == 137
21-
raise ExtractionFailed, result if exit_code != 0
22-
23-
result
24-
end
15+
#
16+
# command - shell command string (may include 2>&1, which is stripped)
17+
# env - Hash of extra environment variables (default: {})
18+
# timeout - seconds before the process is sent SIGTERM (nil = no timeout)
19+
# max_rss: - RSS byte threshold; defaults to DEFAULT_MAX_RSS
20+
def run(command, env = {}, timeout = nil, max_rss: DEFAULT_MAX_RSS)
21+
# Strip 2>&1 redirects — stderr is captured separately by Open3Safe.
22+
cmd = Shellwords.split(command.gsub(/\s*2>&1\s*/, ' ').strip)
2523

26-
def timeout_prefix(timeout)
27-
timeout ? "#{timeout_bin} #{timeout}" : ""
28-
end
24+
opts = { signal: :TERM, kill_after: KILL_AFTER, max_rss: max_rss }
25+
opts[:timeout] = timeout if timeout
26+
27+
args = env.empty? ? [*cmd, opts] : [env, *cmd, opts]
28+
result = Open3Safe.capture3_safe(*args)
29+
30+
# Combine stdout+stderr (previously unified via 2>&1 in callers), then filter blank
31+
# lines and consecutive duplicates. This avoids memory bloat from corrupt PDFs that
32+
# generate an infinite stream of identical warnings — see silverfin/issues/1998.
33+
output = (result[:stdout] + result[:stderr])
34+
.lines
35+
.reject { |l| l.chomp.empty? }
36+
.each_with_object([]) { |l, acc| acc << l unless acc.last == l }
37+
.join
38+
.chomp
39+
40+
raise TimeoutError, command if result[:timeout] || result[:oom_killed]
41+
raise ExtractionFailed, output if result[:status].exitstatus != 0
2942

30-
def timeout_bin
31-
# gtimeout on Mac
32-
`which timeout` != "" ? "timeout --signal=KILL" : "gtimeout --signal=KILL"
43+
output
3344
end
3445
end
35-
end
46+
end

lib/docsplit/image_extractor.rb

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ class ImageExtractor
88
MEMORY_ARGS = "-limit memory 256MiB -limit map 512MiB"
99
DEFAULT_FORMAT = :png
1010
DEFAULT_DENSITY = '150'
11+
MAX_RSS = 512 * 1024 * 1024
1112

1213
def initialize(timeout = nil, item_timeout = nil)
1314
@timeout = timeout
@@ -42,20 +43,16 @@ def convert(pdf, size, format, previous=nil)
4243
pages = @pages || '1-' + Docsplit.extract_length(pdf).to_s
4344
escaped_pdf = ESCAPE[pdf]
4445
FileUtils.mkdir_p(directory) unless File.exist?(directory)
45-
env = "MAGICK_TMPDIR=#{tempdir} OMP_NUM_THREADS=2"
46+
env = { "MAGICK_TMPDIR" => tempdir, "OMP_NUM_THREADS" => "2" }
4647
common = "#{MEMORY_ARGS} -density #{@density} #{resize_arg(size)} #{quality_arg(format)}"
4748

4849
if previous
4950
FileUtils.cp(Dir[directory_for(previous) + '/*'], directory)
50-
# We're adding `| grep -v '^$' | uniq` here and below because if a corrupt PDF is parsed, it generates an infinite amount of identical warnings (with blank lines in between).
51-
# By filtering these we avoid memory bloat when the executing process tries to capture stdout.
52-
# See https://github.com/GetSilverfin/silverfin/issues/1998
53-
54-
run("gm mogrify #{common} -unsharp 0x0.5+0.75 \"#{directory}/*.#{format}\" 2>&1", env, @timeout)
51+
run("gm mogrify #{common} -unsharp 0x0.5+0.75 \"#{directory}/*.#{format}\"", env, @timeout, max_rss: MAX_RSS)
5552
else
5653
page_list(pages).each do |page|
5754
out_file = ESCAPE[File.join(directory, "#{basename}_#{page}.#{format}")]
58-
run("gm convert +adjoin -define pdf:use-cropbox=true #{common} #{escaped_pdf}[#{page - 1}] #{out_file} 2>&1", env, @item_timeout)
55+
run("gm convert +adjoin -define pdf:use-cropbox=true #{common} #{escaped_pdf}[#{page - 1}] #{out_file}", env, @item_timeout, max_rss: MAX_RSS)
5956
end
6057
end
6158
ensure

lib/docsplit/info_extractor.rb

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ module Docsplit
22

33
# Delegates to **pdfinfo** in order to extract information about a PDF file.
44
class InfoExtractor
5+
include ExternalProcess
56

67
# Regex matchers for different bits of information.
78
MATCHERS = {
@@ -24,9 +25,7 @@ def extract(key, pdfs, opts)
2425

2526
def extract_all(pdfs, opts)
2627
pdf = [pdfs].flatten.first
27-
cmd = "pdfinfo #{ESCAPE[pdf]} 2>&1"
28-
result = `#{cmd}`.chomp
29-
raise ExtractionFailed, result if $? != 0
28+
result = run("pdfinfo #{ESCAPE[pdf]}")
3029
# ruby 1.8 (iconv) and 1.9 (String#encode) :
3130
if String.method_defined?(:encode)
3231
result.encode!('UTF-8', 'binary', :invalid => :replace, :undef => :replace, :replace => "") unless result.valid_encoding?

lib/docsplit/page_extractor.rb

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module Docsplit
33
# Delegates to **pdftk** in order to create bursted single pages from
44
# a PDF document.
55
class PageExtractor
6+
include ExternalProcess
67

78
# Burst a list of pdfs into single pages, as `pdfname_pagenumber.pdf`.
89
def extract(pdfs, opts)
@@ -11,16 +12,17 @@ def extract(pdfs, opts)
1112
pdf_name = File.basename(pdf, File.extname(pdf))
1213
page_path = ESCAPE[File.join(@output, "#{pdf_name}")] + "_%d.pdf"
1314
FileUtils.mkdir_p @output unless File.exist?(@output)
14-
15+
1516
cmd = if DEPENDENCIES[:pdftailor] # prefer pdftailor, but keep pdftk for backwards compatability
16-
"pdftailor unstitch --output #{page_path} #{ESCAPE[pdf]} 2>&1"
17+
"pdftailor unstitch --output #{page_path} #{ESCAPE[pdf]}"
1718
else
18-
"pdftk #{ESCAPE[pdf]} burst output #{page_path} 2>&1"
19+
"pdftk #{ESCAPE[pdf]} burst output #{page_path}"
20+
end
21+
begin
22+
run(cmd)
23+
ensure
24+
FileUtils.rm('doc_data.txt') if File.exist?('doc_data.txt')
1925
end
20-
result = `#{cmd}`.chomp
21-
FileUtils.rm('doc_data.txt') if File.exist?('doc_data.txt')
22-
raise ExtractionFailed, result if $? != 0
23-
result
2426
end
2527
end
2628

0 commit comments

Comments
 (0)