Skip to content

Distinguish sent from unsent commands on connection loss (2013 vs 2006) - #251

Merged
quinnj-bot[bot] merged 6 commits into
mainfrom
claude/issue-248-send-state
Oct 27, 2026
Merged

quinnj-bot[bot] merged 6 commits into
mainfrom
claude/issue-248-send-state

Conversation

@quinnj-bot

@quinnj-bot quinnj-bot Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #248.

What changes for users

When a connection drops, the error now says whether the command reached the server:

  • 2006 "MySQL server has gone away": the command was not sent. Either the connection
    was already closed or broken (and reconnect could not be used), or writing the command
    failed.
  • 2013 "Lost connection to MySQL server during query": the command was written. The
    server may have executed and committed it, even if no answer arrived.

Until now, a command that was written but got no answer byte back also raised 2006, the same
error as a command the driver refused without sending. A caller could not tell "never ran"
from "may have committed". This restores what the two codes meant in MySQL.jl 1.x (MariaDB
Connector/C). The 2.x migration guide already says the codes are unchanged.

Connection resets now follow the same rule. Until now they surfaced as a raw SystemError
(plaintext) or as ProtocolError("TLS transport failure …") (TLS), whether or not the
command had been sent.

No new types, fields, or exports.

What 2006 does not promise: it covers only the command that raised it. A transaction
open on the lost connection is gone, and an earlier command that failed with 2013 or a
timeout may have committed. The Errors docs now say so.

Evidence

Setup: MySQL 8.4.13 behind a proxy that drops the server's reply. Row counts were read back
over a separate connection.

Fault Rows committed Before After
INSERT written, reply dropped (FIN) 1 2006 2013
execute(conn, sql, params), EXECUTE reply dropped 1 2006 2013
DBInterface.transaction, COMMIT reply dropped 1 CompositeException[2006, 2006] CompositeException[2013, 2006]
INSERT written, connection reset (RST) 1 SystemError (read) 2013
Idle connection reset, then INSERT (the write fails) 0 SystemError (write) 2006
Next command on the dead connection 0, nothing sent 2006 2006

Against the same faults, MySQL.jl 1.6.0 reported 2013 for every loss after a successful write.
It reported 2006 for the refusal and for a failed write.

Compatibility

  • Code that treats only 2006 as "connection lost" now sees 2013 when the command was already
    written. A MySQL.ping-based pool check is one example. Such code should match 2006, 2013,
    4031 and TimeoutError.
  • The connection is still closed afterwards. The next command still raises 2006, or
    reconnects with reconnect=true.
  • During connection setup, a plaintext reset is now ProtocolError("connection closed by the server …") instead of a raw SystemError. Over TLS, a reset or close before
    authentication completes is TLSNegotiationError, the same error as a TLS alert at that
    point. A TLS reset there already gave TLSNegotiationError; a clean TLS close gave
    ProtocolError.
  • The version is bumped to 2.2.0 for this change.

Changes

  • fault! chooses 2006 vs 2013 by phase: in READY the write itself failed, otherwise the
    command was written. It no longer uses "no answer bytes". The received_bytes counter,
    which only fed the old rule, is deleted.
  • transport_read_some! and transport_write report a socket error, or a TLSError caused
    by one, as EOF and keep the original as the cause. This is done there rather than in
    fault!, because fault! also receives errors from reading a LOCAL INFILE source file.
  • Before authentication, fault! reports a TLS close or reset as TLSNegotiationError. A
    TLS 1.3 server that rejects the client certificate may reset the socket before its alert
    arrives, so the error no longer depends on which one the client sees first.
  • Docs: the Errors section, the migration guide's connection-loss row, the protocol notes,
    and the TLSNegotiationError summary.
  • Tests:
    • Updated the cases that pinned the old rule.
    • Added hermetic fake-peer tests for a command written and never answered (text, prepared,
      and COMMIT inside DBInterface.transaction): 2013, then 2006 for the refused command.
    • A peer reset after the command: 2013, over plaintext and over TLS 1.2 and 1.3. A write
      EPIPE: 2006.
    • A TLS reset before authentication (TLS 1.2 and 1.3): TLSNegotiationError.
    • A LOCAL INFILE source that throws SystemError keeps that error; it is not mistaken for
      a lost connection.
    • The live deadlock test now waits until each transaction holds its first row. Before, the
      peer could take both rows and commit, so the test could see no deadlock.

Verification

  • Final source on Julia 1.12.6/macOS: 1,794 checks passed, including the --trim=safe executable. The err::Exception assertion keeps that build static on Julia 1.12.
  • Julia 1.13.3/macOS review suite: 1,936 checks passed, including the trim executable.
  • Julia 1.10.12 with minimum supported Reseau 1.4.1: 1,787 checks passed.
  • Full local MySQL/MariaDB Docker integration after the deadlock-test repair: 2,281 checks passed. Twenty repeated transaction workflows (ten per server) passed all 640 checks.
  • Docker-backed performance/allocation gates passed during review. Windows was verified by CI.
  • The new TLS setup-reset tests fail before the correction and pass after it on TLS 1.2 and TLS 1.3. The authenticated TLS-reset tests fail on the original base and pass with the connection-loss fix.
  • All active PR CI and merge-commit CI checks passed. All active v2.2.0 tag CI checks also passed, including the Docker integration lane and the full Linux/macOS/Windows matrix.
  • The extended nightly fuzz campaign was not run for this release. Its scheduled-only CI skip is expected; deterministic fuzz smoke tests run in the normal suite.

