activestorage-hotcell-server 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 89dcb6743b4bb87dacc1351b9dec256ef7dd1c887caf13c9242c4c4abc05c1de
4
- data.tar.gz: 93b579fc94d6670fd6008b58e98c10fba06d50c3b63b8bf691ea6f35ae1526bf
3
+ metadata.gz: f8f31eda3f64a83828d024483bd31e733927fdf2d7ea0b02796bbef8ead35509
4
+ data.tar.gz: 0e9975a3262d57ea6caecc8351903297ef6043412ac2bae7ad45a4f5783efaeb
5
5
  SHA512:
6
- metadata.gz: 999c462832530390ac5cc300a69b2673246e1a99759bdb9a92625af721523ec72ab16988107a1b4049e4a8e41cdb8f7d6612a87b7e33d647e5947a98325d2b78
7
- data.tar.gz: c076edca15955f2cd668a43ac54a3c23011f6f8ca935ab4d08a7db7a1623fc2b1d2906d719070a4a09f4ea99a9d75c2356abac236bdc5cb4df746eec1d438b06
6
+ metadata.gz: '078512cbc8c4bc0559effd79d63f3e5b30b24c308fe5183bc85e054dea4a5829d34b28ebdc43173503893c94babd16e8de91b9217b76229150fd01c633cad575'
7
+ data.tar.gz: afb813f18f6e85c61389af36c9dc731ca3ff64ac18c24523a0015f8f1f25a5616ebfd307f235e15fbe5038c5fc6d7ebe7776a9d8a44c713630e6ff9b22beff0f
@@ -25,7 +25,7 @@ module ActiveStorage
25
25
 
26
26
  def perform(inputs, _outputs)
27
27
  source, = inputs
28
- frames = identified(source.path)
28
+ frames = identified(source)
29
29
  width, height, orientation = frames.first
30
30
 
