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
2 changes: 1 addition & 1 deletion Project.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name = "MySQL"
uuid = "39abe10b-433b-5dbd-92d4-e302a9df00cd"
version = "2.1.0"
version = "2.2.0"
author = ["quinnj"]

[deps]
Expand Down
3 changes: 2 additions & 1 deletion docs/protocol-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,4 +333,5 @@ reader accepts a valid ERR in that position (`stale_err`: first packet of a fres
response, sequence 0, ERR header), regardless of its error code. The session reports the
server's error (4031 for this idle timeout) as terminal (BROKEN, transport closed), so
`reconnect=true` recovers on the following command. Every other
sequence mismatch still faults. MariaDB closes idle connections silently (peer EOF → 2006).
sequence mismatch still faults. MariaDB closes idle connections silently (peer EOF after the
next command was written → 2013).
12 changes: 9 additions & 3 deletions docs/src/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,9 +253,15 @@ represented exactly. Construct one with `Timestamp{Microsecond}(2024, 2, 29, 13,

Server errors are thrown as `MySQL.Error` (or `MySQL.StmtError` from prepared-statement
operations) with `errno`, `msg`, and `sqlstate` fields. A connection the server dropped
reports 4031 when MySQL supplies an idle-disconnect ERR, or the classic client codes
2006 ("MySQL server has gone away") and 2013 ("Lost connection to MySQL server during query")
when the transport closes. Protocol and server exceptions derive from
reports 4031 when MySQL supplies an idle-disconnect ERR. Otherwise a lost connection reports
a classic client code that tells whether the command reached the server: 2006 ("MySQL server
has gone away") when it was not sent (the connection was already closed or broken, or
writing the command failed), and 2013 ("Lost connection to MySQL server during query") once
it was sent; the server may then have executed and committed it although no answer arrived.
A 2006 covers only the command that raised it: a transaction open on the lost connection is
gone, so re-sending that command elsewhere is safe only if it did not depend on the lost
transaction or session state. A `TimeoutError` while waiting for an answer also leaves the
outcome unknown. Protocol and server exceptions derive from
`MySQL.MySQLError`: besides the server errors there are `ProtocolError` (the byte stream
violated the protocol or a limit; the connection is closed), `TimeoutError`, `AuthError`,
`TLSNegotiationError`, `ConversionError` (a value cannot be represented by the column's
Expand Down
2 changes: 1 addition & 1 deletion docs/src/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ Deliberate, documented changes relative to Connector/C 1.6.0:
| `connect_timeout` | C socket timeout with platform-dependent meaning | one monotonic establishment deadline spanning dial, greeting, TLS, the whole auth exchange, and the charset bootstrap |
| `read_timeout` / `write_timeout` | `MYSQL_OPT_READ_TIMEOUT`/`MYSQL_OPT_WRITE_TIMEOUT`: per socket operation | same meaning: re-armed before every transport read/write, so a slowly-consumed streaming result never expires while the server keeps answering; expiry closes the connection |
| `reconnect` | C auto-reconnect | the same contract, made explicit: the command that hits a dead connection reports it (`MySQL.Error` 2006/2013), and the *next* command reconnects — only before a send, never mid-command, never inside a transaction; a reconnect that itself fails leaves the connection retryable, not closed |
| Connection loss | `API.Error` 2006 "server has gone away" / 2013 "lost connection during query" | the same `MySQL.Error` codes: 2006 when the server dropped the connection instead of answering, 2013 mid-response; a MySQL 8.0.24+ server that reaps an idle connection (`wait_timeout`) announces it, and that announcement is reported as `MySQL.Error` 4031; the connection is closed until `reconnect` or a new connect |
| Connection loss | `API.Error` 2006 "server has gone away" / 2013 "lost connection during query" | the same `MySQL.Error` codes and meaning: 2006 when the command was not sent (the connection was already known dead, or the write failed), 2013 once it was sent, whether or not any answer arrived (the server may have executed it); a MySQL 8.0.24+ server that reaps an idle connection (`wait_timeout`) announces it, and that announcement is reported as `MySQL.Error` 4031; the connection is closed until `reconnect` or a new connect |
| `executemultiple` | first-OK result yielded nothing; later results mutated one cursor (stale `lookup`, aliased metadata) | every result (DML/OK included) is a **distinct cursor** with immutable metadata and its own OK snapshot; advancing past an unconsumed streaming result drains and invalidates it |
| `lastrowid` | read live connection/statement state (sticky) | snapshot from the cursor's own OK/terminator (a SELECT cursor reports 0) |
| DML cursor `length` | `-1` (the C client's sentinel; `collect` threw) | a result-less cursor has `length` 0 and iterates empty; **buffered SELECT cursors report the row count**; the outcome is in `rows_affected`/`lastrowid` |
Expand Down
2 changes: 1 addition & 1 deletion src/Protocol/errors.jl
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Root of the native backend's exception hierarchy.
- `AuthError` / `UnsupportedAuthError`: authentication policy or plugin problems
- `TimeoutError`: a deadline expired
- `ConversionError`: a wire value cannot be represented by the requested Julia type
- `TLSNegotiationError`: the TLS handshake failed while establishing the connection
- `TLSNegotiationError`: TLS could not be set up, or the TLS connection failed or closed before authentication completed
- `LocalInfileRefused`: the LOCAL INFILE handler declined a server request
"""
abstract type MySQLError <: Exception end
Expand Down
13 changes: 3 additions & 10 deletions src/Protocol/packets.jl
Original file line number Diff line number Diff line change
Expand Up @@ -41,25 +41,22 @@ command phase (`readbuf[readpos:readlim]` holds bytes already taken from the tra
the per-operation timeouts (`read_timeout_ns`/`write_timeout_ns`, 0 = none): every transport
read or write re-arms its deadline `timeout` from now, like Connector/C's
`MYSQL_OPT_READ_TIMEOUT`/`MYSQL_OPT_WRITE_TIMEOUT`, so a slowly consumed streaming result
never expires while the server keeps answering. `received_bytes` counts the raw bytes taken
from the transport since the last `newcommand!` (partial packets included), so a peer EOF
can be told apart as "no answer at all" (2006) or "answer cut short" (2013).
never expires while the server keeps answering.
"""
mutable struct PacketIO
seq::UInt8
inbuf::Vector{UInt8}
header::Vector{UInt8}
outbuf::Vector{UInt8}
response_bytes::UInt64
received_bytes::Int
readbuf::Vector{UInt8}
readpos::Int
readlim::Int
read_timeout_ns::Int64
write_timeout_ns::Int64
end

PacketIO() = return PacketIO(0x00, UInt8[], zeros(UInt8, PACKET_HEADER_LEN), UInt8[], 0x0000000000000000, 0, Vector{UInt8}(undef, READBUF_SIZE), 1, 0, 0, 0)
PacketIO() = return PacketIO(0x00, UInt8[], zeros(UInt8, PACKET_HEADER_LEN), UInt8[], 0x0000000000000000, Vector{UInt8}(undef, READBUF_SIZE), 1, 0, 0, 0)

buffered_bytes_available(io::PacketIO) = return io.readlim - io.readpos + 1

Expand Down Expand Up @@ -89,21 +86,18 @@ function fill_readbuf!(io::PacketIO, transport::Transport, needed::Int)
arm_read_deadline!(io, transport)
got = transport_read_some!(transport, io.readbuf, total + 1, length(io.readbuf) - total)
got == 0 && throw(EOFError())
io.received_bytes += got
total += got
end
io.readlim = total
return nothing
end

# Count each partial read before the next read can throw. Exact unsafe_read cannot report
# how many bytes it consumed before EOF (including a cut-short first packet header).
# Reads exactly `n` bytes, re-arming the read deadline before every partial transport read.
function packet_read_exact!(io::PacketIO, transport::Transport, dest::Vector{UInt8}, offset::Int, n::Int)
while n > 0
arm_read_deadline!(io, transport)
got = transport_read_some!(transport, dest, offset, n)
got == 0 && throw(EOFError())
io.received_bytes += got
offset += got
n -= got
end
Expand Down Expand Up @@ -144,7 +138,6 @@ end
function newcommand!(io::PacketIO)
io.seq = 0x00
io.response_bytes = 0
io.received_bytes = 0
return nothing
end

Expand Down
24 changes: 16 additions & 8 deletions src/Protocol/session.jl
Original file line number Diff line number Diff line change
Expand Up @@ -83,21 +83,29 @@ end
fault!(s, err) -> Exception

Marks the session `BROKEN`, closes the transport, and returns the exception the caller
should throw: deadlines become `TimeoutError`; a peer EOF in the command phase becomes the
classic client error — `Error(2006)` "MySQL server has gone away" when the server closed
the connection instead of answering a command (an idle connection reaped by `wait_timeout`,
a restart), `Error(2013)` "Lost connection to MySQL server during query" when it went away
mid-response — and `ProtocolError` during the connection phase; everything else (including
`InterruptException` and `ProtocolError`) is returned as is.
should throw: deadlines become `TimeoutError`; a peer EOF in the command phase (the
transport reports a reset the same way) becomes the classic client error, chosen by whether
the command was written — `Error(2006)` "MySQL server has gone away" when writing it failed
(phase `READY`: the server never received the whole command), `Error(2013)` "Lost
connection to MySQL server during query" once it was written (the server may have executed
and committed it, even if no answer byte arrived). During connection setup it becomes
`ProtocolError` over plaintext or `TLSNegotiationError` over TLS; everything else
(including `InterruptException` and `ProtocolError`) is returned as is.
"""
@inline function fault!(s::Session, err)
phase = s.phase
is_terminal(s.phase) || transition!(s, :fault, BROKEN)
transport_close(s.transport)
is_deadline_error(err) && return TimeoutError("deadline expired while waiting for the server (phase $phase); the connection has been closed")
if err isa EOFError || (err isa Reseau.TLS.TLSError && err.cause isa EOFError)
s.authenticated || return ProtocolError("connection closed by the server in the middle of the protocol stream")
((phase == READY || phase == CMD_SENT) && s.io.received_bytes == 0) && return Error(CR_SERVER_GONE_ERROR, "MySQL server has gone away", "HY000")
if !s.authenticated
# A TLS certificate rejection may close the socket before its alert arrives.
# Inference does not narrow `err` through the `||` above; `::Exception` keeps the
# call static, which `--trim=safe` requires on Julia 1.12.
s.transport isa Reseau.TLS.Conn && return TLSNegotiationError("TLS connection closed before authentication completed", err::Exception)
return ProtocolError("connection closed by the server in the middle of the protocol stream")
end
phase == READY && return Error(CR_SERVER_GONE_ERROR, "MySQL server has gone away", "HY000")
return Error(CR_SERVER_LOST, "Lost connection to MySQL server during query", "HY000")
end
# A TLS 1.3 server may reject the session (e.g. a missing client certificate) on the
Expand Down
24 changes: 22 additions & 2 deletions src/Protocol/transport.jl
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,22 @@ end
supports_buffered_reads(::Union{Reseau.TCP.Conn, Reseau.TLS.Conn}) = return true
supports_buffered_reads(::FaultTransport) = return false

# A socket error on an open connection (a reset or broken pipe; under TLS the cause of a
# `TLSError`) means the peer is gone. It is reported as EOF, with the socket error kept as
# the cause, so the session classifies it like any other lost connection. `@inline` keeps
# the callers' catch blocks free of a dynamic `::Any`-argument call under `--trim=safe`.
@inline function throw_lost_peer(err)
(err isa SystemError || (err isa Reseau.TLS.TLSError && err.cause isa SystemError)) && throw(EOFError())
rethrow()
end

# Reads 1..n available bytes into `buf[offset:end]` (one transport read); 0 means EOF.
function transport_read_some!(t::Union{Reseau.TCP.Conn, Reseau.TLS.Conn}, buf::Vector{UInt8}, offset::Int, n::Int)
return Base.readbytes!(t, view(buf, offset:lastindex(buf)), n; all=false)
try
return Base.readbytes!(t, view(buf, offset:lastindex(buf)), n; all=false)
catch err
throw_lost_peer(err)
end
end

function transport_read_some!(t::FaultTransport, buf::Vector{UInt8}, offset::Int, n::Int)
Expand All @@ -115,7 +128,14 @@ function transport_read_some!(t::FaultTransport, buf::Vector{UInt8}, offset::Int
return got
end

@inline transport_write(t::Transport, bytes::Vector{UInt8}) = return (write(t, bytes); nothing)
@inline function transport_write(t::Transport, bytes::Vector{UInt8})
try
write(t, bytes)
catch err
throw_lost_peer(err)
end
return nothing
end

transport_isopen(t::Transport) = return isopen(t)

Expand Down
14 changes: 14 additions & 0 deletions test/protocol/binary_tests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -1284,3 +1284,17 @@ end
@test MySQL.load(ReusedLoadBytes(UInt8[0], 3), conn, "bytes") == "`bytes`"
end
end

@testset "prepared connection loss after EXECUTE was written (#248)" begin
# PREPARE is answered; EXECUTE is written and never answered, so the row may exist: 2013.
with_server(conn -> plain_peer_connect!(conn; caps=MYSQL8_SERVER_CAPS & ~P.CLIENT_SSL, after=c -> begin
expect_prepare(c); send_prepare_ok(c, 1, 7, paramdefs(1), P.ColumnDef[])
expect_execute(c)
end)) do port
conn = DBInterface.connect(N.Connection, "127.0.0.1", "root", "pw"; port=port, ssl_mode=:disabled, connect_timeout=10)
err = try; DBInterface.execute(conn, "INSERT INTO t VALUES (?)", (1,)); nothing; catch e; e; end
@test err isa MySQL.Error && err.errno == P.CR_SERVER_LOST
@test !isopen(conn)
DBInterface.close!(conn)
end
end
41 changes: 38 additions & 3 deletions test/protocol/cursor_tests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -952,10 +952,10 @@ end
@test (@atomic accepted.value) == 2
conn.handle.session.status = P.SERVER_STATUS_AUTOCOMMIT
conn.handle.session.phase = P.READY
# the server drops the connection instead of answering: the classic 2006, the session
# is BROKEN, and the *next* command reconnects (the libmysqlclient contract)
# the server drops the connection after the command was written, without answering:
# 2013 (it may have run), the session is BROKEN, and the *next* command reconnects
err = try; DBInterface.execute(conn, "peer hangs up"); nothing; catch e; e; end
@test err isa P.Error && err.errno == P.CR_SERVER_GONE_ERROR
@test err isa P.Error && err.errno == P.CR_SERVER_LOST
@test !isopen(conn) && conn.handle.session.phase == P.BROKEN
@test DBInterface.execute(conn, "after broken").rows_affected == 0
@test (@atomic accepted.value) == 3 && isopen(conn)
Expand All @@ -965,6 +965,41 @@ end
end
end

@testset "connection loss tells a sent command from a refused one (#248)" begin
# The peer reads the command and hangs up without answering: the server may have run it
# (2013). The next command is refused before anything is written (2006).
received = String[]
with_server(conn -> plain_peer_connect!(conn; caps=MYSQL8_SERVER_CAPS & ~P.CLIENT_SSL, after=c -> push!(received, expect_query(c)))) do port
conn = DBInterface.connect(N.Connection, "127.0.0.1", "root", "pw"; port=port, ssl_mode=:disabled, connect_timeout=10)
err = try; DBInterface.execute(conn, "INSERT 1"); nothing; catch e; e; end
@test err isa MySQL.Error && err.errno == P.CR_SERVER_LOST && err.sqlstate == "HY000"
@test !isopen(conn)
err = try; DBInterface.execute(conn, "INSERT 2"); nothing; catch e; e; end
@test err isa MySQL.Error && err.errno == P.CR_SERVER_GONE_ERROR && err.sqlstate == "HY000"
DBInterface.close!(conn)
end
@test received == ["INSERT 1"]
end

@testset "TLS connection reset after a command was written" begin
for version in ("TLSv1.2", "TLSv1.3")
with_server(raw -> tls_peer_connect!(raw; after=tls -> begin
_, command, sql = read_command(tls)
@test command == P.COM_QUERY && String(sql) == "INSERT 1"
Reseau.TCP.set_linger!(raw, 0)
close(raw)
end)) do port
conn = DBInterface.connect(N.Connection, "127.0.0.1", "root", "pw"; port=port, ssl_mode=:required, tls_version=version, connect_timeout=10)
err = try; DBInterface.execute(conn, "INSERT 1"); nothing; catch e; e; end
@test err isa MySQL.Error && err.errno == P.CR_SERVER_LOST && err.sqlstate == "HY000"
@test !isopen(conn)
err = try; DBInterface.execute(conn, "INSERT 2"); nothing; catch e; e; end
@test err isa MySQL.Error && err.errno == P.CR_SERVER_GONE_ERROR
DBInterface.close!(conn)
end
end
end

@testset "a failed reconnect leaves the connection retryable, not closed" begin
# When the dead session cannot be re-established, ensure_live! keeps the old (closed)
# handle so the next command retries the reconnect, instead of reporting the connection
Expand Down
5 changes: 3 additions & 2 deletions test/protocol/live_tests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,8 @@ function run_live_lane(ref::String; soak::Bool=false, manifest::Bool=false)
end
@test !isopen(conn)
# an idle connection reaped by wait_timeout: MySQL 8.0.24+ announces it (4031),
# MariaDB just closes (2006); either way the next command reconnects
# MariaDB just closes (2013: the command was already written); either way the next
# command reconnects
conn = DBInterface.connect(MySQL.Connection, "127.0.0.1", "root", ROOT_PW; port=port, reconnect=true)
stmt = nothing
try
Expand All @@ -158,7 +159,7 @@ function run_live_lane(ref::String; soak::Bool=false, manifest::Bool=false)
N.send_long_data!(stmt, 0, UInt8[0xff])
sleep(2.5)
err = try; DBInterface.execute(conn, "SELECT 1"); nothing; catch e; e; end
@test err isa MySQL.Error && err.errno in (4031, P.CR_SERVER_GONE_ERROR)
@test err isa MySQL.Error && err.errno in (4031, P.CR_SERVER_LOST)
@test !isopen(conn)
# The next execute re-prepares on the new session and replays copied
# long-data chunks. No statement execute was attempted on the dead one.
Expand Down
Loading
Loading