Repository: bitcrowd/chromic_pdf
Pull request #121 (2021-03-29 18:36:00)

[120] Explicit connection lost error and helpful sandbox docs.
* When Chrome dies at startup, display a helpful error message.
* Improve sandbox docs, mention Docker & seccomp.

Addresses #120.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 601d62e..d41c01d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,10 @@
+## Unreleased
+
+### Changed
+
+- Improved error message when Chrome dies at startup.
+- Improved docs on the `no_sandbox` option.
+
 ## [1.0.0] - 2021-03-23
 
 ### Added
diff --git a/lib/chromic_pdf.ex b/lib/chromic_pdf.ex
index 6408840..12b494e 100644
--- a/lib/chromic_pdf.ex
+++ b/lib/chromic_pdf.ex
@@ -54,10 +54,25 @@ defmodule ChromicPDF do
         [offline: true]
       end
 
-  ### Chrome Sandbox
+  ### Chrome Sandbox in Docker containers
 
-  By default, ChromicPDF will run Chrome targets in a sandboxed OS process. If you absolutely
-  must run Chrome as root, you can turn of its sandbox by passing the `no_sandbox: true` option.
+  By default, ChromicPDF will allow Chrome to make use of its own ["sandbox" process jail](https://chromium.googlesource.com/chromium/src/+/master/docs/design/sandbox.md).
+  The sandbox tries to limit system resource access of the renderer processes to the minimum
+  resources they require to perform their task.
+
+  However, in Docker containers running Linux images (e.g. images based on Alpine), and which
+  are configured to run their main job as a non-root user, this causes Chrome to crash on startup
+  as it requires root privileges to enter the sandbox.
+
+  The error output (`discard_stderr: false` option) looks as follows:
+
+      Failed to move to new namespace: PID namespaces supported, Network namespace supported,
+      but failed: errno = Operation not permitted
+
+  The best way to resolve this issue is to configure your Docker container to use seccomp rules
+  that grant Chrome access to the relevant system calls. See the excellent [Zenika/alpine-chrome](https://github.com/Zenika/alpine-chrome#-the-best-with-seccomp) repository for details on how to make this work.
+
+  Alternatively, you may choose to disable Chrome's sandbox with the `no_sandbox` option.
 
       defp chromic_pdf_opts do
         [no_sandbox: true]
diff --git a/lib/chromic_pdf/pdf/connection.ex b/lib/chromic_pdf/pdf/connection.ex
index 22f4735..f338ab6 100644
--- a/lib/chromic_pdf/pdf/connection.ex
+++ b/lib/chromic_pdf/pdf/connection.ex
@@ -2,11 +2,12 @@ defmodule ChromicPDF.Connection do
   @moduledoc false
 
   use GenServer
-  alias ChromicPDF.Connection.{Dispatcher, JsonRPC, Tokenizer}
+  alias ChromicPDF.Connection.{ConnectionLostError, Dispatcher, JsonRPC, Tokenizer}
 
   @chrome Application.compile_env(:chromic_pdf, :chrome, ChromicPDF.ChromeImpl)
 
   @type state :: %{
+          port: port(),
           parent_pid: pid(),
           tokenizer: Tokenizer.t(),
           dispatcher: Dispatcher.t()
@@ -24,6 +25,11 @@ defmodule ChromicPDF.Connection do
     GenServer.call(pid, {:dispatch_call, msg})
   end
 
+  @spec port_info(pid) :: keyword()
+  def port_info(pid) do
+    GenServer.call(pid, :port_info)
+  end
+
   # ------------ Server --------------
 
   @impl GenServer
@@ -33,6 +39,7 @@ defmodule ChromicPDF.Connection do
     Process.flag(:trap_exit, true)
 
     state = %{
+      port: port,
       parent_pid: parent_pid,
       tokenizer: Tokenizer.init(),
       dispatcher: Dispatcher.init(port)
@@ -54,6 +61,10 @@ defmodule ChromicPDF.Connection do
     {:reply, reply, %{state | dispatcher: dispatcher}}
   end
 
+  def handle_call(:port_info, _from, %{port: port} = state) do
+    {:reply, Port.info(port), state}
+  end
+
   @impl GenServer
   # Message from Chrome through the port.
   def handle_info({_port, {:data, data}}, state) do
@@ -75,12 +86,25 @@ defmodule ChromicPDF.Connection do
   # EXIT signal from port process since we trap signals.
   def handle_info({:EXIT, _port, _reason}, state) do
     # Chrome has crashed or was terminated externally.
-    {:stop, :connection_terminated, state}
+    {:stop, :connection_lost, state}
   end
 
   @impl GenServer
   def terminate(:normal, _state), do: :ok
-  def terminate(:connection_terminated, _state), do: :ok
+
+  def terminate(:connection_lost, _state) do
+    raise(ConnectionLostError, """
+    Chrome has stopped or was terminated by an external program.
+
+    If this happened while you were printing a PDF, this may be a problem with Chrome itelf.
+    If this happens at startup and you are running inside a Docker container with a Linux-based
+    image, please see the "Chrome Sandbox in Docker containers" section of the documentation.
+
+    Either way, to see Chrome's error output, configure ChromicPDF with the option
+
+        discard_stderr: false
+    """)
+  end
 
   def terminate(:shutdown, state) do
     # Graceful shutdown: Dispatch the Browser.close call to Chrome which will cause it to detach
diff --git a/lib/chromic_pdf/pdf/connection/connection_lost.ex b/lib/chromic_pdf/pdf/connection/connection_lost.ex
new file mode 100644
index 0000000..45d302e
--- /dev/null
+++ b/lib/chromic_pdf/pdf/connection/connection_lost.ex
@@ -0,0 +1,7 @@
+defmodule ChromicPDF.Connection.ConnectionLostError do
+  @moduledoc """
+  Exception raised when Chrome process has stopped unexpectedly.
+  """
+
+  defexception [:message]
+end


Test patch:
diff --git a/test/integration/connection_lost_test.exs b/test/integration/connection_lost_test.exs
new file mode 100644
index 0000000..fc1e3b1
--- /dev/null
+++ b/test/integration/connection_lost_test.exs
@@ -0,0 +1,27 @@
+defmodule ChromicPDF.ConnectionLostTest do
+  use ExUnit.Case, async: false
+  alias ChromicPDF.Connection
+  alias ChromicPDF.Connection.ConnectionLostError
+
+  describe "when the Chrome process fails at startup or is killed externally" do
+    @tag :capture_log
+    test "an exception with a nice error message is raised" do
+      {:ok, pid} = Connection.start_link(self(), [])
+
+      port_info = Connection.port_info(pid)
+
+      Process.unlink(pid)
+      Process.monitor(pid)
+
+      System.cmd("kill", [to_string(port_info[:os_pid])])
+
+      receive do
+        {:DOWN, _ref, :process, ^pid, {%ConnectionLostError{message: msg}, _trace}} ->
+          assert String.contains?(
+                   msg,
+                   "Chrome has stopped or was terminated by an external program."
+                 )
+      end
+    end
+  end
+end
diff --git a/test/unit/chromic_pdf/pdf/connection_test.exs b/test/unit/chromic_pdf/pdf/connection_test.exs
index 391a609..20d3f04 100644
--- a/test/unit/chromic_pdf/pdf/connection_test.exs
+++ b/test/unit/chromic_pdf/pdf/connection_test.exs
@@ -9,6 +9,7 @@ defmodule ChromicPDF.ConnectionTest do
 
   defp new_state do
     %{
+      port: @port,
       parent_pid: self(),
       tokenizer: [],
       dispatcher: %ChromicPDF.Connection.Dispatcher{
@@ -35,10 +36,10 @@ defmodule ChromicPDF.ConnectionTest do
 
     test "it suicides when Chrome is terminated externally", %{state: state} do
       assert handle_info({:EXIT, @port, :normal}, state) ==
-               {:stop, :connection_terminated, state}
+               {:stop, :connection_lost, state}
 
       assert handle_info({:EXIT, @port, :other_reason}, state) ==
-               {:stop, :connection_terminated, state}
+               {:stop, :connection_lost, state}
     end
   end