31
31
  { **dimensions(width, height, orientation), pages: frames.size, animated: frames.size > 1,
@@ -36,10 +36,10 @@ module ActiveStorage
36
36
  # One `identify` run reports every frame's dimensions and orientation, where MiniMagick::Image's
37
37
  # accessors (`valid?`, `width`, `pages`) each spawn an identify of their own — four execs per
38
38
  # analysis, two of them decoding every frame.
39
- def identified(path)
40
- frames = MiniMagick.identify do |identify|
39
+ def identified(source)
40
+ frames = MiniMagick.identify(inherit_fds: [ source.to_io ]) do |identify|
41
41
  identify.format "%w %h %[orientation]\n"
42
- identify << path
42
+ identify << source.fd_path
43
43
  end.lines.map(&:split)
44
44
 
45
45
  raise MiniMagick::Invalid, "ImageMagick does not recognise this as an image" if frames.empty?
@@ -8,29 +8,6 @@ require "active_storage/hot_cell/server/operation"
8
8
  # toolchains' loads in the same place is what makes a single cell carrying both legible.
9
9
  require "image_processing/mini_magick"
10
10
 
11
- # Invariant 9: a tool sees only the environment its operation wrote for it. `Operation#run_tool` holds it
12
- # with `unsetenv_others: true`, and these operations do not use it — mini_magick spawns `magick` itself.
13
- # `MiniMagick.restricted_env` is `false` by default, and with it false mini_magick calls
14
- # `Open3.popen3({}, *command, unsetenv_others: false)`, so `magick` inherited this worker's whole
15
- # environment. `bin/conformance` did not catch it, because its environment check drives an operation that
16
- # goes through `run_tool`.
17
- #
18
- # `cli_env` names the locale for the reason `tool_environment` does: a tool's output must not shift under
19
- # it. mini_magick passes `HOME`, `PATH` and `LANG` and does not pass `LC_ALL`, so both go here.
20
- #
21
- # Set at require time rather than in `before_worker_boot`, so that the binary lookup this require performs
22
- # is covered too. Note what this is: mini_magick filters the environment it was given, where `run_tool`
23
- # writes a fresh one. Driving `magick` directly would make it ours, and that is the separate enhancement
24
- # this class already names.
25
- #
26
- # The OpenMP variables come from the cell's environment rather than being named here: the number belongs
27
- # to the image, which is configured to match the container's CPU quota. Without them the restriction would
28
- # hand `magick` the pool the image's bound was meant to take away. `run_tool` carries the same pair.
29
- MiniMagick.restricted_env = true
30
- MiniMagick.cli_env = { "LANG" => "C.UTF-8", "LC_ALL" => "C.UTF-8",
31
- "OMP_NUM_THREADS" => ENV["OMP_NUM_THREADS"],
32
- "OMP_THREAD_LIMIT" => ENV["OMP_THREAD_LIMIT"] }.compact
33
-
34
11
  module ActiveStorage
35
12
  module HotCell
36
13
  module Server
@@ -41,14 +18,46 @@ module ActiveStorage
41
18
  # own output — dimensions from `identify`, an exit status — which is a number and a string, not a
42
19
  # decoder. The distinction is the same one probe_media draws for ffprobe.
43
20
  #
44
- # Unlike the vips operations, the input is staged onto scratch: mini_magick spawns its own `magick` and
45
- # does not inherit this worker's descriptors, so an input cannot be handed to it as /dev/fd. That
46
- # returns the file_size ceiling to this path — an input larger than the operation's file_size dies
47
- # being staged — which the vips operations shed. Removing it means driving `magick` directly rather
48
- # than through mini_magick, and is a separate enhancement.
21
+ # Both operations hand mini_magick the input descriptor with `inherit_fds:` and name it by its
22
+ # Descriptor#fd_path, so neither stages its input and neither carries the file_size ceiling the vips
23
+ # operations never had.
49
24
  class MagickOperation < Operation
50
25
  abstract_operation
51
26
 
27
+ # Invariant 9: a tool sees only the environment its operation wrote for it. `Operation#run_tool` holds
28
+ # it with `unsetenv_others: true`, and these operations do not use it — mini_magick spawns `magick`
29
+ # itself. `MiniMagick.restricted_env` is `false` by default, and with it false mini_magick calls
30
+ # `Open3.popen3({}, *command, unsetenv_others: false)`, so `magick` inherited this worker's whole
31
+ # environment. `bin/conformance` did not catch it, because its environment check drives an operation
32
+ # that goes through `run_tool`. Note what this is: mini_magick filters the environment it was given,
33
+ # where `run_tool` writes a fresh one. Driving `magick` directly would make it ours, and that is the
34
+ # separate enhancement this class already names.
35
+ #
36
+ # The locale is named for the reason `tool_environment` names it: a tool's output must not shift under
37
+ # it. mini_magick passes `HOME`, `PATH` and `LANG` and does not pass `LC_ALL`, so both go here.
38
+ #
39
+ # Everything else comes from the worker's environment rather than being named here. The OpenMP pair
40
+ # belongs to the image, which is configured to match the container's CPU quota. ImageMagick's own
41
+ # resource ceilings, `MAGICK_DISK_LIMIT` and its siblings, are read from its environment as it loads
42
+ # and the image sets them to a worker's share of the scratch; without them `magick` runs under
43
+ # ImageMagick's defaults, the host's RAM and an unbounded disk. `TMPDIR` and `MAGICK_TMPDIR` are where
44
+ # it spills that cache: the worker sets the first to the request's home after the fork and
45
+ # `Operation#initialize` maps the second from it — which is why this is read per request, after
46
+ # `super`, and not once at require: a hash built then hands `magick` the supervisor's `/tmp`, which is
47
+ # swept only at the next boot.
48
+ def self.cli_env
49
+ { "LANG" => "C.UTF-8", "LC_ALL" => "C.UTF-8",
50
+ "OMP_NUM_THREADS" => ENV["OMP_NUM_THREADS"], "OMP_THREAD_LIMIT" => ENV["OMP_THREAD_LIMIT"],
51
+ "TMPDIR" => ENV["TMPDIR"], "MAGICK_TMPDIR" => ENV["MAGICK_TMPDIR"] }
52
+ .merge(ENV.select { |name, _| name.match?(/\AMAGICK_\w+_LIMIT\z/) })
53
+ .compact
54
+ end
55
+
56
+ def initialize
57
+ super
58
+ MiniMagick.cli_env = self.class.cli_env
59
+ end
60
+
52
61
  # MiniMagick::Error is how mini_magick reports a `magick` that exited non-zero — the common shape of an
53
62
  # input it cannot decode. MiniMagick::Invalid is an input `identify` rejects outright.
54
63
  # ImageProcessing::Error is the pipeline's own verdict on the input against the requested
@@ -71,3 +80,7 @@ module ActiveStorage
71
80
  end
72
81
  end
73
82
  end
83
+
84
+ # Also at require time, so that the binary lookup the require above performs is covered too.
85
+ MiniMagick.restricted_env = true
86
+ MiniMagick.cli_env = ActiveStorage::HotCell::Server::MagickOperation.cli_env
@@ -30,6 +30,15 @@ module ActiveStorage
30
30
  "tiff" => "image/tiff",
31
31
  }.freeze
32
32
 
33
+ # The cell describes the request's scratch as `TMPDIR`, and knows nothing about the libraries that run
34
+ # here. ImageMagick reads `MAGICK_TMPDIR` first, so the mapping is this gem's — for the `magick` the
35
+ # magick operations exec and for the `magickload` libvips delegates to in-process. Per request rather
36
+ # than at load: `TMPDIR` is the request's home, which is fresh every time.
37
+ def initialize
38
+ super
39
+ ENV["MAGICK_TMPDIR"] = ENV["TMPDIR"] if ENV.key?("TMPDIR")
40
+ end
41
+
33
42
  private
34
43
  # A caller breaking the protocol is a caller bug, not a bad document. Raising MessageError is what
35
44
  # makes the cell answer `invalid`, which is permanent and which the client raises rather than
@@ -29,9 +29,17 @@ module ActiveStorage
29
29
  ImageProcessing::MiniMagick
30
30
  end
31
31
 
32
- # The staged path rather than /dev/fd; MagickOperation's comment says why.
33
32
  def source_path(source)