Release

Published as MySQL v2.2.0. General registration #173773 is merged. The registered source tree and annotated tag both match merge commit ddc8df6.

Co-authored by Codex

Original implementation generated with Claude Code.

quinnj and others added 5 commits October 27, 2026 14:06
A peer EOF after a command was written was reported as 2006 "MySQL server
has gone away" whenever no answer byte had arrived, the same error the
driver raises when it refuses a command before sending anything. The server
may have executed and committed such a command. Classify the loss by
whether the command was written instead: 2006 when writing it failed
(phase READY), 2013 once it was written, as MySQL.jl 1.x (MariaDB
Connector/C) did. Remove the received-bytes counter that only fed the old
rule.

Refs #248

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A connection reset (ECONNRESET/EPIPE, or a TLSError caused by one) escaped
as a raw SystemError, or as a ProtocolError over TLS, whether or not the
command had been written. Report it as EOF from transport_read_some! and
transport_write so fault! applies the same rule: 2006 if writing the
command failed, 2013 once it was written. The socket error stays attached
as the exception's cause.

Refs #248

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The peer could update both rows and commit before the main transaction acquired its first row, so the test sometimes ran without a deadlock. Wait for both first-row locks before either transaction requests the other row. Ten transaction workflow runs on each of MySQL 8.4 and MariaDB 11.4 passed all 640 checks.
Normalizing socket errors to EOF discarded the TLS exception type before authentication. A TLS certificate rejection could therefore report ProtocolError, depending on whether its alert or socket reset arrived first. Use the session transport to classify a pre-authentication TLS close as TLSNegotiationError. Authenticated connection-loss codes are unchanged. Add deterministic TLS 1.2 and 1.3 reset tests that fail before this fix and pass after it.

@quinnj-bot quinnj-bot Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No remaining correctness findings after reviewing f939830...e2126bc (merge base f939830). All review changes were made in an isolated checkout and pushed to this PR.

Fixed during review:

  • Socket-reset normalization erased the TLS error type before authentication. A client-certificate rejection could then report ProtocolError, depending on whether its alert or reset arrived first. The session now preserves TLSNegotiationError for a TLS close before authentication completes. Deterministic TLS 1.2/1.3 tests fail before this fix and pass after it.
  • The existing live deadlock test could let the peer commit before the other transaction held its first row. Both first-row locks are now coordinated before either transaction requests the other row. Ten transaction workflow runs on each real server passed all 640 checks.

Added authenticated TLS reset coverage (2013 for the written command, then 2006 for the refused next command) and strengthened LOCAL INFILE coverage so local file SystemErrors cannot be mistaken for socket failures.

Validation: the final Julia 1.13.3/macOS suite passed 1,936 checks, including the --trim=safe executable. Julia 1.10.12 with minimum Reseau 1.4.1 passed 1,787 checks. Full MySQL/MariaDB integration and Docker-backed performance/allocation gates passed during review. All 15 active checks on final commit e2126bc are green: Julia 1.10/latest/nightly on Linux, macOS, and Windows; Docker integration; coverage; and documentation. The two schedule/manual-only jobs are correctly skipped. CI run.

Version 2.2.0 is prepared because callers that match only error 2006 need to account for 2013 once a command was written. The execution outcome after 2013 or a response timeout remains unknown; retrying can duplicate a write.

I recommend merging and releasing 2.2.0 after the required independent approval. The PR author is this app, so this review is a comment rather than the separate approval GitHub requires.

Inference does not narrow `err` through the `||` in `fault!`'s EOF branch,
so on Julia 1.12 `TLSNegotiationError(msg, err)` compiled to a dynamic call
and the `--trim=safe` verifier rejected the trim workload. Julia 1.13
union-splits the constructor's `convert`, so CI, which runs the trim build
only on the latest release, passed. `err::Exception` makes the call static
on both.

Also describe `TLSNegotiationError` as covering a TLS failure or close
before authentication completes, not only a failed handshake.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@quinnj-bot quinnj-bot Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the follow-up through 3095913. The err::Exception assertion is valid on both branches of the EOF/TLSError condition and keeps the TLS setup error constructor static on Julia 1.12. The updated error summary matches the implemented behavior. A fresh Julia 1.12.6/macOS suite passed all 1,794 checks, including the trim executable; all 15 active CI checks for this exact commit are green. No additional findings.

@quinnj quinnj left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved for merge.

@quinnj-bot
quinnj-bot Bot merged commit ddc8df6 into main Oct 27, 2026
17 checks passed
@quinnj-bot
quinnj-bot Bot deleted the claude/issue-248-send-state branch October 27, 2026 22:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Error 2006 is raised both before a command is sent and after a sent command may have committed

1 participant