Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@

## [Unreleased]

## [v0.11.0] - 2026-08-20

### Changed

- **Breaking:** report socket failures as atoms. A call that fails at the socket now returns
`{:error, :timeout}`, `{:error, :closed}` or `{:error, <posix>}` where it previously returned
a message string built from a `MatchError`, so callers can tell a `soffice` that stopped
answering from a document it refused. Errors from `soffice` itself are still message strings;
code that assumed every reason was a binary has to handle both.

### Fixed

- Raise `URP.SocketError` from `URP.Protocol` when a `:gen_tcp` send or recv fails, instead of
failing a match and reporting the resulting `MatchError` message as the error.

## [v0.10.3] - 2026-08-03

### Fixed
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,22 @@ config :urp, :default,
> pool sends every worker to the configured host and port; distributing workers
> across containers requires separate named pools or an external TCP balancer.

### Errors

A failure is `{:error, reason}`. A message string means soffice objected to the
document or the filter; an atom means the socket gave out — `:timeout` when
soffice stopped answering, `:closed` when it hung up, or a POSIX error. The two
call for different handling: retrying a document soffice refused is pointless,
and a wedged soffice is not the document's fault.

```elixir
case URP.convert(path, filter: "writer_pdf_Export", output: pdf) do
{:ok, ^pdf} -> :converted
{:error, reason} when is_atom(reason) -> {:unavailable, reason}
{:error, message} -> {:refused, message}
end
```

### Testing

Stub conversions in tests — no running soffice needed. See `URP.Test`.
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.10.3
0.11.0
50 changes: 38 additions & 12 deletions lib/urp.ex
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ defmodule URP do
See `URP.Test` for details.
"""

@typedoc """
Why a call failed: a `soffice` message, or the atom `:gen_tcp` reported when
the socket itself gave out (`:timeout`, `:closed`, a POSIX error).
"""
@type error :: String.t() | atom()

@type setting :: {String.t(), String.t(), boolean() | integer() | String.t()}
@type output :: Path.t() | :binary | (binary() -> any())
@type io_mode :: :file | :stream | {:file | :stream, :file | :stream}
Expand Down Expand Up @@ -79,7 +85,7 @@ defmodule URP do
* `:pool` — named pool to use (default: the auto-started pool)
* `:timeout` — checkout timeout in ms (default `120_000`)
"""
@spec version(keyword()) :: {:ok, String.t()} | {:error, String.t()}
@spec version(keyword()) :: {:ok, String.t()} | {:error, error()}
def version(opts \\ []) do
{pool, opts} = resolve_pool(opts)
URP.Pool.version(pool, opts)
Expand All @@ -90,7 +96,7 @@ defmodule URP do
def version!(opts \\ []) do
case version(opts) do
{:ok, v} -> v
{:error, message} -> raise message
{:error, reason} -> raise_reason(reason)
end
end

Expand All @@ -110,7 +116,7 @@ defmodule URP do
* `:pool` — named pool to use (default: the auto-started pool)
* `:timeout` — checkout timeout in ms (default `120_000`)
"""
@spec services(keyword()) :: {:ok, [String.t()]} | {:error, String.t()}
@spec services(keyword()) :: {:ok, [String.t()]} | {:error, error()}
def services(opts \\ []) do
{pool, opts} = resolve_pool(opts)
URP.Pool.services(pool, opts)
Expand All @@ -121,7 +127,7 @@ defmodule URP do
def services!(opts \\ []) do
case services(opts) do
{:ok, v} -> v
{:error, message} -> raise message
{:error, reason} -> raise_reason(reason)
end
end

Expand All @@ -142,7 +148,7 @@ defmodule URP do
* `:pool` — named pool to use (default: the auto-started pool)
* `:timeout` — checkout timeout in ms (default `120_000`)
"""
@spec filters(keyword()) :: {:ok, [String.t()]} | {:error, String.t()}
@spec filters(keyword()) :: {:ok, [String.t()]} | {:error, error()}
def filters(opts \\ []) do
{pool, opts} = resolve_pool(opts)
URP.Pool.filters(pool, opts)
Expand All @@ -153,7 +159,7 @@ defmodule URP do
def filters!(opts \\ []) do
case filters(opts) do
{:ok, v} -> v
{:error, message} -> raise message
{:error, reason} -> raise_reason(reason)
end
end