34
- source.path
33
+ source.fd_path
34
+ end
35
+
36
+ # `source_path` names the input as the source's `/dev/fd` path, which `magick` can open only if it
37
+ # inherits the descriptor, so name the IO for mini_magick to put in its spawn map. Through the
38
+ # loader options rather than by pre-building a MiniMagick::Tool: a pre-built tool reaches
39
+ # ImageProcessing's `load_image` by its first branch, which drops `page`, `loader` and `geometry`
40
+ # without a word.
41
+ def source_loader_options(source)
42
+ { inherit_fds: [ source.to_io ] }
35
43
  end
36
44
 
37
45
  def describe(path, format)
@@ -21,23 +21,40 @@ module ActiveStorage
21
21
  # it to the library, which cannot reproduce those without restating them. Output#post makes the
22
22
  # one remaining copy, out through the caller's descriptor.
23
23
  encoded = destination.path(extension: format)
24
- pipeline(source_path(source), format, operations).call(destination: encoded)
24
+ pipeline(source, format, operations).call(destination: encoded)
25
25
  destination.adopt encoded
26
26
 
27
27
  describe destination.path, format
28
28
  end
29
29
 
30
30
  private
31
- # `loader(page: 0)` first is what Rails does, and it is what stops a multi-page TIFF or a
32
- # hundred-frame GIF being decoded in full to produce one thumbnail. A caller's own `loader` arrives
33
- # through `apply` and merges over it, which is also what Rails does — asking for every frame is
34
- # `loader: { n: -1 }`.
35
- def pipeline(path, format, operations)
31
+ # Three `loader` calls, and the order is the point: ImageProcessing merges them key by key, so the
32
+ # last one to write a key wins.
33
+ #
34
+ # `page: 0` is first, as a default the caller may replace. It is what Rails does, and what stops a
35
+ # multi-page TIFF or a hundred-frame GIF being decoded in full to produce one thumbnail; a caller
36
+ # who wants every frame sends `loader: { n: -1 }` through `apply` and gets it.
37
+ #
38
+ # `source_loader_options` is last, so the caller cannot replace it.
39
+ def pipeline(source, format, operations)
36
40
  processor
37
- .source(path)
41
+ .source(source_path(source))
38
42
  .loader(page: 0)
39
43
  .convert(format)
40
44
  .apply(operations_for(operations))
45
+ .loader(**source_loader_options(source))
46
+ end
47
+
48
+ # What the toolchain needs in order to reach the source at all, which is not the same question as how
49
+ # the image is decoded. Empty here, because `source_path` normally answers a filename and a filename
50
+ # needs no help. An includer whose `source_path` names something the tool cannot open on its own
51
+ # overrides this to supply the rest — `Transformers::Image::Magick` is the one that does.
52
+ #
53
+ # It goes last in `pipeline` because it is not the caller's to change: a payload sending
54
+ # `loader: { inherit_fds: [] }` would otherwise leave the source naming a descriptor the tool has no
55
+ # way to open.
56
+ def source_loader_options(_source)
57
+ {}
41
58
  end
42
59
 
43
60
  # What Rails validates, and no more. `combine_options` is refused because it can never be one
@@ -3,7 +3,7 @@
3
3
  module ActiveStorage
4
4
  module HotCell
5
5
  module Server
6
- VERSION = "0.3.1"
6
+ VERSION = "0.4.0"
7
7
  end
8
8
  end
9
9
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activestorage-hotcell-server
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Dalessio
@@ -15,42 +15,42 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.3.1
18
+ version: 0.4.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - '='
24
24
  - !ruby/object:Gem::Version
25
- version: 0.3.1
25
+ version: 0.4.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: image_processing
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: '2.0'
32
+ version: 2.1.0
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
- version: '2.0'
39
+ version: 2.1.0
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: mini_magick
42
42
  requirement: !ruby/object:Gem::Requirement
43
43
  requirements:
44
44
  - - ">="
45
45
  - !ruby/object:Gem::Version
46
- version: 5.2.0
46
+ version: 5.4.0
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - ">="
52
52
  - !ruby/object:Gem::Version
53
- version: 5.2.0
53
+ version: 5.4.0
54
54
  - !ruby/object:Gem::Dependency
55
55
  name: ruby-vips
56
56
  requirement: !ruby/object:Gem::Requirement
@@ -99,8 +99,8 @@ licenses:
99
99
  - MIT
100
100
  metadata:
101
101
  homepage_uri: https://github.com/basecamp/hotcell
102
- source_code_uri: https://github.com/basecamp/hotcell/tree/v0.3.1/activestorage-hotcell-server
103
- changelog_uri: https://github.com/basecamp/hotcell/blob/v0.3.1/CHANGELOG.md
102
+ source_code_uri: https://github.com/basecamp/hotcell/tree/v0.4.0/activestorage-hotcell-server
103
+ changelog_uri: https://github.com/basecamp/hotcell/blob/v0.4.0/CHANGELOG.md
104
104
  bug_tracker_uri: https://github.com/basecamp/hotcell/issues
105
105
  rubygems_mfa_required: 'true'
106
106
  rdoc_options: []