From 8d5e4ef750eea41a127d1931f47bd9166d873666 Mon Sep 17 00:00:00 2001 From: Kevin Koltz Date: Thu, 28 May 2026 11:57:01 -0500 Subject: [PATCH 1/2] Surface MSSQL token detail when the connection closes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SQL Server frequently sends an error token immediately before closing the connection (login failure, fatal severity, kill by DBA). The token was parsed and surfaced to the caller of the failing statement, then dropped when the socket closed — the disconnect error reported only "Connection closed." / "tcp closed" / "Connection failed to receive packet due :closed", forcing operators to dig through server-side logs to learn the actual reason. This change carries the most recent token on the protocol state (`last_mssql_error`, captured in the existing `msg_error` handler and cleared on `checkout/1`) and uses it when building the disconnect error at the five masking sites in `protocol.ex` (`ping/1`, the two `handle_info` clauses for `:tcp_closed` / `:ssl_closed` / `:tcp_error` / `:ssl_error`, the `flush/1` receive, and the `msg_recv/1` socket read-error path). The disconnect-vs-stop return semantics are unchanged, so DBConnection's pool-managed reconnect continues to work exactly as before. `Tds.Error.message/1` gains a head that combines `:message` and `:mssql` when both are populated, so existing callers that pattern-match on either field individually keep working while connection-close errors now surface the underlying server reason. The `error_details` typespec is broadened to reflect the full set of fields the token stream decodes in `Tokens.decode_error/2`. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 4 ++++ lib/tds/error.ex | 28 +++++++++++++++++++++++----- lib/tds/protocol.ex | 32 +++++++++++++++++++++----------- test/error_test.exs | 10 ++++++++++ 4 files changed, 58 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2098ba0..2739e77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased +### Improvements +* Surface the last MSSQL error token alongside connection-close errors. When the server sends an error token immediately before closing the connection (login failure, fatal severity, kill-by-DBA), the token detail is preserved on the protocol state and combined with the transport-level reason in the resulting `Tds.Error`, so callers no longer see only "Connection closed." / "tcp closed". + ## v2.3.8 (2026-05-18) ### Improvements * Relax decimal dependency diff --git a/lib/tds/error.ex b/lib/tds/error.ex index 0125fa7..331e6c3 100644 --- a/lib/tds/error.ex +++ b/lib/tds/error.ex @@ -5,8 +5,13 @@ defmodule Tds.Error do The struct has two fields: * `:message`: expected to be a string - * `:mssql`: expected to be a keyword list with the fields `line_number`, - `number` and `msg_text` + * `:mssql`: expected to be a map carrying the fields decoded from a + SQL Server error token: `number`, `state`, `class`, `msg_text`, + `server_name`, `proc_name`, and `line_number` + + When both fields are populated — for example, when a connection-close is + preceded by a server-side error token — `message/1` combines them so the + underlying server error is surfaced alongside the transport-level reason. ## Usage @@ -21,8 +26,16 @@ defmodule Tds.Error do """ - @type error_details :: %{line_number: integer(), number: integer(), msg_text: String.t()} - @type t :: %__MODULE__{message: String.t(), mssql: error_details} + @type error_details :: %{ + optional(:state) => integer(), + optional(:class) => integer(), + optional(:server_name) => String.t(), + optional(:proc_name) => String.t(), + required(:line_number) => integer(), + required(:number) => integer(), + required(:msg_text) => String.t() + } + @type t :: %__MODULE__{message: String.t() | nil, mssql: error_details | nil} defexception [:message, :mssql] @@ -45,8 +58,13 @@ defmodule Tds.Error do end @spec message(%__MODULE__{}) :: String.t() + def message(%__MODULE__{mssql: mssql, message: message}) + when is_map(mssql) and is_binary(message) do + "#{message} Line #{mssql.line_number} (Error #{mssql.number}): #{mssql.msg_text}" + end + def message(%__MODULE__{mssql: mssql}) when is_map(mssql) do - "Line #{mssql[:line_number]} (Error #{mssql[:number]}): #{mssql[:msg_text]}" + "Line #{mssql.line_number} (Error #{mssql.number}): #{mssql.msg_text}" end def message(%__MODULE__{message: message}) when is_binary(message) do diff --git a/lib/tds/protocol.ex b/lib/tds/protocol.ex index 2fc918e..ef4a8a8 100644 --- a/lib/tds/protocol.ex +++ b/lib/tds/protocol.ex @@ -42,7 +42,8 @@ defmodule Tds.Protocol do result: nil | list(), query: nil | String.t(), transaction: transaction, - env: env + env: env, + last_mssql_error: nil | Tds.Error.error_details() } defstruct sock: nil, @@ -59,7 +60,8 @@ defmodule Tds.Protocol do savepoint: 0, collation: %Tds.Protocol.Collation{}, packetsize: 4096 - } + }, + last_mssql_error: nil @spec connect(opts :: Keyword.t()) :: {:ok, state :: t()} | {:error, Exception.t()} def connect(opts) do @@ -100,7 +102,7 @@ defmodule Tds.Protocol do {:ok, s} {:disconnect, :closed, s} -> - {:disconnect, %Tds.Error{message: "Connection closed."}, s} + {:disconnect, disconnect_error("Connection closed.", s), s} {:error, err, s} -> err = @@ -126,6 +128,7 @@ defmodule Tds.Protocol do def checkout(%{sock: {mod, _sock}} = s) do sock_mod = inspect(mod) + s = %{s | last_mssql_error: nil} case setopts(s.sock, active: false) do :ok -> @@ -460,11 +463,11 @@ defmodule Tds.Protocol do end def handle_info({tag, _}, s) when tag in [:tcp_closed, :ssl_closed] do - {:stop, Tds.Error.exception("tcp closed"), s} + {:stop, disconnect_error("tcp closed", s), s} end def handle_info({tag, _, reason}, s) when tag in [:tcp_error, :ssl_error] do - {:stop, Tds.Error.exception("tcp error: #{reason}"), s} + {:stop, disconnect_error("tcp error: #{reason}", s), s} end def handle_info(msg, s) do @@ -505,10 +508,10 @@ defmodule Tds.Protocol do {:ok, s} {:tcp_closed, ^sock} -> - {:disconnect, %Tds.Error{message: "tcp closed"}, s} + {:disconnect, disconnect_error("tcp closed", s), s} {:tcp_error, ^sock, reason} -> - {:disconnect, %Tds.Error{message: "tcp error: #{reason}"}, s} + {:disconnect, disconnect_error("tcp error: #{reason}", s), s} after 0 -> # There might not be any socket messages. @@ -796,7 +799,7 @@ defmodule Tds.Protocol do ## Error def message(_, msg_error(error: e), %{} = s) do error = %Tds.Error{mssql: e} - {:error, error, mark_ready(s)} + {:error, error, mark_ready(%{s | last_mssql_error: e})} end ## ATTN Ack @@ -810,6 +813,15 @@ defmodule Tds.Protocol do %{s | state: :ready} end + @spec disconnect_error(String.t(), t()) :: Tds.Error.t() + defp disconnect_error(message, %__MODULE__{last_mssql_error: nil}) do + %Tds.Error{message: message} + end + + defp disconnect_error(message, %__MODULE__{last_mssql_error: %{} = mssql}) do + %Tds.Error{message: message, mssql: mssql} + end + # Send Command To Sql Server defp login_send(msg, %{sock: {mod, sock}, env: env, opts: opts} = s) do paks = encode_msg(msg, env) @@ -866,9 +878,7 @@ defmodule Tds.Protocol do {:error, error} -> {:disconnect, - %Tds.Error{ - message: "Connection failed to receive packet due #{inspect(error)}" - }, s} + disconnect_error("Connection failed to receive packet due #{inspect(error)}", s), s} end catch {:error, error} -> {:disconnect, error, s} diff --git a/test/error_test.exs b/test/error_test.exs index 8dceb83..9215fe8 100644 --- a/test/error_test.exs +++ b/test/error_test.exs @@ -13,6 +13,16 @@ defmodule ErrorTest do end end + test "combines :message and :mssql when both are populated" do + error = %Tds.Error{ + message: "Connection closed.", + mssql: %{line_number: 1, number: 18_456, msg_text: "Login failed for user 'sa'"} + } + + assert Tds.Error.message(error) == + "Connection closed. Line 1 (Error 18456): Login failed for user 'sa'" + end + test "raises a Tds.Error with a default message as a fallback" do # no arguments assert_raise Tds.Error, "An error occured.", fn -> From 535e95e248727ce269245c00305eb4972c1679d1 Mon Sep 17 00:00:00 2001 From: Kevin Koltz Date: Fri, 29 May 2026 10:21:47 -0500 Subject: [PATCH 2/2] fix(ci): resolve signed-by key conflict in mssql-tools install GitHub Actions runners now ship with a pre-existing /etc/apt/sources.list.d/microsoft-prod.list that references signed-by=/usr/share/keyrings/microsoft-prod.gpg. The workflow added a second source for the same repository signed by a key in /etc/apt/trusted.gpg.d, which apt rejected as a conflicting signed-by configuration (exit code 100 from apt-get update). Remove the runner-provided list and install the key into the expected keyrings location with an explicit signed-by entry to match. Same fix adopted by #184 to unblock CI on PRs against master. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/elixir.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index 60b1512..37b2d46 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -51,10 +51,11 @@ jobs: steps: - name: Install MSSql Client Tools run: | - curl https://packages.microsoft.com/keys/microsoft.asc | sudo tee /etc/apt/trusted.gpg.d/microsoft.asc - curl https://packages.microsoft.com/config/ubuntu/22.04/prod.list | sudo tee /etc/apt/sources.list.d/mssql-release.list + sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list + curl https://packages.microsoft.com/keys/microsoft.asc | sudo gpg --batch --yes --dearmor -o /usr/share/keyrings/microsoft-prod.gpg + echo "deb [signed-by=/usr/share/keyrings/microsoft-prod.gpg arch=amd64,arm64,armhf] https://packages.microsoft.com/ubuntu/22.04/prod jammy main" | sudo tee /etc/apt/sources.list.d/mssql-release.list sudo apt-get update - sudo apt-get install mssql-tools18 unixodbc-dev + sudo ACCEPT_EULA=Y apt-get install -y mssql-tools18 unixodbc-dev - uses: actions/checkout@v2