Expand All @@ -174,7 +180,7 @@ defmodule URP do
* `:pool` — named pool to use (default: the auto-started pool)
* `:timeout` — checkout timeout in ms (default `120_000`)
"""
@spec types(keyword()) :: {:ok, [String.t()]} | {:error, String.t()}
@spec types(keyword()) :: {:ok, [String.t()]} | {:error, error()}
def types(opts \\ []) do
{pool, opts} = resolve_pool(opts)
URP.Pool.types(pool, opts)
Expand All @@ -185,7 +191,7 @@ defmodule URP do
def types!(opts \\ []) do
case types(opts) do
{:ok, v} -> v
{:error, message} -> raise message
{:error, reason} -> raise_reason(reason)
end
end

Expand All @@ -204,7 +210,7 @@ defmodule URP do
* `:pool` — named pool to use (default: the auto-started pool)
* `:timeout` — checkout timeout in ms (default `120_000`)
"""
@spec locale(keyword()) :: {:ok, String.t()} | {:error, String.t()}
@spec locale(keyword()) :: {:ok, String.t()} | {:error, error()}
def locale(opts \\ []) do
{pool, opts} = resolve_pool(opts)
URP.Pool.locale(pool, opts)
Expand All @@ -215,7 +221,7 @@ defmodule URP do
def locale!(opts \\ []) do
case locale(opts) do
{:ok, v} -> v
{:error, message} -> raise message
{:error, reason} -> raise_reason(reason)
end
end

Expand Down Expand Up @@ -302,9 +308,24 @@ defmodule URP do
:ok
iex> URP.convert("/tmp/test.docx", filter: "writer_pdf_Export")
{:ok, "/tmp/fake.pdf"}

## Errors

A failure is `{:error, reason}`. `reason` is a message string when soffice
itself objected — a document it would not open, a filter it does not know —
and the atom `:gen_tcp` reported when the socket gave out: `:timeout` if
soffice stopped answering, `:closed` if it hung up, or a POSIX error. A caller
that has to tell "this document is bad" from "soffice is wedged" matches on
the shape:

case URP.convert(path, filter: "writer_pdf_Export", output: pdf) do
{:ok, ^pdf} -> :converted
{:error, reason} when is_atom(reason) -> {:unavailable, reason}
{:error, message} -> {:refused, message}
end
"""
@spec convert(binary() | {:binary, binary()} | Enumerable.t(), [opt()]) ::
{:ok, Path.t()} | {:ok, binary()} | :ok | {:error, String.t()}
{:ok, Path.t()} | {:ok, binary()} | :ok | {:error, error()}
def convert(input, opts \\ [])

def convert(input, opts) when is_binary(input) and is_list(opts) do
Expand Down Expand Up @@ -332,7 +353,7 @@ defmodule URP do
case convert(input, opts) do
{:ok, v} -> v
:ok -> :ok
{:error, message} -> raise message
{:error, reason} -> raise_reason(reason)
end
end

Expand Down Expand Up @@ -423,6 +444,11 @@ defmodule URP do
":io must be :file, :stream, or {:file | :stream, :file | :stream}; got: #{inspect(value)}"
end

# A socket failure re-raises as itself, so the reason survives into the message.
@spec raise_reason(error()) :: no_return()
defp raise_reason(reason) when is_binary(reason), do: raise(reason)
defp raise_reason(reason), do: raise(URP.SocketError, reason: reason)

defp validate_timeout!(_name, :infinity), do: :ok
defp validate_timeout!(_name, value) when is_integer(value) and value >= 0, do: :ok

Expand Down
9 changes: 8 additions & 1 deletion lib/urp/bridge.ex
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ defmodule URP.Bridge do
recv_timeout: timeout(),
max_frame_size: pos_integer(),
reply: term(),
error: String.t() | nil,
error: String.t() | atom() | nil,
tid_cache: map(),
oid_cache: map(),
private: map()
Expand Down Expand Up @@ -589,6 +589,7 @@ defmodule URP.Bridge do
{:error, message} -> %{conn | error: message}
end
rescue
e in URP.SocketError -> %{conn | error: e.reason}
error -> %{conn | error: "input stream failed: #{Exception.message(error)}"}
end
end
Expand Down Expand Up @@ -645,6 +646,10 @@ defmodule URP.Bridge do
%{conn | reply: result}
end
rescue
e in URP.SocketError ->
remove_partial_sink(sink)
%{conn | error: e.reason, reply: nil}

error ->
remove_partial_sink(sink)
%{conn | error: "output stream failed: #{Exception.message(error)}", reply: nil}
Expand Down Expand Up @@ -787,6 +792,7 @@ defmodule URP.Bridge do
P.send_frame(conn.sock, frame)
conn
rescue
e in URP.SocketError -> %{conn | error: e.reason}
e -> %{conn | error: Exception.message(e)}
end

Expand All @@ -813,6 +819,7 @@ defmodule URP.Bridge do
end
end
rescue
e in URP.SocketError -> %{conn | error: e.reason}
e -> %{conn | error: Exception.message(e)}
end

Expand Down
16 changes: 8 additions & 8 deletions lib/urp/pool.ex
Original file line number Diff line number Diff line change
Expand Up @@ -42,23 +42,23 @@ defmodule URP.Pool do
end

@doc false
@spec version(NimblePool.pool(), keyword()) :: {:ok, String.t()} | {:error, String.t()}
@spec version(NimblePool.pool(), keyword()) :: {:ok, String.t()} | {:error, URP.error()}
def version(pool, opts \\ []), do: query(pool, opts, &Bridge.version/1, :version)

@doc false
@spec services(NimblePool.pool(), keyword()) :: {:ok, [String.t()]} | {:error, String.t()}
@spec services(NimblePool.pool(), keyword()) :: {:ok, [String.t()]} | {:error, URP.error()}
def services(pool, opts \\ []), do: query(pool, opts, &Bridge.services/1, :services)

@doc false
@spec filters(NimblePool.pool(), keyword()) :: {:ok, [String.t()]} | {:error, String.t()}
@spec filters(NimblePool.pool(), keyword()) :: {:ok, [String.t()]} | {:error, URP.error()}
def filters(pool, opts \\ []), do: query(pool, opts, &Bridge.filters/1, :filters)

@doc false
@spec types(NimblePool.pool(), keyword()) :: {:ok, [String.t()]} | {:error, String.t()}
@spec types(NimblePool.pool(), keyword()) :: {:ok, [String.t()]} | {:error, URP.error()}
def types(pool, opts \\ []), do: query(pool, opts, &Bridge.types/1, :types)

@doc false
@spec locale(NimblePool.pool(), keyword()) :: {:ok, String.t()} | {:error, String.t()}
@spec locale(NimblePool.pool(), keyword()) :: {:ok, String.t()} | {:error, URP.error()}
def locale(pool, opts \\ []), do: query(pool, opts, &Bridge.locale/1, :locale)

defp query(pool, opts, bridge_fun, key) do
Expand All @@ -78,7 +78,7 @@ defmodule URP.Pool do

@doc false
@spec convert(NimblePool.pool(), binary() | {:binary, binary()} | Enumerable.t(), keyword()) ::
{:ok, binary()} | :ok | {:error, String.t()}
{:ok, binary()} | :ok | {:error, URP.error()}
def convert(pool, input, opts \\ []) do
{timeout, opts} = Keyword.pop(opts, :timeout, @default_timeout)
{sink, opts} = Keyword.pop(opts, :sink)
Expand Down Expand Up @@ -123,8 +123,8 @@ defmodule URP.Pool do
end

@doc false
@spec checkout_outcome(term(), String.t() | nil, String.t() | nil, boolean()) ::
{:ok | {:ok, binary()} | {:error, String.t()}, :reuse | :discard}
@spec checkout_outcome(term(), URP.error() | nil, URP.error() | nil, boolean()) ::
{:ok | {:ok, binary()} | {:error, URP.error()}, :reuse | :discard}
def checkout_outcome(result, convert_error, error, stream_input?) do
# Stream-based input registers an XInputStream at a fixed OID cache slot.
# soffice's URP cache doesn't fully reset on reuse, producing truncated
Expand Down
22 changes: 17 additions & 5 deletions lib/urp/protocol.ex
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ defmodule URP.Protocol do
@spec send_frame(:gen_tcp.socket(), iodata()) :: :ok
def send_frame(sock, payload) do
size = IO.iodata_length(payload)
:ok = :gen_tcp.send(sock, [<<size::32, 1::32>>, payload])

case :gen_tcp.send(sock, [<<size::32, 1::32>>, payload]) do
:ok -> :ok
{:error, reason} -> raise URP.SocketError, reason: reason
end
end

@doc """
Expand All @@ -55,7 +59,7 @@ defmodule URP.Protocol do
"""
@spec recv_frame(:gen_tcp.socket(), timeout(), pos_integer()) :: binary()
def recv_frame(sock, timeout \\ @recv_timeout, max_frame_size \\ @max_frame_size) do
{:ok, <<size::32, count::32>>} = :gen_tcp.recv(sock, 8, timeout)
<<size::32, count::32>> = recv(sock, 8, timeout)

if count != 1 do
raise "URP: received block with count=#{count}, expected 1 (multi-message blocks not supported)"
Expand All @@ -75,8 +79,7 @@ defmodule URP.Protocol do
defp recv_exact(_sock, 0, _timeout), do: <<>>

defp recv_exact(sock, size, timeout) when size <= @recv_chunk_size do
{:ok, payload} = :gen_tcp.recv(sock, size, timeout)
payload
recv(sock, size, timeout)
end

defp recv_exact(sock, size, timeout) do
Expand All @@ -89,10 +92,19 @@ defmodule URP.Protocol do

defp recv_chunks(sock, remaining, timeout, acc) do
chunk_size = min(remaining, @recv_chunk_size)
{:ok, chunk} = :gen_tcp.recv(sock, chunk_size, timeout)
chunk = recv(sock, chunk_size, timeout)
recv_chunks(sock, remaining - chunk_size, timeout, [chunk | acc])
end

# A socket failure carries the reason as an atom, so callers can tell a
# soffice that went quiet from one that refused the document.
defp recv(sock, size, timeout) do
case :gen_tcp.recv(sock, size, timeout) do
{:ok, payload} -> payload
{:error, reason} -> raise URP.SocketError, reason: reason
end
end

## Request header builder

@doc """
Expand Down
27 changes: 27 additions & 0 deletions lib/urp/socket_error.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
defmodule URP.SocketError do
@moduledoc """
Raised when the socket to `soffice` fails during a call.

`reason` is the atom `:gen_tcp` reported — `:timeout`, `:closed`, or a POSIX
error. Conversion functions return that atom as-is, so a caller can tell a
`soffice` that stopped answering from a document it refused, which arrives as
a message string.
"""

defexception [:reason, :message]

@type t :: %__MODULE__{reason: :timeout | :closed | :inet.posix(), message: String.t()}

@impl true
@spec exception(keyword()) :: t()
def exception(opts) do
reason = Keyword.fetch!(opts, :reason)

%__MODULE__{reason: reason, message: describe(reason)}
end

@spec describe(atom()) :: String.t()
defp describe(:timeout), do: "soffice did not answer in time"
defp describe(:closed), do: "soffice closed the connection"
defp describe(reason), do: "socket failed: #{:inet.format_error(reason)}"
end
5 changes: 5 additions & 0 deletions test/urp/pool_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ defmodule URP.PoolTest do
{{:error, "connection closed"}, :discard}
end

test "passes a socket reason through as the atom it came in as" do
assert URP.Pool.checkout_outcome(nil, :timeout, :timeout, false) ==
{{:error, :timeout}, :discard}
end

test "reports the error when a failed conversion left stale bytes in the reply" do
assert URP.Pool.checkout_outcome(<<0x80, 0, 0, 0, 0>>, "timeout", "timeout", true) ==
{{:error, "timeout"}, :discard}
Expand Down
Loading