From e5f71aff9652d37db7bb59d6f5e025a0b74e409b Mon Sep 17 00:00:00 2001 From: Maqbool Date: Wed, 28 Oct 2026 22:01:55 +0400 Subject: [PATCH 1/2] Read a whole message off a transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The socket half of framing. decode_message/1 says :more when the buffer holds less than a message; this reads again until it does not, so nothing above this layer sees a partial read. The socket is reached through a Mysqlex.Transport behaviour rather than :gen_tcp directly. That is what makes the read loop testable: the test scripts a transport that delivers "hello" across three reads, splitting mid-header, which is exactly the case a real server produces under load and the case the 2018 code could not be made to exercise at all. Callbacks get added to the behaviour as the client needs them — recv/3 is all this slice uses. --- lib/mysqlex/client.ex | 30 ++++++++++++++++++++++++++++++ lib/mysqlex/tcp.ex | 3 +++ lib/mysqlex/transport.ex | 15 +++++++++++++++ mix.exs | 4 ++++ test/mysqlex/client_test.exs | 12 ++++++++++++ test/support/fake_transport.ex | 24 ++++++++++++++++++++++++ 6 files changed, 88 insertions(+) create mode 100644 lib/mysqlex/client.ex create mode 100644 lib/mysqlex/transport.ex create mode 100644 test/mysqlex/client_test.exs create mode 100644 test/support/fake_transport.ex diff --git a/lib/mysqlex/client.ex b/lib/mysqlex/client.ex new file mode 100644 index 0000000..3285efa --- /dev/null +++ b/lib/mysqlex/client.ex @@ -0,0 +1,30 @@ +defmodule Mysqlex.Client do + @moduledoc """ + Reads and writes packets over a `Mysqlex.Transport`. + + This is where the socket lives. Everything it hands upwards is a whole + message, so no parser above it ever sees a partial read. + """ + + alias Mysqlex.Packet + + @doc """ + Reads one message, reading again for as long as the buffer holds less than a + whole message. + + `buffer` carries over whatever a previous read left unconsumed, and the + returned `rest` is what this read left behind. + """ + def recv_message(transport, socket, buffer, timeout) do + case Packet.decode_message(buffer) do + {:ok, sequence_id, message, rest} -> + {:ok, sequence_id, message, rest} + + :more -> + case transport.recv(socket, 0, timeout) do + {:ok, data} -> recv_message(transport, socket, buffer <> data, timeout) + {:error, reason} -> {:error, reason} + end + end + end +end diff --git a/lib/mysqlex/tcp.ex b/lib/mysqlex/tcp.ex index c1bae93..eda2002 100644 --- a/lib/mysqlex/tcp.ex +++ b/lib/mysqlex/tcp.ex @@ -3,12 +3,15 @@ defmodule Mysqlex.Tcp do Thin wrapper around `:gen_tcp` for the connection's socket. """ + @behaviour Mysqlex.Transport + @socket_opts [packet: :raw, mode: :binary, active: false] def connect(host, port, opts \\ [], timeout \\ 5_000) do :gen_tcp.connect(host, port, @socket_opts ++ opts, timeout) end + @impl true def recv(socket, bytes, timeout \\ :infinity) do :gen_tcp.recv(socket, bytes, timeout) end diff --git a/lib/mysqlex/transport.ex b/lib/mysqlex/transport.ex new file mode 100644 index 0000000..9644408 --- /dev/null +++ b/lib/mysqlex/transport.ex @@ -0,0 +1,15 @@ +defmodule Mysqlex.Transport do + @moduledoc """ + The socket operations the client needs. + + Reading is expressed against this behaviour rather than `:gen_tcp` directly so + that framing and, later, the connection phase can be driven by a scripted + transport in tests. `Mysqlex.Tcp` is the real implementation. + + Callbacks are added as the client comes to need them. + """ + + @type socket :: term + + @callback recv(socket, bytes :: non_neg_integer, timeout) :: {:ok, binary} | {:error, term} +end diff --git a/mix.exs b/mix.exs index e51fc85..e2d3742 100644 --- a/mix.exs +++ b/mix.exs @@ -9,6 +9,7 @@ defmodule Mysqlex.MixProject do app: :mysqlex, version: @version, elixir: "~> 1.17", + elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, deps: deps(), name: "Mysqlex", @@ -18,6 +19,9 @@ defmodule Mysqlex.MixProject do ] end + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + # Run "mix help compile.app" to learn about applications. def application do [ diff --git a/test/mysqlex/client_test.exs b/test/mysqlex/client_test.exs new file mode 100644 index 0000000..c9b4ecf --- /dev/null +++ b/test/mysqlex/client_test.exs @@ -0,0 +1,12 @@ +defmodule Mysqlex.ClientTest do + use ExUnit.Case, async: true + + alias Mysqlex.{Client, FakeTransport} + + test "reads a message delivered across several reads" do + socket = FakeTransport.script([<<5::little-24>>, <<0, "hel">>, "lo"]) + + assert {:ok, 0, "hello", ""} = + Client.recv_message(FakeTransport, socket, "", 1_000) + end +end diff --git a/test/support/fake_transport.ex b/test/support/fake_transport.ex new file mode 100644 index 0000000..c67a0b0 --- /dev/null +++ b/test/support/fake_transport.ex @@ -0,0 +1,24 @@ +defmodule Mysqlex.FakeTransport do + @moduledoc """ + A transport that replays a scripted list of byte chunks, so framing and the + connection phase can be driven without a server. + + Each `recv/3` returns the next chunk, which lets a test deliver one packet + across several reads, or several packets in one read. + """ + + @behaviour Mysqlex.Transport + + def script(chunks) do + {:ok, agent} = Agent.start_link(fn -> chunks end) + agent + end + + @impl true + def recv(agent, _bytes, _timeout) do + Agent.get_and_update(agent, fn + [chunk | rest] -> {{:ok, chunk}, rest} + [] -> {{:error, :closed}, []} + end) + end +end From f857de87c70b6be3a85b78ca2f2eda699c4ab5f0 Mon Sep 17 00:00:00 2001 From: Maqbool Date: Wed, 28 Oct 2026 22:02:11 +0400 Subject: [PATCH 2/2] Pin that a truncated message surfaces the transport error A server that disconnects mid-message must not leave the read loop spinning. The error comes back to the caller, who decides whether the connection is still usable. --- test/mysqlex/client_test.exs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/mysqlex/client_test.exs b/test/mysqlex/client_test.exs index c9b4ecf..a6689f0 100644 --- a/test/mysqlex/client_test.exs +++ b/test/mysqlex/client_test.exs @@ -9,4 +9,10 @@ defmodule Mysqlex.ClientTest do assert {:ok, 0, "hello", ""} = Client.recv_message(FakeTransport, socket, "", 1_000) end + + test "propagates a transport error when the message never completes" do + socket = FakeTransport.script([<<5::little-24>>, <<0, "hel">>]) + + assert {:error, :closed} = Client.recv_message(FakeTransport, socket, "", 1_000) + end end