Skip to main content

oracledb/
lib.rs

1// Unit-test assertions intentionally panic on invariant violations.
2#![cfg_attr(test, allow(clippy::unwrap_used))]
3
4//! A pure-Rust, thin-mode driver for Oracle Database.
5//!
6//! `oracledb` speaks the Oracle TNS/TTC wire protocol directly over TCP. It
7//! needs no Oracle Instant Client, no OCI libraries, and no C toolchain: add
8//! the crate, point it at a listener, and connect. The driver is a faithful
9//! port of the python-oracledb thin client, so its behavior tracks that
10//! reference implementation.
11//!
12//! # Why thin mode
13//!
14//! A traditional OCI-based client links a large native library and inherits
15//! whatever identity the OS hands it. Because this driver builds every packet
16//! itself, the application controls the full connection envelope, including the
17//! session identity the database records.
18//!
19//! # Caller-set identity (the differentiator)
20//!
21//! Every connection carries a [`ClientIdentity`] the
22//! caller supplies: `program`, `machine`, `osuser`, and `terminal`. The
23//! database stores those exact values in `v$session`. An OCI client reports the
24//! host process and OS user it happens to run as; here the application decides.
25//! This is invaluable for multi-tenant services and connection multiplexers
26//! that need each logical user attributed correctly in the DBA's session views,
27//! audit trail, and resource-manager rules.
28//!
29//! ```no_run
30//! use oracledb::{BlockingConnection, ConnectOptions};
31//! use oracledb::protocol::ClientIdentity;
32//!
33//! # fn main() -> Result<(), oracledb::Error> {
34//! // The identity the database will record for this session.
35//! let identity = ClientIdentity::new(
36//!     "billing-worker", // program
37//!     "edge-pod-7",     // machine
38//!     "tenant-42",      // osuser
39//!     "shard-a",        // terminal
40//!     "rust-oracledb",  // driver name
41//! )?;
42//!
43//! let options = ConnectOptions::new(
44//!     "dbhost:1521/FREEPDB1", // EasyConnect string
45//!     "app_user",
46//!     "app_password",
47//!     identity,
48//! );
49//!
50//! let mut conn = BlockingConnection::connect(options)?;
51//!
52//! // Bind parameters positionally (:1, :2, ...) as a tuple.
53//! let row = BlockingConnection::query_one(
54//!     &mut conn,
55//!     "select :1 + :2 from dual",
56//!     (40, 2),
57//! )?;
58//!
59//! // Typed column access converts the Oracle NUMBER straight into an integer.
60//! let sum: i64 = row.get(0)?;
61//! assert_eq!(sum, 42);
62//!
63//! BlockingConnection::close(conn)?;
64//! # Ok(())
65//! # }
66//! ```
67//!
68//! # Choosing an API surface
69//!
70//! Two equivalent surfaces are exposed:
71//!
72//! - [`BlockingConnection`] runs each operation on a private single-threaded
73//!   runtime and blocks the calling thread. Use it from synchronous code; it is
74//!   the simplest way to use the driver as a library.
75//! - [`Connection`] is the native asynchronous API. Every method takes an
76//!   `&Cx` (the Asupersync request context) so the work participates in
77//!   structured concurrency and cancellation. Use it inside an Asupersync
78//!   runtime.
79//!
80//! `BlockingConnection` is a thin shim over `Connection`: it owns a
81//! [`Connection`] and drives the async methods to completion.
82//!
83//! # Working with values
84//!
85//! Fetched cells are [`QueryValue`](protocol::thin::QueryValue), a sum type
86//! over every Oracle scalar (NUMBER carried as lossless text, VARCHAR2, DATE /
87//! TIMESTAMP, RAW, ROWID, BOOLEAN, BINARY_DOUBLE, VECTOR, JSON, LOB locators,
88//! object images, ...). Convenience accessors
89//! ([`as_i64`](protocol::thin::QueryValue::as_i64),
90//! [`as_text`](protocol::thin::QueryValue::as_text),
91//! [`as_f64`](protocol::thin::QueryValue::as_f64), and friends) and
92//! [`QueryResult::cell`](protocol::thin::QueryResult::cell) cover the common
93//! cases without an explicit `match`.
94//!
95//! Columns that stream their value through a client-side define (`CLOB`,
96//! `BLOB`, `VECTOR`, native `JSON`) come back from a plain
97//! [`Connection::execute_raw`] as describe-only metadata with a `None` cell,
98//! matching the wire protocol. Use
99//! [`Connection::define_and_fetch_rows_with_columns`] after opening the cursor
100//! when you need the first batch materialized explicitly.
101//!
102//! # Optional features
103//!
104//! - `arrow`: fetch result sets directly into Apache Arrow `RecordBatch`es via
105//!   `Connection::fetch_all_record_batch` and
106//!   `Connection::fetch_record_batches`.
107//!
108//! # Connection pooling
109//!
110//! The [`pool`] module provides async [`Pool`](pool::Pool) and
111//! [`BlockingPool`](pool::BlockingPool) facades that mirror python-oracledb's
112//! thin pool: free/busy lists, growth planning, getmode semantics, ping policy,
113//! idle timeout, and max lifetime. The pool is generic over a
114//! [`PoolBackend`](pool::PoolBackend) so the embedder supplies how a pooled
115//! connection is created, pinged, and closed.
116#![forbid(unsafe_code)]
117
118use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
119use std::future::Future;
120use std::num::NonZeroU32;
121use std::process;
122use std::sync::Arc;
123use std::time::Duration;
124
125use asupersync::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
126use asupersync::net::TcpStream;
127use asupersync::runtime::{reactor, Runtime, RuntimeBuilder};
128use asupersync::sync::Mutex as AsyncMutex;
129use asupersync::{time, Cx};
130use oracledb_protocol::thin::aq::{
131    build_aq_array_deq_payload, build_aq_array_enq_payload, build_aq_deq_payload,
132    build_aq_enq_payload, parse_aq_array_response_with_limits, parse_aq_deq_response_with_limits,
133    parse_aq_enq_response_with_limits, AqArrayResult, AqDeqOptions, AqDeqResult, AqEnqOptions,
134    AqMsgProps, AqQueueDesc,
135};
136use oracledb_protocol::thin::{
137    adjust_refetch_metadata, build_auth_phase_one_payload,
138    build_auth_phase_two_payload_with_proxy_with_seq,
139    build_auth_phase_two_token_payload_with_pop_and_proxy, build_begin_pipeline_piggyback,
140    build_change_password_payload_with_seq, build_connect_packet_payload, build_data_types_payload,
141    build_define_fetch_payload_with_seq, build_end_pipeline_payload_with_seq,
142    build_execute_payload_with_bind_rows_and_options_with_seq, build_fast_auth_phase_one_payload,
143    build_fast_auth_token_payload_with_pop_and_proxy, build_fetch_payload_with_seq,
144    build_function_payload_with_seq, build_function_payload_with_seq_and_token,
145    build_lob_create_temp_payload_with_seq, build_lob_free_temp_payload_with_seq,
146    build_lob_read_payload_with_seq, build_lob_trim_payload_with_seq,
147    build_lob_write_payload_with_seq, build_protocol_negotiation_payload,
148    classic_connect_response_is_complete, connect_data_fits_inline, parse_accept_payload,
149    parse_auth_response_with_limits, parse_define_fetch_response_borrowed_with_limits,
150    parse_define_fetch_response_with_context_and_limits,
151    parse_fetch_response_with_context_and_limits, parse_lob_create_temp_response_with_limits,
152    parse_lob_free_temp_response_with_limits, parse_lob_read_response_with_limits,
153    parse_lob_trim_response_with_limits, parse_lob_write_response_with_limits,
154    parse_plain_function_response_with_limits, parse_query_response_borrowed_with_limits,
155    parse_query_response_with_binds_options_columns_and_limits,
156    parse_tpc_txn_switch_response_with_limits, AuthResponse, BindValue, BorrowedFetchResult,
157    ClientCapabilities, ColumnMetadata, CursorValue, ExecuteOptions, LobReadResult, QueryResult,
158    QueryValueRef, SessionlessTxnState, TokenPop, TpcChangeStateResponse, TpcSwitchResponse,
159    TpcXid, TNS_DATA_FLAGS_BEGIN_PIPELINE, TNS_DATA_FLAGS_END_OF_REQUEST, TNS_FUNC_COMMIT,
160    TNS_FUNC_LOGOFF, TNS_FUNC_PING, TNS_FUNC_ROLLBACK, TNS_MSG_TYPE_END_OF_RESPONSE,
161    TNS_MSG_TYPE_FLUSH_OUT_BINDS, TNS_PACKET_FLAG_REDIRECT, TNS_PACKET_TYPE_ACCEPT,
162    TNS_PACKET_TYPE_CONNECT, TNS_PACKET_TYPE_DATA, TNS_PACKET_TYPE_REDIRECT,
163    TNS_PACKET_TYPE_REFUSE, TNS_PACKET_TYPE_RESEND, TNS_PIPELINE_MODE_ABORT_ON_ERROR,
164    TNS_PIPELINE_MODE_CONTINUE_ON_ERROR, TNS_TPC_TXN_ABORT, TNS_TPC_TXN_COMMIT, TNS_TPC_TXN_DETACH,
165    TNS_TPC_TXN_POST_DETACH, TNS_TPC_TXN_PREPARE, TNS_TPC_TXN_START, TNS_TPC_TXN_STATE_ABORTED,
166    TNS_TPC_TXN_STATE_COMMITTED, TNS_TPC_TXN_STATE_FORGOTTEN, TNS_TPC_TXN_STATE_PREPARE,
167    TNS_TPC_TXN_STATE_READ_ONLY, TNS_TPC_TXN_STATE_REQUIRES_COMMIT, TPC_TXN_FLAGS_NEW,
168    TPC_TXN_FLAGS_RESUME, TPC_TXN_FLAGS_SESSIONLESS,
169};
170use oracledb_protocol::thin::{
171    build_notify_payload_with_seq, build_subscribe_payload_with_seq,
172    check_notification_header_with_limits, parse_subscribe_response_with_limits,
173    try_parse_oac_record_with_limits, NotificationRecord, SubscribeResult, TNS_SUBSCR_OP_REGISTER,
174    TNS_SUBSCR_OP_UNREGISTER,
175};
176use oracledb_protocol::thin::{
177    build_sessionless_piggyback, build_tpc_change_state_payload_with_seq_and_version,
178    build_tpc_switch_payload_with_seq_and_version, build_tpc_txn_switch_payload_with_seq,
179    parse_tpc_change_state_response_with_limits, parse_tpc_switch_response_with_limits,
180};
181use oracledb_protocol::thin::{TNS_AQ_ARRAY_DEQ, TNS_AQ_ARRAY_ENQ};
182use oracledb_protocol::wire::{encode_packet, PacketLengthWidth, ProtocolLimits};
183use oracledb_protocol::{
184    net::{
185        connectstring::{
186            Address, AddressList, Description, Descriptor, DEFAULT_SDU as DSN_DEFAULT_SDU,
187        },
188        EasyConnect, Protocol as NetProtocol,
189    },
190    ClientIdentity,
191};
192
193const PYTHON_ORACLEDB_COMPAT_VERSION_NUM: u32 = 0x0400_1000;
194const DEFAULT_SDU: usize = 8192;
195
196/// Upper bound on server-requested CONNECT resends before giving up. A real
197/// server asks once (pre-23ai, long connect data); the bound only guards
198/// against a peer that never stops answering RESEND.
199const MAX_CONNECT_RESEND_ROUNDS: u8 = 8;
200
201/// Upper bound on listener REDIRECT hops before giving up. A real redirect
202/// chain is one hop (shared server / RAC dispatcher); the reference does not
203/// bound the loop, but a pair of misconfigured listeners redirecting to each
204/// other must terminate here instead of spinning forever.
205const MAX_CONNECT_REDIRECT_ROUNDS: u8 = 8;
206
207/// Human name for a TNS network packet type, for connect-phase diagnostics.
208/// These are packet-layer types (header offset 4), not TTC message types.
209fn tns_packet_type_name(packet_type: u8) -> &'static str {
210    match packet_type {
211        1 => "CONNECT",
212        2 => "ACCEPT",
213        3 => "ACK",
214        4 => "REFUSE",
215        5 => "REDIRECT",
216        6 => "DATA",
217        7 => "NULL",
218        9 => "ABORT",
219        11 => "RESEND",
220        12 => "MARKER",
221        13 => "ATTENTION",
222        14 => "CONTROL",
223        _ => "unknown",
224    }
225}
226const TNS_DATA_PACKET_OVERHEAD: usize = 10;
227
228pub use oracledb_protocol as protocol;
229
230/// The version of this driver crate, supplied by Cargo at build time.
231///
232/// Consumers that wrap the driver (for example `oraclemcp-db`'s `doctor`) must
233/// report the *driver's* real version, not their own: `env!("CARGO_PKG_VERSION")`
234/// evaluated inside a wrapping crate resolves to that wrapper's version. This
235/// const is evaluated in the driver crate, so it always reflects the actual
236/// `oracledb` version the wrapper is linked against.
237pub const VERSION: &str = env!("CARGO_PKG_VERSION");
238
239/// Profiling-only read/decode attribution counters for the fetch paging loop.
240///
241/// **This is measurement-only instrumentation, not part of the optimization.**
242/// When the `ORACLEDB_PROFILE_FETCH` environment variable is set (checked once,
243/// lazily), [`fetch_rows_with_columns`](Connection::fetch_rows_with_columns)
244/// accumulates the wall time spent in the socket read (`read_response`) vs the
245/// CPU decode (`parse_fetch_response`) into these atomics. A bench / example can
246/// read the split with [`fetch_profile_read_decode_ns`] to attribute how much of
247/// a paged fetch is socket-bound (overlap candidate) vs CPU-bound. The flag is
248/// off by default, so the production path pays one relaxed atomic load per page
249/// and nothing else.
250mod fetch_profile {
251    use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
252    use std::sync::OnceLock;
253
254    static READ_NS: AtomicU64 = AtomicU64::new(0);
255    static DECODE_NS: AtomicU64 = AtomicU64::new(0);
256    static ENABLED: OnceLock<bool> = OnceLock::new();
257    /// Lets a bench arm/disarm attribution without an env var (so a single
258    /// process can measure a clean window).
259    static FORCE: AtomicBool = AtomicBool::new(false);
260
261    #[inline]
262    pub(crate) fn enabled() -> bool {
263        FORCE.load(Ordering::Relaxed)
264            || *ENABLED.get_or_init(|| std::env::var_os("ORACLEDB_PROFILE_FETCH").is_some())
265    }
266
267    #[inline]
268    pub(crate) fn add_read(ns: u64) {
269        READ_NS.fetch_add(ns, Ordering::Relaxed);
270    }
271
272    #[inline]
273    pub(crate) fn add_decode(ns: u64) {
274        DECODE_NS.fetch_add(ns, Ordering::Relaxed);
275    }
276
277    pub(crate) fn snapshot() -> (u64, u64) {
278        (
279            READ_NS.load(Ordering::Relaxed),
280            DECODE_NS.load(Ordering::Relaxed),
281        )
282    }
283
284    pub(crate) fn reset() {
285        READ_NS.store(0, Ordering::Relaxed);
286        DECODE_NS.store(0, Ordering::Relaxed);
287    }
288
289    pub(crate) fn set_force(on: bool) {
290        FORCE.store(on, Ordering::Relaxed);
291    }
292}
293
294/// Profiling-only: snapshot the cumulative `(read_ns, decode_ns)` split that the
295/// fetch paging loop has accumulated since the last [`fetch_profile_reset`].
296///
297/// Only populated when fetch profiling is armed (the `ORACLEDB_PROFILE_FETCH`
298/// env var is set, or [`fetch_profile_arm(true)`](fetch_profile_arm) was called).
299/// This is benchmark/diagnostic instrumentation; it is not part of the normal
300/// data path.
301pub fn fetch_profile_read_decode_ns() -> (u64, u64) {
302    fetch_profile::snapshot()
303}
304
305/// Profiling-only: zero the fetch read/decode attribution counters.
306pub fn fetch_profile_reset() {
307    fetch_profile::reset();
308}
309
310/// Profiling-only: arm or disarm fetch read/decode attribution for this process
311/// without setting an environment variable.
312pub fn fetch_profile_arm(on: bool) {
313    fetch_profile::set_force(on);
314}
315
316#[cfg(feature = "arrow")]
317#[path = "arrow/mod.rs"]
318pub mod arrow;
319/// Executemany batch-chunk bookkeeping. Private module: the user-facing surface
320/// is the three items re-exported at the crate root below, so there is exactly
321/// one public path per item (no `oracledb::cursor_logic::…` second path).
322mod cursor_logic;
323mod iam_pop;
324/// Feature-gated observability seam (bead rust-oracledb-lv6). Always compiled so
325/// the `obs_span!` / `obs_record!` macros resolve, but its `tracing`-touching
326/// items are themselves `#[cfg(feature = "tracing")]`, so the off-build pulls in
327/// no `tracing` dependency. See `docs/OBSERVABILITY.md`.
328#[macro_use]
329mod obs;
330/// Lazy, on-demand streaming over LOB locators (bead a4-bbx). The user-facing
331/// types are re-exported at the crate root (the single canonical public path).
332pub(crate) mod lob_stream;
333pub mod pool;
334mod recovery;
335mod request;
336/// Idempotency-gated retry executor over the ORA error taxonomy (bead a4-r9a).
337pub mod retry;
338mod routine;
339/// Owning row-by-row query stream (K10). The user-facing [`OwnedRowStream`] is
340/// re-exported at the crate root (the single canonical public path).
341mod row_stream;
342mod rows;
343/// Cross-connection statement-shape cache with DDL-invalidation self-heal
344/// (bead a4-8pp). The user-facing types are re-exported at the crate root (the
345/// single canonical public path).
346pub(crate) mod shape_cache;
347#[cfg(feature = "soda")]
348pub mod soda;
349mod sql_convert;
350pub(crate) mod tls;
351// The `tls` module itself is crate-private, but the wallet-precedence
352// resolution accessor and its outcome types are part of the public API (a
353// server doctor calls them to report which wallet file wins without re-deriving
354// the driver's precedence). Re-exported flat at the crate root — the single
355// public path for each.
356pub use tls::{resolve_wallet, WalletFile, WalletResolution};
357pub mod transport;
358
359/// L2 version cassettes: live capture + offline replay of the per-version
360/// connect-negotiation wire exchange (bead rust-oracledb-xver-parity-so3w.3).
361/// Test-only, and only with the `cassette` feature (it uses the record/replay
362/// transport seam). Not part of the public API and compiled out of every
363/// shipping build.
364#[cfg(all(test, feature = "cassette"))]
365mod version_cassettes;
366
367/// Re-export of the `tracing` crate for the `obs_span!` / `obs_record!` macros
368/// (`$crate::__tracing::…`). Hidden and feature-gated; not part of the public
369/// API. Only exists when the `tracing` feature is on.
370#[cfg(feature = "tracing")]
371#[doc(hidden)]
372pub use tracing as __tracing;
373
374/// Off-build no-op span guard the `obs_span!` macro yields when the `tracing`
375/// feature is off (hoisted to crate root for `$crate::ObsSpanGuard`).
376#[cfg(not(feature = "tracing"))]
377#[doc(hidden)]
378pub use obs::ObsSpanGuard;
379
380pub use cursor_logic::{
381    bind_rows_need_iterative_plsql, ExecutemanyManager, ExecutemanyManagerError,
382};
383
384pub use request::{Batch, BatchRows, Execute, Query, Registration, Scroll};
385use request::{DeadlineExpiry, QueryDeadline};
386pub use row_stream::OwnedRowStream;
387pub use rows::{
388    BatchError, BatchOutcome, BlockingRows, ExecuteOutcome, OutBinds, RegistrationOutcome,
389    ReturningRows, Row, Rows,
390};
391
392pub use routine::{OutType, RoutineCall, RoutineOutcome};
393
394pub use shape_cache::{ColumnShape, ShapeObservation, StatementShapeCache};
395
396pub use lob_stream::{ClobReader, LobReader, LobWriter};
397
398pub use sql_convert::{
399    check_bind_rows, check_positional_binds, declared_bind_count, BindError, ConversionError,
400    FromRow, FromSql, IntoBinds, Params, QueryResultExt, ToSql, TypedRow,
401};
402
403/// Derive a [`FromRow`] implementation that maps a query row into a struct with
404/// compile-time-checked field types.
405///
406/// Available with the default-on `derive` feature. The derive and the
407/// [`FromRow`] trait share a name, so a single `use oracledb::FromRow;` brings
408/// both into scope.
409///
410/// ```no_run
411/// use oracledb::FromRow;
412///
413/// #[derive(FromRow)]
414/// struct Emp {
415///     id: i64,
416///     name: String,
417///     manager_id: Option<i64>,
418/// }
419/// ```
420///
421/// See the [`FromRow`] trait docs for the supported shapes and `#[oracledb(...)]`
422/// attributes.
423#[cfg(feature = "derive")]
424pub use oracledb_derive::FromRow;
425
426/// The everyday types and traits, for a single glob import.
427///
428/// A typical program needs a connection type, [`ConnectOptions`], the
429/// [`ClientIdentity`] the database records, the value
430/// types it binds and reads back ([`BindValue`] /
431/// [`QueryValue`](protocol::thin::QueryValue)), and the typed-row helpers
432/// ([`FromRow`], [`QueryResultExt`], [`params!`]). Those live across two
433/// namespaces (the driver root and the [`protocol`] re-export), so the prelude
434/// gathers them so callers can write one line:
435///
436/// ```no_run
437/// use oracledb::prelude::*;
438///
439/// # fn main() -> oracledb::Result<()> {
440/// let identity = ClientIdentity::new("app", "host", "user", "term", "rust-oracledb")?;
441/// let options = ConnectOptions::new("dbhost:1521/FREEPDB1", "app_user", "app_pw", identity);
442/// let mut conn = BlockingConnection::connect(options)?;
443/// let binds = params! { "id" => 42_i64 };
444/// # let _ = (&mut conn, binds);
445/// # BlockingConnection::close(conn)?;
446/// # Ok(())
447/// # }
448/// ```
449///
450/// The prelude is a curated convenience namespace, not a second canonical home:
451/// each item's one obvious path is still its non-prelude path
452/// (`oracledb::Connection`, `oracledb::protocol::thin::QueryValue`, ...). Reach
453/// for an explicit `use` when you want exactly one name or a less common type.
454///
455/// It deliberately does **not** re-export [`Result`] or [`Error`]: a 1-argument
456/// `Result` alias and an `Error` type in a glob import shadow
457/// `std::result::Result` / `std::error::Error`, which surprises callers. Name
458/// those explicitly as `oracledb::Result` / `oracledb::Error`.
459pub mod prelude {
460    pub use crate::protocol::thin::{BindValue, QueryValue};
461    pub use crate::protocol::ClientIdentity;
462    pub use crate::{
463        params, BlockingConnection, ConnectOptions, Connection, FromRow, Params, QueryResultExt,
464    };
465}
466
467use recovery::{
468    cancel_disposition, classify_recovery_result, decode_server_version_number,
469    observe_cancellation_between_round_trips, post_sync_protocol_error_disposition,
470    protocol_error_is_session_dead, protocol_error_kind, protocol_error_offset,
471    protocol_error_ora_code, run_recovery_without_current_cx,
472    server_version_number_uses_extended_layout, CancelDisposition, PostSyncProtocolDisposition,
473    RecoveryWireAction, SessionRecovery, SessionRecoveryPhase, CONNECTION_LOST_ORA_CODES,
474    SESSION_DEAD_ORA_CODES, TRANSIENT_ORA_CODES,
475};
476
477use transport::{Connector, WireTransport};
478
479type DriverConnector = transport::OracleConnector;
480type DriverTransport = <DriverConnector as Connector>::Transport;
481type SharedWriteHalf<T = DriverTransport> = Arc<AsyncMutex<<T as WireTransport>::Write>>;
482type DriverCore = ConnectionCore<DriverTransport>;
483
484struct TokenAuthentication<'a> {
485    user: &'a str,
486    token: &'a str,
487    driver_name: &'a str,
488    version_num: u32,
489    connect_string: &'a str,
490    edition: Option<&'a str>,
491    /// OCI IAM database-token proof-of-possession (`AUTH_HEADER`/`AUTH_SIGNATURE`).
492    /// `None` for a plain OAuth2 bearer token.
493    pop: Option<TokenPop<'a>>,
494    /// Proxy client name (`PROXY_CLIENT_NAME`); `None` when not proxying.
495    /// Empty base `user` with `Some(proxy)` is the token-auth `[proxy]` form.
496    proxy_user: Option<&'a str>,
497}
498
499#[derive(Debug)]
500struct ConnectionCore<T: WireTransport> {
501    read: Option<T::Read>,
502    write: SharedWriteHalf<T>,
503    recovery: Arc<SessionRecovery>,
504    protocol_limits: ProtocolLimits,
505    /// Optional per-read inactivity deadline (GH#14). `None` = unbounded reads
506    /// (prior behaviour); `Some(d)` fails a stalled post-auth read with
507    /// [`Error::CallTimeout`] after `d`. Set once at connect from
508    /// [`ConnectOptions::inactivity_timeout`].
509    inactivity_timeout: Option<Duration>,
510    /// Whether this session negotiated the *classic* (pre-END_OF_RESPONSE)
511    /// framing, i.e. `!supports_end_of_response`. Set once at connect time from
512    /// the ACCEPT capabilities. The recovery drain reads this to decide the
513    /// trailing-error boundary the way the connected server frames it (bead
514    /// rust-oracledb-99xu); `false` (23ai framing) until the session negotiates.
515    classic_framing: bool,
516}
517
518impl<T: WireTransport> ConnectionCore<T> {
519    fn from_halves(read: T::Read, write: T::Write, write_name: &'static str) -> Self {
520        Self {
521            read: Some(read),
522            write: Arc::new(AsyncMutex::with_name(write_name, write)),
523            recovery: Arc::new(SessionRecovery::new()),
524            protocol_limits: ProtocolLimits::DEFAULT,
525            inactivity_timeout: None,
526            classic_framing: false,
527        }
528    }
529
530    /// Records whether this session uses classic (pre-END_OF_RESPONSE) framing,
531    /// so the recovery drain can decide the trailing-error boundary correctly
532    /// on pre-23ai servers (bead rust-oracledb-99xu).
533    fn set_classic_framing(&mut self, classic: bool) {
534        self.classic_framing = classic;
535    }
536
537    fn set_protocol_limits(&mut self, limits: ProtocolLimits) -> Result<()> {
538        self.protocol_limits = limits.validate()?;
539        Ok(())
540    }
541
542    /// Sets the per-read inactivity deadline for this session (GH#14). `None`
543    /// leaves reads unbounded (prior behaviour).
544    fn set_inactivity_timeout(&mut self, timeout: Option<Duration>) {
545        self.inactivity_timeout = timeout;
546    }
547
548    fn read_mut(&mut self) -> Result<&mut T::Read> {
549        self.read.as_mut().ok_or_else(|| {
550            Error::ConnectionClosed("connection read half unavailable during recovery".into())
551        })
552    }
553
554    fn take_read(&mut self) -> Result<T::Read> {
555        self.read.take().ok_or_else(|| {
556            Error::ConnectionClosed("connection read half unavailable during recovery".into())
557        })
558    }
559
560    fn write_handle(&self) -> SharedWriteHalf<T> {
561        Arc::clone(&self.write)
562    }
563
564    async fn write_all(&self, cx: &Cx, packet: &[u8]) -> Result<()> {
565        write_all_shared(cx, &self.write, packet).await
566    }
567
568    async fn shutdown_write(&self, cx: &Cx) -> Result<()> {
569        shutdown_write_shared(cx, &self.write).await
570    }
571
572    async fn send_data_packet(&self, cx: &Cx, payload: &[u8], sdu: usize) -> Result<()> {
573        send_data_packet_shared(cx, &self.write, payload, sdu).await
574    }
575
576    /// Sends the overflow CONNECT descriptor before the listener has replied
577    /// with ACCEPT. This exchange still uses the legacy 16-bit TNS header;
578    /// normal TTC DATA switches to `Large32` only after ACCEPT.
579    async fn send_connect_data_packet(&self, cx: &Cx, payload: &[u8], sdu: usize) -> Result<()> {
580        send_data_packet_shared_with_width(
581            cx,
582            &self.write,
583            payload,
584            sdu,
585            PacketLengthWidth::Legacy16,
586        )
587        .await
588    }
589
590    async fn send_data_packet_with_flags(
591        &self,
592        cx: &Cx,
593        payload: &[u8],
594        sdu: usize,
595        first_packet_flags: u16,
596        last_packet_flags: u16,
597    ) -> Result<()> {
598        send_data_packet_shared_with_flags(
599            cx,
600            &self.write,
601            payload,
602            sdu,
603            first_packet_flags,
604            last_packet_flags,
605        )
606        .await
607    }
608
609    async fn read_packet(&mut self, width: PacketLengthWidth) -> Result<IncomingPacket> {
610        let limits = self.protocol_limits;
611        let inactivity = self.inactivity_timeout;
612        let result = {
613            let mut read = InactivityRead::new(self.read_mut()?, inactivity);
614            read_packet_with_limits(&mut read, width, limits).await
615        };
616        self.note_post_sync_result(map_inactivity_timeout(result))
617    }
618
619    async fn read_data_response(&mut self, cx: &Cx) -> Result<Vec<u8>> {
620        let write = Arc::clone(&self.write);
621        let limits = self.protocol_limits;
622        let inactivity = self.inactivity_timeout;
623        let result = {
624            let mut read = InactivityRead::new(self.read_mut()?, inactivity);
625            read_data_response_with_limits(&mut read, cx, &write, limits).await
626        };
627        self.note_post_sync_result(map_inactivity_timeout(result))
628    }
629
630    /// Reads one classic (pre-END_OF_RESPONSE) connect-phase response. Servers
631    /// that did not negotiate END_OF_RESPONSE framing never set the
632    /// end-of-response DATA flag, so the flag-driven boundary reader would wait
633    /// forever; completion is decided by the payload's terminal message instead
634    /// (`classic_connect_response_is_complete`).
635    async fn read_classic_data_response(&mut self, cx: &Cx) -> Result<Vec<u8>> {
636        let write = Arc::clone(&self.write);
637        let limits = self.protocol_limits;
638        let inactivity = self.inactivity_timeout;
639        let result = {
640            let mut read = InactivityRead::new(self.read_mut()?, inactivity);
641            read_classic_data_response_with_limits(&mut read, cx, &write, limits).await
642        };
643        self.note_post_sync_result(map_inactivity_timeout(result))
644    }
645
646    /// Authenticates an OCI IAM/OAuth database token over the pre-23ai classic
647    /// connect sequence. The token payload is deliberately never passed to the
648    /// packet trace helper.
649    async fn authenticate_classic_token(
650        &mut self,
651        cx: &Cx,
652        token_auth: TokenAuthentication<'_>,
653        sdu: usize,
654    ) -> Result<(AuthResponse, ClientCapabilities)> {
655        let protocol_limits = self.protocol_limits;
656        // Mirror the password path's classic negotiation, then send the
657        // self-contained token phase-two payload (there is no password
658        // challenge/response).
659        let protocol_payload = build_protocol_negotiation_payload()?;
660        trace_connect_step("send protocol negotiation (classic token)");
661        self.send_data_packet(cx, &protocol_payload, sdu).await?;
662        trace_connect_step("read protocol negotiation");
663        let response = self.read_classic_data_response(cx).await?;
664        trace_connect_bytes("protocol negotiation response", &response);
665        let negotiated = parse_auth_response_with_limits(&response, protocol_limits)?;
666
667        let data_types_payload = build_data_types_payload()?;
668        trace_connect_step("send data types (classic token)");
669        self.send_data_packet(cx, &data_types_payload, sdu).await?;
670        trace_connect_step("read data types");
671        let response = self.read_classic_data_response(cx).await?;
672        trace_connect_bytes("data types response", &response);
673        parse_auth_response_with_limits(&response, protocol_limits)?;
674
675        let auth_payload = build_auth_phase_two_token_payload_with_pop_and_proxy(
676            token_auth.user,
677            token_auth.token,
678            token_auth.driver_name,
679            token_auth.version_num,
680            token_auth.connect_string,
681            token_auth.edition,
682            token_auth.pop,
683            token_auth.proxy_user,
684        )?;
685        trace_connect_step("send AUTH token (classic phase two)");
686        self.send_data_packet(cx, &auth_payload, sdu).await?;
687        trace_connect_step("read AUTH token response");
688        let response = self.read_classic_data_response(cx).await?;
689        trace_connect_bytes("AUTH token response", &response);
690        let auth = parse_auth_response_with_limits(&response, protocol_limits)?;
691        let capabilities = negotiated
692            .capabilities
693            .or(auth.capabilities)
694            .unwrap_or_default();
695        Ok((auth, capabilities))
696    }
697
698    /// Reads one TTC response, deciding completion the way the connected
699    /// server framing requires. With `classic == false` this is exactly
700    /// [`read_data_response`](Self::read_data_response) (END_OF_RESPONSE/EOF
701    /// data-flag framing, 23ai+). With `classic == true` (ACCEPT protocol
702    /// version < 319 — the server never sends those flags) DATA packets are
703    /// accumulated and `probe(&accumulated)` decides completion after each
704    /// packet: the caller probes with the same parser it will run on the
705    /// returned buffer, so the response is complete precisely when that parser
706    /// can consume it without running out of bytes (reference
707    /// messages/base.pyx:249-252, 294-298 — a classic response ends at its
708    /// terminal ERROR/STATUS/FLUSH_OUT_BINDS message).
709    async fn read_data_response_probed(
710        &mut self,
711        cx: &Cx,
712        classic: bool,
713        probe: impl Fn(&[u8]) -> bool,
714    ) -> Result<Vec<u8>> {
715        if !classic {
716            return self.read_data_response(cx).await;
717        }
718        let write = Arc::clone(&self.write);
719        let limits = self.protocol_limits;
720        let inactivity = self.inactivity_timeout;
721        let result = {
722            let mut read = InactivityRead::new(self.read_mut()?, inactivity);
723            read_classic_data_response_probed_with_limits(&mut read, cx, &write, &probe, limits)
724                .await
725        };
726        self.note_post_sync_result(map_inactivity_timeout(result))
727    }
728
729    /// Read the response to the terminal LOGOFF request.
730    ///
731    /// Oracle may finish a usable TCPS session by closing TCP without a TLS
732    /// `close_notify`. That transport signal is acceptable only before the
733    /// first byte of this terminal response: at that point LOGOFF has already
734    /// been written and there is no next application response to protect. Any
735    /// bytes followed by the same signal remain a truncated response error.
736    async fn read_session_close_response(
737        &mut self,
738        cx: &Cx,
739        classic: bool,
740        probe: impl Fn(&[u8]) -> bool,
741    ) -> Result<SessionCloseDisposition> {
742        let write = Arc::clone(&self.write);
743        let limits = self.protocol_limits;
744        let inactivity = self.inactivity_timeout;
745        let result = {
746            let mut read = InactivityRead::new(self.read_mut()?, inactivity);
747            read_session_close_response_with_limits(&mut read, cx, &write, classic, &probe, limits)
748                .await
749        };
750        self.note_post_sync_result(map_inactivity_timeout(result))
751    }
752
753    /// Finishes the wire-level half of a terminal LOGOFF exchange.
754    ///
755    /// A peer hard-close accepted at the empty response boundary has already
756    /// made Asupersync's TLS stream terminal, so attempting the ordinary final
757    /// TNS EOF write would only turn a successful close into `BrokenPipe`.
758    /// Complete responses and response timeouts retain the existing EOF write
759    /// and write-shutdown behavior.
760    async fn finish_session_close(
761        &mut self,
762        cx: &Cx,
763        classic: bool,
764        probe: impl Fn(&[u8]) -> bool,
765    ) -> Result<()> {
766        if let Ok(response) = time::timeout(
767            time::wall_now(),
768            Duration::from_secs(5),
769            self.read_session_close_response(cx, classic, probe),
770        )
771        .await
772        {
773            if response? == SessionCloseDisposition::PeerHardClosed {
774                return Ok(());
775            }
776        }
777        let eof = encode_packet(
778            TNS_PACKET_TYPE_DATA,
779            0,
780            Some(oracledb_protocol::thin::TNS_DATA_FLAGS_EOF),
781            &[],
782            PacketLengthWidth::Large32,
783        )?;
784        self.write_all(cx, &eof).await?;
785        let _ = self.shutdown_write(cx).await;
786        Ok(())
787    }
788
789    /// [`read_data_response_probed`](Self::read_data_response_probed) for the
790    /// bind/execute path, which must answer FLUSH_OUT_BINDS requests. With
791    /// `classic == false` this is exactly
792    /// [`read_data_response_flushing_out_binds`](Self::read_data_response_flushing_out_binds).
793    async fn read_data_response_flushing_out_binds_probed(
794        &mut self,
795        cx: &Cx,
796        sdu: usize,
797        classic: bool,
798        probe: impl Fn(&[u8]) -> bool,
799    ) -> Result<Vec<u8>> {
800        if !classic {
801            return self.read_data_response_flushing_out_binds(cx, sdu).await;
802        }
803        let write = Arc::clone(&self.write);
804        let limits = self.protocol_limits;
805        let inactivity = self.inactivity_timeout;
806        let result = {
807            let mut read = InactivityRead::new(self.read_mut()?, inactivity);
808            read_classic_data_response_flushing_out_binds_probed_with_limits(
809                &mut read, cx, &write, sdu, &probe, limits,
810            )
811            .await
812        };
813        self.note_post_sync_result(map_inactivity_timeout(result))
814    }
815
816    async fn read_data_response_boundary(
817        &mut self,
818        cx: &Cx,
819        in_pipeline: bool,
820    ) -> Result<DataResponse> {
821        let write = Arc::clone(&self.write);
822        let limits = self.protocol_limits;
823        let inactivity = self.inactivity_timeout;
824        let result = {
825            let mut read = InactivityRead::new(self.read_mut()?, inactivity);
826            read_data_response_boundary_with_limits(&mut read, cx, &write, in_pipeline, limits)
827                .await
828        };
829        self.note_post_sync_result(map_inactivity_timeout(result))
830    }
831
832    async fn read_data_response_flushing_out_binds(
833        &mut self,
834        cx: &Cx,
835        sdu: usize,
836    ) -> Result<Vec<u8>> {
837        let write = Arc::clone(&self.write);
838        let limits = self.protocol_limits;
839        let inactivity = self.inactivity_timeout;
840        let result = {
841            let mut read = InactivityRead::new(self.read_mut()?, inactivity);
842            read_data_response_flushing_out_binds_with_limits(&mut read, cx, &write, sdu, limits)
843                .await
844        };
845        self.note_post_sync_result(map_inactivity_timeout(result))
846    }
847
848    fn note_post_sync_result<U>(&self, result: Result<U>) -> Result<U> {
849        if let Err(Error::Protocol(err)) = &result {
850            if post_sync_protocol_error_disposition(err) == PostSyncProtocolDisposition::Dead {
851                self.recovery.mark_dead();
852            }
853        }
854        result
855    }
856
857    fn break_and_drain_wire(&mut self, recovery_timeout: Duration) -> Result<()> {
858        self.run_recovery_drain(RecoveryWireAction::BreakAndDrain, recovery_timeout)
859    }
860
861    fn cancel_and_drain_wire(&mut self, recovery_timeout: Duration) -> Result<()> {
862        self.run_recovery_drain(RecoveryWireAction::BreakAndDrain, recovery_timeout)
863    }
864
865    fn drain_cancel_wire(&mut self, recovery_timeout: Duration) -> Result<()> {
866        self.run_recovery_drain(RecoveryWireAction::DrainCancel, recovery_timeout)
867    }
868
869    fn run_recovery_drain(
870        &mut self,
871        action: RecoveryWireAction,
872        recovery_timeout: Duration,
873    ) -> Result<()> {
874        let read = self.take_read()?;
875        let write = Arc::clone(&self.write);
876        let limits = self.protocol_limits;
877        let classic = self.classic_framing;
878        let thread = std::thread::Builder::new()
879            .name("oracledb-recovery-drain".to_string())
880            .spawn(move || {
881                let mut read = read;
882                let result = run_recovery_without_current_cx(
883                    &mut read,
884                    &write,
885                    action,
886                    recovery_timeout,
887                    limits,
888                    classic,
889                );
890                (read, result)
891            })
892            .map_err(|err| {
893                Error::ConnectionClosed(format!("failed to start recovery drain thread: {err}"))
894            })?;
895
896        match thread.join() {
897            Ok((read, result)) => {
898                self.read = Some(read);
899                result
900            }
901            Err(_) => Err(Error::ConnectionClosed(
902                "recovery drain thread panicked".to_string(),
903            )),
904        }
905    }
906}
907
908/// Render a compiler-style caret diagnostic: the line of `sql` containing the
909/// 1-based character `offset` (the position Oracle reports for a parse error),
910/// with a `^` under that character, beneath `headline`.
911///
912/// Pure and panic-free: `offset` is clamped into range (a 0 or out-of-range
913/// offset points at the start / just past the end). Counts in Unicode scalar
914/// values, so multibyte SQL stays aligned; tabs count as one column.
915///
916/// ```
917/// let d = oracledb::render_caret(
918///     "select * from no_such_table",
919///     15,
920///     "ORA-00942: table or view does not exist",
921/// );
922/// assert!(d.contains("no_such_table"));
923/// assert!(d.lines().last().unwrap().trim_end().ends_with('^'));
924/// ```
925pub fn render_caret(sql: &str, offset: usize, headline: &str) -> String {
926    let chars: Vec<char> = sql.chars().collect();
927    let total = chars.len();
928    // 1-based -> 0-based char index, clamped into [0, total].
929    let target = offset.saturating_sub(1).min(total);
930
931    let mut line_start = 0usize;
932    let mut line_no = 1usize;
933    let mut col = 0usize;
934    for (i, &c) in chars.iter().enumerate() {
935        if i == target {
936            break;
937        }
938        if c == '\n' {
939            line_no += 1;
940            line_start = i + 1;
941            col = 0;
942        } else {
943            col += 1;
944        }
945    }
946
947    let mut line_end = line_start;
948    while line_end < total && chars[line_end] != '\n' {
949        line_end += 1;
950    }
951    let line: String = chars[line_start..line_end].iter().collect();
952    let gutter = line_no.to_string();
953    let pad = " ".repeat(gutter.len());
954    let caret_indent = " ".repeat(col);
955    format!("{headline}\n{pad} |\n{gutter} | {line}\n{pad} | {caret_indent}^")
956}
957
958/// Captured `DBMS_OUTPUT`, bounded by the caller's line/char limits. Returned by
959/// [`Connection::read_dbms_output`].
960#[derive(Debug, Clone, Default, PartialEq, Eq)]
961pub struct DbmsOutput {
962    /// The captured lines, in the order `DBMS_OUTPUT.GET_LINE` returned them.
963    lines: Vec<String>,
964    /// Number of lines captured (`== lines.len()`).
965    line_count: usize,
966    /// Total characters (Unicode scalar values) across all captured lines.
967    char_count: usize,
968    /// `true` if capture stopped at a `max_lines`/`max_chars` bound while more
969    /// output was still buffered server-side; `false` if it drained to the end.
970    truncated: bool,
971}
972
973impl DbmsOutput {
974    pub fn new(lines: Vec<String>, truncated: bool) -> Self {
975        let line_count = lines.len();
976        let char_count = lines.iter().map(|line| line.chars().count()).sum();
977        Self {
978            lines,
979            line_count,
980            char_count,
981            truncated,
982        }
983    }
984
985    pub fn lines(&self) -> &[String] {
986        &self.lines
987    }
988
989    pub fn line_count(&self) -> usize {
990        self.line_count
991    }
992
993    pub fn char_count(&self) -> usize {
994        self.char_count
995    }
996
997    pub fn truncated(&self) -> bool {
998        self.truncated
999    }
1000
1001    pub fn into_lines(self) -> Vec<String> {
1002        self.lines
1003    }
1004}
1005
1006/// A scalar attribute of an Oracle ADT/object type (from the data dictionary).
1007#[derive(Debug, Clone, PartialEq, Eq)]
1008pub struct ObjectAttribute {
1009    /// Attribute name, uppercased as Oracle stores it.
1010    pub name: String,
1011    /// Oracle attribute type name (`ALL_TYPE_ATTRS.ATTR_TYPE_NAME`), e.g.
1012    /// `"VARCHAR2"`, `"NUMBER"`, `"DATE"`.
1013    pub type_name: String,
1014    /// `Some(owner)` when the attribute is itself an object/collection type (a
1015    /// nested ADT); `None` for built-in scalar types.
1016    pub type_owner: Option<String>,
1017}
1018
1019/// Element type metadata for an Oracle collection type (VARRAY / nested table),
1020/// from `ALL_COLL_TYPES`.
1021#[derive(Debug, Clone, PartialEq, Eq)]
1022pub struct CollectionElement {
1023    /// Oracle element type name (`ALL_COLL_TYPES.ELEM_TYPE_NAME`), e.g.
1024    /// `"NUMBER"`, `"VARCHAR2"`.
1025    pub type_name: String,
1026    /// `Some(owner)` when the element is itself an object/collection type; `None`
1027    /// for built-in scalar element types.
1028    pub type_owner: Option<String>,
1029}
1030
1031/// Metadata for an Oracle ADT/object type, fetched from the data dictionary by
1032/// [`Connection::describe_object_type`]. A type is either an *object* (carrying
1033/// `attributes`) or a *collection* (carrying `collection_element`).
1034#[derive(Debug, Clone, PartialEq, Eq)]
1035pub struct ObjectType {
1036    /// Owning schema (uppercased).
1037    pub schema: String,
1038    /// Type name (uppercased).
1039    pub name: String,
1040    /// Attributes in declaration order (empty for a collection type).
1041    pub attributes: Vec<ObjectAttribute>,
1042    /// `Some(..)` when this type is a collection (VARRAY / nested table); carries
1043    /// its element metadata. `None` for object types (see `attributes`).
1044    pub collection_element: Option<CollectionElement>,
1045}
1046
1047/// A decoded Oracle ADT value. For an *object* type the scalar attributes are in
1048/// `attributes`; for a *collection* type the elements are in `elements`. Each
1049/// value is decoded with the same rules as a normal column. Returned by
1050/// [`decode_object`].
1051#[derive(Debug, Clone, Default, PartialEq)]
1052pub struct DecodedObject {
1053    /// The object type's schema.
1054    type_schema: String,
1055    /// The object type's name.
1056    type_name: String,
1057    /// `(attribute name, value)` in declaration order; `None` is SQL `NULL`.
1058    /// Empty when the decoded value is a collection (see `elements`).
1059    attributes: Vec<(String, Option<oracledb_protocol::thin::QueryValue>)>,
1060    /// `Some(elements)` when the decoded value is a collection; each entry is one
1061    /// element in order (`None` is a NULL element). `None` for object values.
1062    elements: Option<Vec<Option<oracledb_protocol::thin::QueryValue>>>,
1063}
1064
1065impl DecodedObject {
1066    pub fn object(
1067        type_schema: impl Into<String>,
1068        type_name: impl Into<String>,
1069        attributes: Vec<(String, Option<oracledb_protocol::thin::QueryValue>)>,
1070    ) -> Self {
1071        Self {
1072            type_schema: type_schema.into(),
1073            type_name: type_name.into(),
1074            attributes,
1075            elements: None,
1076        }
1077    }
1078
1079    pub fn collection(
1080        type_schema: impl Into<String>,
1081        type_name: impl Into<String>,
1082        elements: Vec<Option<oracledb_protocol::thin::QueryValue>>,
1083    ) -> Self {
1084        Self {
1085            type_schema: type_schema.into(),
1086            type_name: type_name.into(),
1087            attributes: Vec::new(),
1088            elements: Some(elements),
1089        }
1090    }
1091
1092    pub fn type_schema(&self) -> &str {
1093        &self.type_schema
1094    }
1095
1096    pub fn type_name(&self) -> &str {
1097        &self.type_name
1098    }
1099
1100    pub fn attributes(&self) -> &[(String, Option<oracledb_protocol::thin::QueryValue>)] {
1101        &self.attributes
1102    }
1103
1104    pub fn elements(&self) -> Option<&[Option<oracledb_protocol::thin::QueryValue>]> {
1105        self.elements.as_deref()
1106    }
1107
1108    pub fn is_collection(&self) -> bool {
1109        self.elements.is_some()
1110    }
1111
1112    pub fn into_attributes(self) -> Vec<(String, Option<oracledb_protocol::thin::QueryValue>)> {
1113        self.attributes
1114    }
1115
1116    pub fn into_elements(self) -> Option<Vec<Option<oracledb_protocol::thin::QueryValue>>> {
1117        self.elements
1118    }
1119}
1120
1121/// Decode a returned Oracle ADT object value (the payload of a
1122/// `QueryValue::Object`) into its scalar attributes, using the type metadata
1123/// from [`Connection::describe_object_type`]. Bounded by the object image
1124/// length, so a malformed/huge image cannot cause unbounded work.
1125///
1126/// Scoped to objects with scalar attributes: a nested object/collection
1127/// attribute yields a typed `Error::Protocol(UnsupportedFeature(..))`, so a
1128/// caller can classify "this shape isn't decodable yet" distinctly from a real
1129/// failure. python-oracledb only decodes objects through its thick/DbObject
1130/// machinery; this is the native structured surface.
1131pub fn decode_object(
1132    value: &oracledb_protocol::thin::ObjectValue,
1133    ty: &ObjectType,
1134) -> Result<DecodedObject> {
1135    use oracledb_protocol::thin::DbObjectPackedReader;
1136    use oracledb_protocol::wire::{BoundedReader, ProtocolLimits};
1137    let mut reader = DbObjectPackedReader::new(&value.packed_data);
1138    reader
1139        .limits()
1140        .check_response_bytes(value.packed_data.len())?;
1141    reader.read_header()?;
1142
1143    // Collection type: 1 collection-flags byte, then an element count, then each
1144    // element value (reference impl/thin/dbobject.pyx `_unpack_data_from_buf`).
1145    if let Some(elem) = &ty.collection_element {
1146        if elem.type_owner.is_some() {
1147            return Err(oracledb_protocol::ProtocolError::UnsupportedFeature(
1148                "collection of nested object/collection elements is not decodable yet",
1149            )
1150            .into());
1151        }
1152        let _collection_flags = reader.read_u8()?;
1153        let num_elements = reader.read_length()?;
1154        reader.limits().check_object_elements(num_elements)?;
1155        // Every element consumes at least one byte, so the real count cannot
1156        // exceed the bytes still in the image; cap the pre-allocation against
1157        // `remaining()` so a malformed huge count can't force an unbounded Vec.
1158        // The loop itself is bounded too: `read_value_bytes` errors (truncated)
1159        // once the image is exhausted.
1160        let mut elements: Vec<Option<oracledb_protocol::thin::QueryValue>> =
1161            reader.with_capacity_limited(num_elements, 1, ProtocolLimits::check_object_elements)?;
1162        for _ in 0..num_elements {
1163            let decoded = match reader.read_value_bytes()? {
1164                None => None,
1165                Some(bytes) => Some(decode_object_scalar(&elem.type_name, bytes)?),
1166            };
1167            elements.push(decoded);
1168        }
1169        return Ok(DecodedObject {
1170            type_schema: ty.schema.clone(),
1171            type_name: ty.name.clone(),
1172            attributes: Vec::new(),
1173            elements: Some(elements),
1174        });
1175    }
1176
1177    let mut attributes = Vec::with_capacity(ty.attributes.len());
1178    for attr in &ty.attributes {
1179        if attr.type_owner.is_some() {
1180            return Err(oracledb_protocol::ProtocolError::UnsupportedFeature(
1181                "nested object/collection attribute is not decodable yet",
1182            )
1183            .into());
1184        }
1185        let decoded = match reader.read_value_bytes()? {
1186            None => None,
1187            Some(bytes) => Some(decode_object_scalar(&attr.type_name, bytes)?),
1188        };
1189        attributes.push((attr.name.clone(), decoded));
1190    }
1191    Ok(DecodedObject {
1192        type_schema: ty.schema.clone(),
1193        type_name: ty.name.clone(),
1194        attributes,
1195        elements: None,
1196    })
1197}
1198
1199/// Decode one scalar attribute/element value of an Oracle object/collection,
1200/// using the same codecs as normal column values. Returns a typed
1201/// `UnsupportedFeature` for a type we do not decode yet (so a caller can classify
1202/// the shape distinctly from a real failure).
1203fn decode_object_scalar(
1204    type_name: &str,
1205    bytes: Vec<u8>,
1206) -> Result<oracledb_protocol::thin::QueryValue> {
1207    use oracledb_protocol::thin::{
1208        decode_datetime_value, decode_dbobject_text, decode_number_value, QueryValue,
1209    };
1210    let v = if type_name.starts_with("TIMESTAMP") {
1211        decode_datetime_value(&bytes)?
1212    } else {
1213        match type_name {
1214            "VARCHAR2" | "VARCHAR" | "CHAR" => {
1215                QueryValue::Text(decode_dbobject_text(&bytes, "DB_TYPE_VARCHAR")?)
1216            }
1217            "NVARCHAR2" | "NCHAR" => {
1218                QueryValue::Text(decode_dbobject_text(&bytes, "DB_TYPE_NCHAR")?)
1219            }
1220            "NUMBER" | "FLOAT" | "INTEGER" => decode_number_value(&bytes)?,
1221            "DATE" => decode_datetime_value(&bytes)?,
1222            "RAW" => QueryValue::Raw(bytes),
1223            _ => {
1224                return Err(oracledb_protocol::ProtocolError::UnsupportedFeature(
1225                    "object attribute/element type is not decodable yet",
1226                )
1227                .into())
1228            }
1229        }
1230    };
1231    Ok(v)
1232}
1233
1234/// Stable top-level error bucket for [`Error::kind`].
1235#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
1236#[non_exhaustive]
1237pub enum ErrorKind {
1238    Network,
1239    Timeout,
1240    Cancel,
1241    Protocol,
1242    Database,
1243    Conversion,
1244    Pool,
1245    ResourceLimit,
1246    Authentication,
1247}
1248
1249/// Whether the connection that produced an error can be reused.
1250#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
1251pub enum ConnectionDisposition {
1252    Reusable,
1253    Dead,
1254}
1255
1256/// Conservative retry guidance for caller-proven idempotent operations.
1257#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
1258#[non_exhaustive]
1259pub enum RetryHint {
1260    Never,
1261    RetrySameConnectionIfIdempotent,
1262    ReconnectThenRetryIfIdempotent,
1263}
1264
1265#[derive(Debug, thiserror::Error)]
1266#[non_exhaustive]
1267pub enum Error {
1268    #[error(transparent)]
1269    Protocol(#[from] oracledb_protocol::ProtocolError),
1270    #[error("I/O error: {0}")]
1271    Io(#[from] std::io::Error),
1272    #[error("asupersync runtime error: {0}")]
1273    Runtime(String),
1274    /// The listener redirected the connection to a target whose shape this
1275    /// driver refuses to follow: the redirect address demands a transport
1276    /// protocol CHANGE. Continuing a `tcps` connect over plain `tcp` would be
1277    /// a silent TLS downgrade, and a mid-connect `tcp` -> `tcps` upgrade is
1278    /// not supported; a redirect that keeps the original transport protocol
1279    /// is followed transparently.
1280    #[error(
1281        "listener redirect demands a transport protocol change (e.g. a tcps -> tcp \
1282         downgrade); refusing to follow it"
1283    )]
1284    RedirectUnsupported,
1285    /// The listener answered CONNECT with a REDIRECT packet whose redirect
1286    /// data could not be understood (truncated length prefix, missing the
1287    /// NUL separator between the target address and its connect data, or an
1288    /// unparseable target address). The payload describes the defect; the
1289    /// raw redirect bytes are not echoed verbatim.
1290    #[error("listener redirect data is malformed: {0}")]
1291    InvalidRedirectData(String),
1292    /// The listener kept answering every CONNECT with another REDIRECT. A
1293    /// real redirect chain is one hop (shared server / RAC); the bound only
1294    /// guards against a redirect loop between misconfigured listeners.
1295    #[error("listener kept redirecting the connection ({0} redirects); giving up")]
1296    ConnectRedirectLoop(u8),
1297    #[error("listener refused connection: {0}")]
1298    ListenerRefused(String),
1299    /// Every address in a multi-address connect descriptor (`ADDRESS_LIST` or
1300    /// several `ADDRESS` entries) failed to establish a transport. The payload
1301    /// aggregates the per-address failure reasons in the order they were tried.
1302    /// The connection never reached a listener, so there is nothing to reuse.
1303    #[error("all connect addresses failed: {0}")]
1304    AllAddressesFailed(String),
1305    /// `use_sni=true` was explicitly requested but the Oracle TCPS SNI string
1306    /// (`S{len}.{service}.V3.{version}`) is not a valid rustls DNS name (its
1307    /// trailing all-numeric label is rejected by RFC-strict rustls), so it
1308    /// cannot be transmitted. The driver fails closed here instead of silently
1309    /// connecting without SNI. Reconnect with `use_sni=false` (the default) to
1310    /// rely on the post-handshake Oracle DN match, which secures the connection
1311    /// without SNI.
1312    #[error(
1313        "use_sni=true cannot be honored: the Oracle SNI \"{0}\" is not a valid \
1314         rustls DNS name; reconnect with use_sni=false to secure the connection \
1315         with the post-handshake DN match instead"
1316    )]
1317    UnsupportedSni(String),
1318    #[error(
1319        "unexpected TNS packet type {ty} ({name})",
1320        ty = .0,
1321        name = tns_packet_type_name(*.0)
1322    )]
1323    UnexpectedPacket(u8),
1324    #[error("server kept requesting CONNECT resend ({0} rounds); giving up")]
1325    ConnectResendLoop(u8),
1326    #[error("server did not advertise fast authentication")]
1327    FastAuthRequired,
1328    #[error("server response did not contain {0}")]
1329    MissingSessionField(&'static str),
1330    #[error("call timeout of {0} ms exceeded")]
1331    CallTimeout(u32),
1332    #[error("query returned no rows")]
1333    NoRows,
1334    #[error("query returned more than one row")]
1335    TooManyRows,
1336    /// The in-flight operation was explicitly cancelled by the user (via
1337    /// [`Connection::cancel`] or by dropping a cancellable fetch future), the
1338    /// driver's analog of the server-side `ORA-01013` "user requested cancel of
1339    /// current operation". Like a [`Self::CallTimeout`] (`DPY-4024`), a cancel
1340    /// drains the wire and leaves the session ALIVE and the connection clean and
1341    /// reusable: it is therefore **not** [`Self::is_connection_lost`] and **is**
1342    /// [`Self::is_transient`] (re-run the idempotent call on the same
1343    /// connection). It is distinguished from `CallTimeout` only so callers can
1344    /// tell a deliberate cancel apart from a deadline overrun.
1345    #[error("ORA-01013: user requested cancel of current operation")]
1346    Cancelled,
1347    /// The connection was closed because recovery from a prior failure could
1348    /// not complete: most commonly a **second** timeout while draining the
1349    /// server's response after a [`Self::CallTimeout`] break (mirroring the
1350    /// reference `ERR_CONNECTION_CLOSED` raised when the post-break
1351    /// `_receive_packet` itself times out, protocol.pyx:454-458). Unlike
1352    /// [`Self::CallTimeout`], the wire stream could not be left clean, so the
1353    /// connection is dead and must be discarded — [`Self::is_connection_lost`]
1354    /// is `true` for this variant. The payload is the human-readable reason.
1355    #[error("DPY-4011: the database or network closed the connection: {0}")]
1356    ConnectionClosed(String),
1357    /// A TCPS/TLS transport error (wallet load, handshake, or server-cert /
1358    /// DN-match failure).
1359    #[error("TLS/TCPS error: {0}")]
1360    Tls(String),
1361    /// A wallet-location or wallet-format error. Kept distinct from generic
1362    /// TLS failures so callers can classify unsupported wallet formats and
1363    /// operator setup issues without parsing strings.
1364    #[error("wallet error: {0}")]
1365    Wallet(#[from] oracledb_protocol::tls::wallet::WalletError),
1366    /// Access-token authentication was requested over a non-TLS transport.
1367    /// A database access token must only travel over TCPS so it is not exposed
1368    /// in clear text (reference protocol.pyx `ERR_ACCESS_TOKEN_REQUIRES_TCPS` /
1369    /// DPY-3001). Reconnect with a `tcps://` connect string. The token itself is
1370    /// never included in this error.
1371    #[error("DPY-3001: access token authentication requires a TLS (TCPS) connection")]
1372    AccessTokenRequiresTcps,
1373    /// The OCI IAM database-token proof-of-possession private key could not be
1374    /// used to sign the auth header: it was not a readable PKCS#8 RSA key, or
1375    /// the RSA-PKCS1v15-SHA256 signing operation failed. The key material, the
1376    /// token, and the signed header are never included in this error — only a
1377    /// fixed diagnostic is surfaced.
1378    #[error(
1379        "OCI IAM database-token proof-of-possession signing failed: \
1380         the bound private key is not a usable PKCS#8 RSA key"
1381    )]
1382    IamTokenProofOfPossession,
1383    /// A pluggable [`TokenSource`] failed to produce a database access token.
1384    /// The failure is a redacted [`TokenSourceError`] class; the token and any
1385    /// provider detail never appear in this error.
1386    #[error("{0}")]
1387    TokenSource(TokenSourceError),
1388    /// The caller selected a known authentication mode that this thin build
1389    /// does not implement. The mode is structured so diagnostic tools can
1390    /// distinguish capability gaps from bad credentials, listener failures, or
1391    /// TLS setup errors without parsing the display string.
1392    #[error("{0}")]
1393    UnsupportedAuthMode(UnsupportedAuthMode),
1394    /// A sessionless transaction client-API misuse (reference
1395    /// ERR_SESSIONLESS_* / DPY-3034/3035/3036). The payload is the DPY full
1396    /// code so the shim can raise the matching DatabaseError.
1397    #[error("{0}")]
1398    SessionlessTransaction(SessionlessError),
1399    /// A TPC (two-phase commit) state machine returned an unexpected out state
1400    /// (reference `ERR_UNKNOWN_TRANSACTION_STATE` / DPY-5010). The payload is
1401    /// the unexpected state value.
1402    #[error("DPY-5010: internal error: unknown transaction state {0}")]
1403    UnknownTransactionState(u32),
1404    /// A client-side bind payload was provably incompatible with the SQL text.
1405    #[error("bind validation failed: {0}")]
1406    Bind(#[from] BindError),
1407    /// A typed [`FromSql`] conversion failed: the fetched value did not match
1408    /// the requested Rust type, was out of range, or could not be parsed. The
1409    /// payload describes the mismatch.
1410    #[error("type conversion failed: {0}")]
1411    Conversion(ConversionError),
1412    #[cfg(feature = "arrow")]
1413    #[error(transparent)]
1414    ArrowConversion(#[from] arrow::ArrowConversionError),
1415}
1416
1417pub type Result<T> = std::result::Result<T, Error>;
1418
1419pub(crate) fn duration_to_millis_saturating(duration: Duration) -> u32 {
1420    duration.as_millis().min(u128::from(u32::MAX)) as u32
1421}
1422
1423/// Whether an `io::Error` is Asupersync's own cancellation signal for a
1424/// cancellation-aware read/connect: kind `Interrupted` with the exact payload
1425/// `"cancelled"` (`asupersync::net::tcp::stream` poll paths, pinned `=0.3.9`).
1426/// This marker is set at the instant the runtime observes the cancel, so it is
1427/// a deterministic discriminator from a genuine OS `EINTR` (whose message is the
1428/// OS text, not `"cancelled"`) and is not subject to the interrupt-vs-checkpoint
1429/// visibility race a later `Cx::checkpoint()` on the reading thread can hit.
1430fn is_asupersync_cancellation(io_error: &std::io::Error) -> bool {
1431    io_error.kind() == std::io::ErrorKind::Interrupted && io_error.to_string() == "cancelled"
1432}
1433
1434/// A REF CURSOR handle returned in a row or implicit result set.
1435pub type Cursor = CursorValue;
1436
1437/// Resolve an owned [`Row`] column by index or by case-insensitive column name.
1438///
1439/// This trait is sealed; supported indexes are `usize` and `&str`.
1440pub trait ColumnIndex: column_index_private::Sealed {
1441    #[doc(hidden)]
1442    fn resolve(self, columns: &[ColumnMetadata]) -> std::result::Result<usize, ConversionError>;
1443}
1444
1445mod column_index_private {
1446    pub trait Sealed {}
1447}
1448
1449impl column_index_private::Sealed for usize {}
1450
1451impl ColumnIndex for usize {
1452    fn resolve(self, columns: &[ColumnMetadata]) -> std::result::Result<usize, ConversionError> {
1453        if self < columns.len() {
1454            Ok(self)
1455        } else {
1456            Err(ConversionError::OutOfRange {
1457                expected: "column index",
1458                detail: format!("no column at index {self}"),
1459            })
1460        }
1461    }
1462}
1463
1464impl column_index_private::Sealed for &str {}
1465
1466impl ColumnIndex for &str {
1467    fn resolve(self, columns: &[ColumnMetadata]) -> std::result::Result<usize, ConversionError> {
1468        columns
1469            .iter()
1470            .position(|col| col.name().eq_ignore_ascii_case(self))
1471            .ok_or_else(|| ConversionError::OutOfRange {
1472                expected: "column name",
1473                detail: format!("no column named {self:?}"),
1474            })
1475    }
1476}
1477
1478/// Structured classification of an [`Error`].
1479///
1480/// These accessors promote the driver's *internal* retry knowledge (which ORA
1481/// codes mean "the session died", "the resource was busy", "the cached plan is
1482/// stale") into a public, matchable taxonomy. python-oracledb gives you a bare
1483/// `.code` integer and leaves the classification to you; here the curated lists
1484/// ship with the driver so production retry / circuit-breaker code is trivial:
1485///
1486/// ```no_run
1487/// # use oracledb::Error;
1488/// # fn classify(err: &Error) {
1489/// if err.is_connection_lost() {
1490///     // reconnect, then retry
1491/// } else if err.is_retryable() {
1492///     // back off and retry on the same connection
1493/// }
1494/// # }
1495/// ```
1496///
1497/// The classification sources, in priority order:
1498///
1499/// 1. The structured server error code (`ServerErrorInfo.code`) when present.
1500/// 2. Otherwise the `ORA-NNNNN` prefix parsed from the error message.
1501///
1502/// The curated transient and connection-lost code sets are maintained in this
1503/// module and exposed through the stable methods below.
1504impl Error {
1505    /// Stable top-level error category.
1506    pub fn kind(&self) -> ErrorKind {
1507        match self {
1508            Error::Protocol(err) => protocol_error_kind(err),
1509            Error::Io(_)
1510            | Error::ListenerRefused(_)
1511            | Error::AllAddressesFailed(_)
1512            | Error::ConnectionClosed(_)
1513            | Error::Tls(_)
1514            | Error::UnsupportedSni(_)
1515            | Error::Wallet(_) => ErrorKind::Network,
1516            Error::CallTimeout(_) => ErrorKind::Timeout,
1517            Error::Cancelled => ErrorKind::Cancel,
1518            Error::Conversion(_) | Error::Bind(_) => ErrorKind::Conversion,
1519            #[cfg(feature = "arrow")]
1520            Error::ArrowConversion(_) => ErrorKind::Conversion,
1521            Error::AccessTokenRequiresTcps
1522            | Error::UnsupportedAuthMode(_)
1523            | Error::IamTokenProofOfPossession
1524            | Error::TokenSource(_) => ErrorKind::Authentication,
1525            Error::RedirectUnsupported
1526            | Error::InvalidRedirectData(_)
1527            | Error::ConnectRedirectLoop(_)
1528            | Error::Runtime(_)
1529            | Error::FastAuthRequired
1530            | Error::UnexpectedPacket(_)
1531            | Error::ConnectResendLoop(_)
1532            | Error::MissingSessionField(_)
1533            | Error::SessionlessTransaction(_)
1534            | Error::UnknownTransactionState(_) => ErrorKind::Protocol,
1535            Error::NoRows | Error::TooManyRows => ErrorKind::Database,
1536        }
1537    }
1538
1539    /// Structured resource-limit details when this error came from
1540    /// [`oracledb_protocol::wire::ProtocolLimits`].
1541    pub fn resource_limit(&self) -> Option<oracledb_protocol::ResourceLimit> {
1542        match self {
1543            Error::Protocol(err) => err.resource_limit(),
1544            _ => None,
1545        }
1546    }
1547
1548    /// The Oracle error number (`ORA-NNNNN`) this error carries, if any.
1549    ///
1550    /// Returns the structured `ServerErrorInfo.code` when the error came back
1551    /// on the structured path; otherwise it parses the `ORA-` prefix out of the
1552    /// server message. `None` for non-server errors (I/O, timeouts, protocol
1553    /// decode failures) and server messages with no `ORA-` code.
1554    ///
1555    /// The value is an `i32` (rather than `u32`) so it composes directly with
1556    /// the `i32`-typed codes most retry tables and logging layers already use;
1557    /// every real Oracle code fits comfortably.
1558    pub fn ora_code(&self) -> Option<i32> {
1559        match self {
1560            Error::Protocol(err) => protocol_error_ora_code(err).map(|code| code as i32),
1561            // A user cancel is the client-side shape of the server's ORA-01013
1562            // "user requested cancel of current operation".
1563            Error::Cancelled => Some(1013),
1564            _ => None,
1565        }
1566    }
1567
1568    /// Alias for [`Self::ora_code`] using the API-design terminology.
1569    pub fn oracle_code(&self) -> Option<i32> {
1570        self.ora_code()
1571    }
1572
1573    /// The server-reported parse offset / error position for this error, if the
1574    /// server provided one (1-based character offset into the SQL text for a
1575    /// parse error). Only the structured server-error path retains the offset;
1576    /// `None` everywhere else, and `None` when the server reported offset 0.
1577    pub fn offset(&self) -> Option<i32> {
1578        match self {
1579            Error::Protocol(err) => protocol_error_offset(err),
1580            _ => None,
1581        }
1582    }
1583
1584    /// Render a compiler-style caret diagnostic for a parse error, pointing at
1585    /// the exact character in `sql` the server flagged ([`Error::offset`]):
1586    ///
1587    /// ```text
1588    /// ORA-00942: table or view does not exist
1589    ///   |
1590    /// 1 | select * from no_such_table
1591    ///   |               ^
1592    /// ```
1593    ///
1594    /// Returns `None` when the error carries no parse offset (only structured
1595    /// server parse errors do). The headline is this error's first `Display`
1596    /// line (the `ORA-` code + message). python-oracledb hands you a bare offset
1597    /// integer and leaves the rendering to you; this does it.
1598    pub fn caret(&self, sql: &str) -> Option<String> {
1599        let offset = usize::try_from(self.offset()?).ok()?;
1600        let full = self.to_string();
1601        let headline = full.lines().next().unwrap_or(full.as_str());
1602        Some(render_caret(sql, offset, headline))
1603    }
1604
1605    /// Whether the connection that surfaced this error is still reusable. A
1606    /// dead disposition means the session was killed, the socket was reset, or
1607    /// the server reported one of the session-dead Oracle codes; callers should
1608    /// discard the connection before continuing.
1609    ///
1610    /// Raw I/O errors ([`Error::Io`]) and the recovery-failure
1611    /// [`Error::ConnectionClosed`] also count as connection-lost: the transport
1612    /// is no longer usable.
1613    ///
1614    /// A plain [`Error::CallTimeout`] is deliberately **not** connection-lost.
1615    /// On a call timeout the driver sends a BREAK, then drains the server's
1616    /// in-flight response and the RESET handshake, leaving the wire stream
1617    /// clean and the connection reusable — exactly as python-oracledb does for
1618    /// `DPY-4024` (`ERR_CALL_TIMEOUT_EXCEEDED`), which, unlike `DPY-4011`
1619    /// (`ERR_CONNECTION_CLOSED`), does **not** set `is_session_dead`
1620    /// (errors.py:124-125). The connection survives; retry on the same one. Only
1621    /// when that drain itself fails (a *second* timeout) does the driver give up
1622    /// and surface [`Error::ConnectionClosed`], which *is* connection-lost.
1623    pub fn connection_disposition(&self) -> ConnectionDisposition {
1624        match self {
1625            Error::Io(_) | Error::ConnectionClosed(_) => ConnectionDisposition::Dead,
1626            _ if self
1627                .ora_code()
1628                .is_some_and(|code| SESSION_DEAD_ORA_CODES.contains(&(code as u32))) =>
1629            {
1630                ConnectionDisposition::Dead
1631            }
1632            _ => ConnectionDisposition::Reusable,
1633        }
1634    }
1635
1636    pub fn is_connection_lost(&self) -> bool {
1637        match self {
1638            Error::Io(_) | Error::ConnectionClosed(_) => true,
1639            _ => self
1640                .ora_code()
1641                .is_some_and(|code| CONNECTION_LOST_ORA_CODES.contains(&(code as u32))),
1642        }
1643    }
1644
1645    /// Whether this error is *transient*: the operation failed for a reason
1646    /// expected to clear on its own (lock contention, deadlock victim, listener
1647    /// hand-off congestion, resource-manager throttle, or a call timeout), so
1648    /// the same call may be retried on the **same** connection after a short
1649    /// back-off. Does **not** include connection-lost codes (those need a
1650    /// reconnect first — use [`Self::is_connection_lost`]).
1651    ///
1652    /// [`Error::CallTimeout`] is transient: after the driver drains the wire the
1653    /// connection is clean and reusable, so re-running the (idempotent) call on
1654    /// the same connection — e.g. with a longer timeout — is the natural retry.
1655    /// [`Error::Cancelled`] is transient for the same reason: an explicit cancel
1656    /// also drains the wire and leaves the session alive.
1657    pub fn is_transient(&self) -> bool {
1658        matches!(self, Error::CallTimeout(_) | Error::Cancelled)
1659            || self
1660                .ora_code()
1661                .is_some_and(|code| TRANSIENT_ORA_CODES.contains(&(code as u32)))
1662    }
1663
1664    /// Conservative retry guidance. Any returned retry action still requires
1665    /// the caller to know the operation is idempotent; non-idempotent operations
1666    /// must not be replayed automatically.
1667    pub fn retry_hint(&self) -> RetryHint {
1668        if self.is_transient() {
1669            RetryHint::RetrySameConnectionIfIdempotent
1670        } else if self.is_connection_lost() {
1671            RetryHint::ReconnectThenRetryIfIdempotent
1672        } else {
1673            RetryHint::Never
1674        }
1675    }
1676
1677    pub fn is_retryable(&self) -> bool {
1678        !matches!(self.retry_hint(), RetryHint::Never)
1679    }
1680}
1681
1682/// Client-API misuse of the sessionless transaction API, mirroring the
1683/// reference `ERR_SESSIONLESS_*` errors (impl/oracledb/errors.py:338-340).
1684#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1685#[non_exhaustive]
1686pub enum SessionlessError {
1687    /// DPY-3034: suspend/resume was attempted on a transaction started with
1688    /// DBMS_TRANSACTION (or vice versa).
1689    DifferingMethods,
1690    /// DPY-3035: a sessionless transaction is already active on the connection.
1691    AlreadyActive,
1692    /// DPY-3036: no sessionless transaction is active on the connection.
1693    Inactive,
1694}
1695
1696impl SessionlessError {
1697    /// The DPY full code (reference errors.py full codes).
1698    pub fn full_code(self) -> &'static str {
1699        match self {
1700            Self::DifferingMethods => "DPY-3034",
1701            Self::AlreadyActive => "DPY-3035",
1702            Self::Inactive => "DPY-3036",
1703        }
1704    }
1705
1706    /// The reference error message text (errors.py:945-953).
1707    pub fn message(self) -> &'static str {
1708        match self {
1709            Self::DifferingMethods => {
1710                "suspending or resuming a Sessionless Transaction can be done with \
1711                 DBMS_TRANSACTION or with python-oracledb, but not both"
1712            }
1713            Self::AlreadyActive => {
1714                "suspend, commit, or rollback the current active sessionless \
1715                 transaction before beginning or resuming another one"
1716            }
1717            Self::Inactive => "no Sessionless Transaction is active",
1718        }
1719    }
1720}
1721
1722impl std::fmt::Display for SessionlessError {
1723    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1724        write!(f, "{}: {}", self.full_code(), self.message())
1725    }
1726}
1727
1728/// Whether an execute error is the server signalling that the cached
1729/// statement's types no longer match (ORA-00932 inconsistent datatypes /
1730/// ORA-01007 variable not in select list), the two errors the reference
1731/// retries with a full parse (impl/thin/constants.pxi:166-167,
1732/// messages/base.pyx:1199-1213).
1733fn refetch_retry_applies(err: &Error) -> bool {
1734    let message = match err {
1735        Error::Protocol(oracledb_protocol::ProtocolError::ServerError(message)) => message,
1736        Error::Protocol(oracledb_protocol::ProtocolError::ServerErrorWithRowCount {
1737            message,
1738            ..
1739        }) => message,
1740        Error::Protocol(oracledb_protocol::ProtocolError::ServerErrorInfo(details)) => {
1741            // structured error path: match by ORA code directly (ORA-00932
1742            // inconsistent data types / ORA-01007 variable not in select list)
1743            return details.code == 932 || details.code == 1007;
1744        }
1745        _ => return false,
1746    };
1747    message.starts_with("ORA-00932") || message.starts_with("ORA-01007")
1748}
1749
1750/// A database access token used in place of a password — an OCI IAM database
1751/// token or an OAuth2 token. Its [`Debug`] output is redacted so the secret
1752/// never leaks into logs, error messages, or panic output. Set it with
1753/// [`ConnectOptions::with_access_token`].
1754#[derive(Clone)]
1755pub struct AccessToken(String);
1756
1757const REDACTED_SECRET: &str = "***redacted***";
1758
1759impl AccessToken {
1760    /// Wrap a token string. The value is never printed; see the type docs.
1761    pub fn new(token: impl Into<String>) -> Self {
1762        Self(token.into())
1763    }
1764
1765    /// The raw token, for sending on the wire. Crate-internal so callers cannot
1766    /// accidentally route the secret through `Display`/formatting.
1767    pub(crate) fn expose(&self) -> &str {
1768        &self.0
1769    }
1770}
1771
1772impl std::fmt::Debug for AccessToken {
1773    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1774        // Never render the token, regardless of formatter flags.
1775        f.write_str("AccessToken(")?;
1776        f.write_str(REDACTED_SECRET)?;
1777        f.write_str(")")
1778    }
1779}
1780
1781/// The RSA private key (PKCS#8 PEM) an OCI IAM database token is bound to, used
1782/// to sign the proof-of-possession header. Like [`AccessToken`] its [`Debug`]
1783/// is redacted so the key never leaks into logs, errors, or panics. Set it with
1784/// [`ConnectOptions::with_access_token_and_key`] or
1785/// [`ConnectOptions::with_token_source_and_key`].
1786#[derive(Clone)]
1787pub struct TokenPrivateKey(String);
1788
1789impl TokenPrivateKey {
1790    /// Wrap a PKCS#8 private-key PEM. The value is never printed; see the docs.
1791    pub fn new(pem: impl Into<String>) -> Self {
1792        Self(pem.into())
1793    }
1794
1795    /// The raw PEM, for signing only. Crate-internal so callers cannot route the
1796    /// secret through `Display`/formatting.
1797    pub(crate) fn expose(&self) -> &str {
1798        &self.0
1799    }
1800}
1801
1802impl std::fmt::Debug for TokenPrivateKey {
1803    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1804        f.write_str("TokenPrivateKey(")?;
1805        f.write_str(REDACTED_SECRET)?;
1806        f.write_str(")")
1807    }
1808}
1809
1810/// A boxed, `Send` future — the return type of [`TokenSource::get_token`].
1811///
1812/// Defined locally because the driver takes no dependency on the `futures`
1813/// crate; it is the conventional `Pin<Box<dyn Future + Send>>` shape.
1814pub type BoxFuture<'a, T> = std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send + 'a>>;
1815
1816/// Failure classes a [`TokenSource`] may report.
1817///
1818/// Every variant is **fully redacted**: neither [`Debug`] nor [`std::fmt::Display`]
1819/// reveals any inner detail. The variants carry no payload by construction, so a
1820/// token, a signed assertion, or a raw provider response can never leak through
1821/// a token-source failure into logs, error chains, or panic output. A provider
1822/// maps its underlying error into one of these classes and drops the detail —
1823/// fail-closed.
1824#[derive(Clone, Copy, Eq, PartialEq)]
1825#[non_exhaustive]
1826pub enum TokenSourceError {
1827    /// The provider (command / process / HTTP call) failed to execute or exited
1828    /// unsuccessfully.
1829    Exec,
1830    /// The provider ran but returned something that is not a usable token.
1831    Invalid,
1832    /// The provider did not produce a token within its own deadline.
1833    Timeout,
1834    /// Any other provider failure.
1835    Other,
1836}
1837
1838impl TokenSourceError {
1839    /// A stable, non-secret label for this failure class.
1840    pub fn as_str(&self) -> &'static str {
1841        match self {
1842            Self::Exec => "exec",
1843            Self::Invalid => "invalid",
1844            Self::Timeout => "timeout",
1845            Self::Other => "other",
1846        }
1847    }
1848}
1849
1850impl std::fmt::Display for TokenSourceError {
1851    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1852        let msg = match self {
1853            Self::Exec => "token source failed to execute",
1854            Self::Invalid => "token source returned an invalid token",
1855            Self::Timeout => "token source timed out",
1856            Self::Other => "token source failed",
1857        };
1858        f.write_str(msg)
1859    }
1860}
1861
1862impl std::fmt::Debug for TokenSourceError {
1863    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1864        // Only the class name — there is no payload and nothing to leak.
1865        write!(f, "TokenSourceError::{}", {
1866            match self {
1867                Self::Exec => "Exec",
1868                Self::Invalid => "Invalid",
1869                Self::Timeout => "Timeout",
1870                Self::Other => "Other",
1871            }
1872        })
1873    }
1874}
1875
1876impl std::error::Error for TokenSourceError {}
1877
1878/// A pluggable source of OCI IAM / OAuth2 database access tokens.
1879///
1880/// Implement this to obtain a fresh token at connect time (for example by
1881/// shelling out to the OCI CLI, calling an instance-principal endpoint, or
1882/// reading a short-lived token file). The driver calls [`get_token`] **once
1883/// for each physical connection attempt**, before it dials a TCPS endpoint.
1884/// The source owns freshness: this interface returns only a token string, so
1885/// the driver cannot inspect token expiry and does not retry after an
1886/// authentication rejection. Return a current token on every call (or retain
1887/// and refresh it inside the source). The returned token is placed in
1888/// `AUTH_TOKEN` and therefore requires a TCPS transport; a token source on a
1889/// plaintext descriptor is refused with [`Error::AccessTokenRequiresTcps`]
1890/// *before* the source is ever consulted, so a token is never fetched for a
1891/// connection that could not carry it securely.
1892///
1893/// The token itself must never be logged; report failures as the redacted
1894/// [`TokenSourceError`].
1895///
1896/// [`get_token`]: TokenSource::get_token
1897pub trait TokenSource: Send + Sync {
1898    /// Fetch a fresh database access token, or a redacted failure class.
1899    fn get_token(&self) -> BoxFuture<'_, std::result::Result<String, TokenSourceError>>;
1900}
1901
1902/// Stable classifier for the thin driver's authentication modes.
1903#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
1904#[non_exhaustive]
1905pub enum AuthModeKind {
1906    Password,
1907    Proxy,
1908    External,
1909    IamToken,
1910    Kerberos,
1911    Radius,
1912}
1913
1914impl std::fmt::Display for AuthModeKind {
1915    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1916        let name = match self {
1917            Self::Password => "password",
1918            Self::Proxy => "proxy",
1919            Self::External => "external",
1920            Self::IamToken => "iam-token",
1921            Self::Kerberos => "kerberos",
1922            Self::Radius => "radius",
1923        };
1924        f.write_str(name)
1925    }
1926}
1927
1928/// Whether a known authentication mode is implemented by this thin driver.
1929#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
1930#[non_exhaustive]
1931pub enum AuthModeSupport {
1932    Supported,
1933    UnsupportedInThin,
1934}
1935
1936/// Queryable authentication capability metadata for this build.
1937#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1938#[non_exhaustive]
1939pub struct AuthCapabilities {
1940    pub password: AuthModeSupport,
1941    pub proxy: AuthModeSupport,
1942    pub external: AuthModeSupport,
1943    pub iam_token: AuthModeSupport,
1944    pub kerberos: AuthModeSupport,
1945    pub radius: AuthModeSupport,
1946}
1947
1948impl AuthCapabilities {
1949    /// Capabilities of the current pure-thin implementation.
1950    pub const THIN: Self = Self {
1951        password: AuthModeSupport::Supported,
1952        proxy: AuthModeSupport::Supported,
1953        external: AuthModeSupport::UnsupportedInThin,
1954        iam_token: AuthModeSupport::Supported,
1955        kerberos: AuthModeSupport::UnsupportedInThin,
1956        radius: AuthModeSupport::UnsupportedInThin,
1957    };
1958
1959    #[must_use]
1960    pub fn support(self, mode: AuthModeKind) -> AuthModeSupport {
1961        match mode {
1962            AuthModeKind::Password => self.password,
1963            AuthModeKind::Proxy => self.proxy,
1964            AuthModeKind::External => self.external,
1965            AuthModeKind::IamToken => self.iam_token,
1966            AuthModeKind::Kerberos => self.kerberos,
1967            AuthModeKind::Radius => self.radius,
1968        }
1969    }
1970}
1971
1972/// A known authentication mode selected by the caller.
1973#[derive(Clone, Eq, PartialEq)]
1974#[non_exhaustive]
1975pub enum AuthMode {
1976    Password,
1977    Proxy,
1978    External,
1979    IamToken,
1980    Kerberos {
1981        principal: Option<String>,
1982        keytab: Option<String>,
1983    },
1984    Radius {
1985        challenge: Option<String>,
1986    },
1987}
1988
1989impl AuthMode {
1990    #[must_use]
1991    pub fn kind(&self) -> AuthModeKind {
1992        match self {
1993            Self::Password => AuthModeKind::Password,
1994            Self::Proxy => AuthModeKind::Proxy,
1995            Self::External => AuthModeKind::External,
1996            Self::IamToken => AuthModeKind::IamToken,
1997            Self::Kerberos { .. } => AuthModeKind::Kerberos,
1998            Self::Radius { .. } => AuthModeKind::Radius,
1999        }
2000    }
2001
2002    fn unsupported_in_thin(&self) -> Option<UnsupportedAuthMode> {
2003        let mode = self.kind();
2004        (AuthCapabilities::THIN.support(mode) == AuthModeSupport::UnsupportedInThin)
2005            .then_some(UnsupportedAuthMode { mode })
2006    }
2007}
2008
2009impl std::fmt::Debug for AuthMode {
2010    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2011        fn redacted(value: &Option<String>) -> Option<&'static str> {
2012            value.as_ref().map(|_| REDACTED_SECRET)
2013        }
2014
2015        match self {
2016            Self::Password => f.write_str("Password"),
2017            Self::Proxy => f.write_str("Proxy"),
2018            Self::External => f.write_str("External"),
2019            Self::IamToken => f.write_str("IamToken"),
2020            Self::Kerberos { principal, keytab } => f
2021                .debug_struct("Kerberos")
2022                .field("principal", &redacted(principal))
2023                .field("keytab", &redacted(keytab))
2024                .finish(),
2025            Self::Radius { challenge } => f
2026                .debug_struct("Radius")
2027                .field("challenge", &redacted(challenge))
2028                .finish(),
2029        }
2030    }
2031}
2032
2033/// Structured unsupported-authentication diagnostic.
2034#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
2035#[error("authentication mode {mode} is not supported by this thin build")]
2036pub struct UnsupportedAuthMode {
2037    mode: AuthModeKind,
2038}
2039
2040impl UnsupportedAuthMode {
2041    #[must_use]
2042    pub fn mode(&self) -> AuthModeKind {
2043        self.mode
2044    }
2045}
2046
2047/// Parse a username that may use the reference bracket form.
2048///
2049/// Mirrors python-oracledb `ConnectParamsImpl.parse_user` after upstream
2050/// e861662a75b5: `base[proxy]` splits into base user + proxy; `[proxy]`
2051/// (leading bracket, empty base) is valid for token-auth proxy-only sessions.
2052/// A string that does not end in `]` or has no `[` is returned unchanged with
2053/// no proxy. The closing `]` must be the final character.
2054pub fn parse_user_and_proxy(user: &str) -> (String, Option<String>) {
2055    if let Some(start) = user.find('[') {
2056        if user.ends_with(']') && start < user.len() - 1 {
2057            let base = user[..start].to_string();
2058            let proxy = user[start + 1..user.len() - 1].to_string();
2059            return (base, Some(proxy));
2060        }
2061    }
2062    (user.to_string(), None)
2063}
2064
2065/// Everything needed to open a connection: where to connect, who to
2066/// authenticate as, and the [`ClientIdentity`] the database will record.
2067///
2068/// Build the required fields with [`ConnectOptions::new`], then layer optional
2069/// settings with the `with_*` methods.
2070#[derive(Clone)]
2071pub struct ConnectOptions {
2072    /// EasyConnect descriptor, `host:port/service_name` (the port and service
2073    /// may be omitted to take the listener defaults).
2074    connect_string: String,
2075    /// Database user to authenticate as.
2076    user: String,
2077    /// Password for `user`.
2078    password: String,
2079    /// Session identity reported to the database (`v$session`).
2080    identity: ClientIdentity,
2081    /// Application-context triples `(namespace, key, value)` set on the
2082    /// session at logon (reference `connection.appcontext`).
2083    app_context: Vec<(String, String, String)>,
2084    /// Session Data Unit (negotiated packet size) in bytes.
2085    sdu: u16,
2086    /// Proxy user for `[proxy_user]` style connections, if any.
2087    proxy_user: Option<String>,
2088    /// Authentication mode selected by the caller. Unsupported thin modes fail
2089    /// before network I/O with [`Error::UnsupportedAuthMode`].
2090    auth_mode: AuthMode,
2091    /// When set, `(SERVER=emon)` is injected into the connect descriptor's
2092    /// `CONNECT_DATA`. This routes the connection to the database EMON process
2093    /// used to push CQN notifications (reference `subscr.pyx` rewrites
2094    /// `description.server_type = "emon"` for the background connection).
2095    server_type_emon: bool,
2096    /// TCPS wallet directory (`MY_WALLET_DIRECTORY` / `wallet_location`). The
2097    /// directory should contain `ewallet.pem`, `ewallet.p12` (requires
2098    /// `wallet_password`), or `cwallet.sso`. When `None`, `TNS_ADMIN` is
2099    /// consulted; the special value `SYSTEM` (case-insensitive) forces the
2100    /// system trust store. Only consulted for TCPS connections.
2101    wallet_location: Option<String>,
2102    /// Password for an encrypted wallet (`ewallet.p12`, or an `ewallet.pem`
2103    /// with an encrypted private key). `None` for auto-login or verify-only
2104    /// wallets.
2105    wallet_password: Option<String>,
2106    /// Oracle edition for Edition-Based Redefinition (`AUTH_ORA_EDITION`),
2107    /// applied during authentication before any user SQL. `None` uses the
2108    /// database default edition.
2109    edition: Option<String>,
2110    /// Run the Oracle server-DN match after the TLS handshake
2111    /// (`ssl_server_dn_match`, reference default `true`).
2112    ssl_server_dn_match: bool,
2113    /// Explicit expected server-certificate distinguished name
2114    /// (`ssl_server_cert_dn`). When set, the server's subject DN must equal
2115    /// this exactly; when `None`, the host name is matched against the
2116    /// certificate's SAN DNS names and common names.
2117    ssl_server_cert_dn: Option<String>,
2118    /// Send the Oracle TCPS SNI string (`use_sni`, reference default `false`).
2119    /// See [`tls::TlsParams::use_sni`] for the rustls-name-validity caveat.
2120    use_sni: bool,
2121    /// Authenticate with a database access token (OCI IAM / OAuth2) instead of
2122    /// `password`. When set, the token is sent as `AUTH_TOKEN` and no password
2123    /// verifier is exchanged. Token auth requires a TLS/TCPS transport; see
2124    /// [`ConnectOptions::with_access_token`]. The token is redacted from `Debug`.
2125    access_token: Option<AccessToken>,
2126    /// The RSA private key (PKCS#8 PEM) an OCI IAM **database** token is bound
2127    /// to. When present (with an `access_token`), the driver performs the
2128    /// token's proof-of-possession: it signs the auth header with this key and
2129    /// sends `AUTH_HEADER`/`AUTH_SIGNATURE` so the server can verify possession
2130    /// against the token's embedded public key. Required for OCI IAM database
2131    /// tokens (`oci iam db-token get`); a plain OAuth2 bearer token needs none.
2132    /// Redacted from `Debug`. Set with
2133    /// [`ConnectOptions::with_access_token_and_key`] or
2134    /// [`ConnectOptions::with_token_source_and_key`].
2135    access_token_private_key: Option<TokenPrivateKey>,
2136    /// Pluggable source of database access tokens (OCI IAM / OAuth2). When set
2137    /// and no static [`access_token`](Self::access_token) is present, the driver
2138    /// calls it once for each physical connection attempt. The source owns
2139    /// freshness; the driver does not inspect expiry or retry a rejected token.
2140    /// Like a static token it requires TCPS; a token source on a plaintext
2141    /// descriptor is refused before it is ever consulted. Set with
2142    /// [`ConnectOptions::with_token_source`].
2143    token_source: Option<std::sync::Arc<dyn TokenSource>>,
2144    /// Maximum number of open statements kept in this connection's statement
2145    /// cache. Defaults to 20 (the reference default). `0` disables caching
2146    /// entirely (every statement's cursor is closed after use, never retained),
2147    /// matching python-oracledb's `stmtcachesize=0`. The cache holds at most this
2148    /// many entries, each a small `(sql, cursor_id)` pair, so it is bounded by
2149    /// construction. Set with [`ConnectOptions::with_statement_cache_size`].
2150    statement_cache_size: usize,
2151    /// Resource policy for thin-protocol decoding and packet reassembly.
2152    protocol_limits: ProtocolLimits,
2153    /// Optional read-inactivity deadline applied to every post-auth wire read
2154    /// (GH#14). When `Some(d)`, a single read operation that makes no progress
2155    /// within `d` fails with [`Error::CallTimeout`] rather than hanging forever
2156    /// on a silent or half-open server. `None` (the default) preserves the prior
2157    /// unbounded-read behaviour; operators opt in per connection with
2158    /// [`ConnectOptions::with_inactivity_timeout`]. The CONNECT/ACCEPT phase is
2159    /// bounded separately by the DSN transport-connect timeout, and TCP keepalive
2160    /// is derived from a DSN `EXPIRE_TIME`.
2161    inactivity_timeout: Option<Duration>,
2162    /// Optional cross-connection statement-shape cache (bead a4-8pp). When set,
2163    /// every connection built from these options shares this cache, so a query's
2164    /// described result-column shape is tracked across connections and a
2165    /// concurrent DDL that changes the shape triggers a self-heal (re-describe)
2166    /// on the next execute instead of a stale decode. `None` (default) gives
2167    /// each connection a private cache, preserving the prior per-connection
2168    /// behaviour. Set with
2169    /// [`ConnectOptions::with_shared_statement_shape_cache`].
2170    statement_shape_cache: Option<Arc<StatementShapeCache>>,
2171}
2172
2173impl std::fmt::Debug for ConnectOptions {
2174    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2175        let wallet_location = self.wallet_location.as_ref().map(|_| REDACTED_SECRET);
2176        let wallet_password = self.wallet_password.as_ref().map(|_| REDACTED_SECRET);
2177        let server_cert_dn = self.ssl_server_cert_dn.as_ref().map(|_| REDACTED_SECRET);
2178        f.debug_struct("ConnectOptions")
2179            .field("connect_string", &self.connect_string)
2180            .field("user", &self.full_user())
2181            .field("password", &REDACTED_SECRET)
2182            .field("identity", &self.identity)
2183            .field("app_context", &self.app_context)
2184            .field("sdu", &self.sdu)
2185            .field("proxy_user", &self.proxy_user)
2186            .field("auth_mode", &self.auth_mode)
2187            .field("server_type_emon", &self.server_type_emon)
2188            .field("wallet_location", &wallet_location)
2189            .field("wallet_password", &wallet_password)
2190            .field("edition", &self.edition)
2191            .field("ssl_server_dn_match", &self.ssl_server_dn_match)
2192            .field("ssl_server_cert_dn", &server_cert_dn)
2193            .field("use_sni", &self.use_sni)
2194            .field("access_token", &self.access_token)
2195            .field("access_token_private_key", &self.access_token_private_key)
2196            .field(
2197                "token_source",
2198                &self.token_source.as_ref().map(|_| "<token source>"),
2199            )
2200            .field("statement_cache_size", &self.statement_cache_size)
2201            .field("protocol_limits", &self.protocol_limits)
2202            .field("inactivity_timeout", &self.inactivity_timeout)
2203            .field(
2204                "statement_shape_cache",
2205                &self
2206                    .statement_shape_cache
2207                    .as_ref()
2208                    .map(|_| "<shared statement-shape cache>"),
2209            )
2210            .finish()
2211    }
2212}
2213
2214impl ConnectOptions {
2215    /// Create connect options with the required fields. `connect_string` is an
2216    /// EasyConnect descriptor (`host:port/service_name`); `identity` is the
2217    /// session identity the database will record. Optional settings default to
2218    /// an 8 KiB SDU, no application context, and no proxy user.
2219    ///
2220    /// `user` may be a plain username or the reference bracket form
2221    /// `base[proxy]` / `[proxy]` (proxy-only, typically with token auth). See
2222    /// [`parse_user_and_proxy`]. An explicit later [`Self::with_proxy_user`]
2223    /// call overwrites any proxy desugared here.
2224    pub fn new(
2225        connect_string: impl Into<String>,
2226        user: impl Into<String>,
2227        password: impl Into<String>,
2228        identity: ClientIdentity,
2229    ) -> Self {
2230        let raw_user = user.into();
2231        let (user, proxy_user) = parse_user_and_proxy(&raw_user);
2232        let auth_mode = if proxy_user.is_some() {
2233            AuthMode::Proxy
2234        } else {
2235            AuthMode::Password
2236        };
2237        Self {
2238            connect_string: connect_string.into(),
2239            user,
2240            password: password.into(),
2241            identity,
2242            app_context: Vec::new(),
2243            sdu: 8192,
2244            proxy_user,
2245            auth_mode,
2246            server_type_emon: false,
2247            wallet_location: None,
2248            wallet_password: None,
2249            ssl_server_dn_match: true,
2250            ssl_server_cert_dn: None,
2251            use_sni: false,
2252            edition: None,
2253            access_token: None,
2254            access_token_private_key: None,
2255            token_source: None,
2256            statement_cache_size: STATEMENT_CACHE_SIZE,
2257            protocol_limits: ProtocolLimits::DEFAULT,
2258            inactivity_timeout: None,
2259            statement_shape_cache: None,
2260        }
2261    }
2262
2263    /// Create connect options that express passwordless external authentication
2264    /// intent without caller-supplied dummy credentials. This thin build
2265    /// currently reports the mode as [`Error::UnsupportedAuthMode`] before any
2266    /// network I/O; use [`Self::auth_capabilities`] to inspect support.
2267    pub fn external_auth(connect_string: impl Into<String>, identity: ClientIdentity) -> Self {
2268        let mut options = Self::new(connect_string, "", "", identity);
2269        options.auth_mode = AuthMode::External;
2270        options
2271    }
2272
2273    /// Create connect options for Kerberos intent. Real Kerberos/GSSAPI
2274    /// exchange is not implemented in this thin build; principal and keytab are
2275    /// carried only for structured diagnostics and are redacted from `Debug`.
2276    pub fn kerberos_auth(
2277        connect_string: impl Into<String>,
2278        principal: impl Into<String>,
2279        keytab: impl Into<String>,
2280        identity: ClientIdentity,
2281    ) -> Self {
2282        let mut options = Self::new(connect_string, "", "", identity);
2283        options.auth_mode = AuthMode::Kerberos {
2284            principal: Some(principal.into()),
2285            keytab: Some(keytab.into()),
2286        };
2287        options
2288    }
2289
2290    /// Create connect options for RADIUS/native-MFA intent. Real challenge
2291    /// exchange is not implemented in this thin build; the challenge hint is
2292    /// carried only for structured diagnostics and is redacted from `Debug`.
2293    pub fn radius_auth(
2294        connect_string: impl Into<String>,
2295        challenge: impl Into<String>,
2296        identity: ClientIdentity,
2297    ) -> Self {
2298        let mut options = Self::new(connect_string, "", "", identity);
2299        options.auth_mode = AuthMode::Radius {
2300            challenge: Some(challenge.into()),
2301        };
2302        options
2303    }
2304
2305    /// Set the thin-protocol resource limits. Invalid policies are rejected at
2306    /// connect time before any network I/O.
2307    #[must_use]
2308    pub fn with_protocol_limits(mut self, limits: ProtocolLimits) -> Self {
2309        self.protocol_limits = limits;
2310        self
2311    }
2312
2313    /// Set a read-inactivity deadline for every post-auth wire read on this
2314    /// connection (GH#14). A read operation that makes no progress within
2315    /// `timeout` fails with [`Error::CallTimeout`] rather than hanging forever on
2316    /// a silent or half-open peer (a half-open connection whose FIN/RST was lost
2317    /// otherwise wedges the read indefinitely). Unset by default, which keeps the
2318    /// prior unbounded-read behaviour. The CONNECT/ACCEPT phase is bounded by the
2319    /// DSN transport-connect timeout; this governs only established-session reads.
2320    #[must_use]
2321    pub fn with_inactivity_timeout(mut self, timeout: Duration) -> Self {
2322        self.inactivity_timeout = Some(timeout);
2323        self
2324    }
2325
2326    /// Set the statement-cache capacity for this connection (number of open
2327    /// statements retained for reuse). `0` disables caching — every statement's
2328    /// cursor is closed after use rather than cached (python-oracledb
2329    /// `stmtcachesize=0` semantics). The default is 20. The cache is bounded to
2330    /// this many small entries, so a large value cannot cause unbounded growth
2331    /// beyond the count of distinct prepared statements.
2332    #[must_use]
2333    pub fn with_statement_cache_size(mut self, size: usize) -> Self {
2334        self.statement_cache_size = size;
2335        self
2336    }
2337
2338    /// Authenticate with a database access token instead of a password — an OCI
2339    /// IAM database token or an OAuth2 token (python-oracledb's `access_token`).
2340    /// The token is sent as `AUTH_TOKEN` with no password-verifier exchange.
2341    ///
2342    /// Token authentication **requires** a TLS/TCPS connection (the token would
2343    /// otherwise travel in clear text); connecting with a token over plain TCP
2344    /// fails with the typed [`Error::AccessTokenRequiresTcps`]. The token is
2345    /// wrapped in [`AccessToken`], whose `Debug` is redacted, so it never appears
2346    /// in logs or error output.
2347    #[must_use]
2348    pub fn with_access_token(mut self, token: impl Into<String>) -> Self {
2349        self.access_token = Some(AccessToken::new(token));
2350        self.auth_mode = AuthMode::IamToken;
2351        self
2352    }
2353
2354    /// Authenticate with an OCI IAM **database** token and its bound RSA private
2355    /// key (`oci iam db-token get` writes the token and a PKCS#8 `oci_db_key.pem`).
2356    ///
2357    /// Unlike a plain OAuth2 bearer token, an OCI IAM database token is a
2358    /// proof-of-possession credential: the token embeds a public key and the
2359    /// database refuses the bearer token alone with `ORA-01017`. With the private
2360    /// key set, the driver signs the auth header (RSA-PKCS1v15 + SHA-256) and
2361    /// sends `AUTH_HEADER`/`AUTH_SIGNATURE`, which the server verifies against the
2362    /// token's embedded public key. Like [`Self::with_access_token`] this selects
2363    /// token auth and therefore **requires** a TLS/TCPS connection. Both the token
2364    /// and the key are redacted from `Debug`.
2365    #[must_use]
2366    pub fn with_access_token_and_key(
2367        mut self,
2368        token: impl Into<String>,
2369        private_key_pkcs8_pem: impl Into<String>,
2370    ) -> Self {
2371        self.access_token = Some(AccessToken::new(token));
2372        self.access_token_private_key = Some(TokenPrivateKey::new(private_key_pkcs8_pem));
2373        self.auth_mode = AuthMode::IamToken;
2374        self
2375    }
2376
2377    /// Authenticate with an OCI IAM **database** token obtained from a
2378    /// refreshable [`TokenSource`] and its bound RSA private key (PKCS#8 PEM).
2379    ///
2380    /// OCI IAM database tokens are proof-of-possession credentials: each token
2381    /// is bound to the supplied key, which the driver uses to produce
2382    /// `AUTH_HEADER`/`AUTH_SIGNATURE`. The source is consulted once for every
2383    /// physical connection attempt, so it owns freshness; no token is stored in
2384    /// these options. As with [`Self::with_token_source`], the descriptor must
2385    /// use TLS/TCPS or the driver fails with [`Error::AccessTokenRequiresTcps`]
2386    /// before consulting the source. Both the source's token and the key remain
2387    /// redacted from [`Debug`].
2388    ///
2389    /// This method deliberately does not set a static access token. Supplying a
2390    /// static token separately still takes precedence, exactly as documented by
2391    /// [`Self::with_token_source`].
2392    #[must_use]
2393    pub fn with_token_source_and_key(
2394        mut self,
2395        source: std::sync::Arc<dyn TokenSource>,
2396        private_key_pkcs8_pem: impl Into<String>,
2397    ) -> Self {
2398        self.token_source = Some(source);
2399        self.access_token_private_key = Some(TokenPrivateKey::new(private_key_pkcs8_pem));
2400        self.auth_mode = AuthMode::IamToken;
2401        self
2402    }
2403
2404    /// Authenticate with a database access token obtained from a pluggable
2405    /// [`TokenSource`] (OCI IAM / OAuth2). The driver calls the source once for
2406    /// each physical connection attempt. The source owns freshness: this API
2407    /// does not expose token expiry, so the driver neither inspects expiry nor
2408    /// retries after an authentication rejection. Like
2409    /// [`Self::with_access_token`] this selects token auth and therefore
2410    /// **requires** a TLS/TCPS connection: a token source on a plaintext
2411    /// descriptor fails with the typed [`Error::AccessTokenRequiresTcps`]
2412    /// *before* the source is consulted, so a token is never fetched for a
2413    /// transport that could not carry it securely.
2414    ///
2415    /// A static [`Self::with_access_token`] takes precedence if both are set.
2416    #[must_use]
2417    pub fn with_token_source(mut self, source: std::sync::Arc<dyn TokenSource>) -> Self {
2418        self.token_source = Some(source);
2419        self.auth_mode = AuthMode::IamToken;
2420        self
2421    }
2422
2423    /// The configured [`TokenSource`], if any.
2424    pub fn token_source(&self) -> Option<&std::sync::Arc<dyn TokenSource>> {
2425        self.token_source.as_ref()
2426    }
2427
2428    /// Share a [`StatementShapeCache`] across every connection built from these
2429    /// options (bead a4-8pp). With a shared cache, a query's described
2430    /// result-column shape is tracked cross-connection: if a concurrent DDL on
2431    /// one connection changes the shape, the next execute of the same statement
2432    /// on any sharing connection self-heals (re-describes) rather than decoding
2433    /// against the stale shape. Without this, each connection keeps a private
2434    /// cache and behaves exactly as before.
2435    #[must_use]
2436    pub fn with_shared_statement_shape_cache(mut self, cache: Arc<StatementShapeCache>) -> Self {
2437        self.statement_shape_cache = Some(cache);
2438        self
2439    }
2440
2441    /// The shared [`StatementShapeCache`], if one was configured.
2442    pub fn statement_shape_cache(&self) -> Option<&Arc<StatementShapeCache>> {
2443        self.statement_shape_cache.as_ref()
2444    }
2445
2446    /// Select passwordless external authentication intent on an existing
2447    /// options value. This is useful for code that starts from a shared
2448    /// `ConnectOptions::new` builder path; [`Self::external_auth`] avoids
2449    /// requiring credentials in the first place.
2450    #[must_use]
2451    pub fn with_external_auth(mut self) -> Self {
2452        self.auth_mode = AuthMode::External;
2453        self.user.clear();
2454        self.password.clear();
2455        self
2456    }
2457
2458    /// Select Kerberos authentication intent. Principal and keytab values are
2459    /// redacted from every `Debug` representation and are not sent on the wire
2460    /// because this thin build returns [`Error::UnsupportedAuthMode`] for the
2461    /// mode before network I/O.
2462    #[must_use]
2463    pub fn with_kerberos_auth(
2464        mut self,
2465        principal: impl Into<String>,
2466        keytab: impl Into<String>,
2467    ) -> Self {
2468        self.auth_mode = AuthMode::Kerberos {
2469            principal: Some(principal.into()),
2470            keytab: Some(keytab.into()),
2471        };
2472        self.user.clear();
2473        self.password.clear();
2474        self
2475    }
2476
2477    /// Select RADIUS/native-MFA authentication intent. The challenge hint is
2478    /// redacted from `Debug` and is not sent on the wire because this thin
2479    /// build returns [`Error::UnsupportedAuthMode`] for the mode before network
2480    /// I/O.
2481    #[must_use]
2482    pub fn with_radius_auth(mut self, challenge: impl Into<String>) -> Self {
2483        self.auth_mode = AuthMode::Radius {
2484            challenge: Some(challenge.into()),
2485        };
2486        self.user.clear();
2487        self.password.clear();
2488        self
2489    }
2490
2491    /// Select the Oracle edition for this session (Edition-Based Redefinition).
2492    /// Applied via `AUTH_ORA_EDITION` during authentication, *before* any user
2493    /// SQL — deterministic even for pooled/reused sessions. Verify with
2494    /// `SYS_CONTEXT('USERENV','CURRENT_EDITION_NAME')`. An invalid/unauthorized
2495    /// edition surfaces as a typed server error at connect time.
2496    #[must_use]
2497    pub fn with_edition(mut self, edition: impl Into<String>) -> Self {
2498        self.edition = Some(edition.into());
2499        self
2500    }
2501
2502    /// Enable sending the Oracle TCPS SNI string (`use_sni`, default off).
2503    #[must_use]
2504    pub fn with_use_sni(mut self, use_sni: bool) -> Self {
2505        self.use_sni = use_sni;
2506        self
2507    }
2508
2509    /// Set the TCPS wallet directory (`wallet_location` /
2510    /// `MY_WALLET_DIRECTORY`). Only used for TCPS connections.
2511    #[must_use]
2512    pub fn with_wallet_location(mut self, location: impl Into<String>) -> Self {
2513        self.wallet_location = Some(location.into());
2514        self
2515    }
2516
2517    /// Set the wallet password — required for `ewallet.p12` wallets and for an
2518    /// `ewallet.pem` whose private key is encrypted (PKCS#8 `ENCRYPTED PRIVATE
2519    /// KEY`). Not needed for auto-login (`cwallet.sso`) or verify-only wallets.
2520    #[must_use]
2521    pub fn with_wallet_password(mut self, password: impl Into<String>) -> Self {
2522        self.wallet_password = Some(password.into());
2523        self
2524    }
2525
2526    /// Enable or disable the Oracle server-DN match (`ssl_server_dn_match`,
2527    /// default enabled).
2528    #[must_use]
2529    pub fn with_ssl_server_dn_match(mut self, enabled: bool) -> Self {
2530        self.ssl_server_dn_match = enabled;
2531        self
2532    }
2533
2534    /// Set the explicit expected server-certificate DN
2535    /// (`ssl_server_cert_dn`).
2536    #[must_use]
2537    pub fn with_ssl_server_cert_dn(mut self, dn: impl Into<String>) -> Self {
2538        self.ssl_server_cert_dn = Some(dn.into());
2539        self
2540    }
2541
2542    /// Route this connection to the database EMON process by injecting
2543    /// `(SERVER=emon)` into the connect descriptor (used by the CQN background
2544    /// notification connection).
2545    pub fn with_server_type_emon(mut self, emon: bool) -> Self {
2546        self.server_type_emon = emon;
2547        self
2548    }
2549
2550    /// Set the application-context triples applied at logon.
2551    pub fn with_app_context(mut self, app_context: Vec<(String, String, String)>) -> Self {
2552        self.app_context = app_context;
2553        self
2554    }
2555
2556    /// Set the proxy user for `[proxy_user]` style authentication.
2557    ///
2558    /// When `Some`, this **overwrites** any proxy desugared from a bracketed
2559    /// username in [`Self::new`] (explicit API wins). Passing `None` clears the
2560    /// proxy and, if the mode was only Proxy, restores password mode.
2561    pub fn with_proxy_user(mut self, proxy_user: Option<String>) -> Self {
2562        if proxy_user.is_some() {
2563            self.auth_mode = AuthMode::Proxy;
2564        } else if matches!(self.auth_mode, AuthMode::Proxy) {
2565            self.auth_mode = AuthMode::Password;
2566        }
2567        self.proxy_user = proxy_user;
2568        self
2569    }
2570
2571    /// Request a Session Data Unit size, clamped to the protocol-legal range
2572    /// `512..=65535` bytes. The value is a hint; the server negotiates the
2573    /// effective SDU at connect time.
2574    pub fn with_sdu(mut self, sdu: u32) -> Self {
2575        let clamped = sdu.clamp(512, u32::from(u16::MAX));
2576        self.sdu = u16::try_from(clamped).unwrap_or(u16::MAX);
2577        self
2578    }
2579
2580    pub fn connect_string(&self) -> &str {
2581        &self.connect_string
2582    }
2583
2584    pub fn user(&self) -> &str {
2585        &self.user
2586    }
2587
2588    /// Full user display including any proxy, matching the reference
2589    /// `get_full_user` form: `base[proxy]`, or `[proxy]` when the base user is
2590    /// empty (token-auth proxy-only sessions). Never includes the password.
2591    pub fn full_user(&self) -> String {
2592        match (self.user.as_str(), self.proxy_user.as_deref()) {
2593            ("", Some(proxy)) => format!("[{proxy}]"),
2594            (base, Some(proxy)) => format!("{base}[{proxy}]"),
2595            (base, None) => base.to_string(),
2596        }
2597    }
2598
2599    pub fn password(&self) -> &str {
2600        &self.password
2601    }
2602
2603    pub fn identity(&self) -> &ClientIdentity {
2604        &self.identity
2605    }
2606
2607    pub fn app_context(&self) -> &[(String, String, String)] {
2608        &self.app_context
2609    }
2610
2611    pub fn sdu(&self) -> u16 {
2612        self.sdu
2613    }
2614
2615    pub fn proxy_user(&self) -> Option<&str> {
2616        self.proxy_user.as_deref()
2617    }
2618
2619    pub fn auth_mode(&self) -> &AuthMode {
2620        &self.auth_mode
2621    }
2622
2623    pub fn auth_capabilities(&self) -> AuthCapabilities {
2624        AuthCapabilities::THIN
2625    }
2626
2627    pub fn server_type_emon(&self) -> bool {
2628        self.server_type_emon
2629    }
2630
2631    pub fn wallet_location(&self) -> Option<&str> {
2632        self.wallet_location.as_deref()
2633    }
2634
2635    pub fn wallet_password(&self) -> Option<&str> {
2636        self.wallet_password.as_deref()
2637    }
2638
2639    pub fn edition(&self) -> Option<&str> {
2640        self.edition.as_deref()
2641    }
2642
2643    pub fn ssl_server_dn_match(&self) -> bool {
2644        self.ssl_server_dn_match
2645    }
2646
2647    pub fn ssl_server_cert_dn(&self) -> Option<&str> {
2648        self.ssl_server_cert_dn.as_deref()
2649    }
2650
2651    pub fn use_sni(&self) -> bool {
2652        self.use_sni
2653    }
2654
2655    pub fn access_token(&self) -> Option<&AccessToken> {
2656        self.access_token.as_ref()
2657    }
2658
2659    pub fn statement_cache_size(&self) -> usize {
2660        self.statement_cache_size
2661    }
2662
2663    pub fn protocol_limits(&self) -> ProtocolLimits {
2664        self.protocol_limits
2665    }
2666
2667    pub fn inactivity_timeout(&self) -> Option<Duration> {
2668        self.inactivity_timeout
2669    }
2670}
2671
2672/// A live asynchronous connection to an Oracle Database session.
2673///
2674/// Every method takes an `&Cx` and runs on an Asupersync runtime. For
2675/// synchronous code use [`BlockingConnection`], which owns a `Connection` and
2676/// drives these methods to completion on a private runtime.
2677///
2678/// A connection is not `Clone` and is not safe to use from two tasks at once;
2679/// drive one operation to completion before starting the next. To pool
2680/// connections, use the [`pool`] engine.
2681#[derive(Debug)]
2682pub struct Connection {
2683    descriptor: EasyConnect,
2684    identity: ClientIdentity,
2685    /// The private transport core owns packet I/O and cancellation-drain state.
2686    /// Cancellable reads arm its drain flag so the next operation can break and
2687    /// drain before issuing a new request.
2688    core: DriverCore,
2689    protocol_limits: ProtocolLimits,
2690    session_id: u32,
2691    serial_num: u16,
2692    server_version: Option<String>,
2693    server_version_tuple: Option<(u8, u8, u8, u8, u8)>,
2694    /// `SYS_CONTEXT('USERENV','DB_UNIQUE_NAME')` from the AUTH phase-two session
2695    /// data key `AUTH_SC_REAL_DBUNIQUE_NAME` (reference `db_unique_name`,
2696    /// upstream 16a57f1cbd58). `None` when the server did not send the key.
2697    db_unique_name: Option<String>,
2698    capabilities: ClientCapabilities,
2699    ttc_seq_num: u8,
2700    sdu: usize,
2701    /// Negotiated TTC protocol version from the server's ACCEPT
2702    /// (`AcceptInfo.protocol_version`). Surfaced via
2703    /// [`Connection::protocol_version`].
2704    protocol_version: u16,
2705    /// Whether authentication used the combined fast-auth bundle: the server
2706    /// advertised `TNS_ACCEPT_FLAG_FAST_AUTH` (`AcceptInfo.supports_fast_auth`)
2707    /// and the driver took the single-round-trip auth path. Surfaced via
2708    /// [`Connection::supports_fast_auth`].
2709    supports_fast_auth: bool,
2710    supports_end_of_response: bool,
2711    /// Whether the server negotiated out-of-band (urgent-TCP) break support
2712    /// (`protocol_options & TNS_GSO_CAN_RECV_ATTENTION`, reference
2713    /// `Capabilities.supports_oob`). Surfaced via [`Connection::supports_oob`];
2714    /// [`Connection::cancel`] always uses the in-band BREAK marker regardless,
2715    /// because the transport does not expose `MSG_OOB`.
2716    supports_oob: bool,
2717    cursor_columns: BTreeMap<u32, Vec<ColumnMetadata>>,
2718    /// Fetch metadata of the most recent execution keyed by SQL text,
2719    /// mirroring the reference statement cache's per-statement
2720    /// `_fetch_var_impls` retention (impl/thin/statement.pyx:300-310) that
2721    /// drives the re-execute type-change adjustment.
2722    fetch_metadata_by_sql: HashMap<String, Vec<ColumnMetadata>>,
2723    /// Insertion order for [`Self::fetch_metadata_by_sql`] eviction.
2724    fetch_metadata_order: VecDeque<String>,
2725    /// Cross-connection statement-shape cache (bead a4-8pp). Private per
2726    /// connection unless a shared one was supplied via
2727    /// [`ConnectOptions::with_shared_statement_shape_cache`]. Each query execute
2728    /// observes its freshly-described shape here; a cross-connection shape change
2729    /// (concurrent DDL) self-heals by dropping this connection's retained per-SQL
2730    /// fetch metadata so it re-describes instead of serving a stale decode.
2731    shape_cache: Arc<StatementShapeCache>,
2732    dead: bool,
2733    /// Logon user, retained for the change-password call.
2734    user: String,
2735    /// Session combo key from verifier generation, retained for the
2736    /// change-password call (reference keeps `conn_impl._combo_key`).
2737    combo_key: Vec<u8>,
2738    /// LRU statement cache: SQL text -> open server cursor id plus the bind
2739    /// TYPE shape the cursor was last bound with (reference
2740    /// thin/statement_cache.pyx, default size 20). The shape guards against
2741    /// reusing a cursor whose server-side bind metadata no longer matches the
2742    /// new binds (bead rust-oracledb-ilel: ORA-01722 when a text bind rides a
2743    /// cursor parsed with a NUMBER bind).
2744    statement_cache: Vec<CachedStatement>,
2745    /// Capacity of [`Self::statement_cache`] (from
2746    /// [`ConnectOptions::statement_cache_size`]); `0` disables caching.
2747    statement_cache_size: usize,
2748    /// Server cursor ids currently held by a live cursor (reference
2749    /// `Statement._in_use`). A cached cursor whose id is in this set must NOT
2750    /// be reused by a second cursor: `get_statement` returns a fresh
2751    /// (re-parsed) cursor instead, so interleaved fetches on different cursors
2752    /// of the same connection cannot reset each other's server-side fetch
2753    /// position (ORA-01002 fetch out of sequence). Cleared when the owning
2754    /// cursor releases the id (close / re-prepare to a different statement).
2755    in_use_cursors: HashSet<u32>,
2756    /// Cursor ids whose active server define returns LOB locator rows with the
2757    /// extra size/chunk fields. Plain `stream_lobs()` cursors are intentionally
2758    /// absent even when their describe metadata contains CLOB/BLOB columns.
2759    lob_prefetch_cursors: BTreeSet<u32>,
2760    /// Server cursor ids that were parsed as a fresh copy because the cached
2761    /// statement was in use (reference statement with `_return_to_cache =
2762    /// False`). These are never returned to the statement cache; when the
2763    /// owning cursor releases the id it is queued for close instead of being
2764    /// kept open (reference `return_statement` -> `_add_cursor_to_close`).
2765    copied_cursors: HashSet<u32>,
2766    /// Server cursor ids queued for the close-cursors piggyback (reference
2767    /// `_cursors_to_close`).
2768    cursors_to_close: Vec<u32>,
2769    /// State of the active sessionless transaction (reference
2770    /// `BaseThinConnImpl._sessionless_data`); `None` when no sessionless
2771    /// transaction is active on this connection.
2772    sessionless_data: Option<SessionlessData>,
2773    /// Leftover (partially-decoded) bytes from the EMON notification stream.
2774    /// The reference `ReadBuffer` chains pushed packets so a single logical
2775    /// `process()` call decodes records that span packet boundaries; this
2776    /// buffer plays the same role for [`Connection::recv_notification`].
2777    notification_buffer: Vec<u8>,
2778    /// Whether the leading `TNS_MSG_TYPE_OAC` byte of the notification stream
2779    /// has been consumed (the reference reads it once via the outer
2780    /// `process()` loop before delivering any record).
2781    notification_header_consumed: bool,
2782    /// The TPC (two-phase commit) transaction context returned by `tpc_begin`,
2783    /// echoed back on `tpc_end`/`tpc_prepare`/`tpc_commit`/`tpc_rollback`
2784    /// (reference `BaseThinConnImpl._transaction_context`). `None` when no XA
2785    /// transaction context has been captured.
2786    transaction_context: Option<Vec<u8>>,
2787    /// Whether a server-side transaction is in progress, derived from the wire
2788    /// end-of-call status bit `TNS_EOCS_FLAGS_TXN_IN_PROGRESS` on every round
2789    /// trip (reference protocol.pyx `_process_call_status` /
2790    /// `_txn_in_progress`).
2791    txn_in_progress: bool,
2792    /// Secret-free support-capture guard (bead K6). Armed only when
2793    /// `ORACLEDB_CAPTURE` was set at connect time and the `cassette` feature is
2794    /// compiled in; on drop/close it writes a scrubbed, secret-free
2795    /// `.tns-cassette` of the whole session to that path (fail-closed: a
2796    /// surviving secret refuses the write and leaves no file). `None` otherwise,
2797    /// in which case the transport path is byte-identical to a non-capturing
2798    /// session.
2799    ///
2800    /// Held only for its `Drop` side effect (persist-on-session-end), never read.
2801    #[cfg(feature = "cassette")]
2802    #[allow(dead_code)]
2803    capture_guard: Option<transport::CaptureGuard>,
2804}
2805
2806/// Owns the lifecycle of the open query cursor used by
2807/// [`Connection::for_each_row_ref`]. The borrowed-row path deliberately keeps a
2808/// speculative response in flight while it runs the user's callback, so every
2809/// post-execute early return -- including cancellation by dropping the method
2810/// future -- must retire the cursor locally. Wire recovery remains the
2811/// transport's job: the next request drains any stranded response before it
2812/// sends this guard's queued close-cursor piggyback.
2813struct BorrowedStreamCursorGuard<'conn> {
2814    connection: &'conn mut Connection,
2815    cursor_id: u32,
2816}
2817
2818impl<'conn> BorrowedStreamCursorGuard<'conn> {
2819    fn new(connection: &'conn mut Connection, cursor_id: u32) -> Self {
2820        Self {
2821            connection,
2822            cursor_id,
2823        }
2824    }
2825
2826    fn connection(&mut self) -> &mut Connection {
2827        self.connection
2828    }
2829
2830    /// The cursor reached normal end-of-data, so it remains valid for statement
2831    /// cache reuse. Disarm the fail-closed drop path after normal release.
2832    fn release(mut self) {
2833        self.connection.release_cursor(self.cursor_id);
2834        self.cursor_id = 0;
2835    }
2836}
2837
2838impl Drop for BorrowedStreamCursorGuard<'_> {
2839    fn drop(&mut self) {
2840        if self.cursor_id != 0 {
2841            self.connection.close_cursor(self.cursor_id);
2842        }
2843    }
2844}
2845
2846/// Mirrors the reference `_SessionlessData` (impl/thin/connection.pyx): the
2847/// pending or active sessionless transaction tracked on the connection.
2848#[derive(Clone, Debug)]
2849struct SessionlessData {
2850    transaction_id: Vec<u8>,
2851    timeout: u32,
2852    /// One of `TNS_TPC_TXN_START` / `TNS_TPC_TXN_DETACH`, optionally OR'd with
2853    /// `TNS_TPC_TXN_POST_DETACH` once a suspend-on-success is folded in.
2854    operation: u32,
2855    /// `TPC_TXN_FLAGS_NEW` or `TPC_TXN_FLAGS_RESUME` (SESSIONLESS is added when
2856    /// the message is built).
2857    flags: u32,
2858    /// A begin/resume that must ride as a piggyback on the next execute
2859    /// (`defer_round_trip=True`, or a folded-in suspend-on-success).
2860    piggyback_pending: bool,
2861    /// The transaction was started via DBMS_TRANSACTION on the server; the
2862    /// client API may not suspend/resume it (reference `started_on_server`).
2863    started_on_server: bool,
2864}
2865
2866/// Result of one bounded notification packet read.
2867enum PacketRead {
2868    /// A DATA packet's payload was appended to the notification buffer.
2869    Appended,
2870    /// The read timed out (no data within the window); the caller should poll
2871    /// its shutdown flag and may retry.
2872    TimedOut,
2873    /// The emon socket was closed or returned a non-DATA packet; the stream is
2874    /// finished.
2875    Closed,
2876}
2877
2878/// Outcome of [`Connection::recv_notification`].
2879#[derive(Clone, Debug)]
2880#[non_exhaustive]
2881pub enum NotificationOutcome {
2882    /// A decoded notification record to deliver to the callback.
2883    Record(NotificationRecord),
2884    /// No record arrived within the read window; poll the shutdown flag and
2885    /// call again to keep waiting.
2886    TimedOut,
2887    /// The emon socket closed (teardown / STOP_NOTIF); stop the receive loop.
2888    Closed,
2889}
2890
2891const STATEMENT_CACHE_SIZE: usize = 20;
2892
2893/// One operation in a pipelined batch (`Connection::run_pipeline`).
2894#[derive(Clone, Debug)]
2895pub enum PipelineRequest {
2896    #[non_exhaustive]
2897    Execute {
2898        sql: String,
2899        bind_rows: Vec<Vec<BindValue>>,
2900        prefetch_rows: u32,
2901    },
2902    Commit,
2903}
2904
2905impl PipelineRequest {
2906    pub fn execute(
2907        sql: impl Into<String>,
2908        bind_rows: Vec<Vec<BindValue>>,
2909        prefetch_rows: u32,
2910    ) -> Self {
2911        Self::Execute {
2912            sql: sql.into(),
2913            bind_rows,
2914            prefetch_rows,
2915        }
2916    }
2917
2918    pub fn commit() -> Self {
2919        Self::Commit
2920    }
2921
2922    pub fn sql(&self) -> Option<&str> {
2923        match self {
2924            Self::Execute { sql, .. } => Some(sql),
2925            Self::Commit => None,
2926        }
2927    }
2928
2929    pub fn bind_rows(&self) -> Option<&[Vec<BindValue>]> {
2930        match self {
2931            Self::Execute { bind_rows, .. } => Some(bind_rows),
2932            Self::Commit => None,
2933        }
2934    }
2935
2936    pub fn prefetch_rows(&self) -> Option<u32> {
2937        match self {
2938            Self::Execute { prefetch_rows, .. } => Some(*prefetch_rows),
2939            Self::Commit => None,
2940        }
2941    }
2942
2943    pub fn is_commit(&self) -> bool {
2944        matches!(self, Self::Commit)
2945    }
2946}
2947
2948#[derive(Debug)]
2949pub struct CancelHandle {
2950    write: SharedWriteHalf,
2951    recovery: Arc<SessionRecovery>,
2952}
2953
2954impl Connection {
2955    /// Open a connection: resolve the EasyConnect descriptor, complete the TNS
2956    /// handshake and TTC capability negotiation, and authenticate `user` with
2957    /// the supplied [`ClientIdentity`]. On success the database has recorded a
2958    /// session whose `program` / `machine` / `osuser` / `terminal` are exactly
2959    /// the identity fields.
2960    pub async fn connect(cx: &Cx, mut options: ConnectOptions) -> Result<Self> {
2961        observe_cancellation_between_round_trips(cx)?;
2962        let protocol_limits = options.protocol_limits.validate()?;
2963        if let Some(unsupported) = options.auth_mode.unsupported_in_thin() {
2964            return Err(Error::UnsupportedAuthMode(unsupported));
2965        }
2966        let descriptor = EasyConnect::parse(&options.connect_string)?;
2967        // Fail closed BEFORE any network I/O: a database access token (OCI IAM /
2968        // OAuth2) must never be put on the wire in clear text. The reference
2969        // enforces this during the auth exchange (protocol.pyx
2970        // `ERR_ACCESS_TOKEN_REQUIRES_TCPS`); we additionally refuse up front, so
2971        // the token never leaves the process when the descriptor is plaintext
2972        // TCP. The in-auth guard below stays as defense in depth. Uniform typed
2973        // error; the token is never rendered. A pluggable token *source* is held
2974        // to the same rule and — crucially — is refused here BEFORE it is ever
2975        // consulted, so no token is fetched for a transport that could not carry
2976        // it securely.
2977        if (options.access_token.is_some() || options.token_source.is_some())
2978            && !descriptor.protocol.is_tls()
2979        {
2980            return Err(Error::AccessTokenRequiresTcps);
2981        }
2982        // Resolve a pluggable token source into a concrete access token once, at
2983        // connect (the transport is now known to be TCPS). A static access token
2984        // takes precedence. The provider's failure is surfaced as the redacted
2985        // `Error::TokenSource`; its detail (and the token) never leak.
2986        if options.access_token.is_none() {
2987            if let Some(source) = options.token_source.clone() {
2988                let token = source.get_token().await.map_err(Error::TokenSource)?;
2989                options.access_token = Some(AccessToken::new(token));
2990            }
2991        }
2992        let full_descriptor = EasyConnect::parse_descriptor(&options.connect_string)?;
2993        let primary_description = full_descriptor.first_description().clone();
2994        let connect_timeout =
2995            transport_connect_timeout_duration(primary_description.tcp_connect_timeout);
2996        let connect_timeout_ms = duration_to_millis_saturating(connect_timeout);
2997        // GH#14: the opt-in per-read inactivity deadline, and the TCP keepalive
2998        // idle interval derived from a DSN `EXPIRE_TIME` (minutes). Both are Copy
2999        // values captured into the connect block: keepalive is set on the socket
3000        // right after dial (before any TLS wrap), and the inactivity deadline is
3001        // installed on the session core once the transport is established.
3002        let inactivity_timeout = options.inactivity_timeout;
3003        let keepalive_idle = keepalive_idle_from_expire_time(primary_description.expire_time);
3004        let connect_result = time::timeout(time::wall_now(), connect_timeout, async {
3005            // Connect span (feature-gated, zero-cost when off). Carries only the
3006            // server address / port / service — never the password.
3007            let _span = obs_span!(
3008                "oracledb.connect",
3009                db.system = "oracle",
3010                server.address = %descriptor.host,
3011                server.port = descriptor.port as u64,
3012                db.name = %descriptor.service_name,
3013            );
3014            let token_auth = options.access_token.is_some();
3015            let descriptor_ssl_server_dn_match =
3016                effective_ssl_server_dn_match(&primary_description, &options);
3017            let descriptor_ssl_server_cert_dn = options
3018                .ssl_server_cert_dn
3019                .as_deref()
3020                .or(primary_description.security.ssl_server_cert_dn.as_deref());
3021            let identity = options.identity;
3022            // F1 (bead rust-oracledb-clvm): apply the DSN-parsed transport
3023            // parameters that were previously parsed and dropped. The SDU
3024            // advertised in the CONNECT packet honours a DSN `(SDU=...)` (see
3025            // `resolve_effective_sdu`); the wallet directory and the SNI toggle
3026            // fall back to the DSN `SECURITY`/`USE_SNI` when the structured
3027            // builder did not set them.
3028            let advertised_sdu =
3029                u16::try_from(resolve_effective_sdu(options.sdu, &primary_description))
3030                    .unwrap_or(u16::MAX);
3031            let effective_use_sni = options.use_sni || primary_description.use_sni;
3032            let effective_wallet_location = options
3033                .wallet_location
3034                .as_deref()
3035                .or(primary_description.security.wallet_location.as_deref());
3036            // TCPS: TLS parameters (wallet, DN-match, SNI policy) are resolved
3037            // once and reused when a listener REDIRECT re-establishes the
3038            // transport to a new address (the redirected connection keeps the
3039            // original transport protocol, reference `_connect_phase_one`).
3040            let server_type = if options.server_type_emon {
3041                Some("emon")
3042            } else {
3043                None
3044            };
3045            // F-DC2 (security fix): feed the TLS verifier the SAME resolved
3046            // `SSL_SERVER_DN_MATCH` / `SSL_SERVER_CERT_DN` values that
3047            // `descriptor_ssl_server_dn_match` / `descriptor_ssl_server_cert_dn`
3048            // already computed above (options merged over the DSN-parsed
3049            // descriptor) and that drive the outbound listener CONNECT
3050            // descriptor below. Passing the raw, unmerged `options.*` fields
3051            // here (the prior bug) meant a cert DN pinned ONLY in the connect
3052            // string (`(SECURITY=(SSL_SERVER_CERT_DN=...)))`, never touching
3053            // the `with_ssl_server_cert_dn` builder) never reached the
3054            // verifier: `run_dn_match` would silently fall back to the
3055            // hostname/SAN/CN match instead of enforcing the pinned DN, so
3056            // DN pinning configured via the DSN did not actually pin. The
3057            // same gap dropped a DSN-only `SSL_SERVER_DN_MATCH=OFF`, so the
3058            // check stayed enforced even when the descriptor asked to
3059            // disable it. One resolved value now feeds both the verifier and
3060            // the wire descriptor, so they can never diverge.
3061            let prepared_tls = if descriptor.protocol.is_tls() {
3062                let tls_params = tls::resolve_tls_params(
3063                    &descriptor,
3064                    effective_wallet_location,
3065                    options.wallet_password.as_deref(),
3066                    descriptor_ssl_server_dn_match,
3067                    descriptor_ssl_server_cert_dn,
3068                    effective_use_sni,
3069                )?;
3070                // Build the rustls config, validate the wallet key, and decide
3071                // SNI before any address is dialled. These deterministic setup
3072                // failures are returned directly and never enter the transient
3073                // transport-failover loop below.
3074                Some(tls::prepare_tls_handshake(
3075                    &descriptor,
3076                    server_type,
3077                    &tls_params,
3078                )?)
3079            } else {
3080                None
3081            };
3082            let connector = DriverConnector::default();
3083            // Secret-free support capture (bead K6): when `ORACLEDB_CAPTURE` is
3084            // set, a recorder is created here and installed around each dial's
3085            // transport split so the WHOLE session (connect + auth + queries) is
3086            // teed into it. The recorder handle rides on the returned
3087            // `Connection`; on drop/close the auth phase is scrubbed and a
3088            // fail-closed refuse gate persists the cassette to the path (or
3089            // refuses if any secret survives). Off / feature-absent = no
3090            // behaviour change.
3091            #[cfg(feature = "cassette")]
3092            let capture_path = transport::capture_path_from_env();
3093            #[cfg(feature = "cassette")]
3094            let capture_recorder = capture_path
3095                .as_ref()
3096                .map(|_| transport::CassetteRecorder::new());
3097            // Dials one listener endpoint: TCP connect, then — for a TCPS
3098            // original — the TLS handshake on the whole socket before
3099            // splitting and before any TNS bytes are sent (implicit TLS,
3100            // matching python-oracledb thin's _connect_tcp ordering). Used
3101            // for the initial address and for every REDIRECT target.
3102            let dial = |host: String, port: u16| {
3103                let connector = &connector;
3104                let prepared_tls = prepared_tls.as_ref();
3105                #[cfg(feature = "cassette")]
3106                let capture_recorder = capture_recorder.as_ref();
3107                async move {
3108                    trace_connect_step("tcp connect");
3109                    let stream = TcpStream::connect_timeout((host, port), connect_timeout).await?;
3110                    stream.set_nodelay(true)?;
3111                    // GH#14: enable TCP keepalive so a half-open/dead peer is
3112                    // detected instead of wedging a later read forever. The idle
3113                    // interval comes from the DSN `EXPIRE_TIME` (minutes); on a
3114                    // TCPS descriptor it is set on the underlying socket here,
3115                    // before the TLS handshake consumes the stream.
3116                    if let Some(idle) = keepalive_idle {
3117                        stream.set_keepalive(Some(idle))?;
3118                    }
3119                    trace_connect_step("tcp connected");
3120                    let halves = if let Some(prepared_tls) = prepared_tls {
3121                        trace_connect_step("tls handshake");
3122                        // Honor the same configured connect timeout that already
3123                        // bounds the TCP dial above, instead of a hard-coded
3124                        // value: `connect_timeout` is resolved uniformly from
3125                        // the DSN `TRANSPORT_CONNECT_TIMEOUT`/`CONNECT_TIMEOUT`
3126                        // for both easy-connect and full-descriptor DSN forms
3127                        // (bead F-DC4).
3128                        let tls_stream = tls::tls_handshake_prepared(
3129                            prepared_tls,
3130                            stream,
3131                            tls::handshake_timeout_from_connect_timeout(connect_timeout),
3132                        )
3133                        .await?;
3134                        trace_connect_step("tls established");
3135                        // Install the capture recorder around the SYNCHRONOUS
3136                        // split only (no await while held) so the thread-local
3137                        // is observed on the thread performing the split.
3138                        #[cfg(feature = "cassette")]
3139                        let _capture =
3140                            capture_recorder.map(|r| transport::install_recorder_scope(r.clone()));
3141                        connector.tls_split(tls_stream)
3142                    } else {
3143                        #[cfg(feature = "cassette")]
3144                        let _capture =
3145                            capture_recorder.map(|r| transport::install_recorder_scope(r.clone()));
3146                        connector.plain_split(stream)
3147                    };
3148                    Ok::<_, TransportEstablishmentError>(halves)
3149                }
3150            };
3151            // F2 (bead rust-oracledb-clvm): sequential multi-address failover.
3152            // A DESCRIPTION with an ADDRESS_LIST (or several ADDRESS entries)
3153            // is tried in order — honouring LOAD_BALANCE (shuffle) and
3154            // RETRY_COUNT/RETRY_DELAY — until one address establishes a
3155            // transport; if every address fails, the per-address reasons are
3156            // aggregated into `Error::AllAddressesFailed`. Only
3157            // transport-establishment errors (TCP dial / TLS handshake) fail
3158            // over to the next address; a configuration error aborts the whole
3159            // connect. The overall connect deadline (the DSN transport connect
3160            // timeout) still bounds the total, shared across attempts.
3161            let candidates = resolve_connect_addresses(&full_descriptor, descriptor.protocol);
3162            let retry_count = primary_description.retry_count;
3163            let retry_delay = Duration::from_secs(u64::from(primary_description.retry_delay));
3164            let mut attempt_errors: Vec<String> = Vec::new();
3165            let mut connected = None;
3166            'failover: for round in 0..=retry_count {
3167                if round > 0 && !retry_delay.is_zero() {
3168                    trace_connect_step("failover retry delay");
3169                    // Sleep `retry_delay` by timing out a never-ready future
3170                    // (asupersync exposes the deadline primitive, not a bare
3171                    // sleep); the elapsed Err is expected and ignored.
3172                    let _ =
3173                        time::timeout(time::wall_now(), retry_delay, std::future::pending::<()>())
3174                            .await;
3175                }
3176                for candidate in &candidates {
3177                    match dial(candidate.host.clone(), candidate.port).await {
3178                        Ok(halves) => {
3179                            connected = Some((halves, candidate.clone()));
3180                            break 'failover;
3181                        }
3182                        Err(err) => {
3183                            if err.is_terminal() {
3184                                return Err(err.into_driver_error());
3185                            }
3186                            attempt_errors
3187                                .push(format!("{}:{} ({err})", candidate.host, candidate.port));
3188                        }
3189                    }
3190                }
3191            }
3192            let ((read, write), active_address) = match connected {
3193                Some(pair) => pair,
3194                None => {
3195                    return Err(Error::AllAddressesFailed(format!(
3196                        "tried {} address(es): {}",
3197                        attempt_errors.len(),
3198                        attempt_errors.join("; ")
3199                    )));
3200                }
3201            };
3202            // Rebind the working descriptor to the address that actually
3203            // connected so the CONNECT descriptor's ADDRESS clause reflects the
3204            // live endpoint (the TLS params/DN match keep the configured host).
3205            let descriptor = EasyConnect {
3206                host: active_address.host,
3207                port: active_address.port,
3208                service_name: descriptor.service_name.clone(),
3209                protocol: descriptor.protocol,
3210            };
3211            let mut core = ConnectionCore::from_halves(read, write, "oracle_tcp_write");
3212            core.set_protocol_limits(protocol_limits)?;
3213            core.set_inactivity_timeout(inactivity_timeout);
3214
3215            let connect_descriptor = listener_connect_descriptor_with_server(
3216                &descriptor,
3217                &primary_description,
3218                &identity,
3219                options.server_type_emon,
3220                token_auth,
3221                descriptor_ssl_server_dn_match,
3222                descriptor_ssl_server_cert_dn,
3223            );
3224            trace_connect_value("CONNECT descriptor", &connect_descriptor);
3225            // A descriptor longer than TNS_MAX_CONNECT_DATA travels in a DATA
3226            // packet right behind the CONNECT packet, and the server may answer
3227            // with a RESEND packet asking for the whole exchange again before it
3228            // ACCEPTs (reference protocol.pyx `_connect_phase_one`: "this may
3229            // request the message to be resent multiple times"). Pre-23ai
3230            // servers RESEND routinely; a bounded loop guards against a
3231            // misbehaving peer that never stops asking. A REDIRECT answer
3232            // reconnects the transport to the redirected address and resends
3233            // the CONNECT there with the REDIRECT packet flag (and the
3234            // redirect-supplied connect data); RESEND and REDIRECT may
3235            // interleave — a RESEND after a redirect resends the redirected
3236            // CONNECT, flag included.
3237            let mut connect_data = connect_descriptor;
3238            let mut packet_flags = 0u8;
3239            let mut resend_rounds = 0u8;
3240            let mut redirect_rounds = 0u8;
3241            let accept = loop {
3242                let connect_payload = build_connect_packet_payload(&connect_data, advertised_sdu)?;
3243                let packet = encode_packet(
3244                    TNS_PACKET_TYPE_CONNECT,
3245                    packet_flags,
3246                    None,
3247                    &connect_payload,
3248                    PacketLengthWidth::Legacy16,
3249                )?;
3250                trace_connect_bytes("CONNECT packet", &packet);
3251                let split_connect_data = !connect_data_fits_inline(&connect_data);
3252                trace_connect_step("send CONNECT");
3253                core.write_all(cx, &packet).await?;
3254                if split_connect_data {
3255                    trace_connect_step("send CONNECT descriptor (data packet)");
3256                    core.send_connect_data_packet(
3257                        cx,
3258                        connect_data.as_bytes(),
3259                        usize::from(advertised_sdu),
3260                    )
3261                    .await?;
3262                }
3263
3264                trace_connect_step("read ACCEPT");
3265                let reply = core.read_packet(PacketLengthWidth::Legacy16).await?;
3266                trace_connect_value(
3267                    "CONNECT reply",
3268                    &format!(
3269                        "type={} flags=0x{:02x} payload_len={}",
3270                        reply.packet_type,
3271                        reply.packet_flags,
3272                        reply.payload.len()
3273                    ),
3274                );
3275                match reply.packet_type {
3276                    TNS_PACKET_TYPE_ACCEPT => break reply,
3277                    TNS_PACKET_TYPE_RESEND => {
3278                        resend_rounds += 1;
3279                        if resend_rounds > MAX_CONNECT_RESEND_ROUNDS {
3280                            return Err(Error::ConnectResendLoop(resend_rounds));
3281                        }
3282                        trace_connect_step("RESEND requested; resending CONNECT");
3283                        continue;
3284                    }
3285                    TNS_PACKET_TYPE_REDIRECT => {
3286                        redirect_rounds += 1;
3287                        if redirect_rounds > MAX_CONNECT_REDIRECT_ROUNDS {
3288                            return Err(Error::ConnectRedirectLoop(redirect_rounds));
3289                        }
3290                        let redirect_data = read_redirect_data(&mut core, &reply.payload).await?;
3291                        let target = parse_redirect_target(&redirect_data, descriptor.protocol)?;
3292                        trace_connect_value(
3293                            "REDIRECT target",
3294                            &format!("{}:{}", target.host, target.port),
3295                        );
3296                        // Reconnect the transport to the redirected listener
3297                        // (dropping the old connection closes it) and resend
3298                        // the CONNECT there, flagged as a redirect follow-up
3299                        // and carrying the redirect-supplied connect data
3300                        // (reference `_connect_phase_one`).
3301                        let (read, write) = dial(target.host, target.port)
3302                            .await
3303                            .map_err(TransportEstablishmentError::into_driver_error)?;
3304                        core = ConnectionCore::from_halves(read, write, "oracle_tcp_write");
3305                        core.set_protocol_limits(protocol_limits)?;
3306                        core.set_inactivity_timeout(inactivity_timeout);
3307                        connect_data = target.connect_data;
3308                        packet_flags = TNS_PACKET_FLAG_REDIRECT;
3309                        // The redirected listener negotiates from scratch and
3310                        // may itself ask for resends.
3311                        resend_rounds = 0;
3312                        trace_connect_step("REDIRECT: reconnected; resending CONNECT");
3313                        continue;
3314                    }
3315                    TNS_PACKET_TYPE_REFUSE => {
3316                        trace_connect_step("REFUSE received");
3317                        return Err(Error::ListenerRefused(
3318                            String::from_utf8_lossy(&reply.payload).to_string(),
3319                        ));
3320                    }
3321                    other => return Err(Error::UnexpectedPacket(other)),
3322                }
3323            };
3324            let accept_info = parse_accept_payload(&accept.payload)?;
3325            // Surface the negotiated ACCEPT capabilities so a captured trace
3326            // shows *why* the auth path forked: `fast_auth=true` takes the
3327            // combined fast-auth bundle, `fast_auth=false` falls back to the
3328            // classic protocol-negotiation + data-types round trips. This is the
3329            // single most useful line for diagnosing a "missing/failed fast-auth"
3330            // exchange. None of these values are secret (they mirror v$session /
3331            // negotiated SDU).
3332            trace_connect_value(
3333                "ACCEPT",
3334                &format!(
3335                    "sdu={} fast_auth={} end_of_response={} oob={}",
3336                    accept_info.sdu,
3337                    accept_info.supports_fast_auth,
3338                    accept_info.supports_end_of_response,
3339                    accept_info.supports_oob,
3340                ),
3341            );
3342            // Record the framing mode so the recovery drain (which runs on a raw
3343            // read half, without the Connection) decides the trailing-error
3344            // boundary the way this server frames it: pre-23ai (no
3345            // END_OF_RESPONSE) needs message-driven completion (bead
3346            // rust-oracledb-99xu).
3347            core.set_classic_framing(!accept_info.supports_end_of_response);
3348            let sdu = usize::try_from(accept_info.sdu)
3349                .unwrap_or(DEFAULT_SDU)
3350                .max(TNS_DATA_PACKET_OVERHEAD + 1);
3351
3352            let mut ttc_seq_num = 1;
3353            let auth_connect_string = auth_connect_descriptor(
3354                &descriptor,
3355                &primary_description,
3356                token_auth,
3357                descriptor_ssl_server_dn_match,
3358                descriptor_ssl_server_cert_dn,
3359            );
3360
3361            // Authentication has two shapes. Token auth (OCI IAM / OAuth2) carries
3362            // the credential in `AUTH_TOKEN` and skips the password-verifier round
3363            // trip entirely; password auth does the classic phase-one challenge /
3364            // phase-two verifier exchange. Both converge on a parsed auth response
3365            // plus the negotiated capabilities; only password auth yields a combo key
3366            // (used later to verify the server's response and for change-password).
3367            let (auth_two, capabilities, combo_key) = if let Some(token) = &options.access_token {
3368                // A database access token must never travel in clear text: require
3369                // TLS/TCPS, exactly as the reference does
3370                // (protocol.pyx `ERR_ACCESS_TOKEN_REQUIRES_TCPS`).
3371                if !descriptor.protocol.is_tls() {
3372                    return Err(Error::AccessTokenRequiresTcps);
3373                }
3374                // OCI IAM *database* tokens are proof-of-possession: the token is
3375                // bound to an RSA private key and the server refuses the bearer
3376                // token alone with ORA-01017. When the caller supplied the key,
3377                // sign the HTTP-Signatures header now and carry it as
3378                // AUTH_HEADER/AUTH_SIGNATURE on whichever auth path is taken. A
3379                // plain OAuth2 bearer token has no key and skips this. Computed
3380                // once; the borrowed `TokenPop` feeds both fast-auth and classic.
3381                let pop_material = match options.access_token_private_key.as_ref() {
3382                    Some(key) => {
3383                        let header = iam_pop::build_signing_header(
3384                            &descriptor.service_name,
3385                            &descriptor.host,
3386                            descriptor.port,
3387                            std::time::SystemTime::now(),
3388                        );
3389                        let signature = iam_pop::sign_signing_header(key.expose(), &header)?;
3390                        Some((header, signature))
3391                    }
3392                    None => None,
3393                };
3394                let pop = pop_material
3395                    .as_ref()
3396                    .map(|(header, signature)| TokenPop { header, signature });
3397                let (auth, capabilities) = if accept_info.supports_fast_auth {
3398                    // One combined fast-auth bundle carrying a phase-two
3399                    // `AUTH_TOKEN` message; no resend. The payload embeds the
3400                    // token, so it is never passed to `trace_connect_bytes`.
3401                    let auth_payload = build_fast_auth_token_payload_with_pop_and_proxy(
3402                        &options.user,
3403                        token.expose(),
3404                        &identity.driver_name,
3405                        PYTHON_ORACLEDB_COMPAT_VERSION_NUM,
3406                        &auth_connect_string,
3407                        options.edition.as_deref(),
3408                        pop,
3409                        options.proxy_user.as_deref(),
3410                    )?;
3411                    trace_connect_step("send AUTH token (fast-auth phase two)");
3412                    core.send_data_packet(cx, &auth_payload, sdu).await?;
3413                    trace_connect_step("read AUTH token response");
3414                    let response = core.read_data_response(cx).await?;
3415                    trace_connect_bytes("AUTH token response", &response);
3416                    let auth = parse_auth_response_with_limits(&response, protocol_limits)?;
3417                    let capabilities = auth.capabilities.unwrap_or_default();
3418                    (auth, capabilities)
3419                } else {
3420                    core.authenticate_classic_token(
3421                        cx,
3422                        TokenAuthentication {
3423                            user: &options.user,
3424                            token: token.expose(),
3425                            driver_name: &identity.driver_name,
3426                            version_num: PYTHON_ORACLEDB_COMPAT_VERSION_NUM,
3427                            connect_string: &auth_connect_string,
3428                            edition: options.edition.as_deref(),
3429                            pop,
3430                            proxy_user: options.proxy_user.as_deref(),
3431                        },
3432                        sdu,
3433                    )
3434                    .await?
3435                };
3436                // Token auth derives no shared password key: there is no combo key,
3437                // hence no server-response MAC to verify and no change-password.
3438                (auth, capabilities, Vec::new())
3439            } else {
3440                let client_pid = process::id();
3441                // Pre-23ai servers do not understand the combined fast-auth
3442                // bundle: run the classic handshake instead — protocol
3443                // negotiation and data types as their own round trips
3444                // (reference protocol.pyx `_connect_phase_two`), then the same
3445                // two-phase password auth. The standalone payloads are exact
3446                // slices of the fast-auth bundle, so both paths negotiate
3447                // byte-identically.
3448                let negotiated_capabilities = if accept_info.supports_fast_auth {
3449                    None
3450                } else {
3451                    let protocol_payload = build_protocol_negotiation_payload()?;
3452                    trace_connect_step("send protocol negotiation (classic)");
3453                    core.send_data_packet(cx, &protocol_payload, sdu).await?;
3454                    trace_connect_step("read protocol negotiation");
3455                    let response = core.read_classic_data_response(cx).await?;
3456                    trace_connect_bytes("protocol negotiation response", &response);
3457                    let negotiated = parse_auth_response_with_limits(&response, protocol_limits)?;
3458
3459                    let data_types_payload = build_data_types_payload()?;
3460                    trace_connect_step("send data types (classic)");
3461                    core.send_data_packet(cx, &data_types_payload, sdu).await?;
3462                    trace_connect_step("read data types");
3463                    let response = core.read_classic_data_response(cx).await?;
3464                    trace_connect_bytes("data types response", &response);
3465                    parse_auth_response_with_limits(&response, protocol_limits)?;
3466
3467                    Some(negotiated.capabilities.unwrap_or_default())
3468                };
3469                let auth_one = if accept_info.supports_fast_auth {
3470                    build_fast_auth_phase_one_payload(
3471                        &options.user,
3472                        &identity.program,
3473                        &identity.machine,
3474                        &identity.osuser,
3475                        &identity.terminal,
3476                        client_pid,
3477                    )?
3478                } else {
3479                    build_auth_phase_one_payload(
3480                        &options.user,
3481                        &identity.program,
3482                        &identity.machine,
3483                        &identity.osuser,
3484                        &identity.terminal,
3485                        client_pid,
3486                    )?
3487                };
3488                trace_connect_bytes("AUTH phase one payload", &auth_one);
3489                trace_connect_step("send AUTH phase one");
3490                core.send_data_packet(cx, &auth_one, sdu).await?;
3491                trace_connect_step("read AUTH phase one");
3492                let auth_one_response = if accept_info.supports_fast_auth {
3493                    core.read_data_response(cx).await?
3494                } else {
3495                    core.read_classic_data_response(cx).await?
3496                };
3497                trace_connect_bytes("AUTH phase one response", &auth_one_response);
3498                let auth_one =
3499                    parse_auth_response_with_limits(&auth_one_response, protocol_limits)?;
3500                let capabilities = negotiated_capabilities
3501                    .or(auth_one.capabilities)
3502                    .unwrap_or_default();
3503                let verifier_type = auth_one
3504                    .verifier_type
3505                    .ok_or(Error::MissingSessionField("AUTH_VFR_DATA verifier type"))?;
3506                let encrypted = oracledb_protocol::crypto::generate_verifier(
3507                    options.password.as_bytes(),
3508                    &auth_one.session_data,
3509                    verifier_type,
3510                )?;
3511                let auth_two_payload = build_auth_phase_two_payload_with_proxy_with_seq(
3512                    &options.user,
3513                    &encrypted,
3514                    &identity.driver_name,
3515                    PYTHON_ORACLEDB_COMPAT_VERSION_NUM,
3516                    &auth_connect_string,
3517                    next_ttc_sequence(&mut ttc_seq_num),
3518                    &options.app_context,
3519                    options.proxy_user.as_deref(),
3520                    options.edition.as_deref(),
3521                    capabilities.ttc_field_version,
3522                )?;
3523                trace_connect_bytes("AUTH phase two payload", &auth_two_payload);
3524                trace_connect_step("send AUTH phase two");
3525                core.send_data_packet(cx, &auth_two_payload, sdu).await?;
3526                trace_connect_step("read AUTH phase two");
3527                let auth_two_response = if accept_info.supports_fast_auth {
3528                    core.read_data_response(cx).await?
3529                } else {
3530                    core.read_classic_data_response(cx).await?
3531                };
3532                trace_connect_bytes("AUTH phase two response", &auth_two_response);
3533                let auth_two =
3534                    parse_auth_response_with_limits(&auth_two_response, protocol_limits)?;
3535                oracledb_protocol::crypto::verify_server_response(
3536                    &encrypted.combo_key,
3537                    &auth_two.session_data,
3538                )?;
3539                (auth_two, capabilities, encrypted.combo_key)
3540            };
3541
3542            let session_id = parse_session_u32(&auth_two.session_data, "AUTH_SESSION_ID")?;
3543            let serial_num = parse_session_u16(&auth_two.session_data, "AUTH_SERIAL_NUM")?;
3544            // Final handshake milestone: authentication succeeded and the server
3545            // handed back a session. `sid`/`serial` are the v$session identifiers
3546            // (not secret) and let an operator correlate a captured trace with a
3547            // server-side session.
3548            trace_connect_value(
3549                "session established",
3550                &format!("sid={session_id} serial={serial_num}"),
3551            );
3552            let server_version = auth_two.session_data.get("AUTH_VERSION_STRING").cloned();
3553            let db_unique_name = parse_db_unique_name(&auth_two.session_data);
3554            let server_version_tuple = auth_two
3555                .session_data
3556                .get("AUTH_VERSION_NO")
3557                .and_then(|value| value.trim().parse::<u32>().ok())
3558                .map(|num| {
3559                    decode_server_version_number(
3560                        num,
3561                        server_version_number_uses_extended_layout(capabilities.ttc_field_version),
3562                    )
3563                });
3564
3565            // Arm the support-capture guard from the recorder installed at dial
3566            // time (both `Some` iff `ORACLEDB_CAPTURE` was set). On drop/close it
3567            // scrubs + gate-checks + persists the cassette.
3568            #[cfg(feature = "cassette")]
3569            let capture_guard = match (capture_recorder, capture_path) {
3570                (Some(recorder), Some(path)) => Some(transport::CaptureGuard::new(recorder, path)),
3571                _ => None,
3572            };
3573            Ok(Self {
3574                descriptor,
3575                identity,
3576                core,
3577                protocol_limits,
3578                session_id,
3579                serial_num,
3580                server_version,
3581                server_version_tuple,
3582                db_unique_name,
3583                capabilities,
3584                ttc_seq_num,
3585                sdu,
3586                protocol_version: accept_info.protocol_version,
3587                supports_fast_auth: accept_info.supports_fast_auth,
3588                supports_end_of_response: accept_info.supports_end_of_response,
3589                supports_oob: accept_info.supports_oob,
3590                cursor_columns: BTreeMap::new(),
3591                fetch_metadata_by_sql: HashMap::new(),
3592                fetch_metadata_order: VecDeque::new(),
3593                shape_cache: options.statement_shape_cache.clone().unwrap_or_default(),
3594                dead: false,
3595                user: options.user,
3596                combo_key,
3597                statement_cache: Vec::new(),
3598                statement_cache_size: options.statement_cache_size,
3599                in_use_cursors: HashSet::new(),
3600                lob_prefetch_cursors: BTreeSet::new(),
3601                copied_cursors: HashSet::new(),
3602                cursors_to_close: Vec::new(),
3603                sessionless_data: None,
3604                notification_buffer: Vec::new(),
3605                notification_header_consumed: false,
3606                transaction_context: None,
3607                txn_in_progress: false,
3608                #[cfg(feature = "cassette")]
3609                capture_guard,
3610            })
3611        })
3612        .await;
3613        match connect_result {
3614            Ok(result) => result,
3615            Err(_) => Err(Error::CallTimeout(connect_timeout_ms)),
3616        }
3617    }
3618
3619    pub fn descriptor(&self) -> &EasyConnect {
3620        &self.descriptor
3621    }
3622
3623    /// Host of the connected endpoint (reference thin-mode `connection.host`,
3624    /// upstream da4ec2d2526a). This is the address the session actually
3625    /// connected to, as captured in the resolved descriptor.
3626    pub fn host(&self) -> &str {
3627        &self.descriptor.host
3628    }
3629
3630    /// Port of the connected endpoint (reference `connection.port`).
3631    pub fn port(&self) -> u16 {
3632        self.descriptor.port
3633    }
3634
3635    /// Transport protocol (TCP / TCPS) of the connected endpoint (reference
3636    /// `connection.protocol`).
3637    pub fn protocol(&self) -> NetProtocol {
3638        self.descriptor.protocol
3639    }
3640
3641    /// The [`ClientIdentity`] this session was opened with (the values the
3642    /// database recorded in `v$session`).
3643    pub fn identity(&self) -> &ClientIdentity {
3644        &self.identity
3645    }
3646
3647    /// Server-assigned session id (`v$session.sid`).
3648    pub fn session_id(&self) -> u32 {
3649        self.session_id
3650    }
3651
3652    /// Server-assigned session serial number (`v$session.serial#`).
3653    pub fn serial_num(&self) -> u16 {
3654        self.serial_num
3655    }
3656
3657    /// Server version banner, if the server reported one.
3658    pub fn server_version(&self) -> Option<&str> {
3659        self.server_version.as_deref()
3660    }
3661
3662    /// The database unique name (`SYS_CONTEXT('USERENV','DB_UNIQUE_NAME')`),
3663    /// parsed from the AUTH phase-two `AUTH_SC_REAL_DBUNIQUE_NAME` field
3664    /// (reference thin-mode `connection.db_unique_name`, upstream 16a57f1cbd58).
3665    /// Empty when the server did not send it.
3666    pub fn db_unique_name(&self) -> &str {
3667        self.db_unique_name.as_deref().unwrap_or("")
3668    }
3669
3670    /// Database version 5-tuple decoded from `AUTH_VERSION_NO`
3671    /// (reference messages/auth.pyx `_get_version_tuple`).
3672    pub fn server_version_tuple(&self) -> Option<(u8, u8, u8, u8, u8)> {
3673        self.server_version_tuple
3674    }
3675
3676    /// Whether the server supports OSON long field names (server major version
3677    /// >= 23). Mirrors the reference `conn_impl.supports_oson_long_field_names`.
3678    fn supports_oson_long_fnames(&self) -> bool {
3679        self.server_version_tuple
3680            .map(|(major, ..)| major >= 23)
3681            .unwrap_or(false)
3682    }
3683
3684    pub fn sdu(&self) -> usize {
3685        self.sdu
3686    }
3687
3688    /// Whether the server negotiated END_OF_RESPONSE framing at accept time
3689    /// (protocol version >= 319 with TNS_ACCEPT_FLAG_HAS_END_OF_RESPONSE) --
3690    /// the prerequisite for pipelining (impl/thin/capabilities.pyx:126-130).
3691    pub fn supports_pipelining(&self) -> bool {
3692        self.supports_end_of_response
3693    }
3694
3695    /// The negotiated TTC protocol version from the server's ACCEPT packet
3696    /// (`AcceptInfo.protocol_version`, reference connect.pyx). This is the
3697    /// TNS_VERSION_* level the client and server agreed on (e.g. 319 for a
3698    /// 19c+/23ai-era server that supports END_OF_RESPONSE framing).
3699    pub fn protocol_version(&self) -> u16 {
3700        self.protocol_version
3701    }
3702
3703    /// Whether this session authenticated over the combined fast-auth bundle:
3704    /// the server advertised `TNS_ACCEPT_FLAG_FAST_AUTH` at accept time and the
3705    /// driver used the single-round-trip fast-auth path (23ai-era servers). A
3706    /// `false` value means the classic protocol-negotiation + data-types
3707    /// handshake was used instead.
3708    pub fn supports_fast_auth(&self) -> bool {
3709        self.supports_fast_auth
3710    }
3711
3712    pub fn cancel_handle(&self) -> Result<CancelHandle> {
3713        Ok(CancelHandle {
3714            write: self.core.write_handle(),
3715            recovery: Arc::clone(&self.core.recovery),
3716        })
3717    }
3718
3719    /// Whether a session-dead Oracle error (mapped to DPY-4011 by the Python
3720    /// layer) has been observed on this connection.
3721    pub fn is_dead(&self) -> bool {
3722        self.dead || self.core.recovery.is_dead()
3723    }
3724
3725    /// Wrap a protocol parse result, recording session-dead errors.
3726    fn note_parse<T>(
3727        &mut self,
3728        result: std::result::Result<T, oracledb_protocol::ProtocolError>,
3729    ) -> Result<T> {
3730        match result {
3731            Ok(value) => Ok(value),
3732            Err(err) => {
3733                if protocol_error_is_session_dead(&err) {
3734                    self.dead = true;
3735                    self.core.recovery.mark_dead();
3736                }
3737                Err(Error::Protocol(err))
3738            }
3739        }
3740    }
3741
3742    /// Round-trip a lightweight PING to verify the session is alive.
3743    pub async fn ping(&mut self, cx: &Cx) -> Result<()> {
3744        self.send_function(cx, TNS_FUNC_PING).await
3745    }
3746
3747    /// Change the session password via the dedicated auth round trip
3748    /// (reference `ThinConnImpl.change_password`): an AUTH_PHASE_TWO message
3749    /// carrying the combo-key-encrypted old/new passwords. Server errors
3750    /// (ORA-28218, ORA-01017, ...) surface unchanged.
3751    pub async fn change_password(
3752        &mut self,
3753        cx: &Cx,
3754        old_password: &str,
3755        new_password: &str,
3756    ) -> Result<()> {
3757        observe_cancellation_between_round_trips(cx)?;
3758        self.ensure_clean_before_request().await?;
3759        let (encoded_password, encoded_newpassword) =
3760            oracledb_protocol::crypto::encrypt_change_password_pair(
3761                &self.combo_key,
3762                old_password.as_bytes(),
3763                new_password.as_bytes(),
3764            )?;
3765        let seq_num = next_ttc_sequence(&mut self.ttc_seq_num);
3766        let payload = build_change_password_payload_with_seq(
3767            &self.user,
3768            &encoded_password,
3769            &encoded_newpassword,
3770            seq_num,
3771            self.capabilities.ttc_field_version,
3772        )?;
3773        self.core.send_data_packet(cx, &payload, self.sdu).await?;
3774        // change_password is an auth-shaped round trip, so the classic probe is
3775        // the same terminal-message rule the connect-phase reads use.
3776        let limits = self.protocol_limits;
3777        let response = self
3778            .read_response_cancellable(cx, !self.supports_end_of_response, |bytes| {
3779                classic_connect_response_is_complete(bytes, limits).unwrap_or(true)
3780            })
3781            .await?;
3782        self.note_parse(
3783            parse_auth_response_with_limits(&response, self.protocol_limits).map(|_| ()),
3784        )?;
3785        Ok(())
3786    }
3787
3788    /// Register a CQN subscription (FUNC 125, opcode 1) on this connection.
3789    /// Returns the registration id (`Subscription.id`) and the EMON client id
3790    /// echoed in the subsequent NOTIFY. `public_qos`/`operations` are the public
3791    /// `SUBSCR_QOS_*` / `OPCODE_*` values; the wire derivation lives in the
3792    /// protocol builder. Reference `ThinSubscrImpl.subscribe`.
3793    #[allow(clippy::too_many_arguments)]
3794    pub async fn subscribe_register(
3795        &mut self,
3796        cx: &Cx,
3797        namespace: u32,
3798        name: Option<&str>,
3799        public_qos: u32,
3800        operations: u32,
3801        timeout: u32,
3802        grouping_class: u8,
3803        grouping_value: u32,
3804        grouping_type: u8,
3805    ) -> Result<SubscribeResult> {
3806        observe_cancellation_between_round_trips(cx)?;
3807        self.ensure_clean_before_request().await?;
3808        let seq_num = next_ttc_sequence(&mut self.ttc_seq_num);
3809        let payload = build_subscribe_payload_with_seq(
3810            seq_num,
3811            TNS_SUBSCR_OP_REGISTER,
3812            Some(&self.user),
3813            None,
3814            namespace,
3815            name,
3816            public_qos,
3817            operations,
3818            timeout,
3819            grouping_class,
3820            grouping_value,
3821            grouping_type,
3822            0,
3823            self.capabilities.ttc_field_version,
3824        )?;
3825        self.core.send_data_packet(cx, &payload, self.sdu).await?;
3826        // Classic-aware read: pre-23ai servers never negotiate END_OF_RESPONSE
3827        // framing, so the flag-driven reader would block forever. The probe
3828        // decides completion by parsing the accumulated payload (bead
3829        // rust-oracledb-eyp7).
3830        let capabilities = self.capabilities;
3831        let limits = self.protocol_limits;
3832        let response = self
3833            .read_response_cancellable(cx, !self.supports_end_of_response, |bytes| {
3834                response_complete(&parse_subscribe_response_with_limits(
3835                    bytes,
3836                    capabilities,
3837                    limits,
3838                ))
3839            })
3840            .await?;
3841        self.note_parse(parse_subscribe_response_with_limits(
3842            &response,
3843            self.capabilities,
3844            self.protocol_limits,
3845        ))
3846    }
3847
3848    /// Unregister a CQN subscription (FUNC 125, opcode 2). The `client_id` is
3849    /// the value returned by [`Self::subscribe_register`] (now non-None so its
3850    /// pointer/bytes are emitted) and `registration_id` rides on the tail.
3851    /// Reference `ThinSubscrImpl.unsubscribe`.
3852    #[allow(clippy::too_many_arguments)]
3853    pub async fn subscribe_unregister(
3854        &mut self,
3855        cx: &Cx,
3856        registration_id: u64,
3857        client_id: &[u8],
3858        namespace: u32,
3859        name: Option<&str>,
3860        public_qos: u32,
3861        operations: u32,
3862        timeout: u32,
3863        grouping_class: u8,
3864        grouping_value: u32,
3865        grouping_type: u8,
3866    ) -> Result<()> {
3867        observe_cancellation_between_round_trips(cx)?;
3868        self.ensure_clean_before_request().await?;
3869        let seq_num = next_ttc_sequence(&mut self.ttc_seq_num);
3870        // unregister reuses the same `_write_message` path: the name/qos/
3871        // operations/grouping fields mirror the original registration so the
3872        // server can match the subscription (reference passes the same
3873        // `subscr_impl`).
3874        let payload = build_subscribe_payload_with_seq(
3875            seq_num,
3876            TNS_SUBSCR_OP_UNREGISTER,
3877            Some(&self.user),
3878            Some(client_id),
3879            namespace,
3880            name,
3881            public_qos,
3882            operations,
3883            timeout,
3884            grouping_class,
3885            grouping_value,
3886            grouping_type,
3887            registration_id,
3888            self.capabilities.ttc_field_version,
3889        )?;
3890        self.core.send_data_packet(cx, &payload, self.sdu).await?;
3891        // Classic-aware read (pre-23ai has no END_OF_RESPONSE framing); bead
3892        // rust-oracledb-eyp7.
3893        let capabilities = self.capabilities;
3894        let limits = self.protocol_limits;
3895        let response = self
3896            .read_response_cancellable(cx, !self.supports_end_of_response, |bytes| {
3897                response_complete(&parse_subscribe_response_with_limits(
3898                    bytes,
3899                    capabilities,
3900                    limits,
3901                ))
3902            })
3903            .await?;
3904        self.note_parse(parse_subscribe_response_with_limits(
3905            &response,
3906            self.capabilities,
3907            self.protocol_limits,
3908        ))?;
3909        Ok(())
3910    }
3911
3912    /// Send the single NOTIFY message (FUNC 187) that arms the EMON push stream
3913    /// on this (emon) connection. No response is read here; pushed notification
3914    /// packets are consumed by [`Self::recv_notification`]. Reference
3915    /// `ThinSubscrImpl._bg_task_func` (sends NOTIFY then blocks reading).
3916    pub async fn notify_register(&mut self, cx: &Cx, client_id: &[u8]) -> Result<()> {
3917        observe_cancellation_between_round_trips(cx)?;
3918        self.ensure_clean_before_request().await?;
3919        let seq_num = next_ttc_sequence(&mut self.ttc_seq_num);
3920        let payload =
3921            build_notify_payload_with_seq(seq_num, client_id, self.capabilities.ttc_field_version)?;
3922        // NOTIFY sets the END_OF_REQUEST data flag on its (single) packet.
3923        self.core
3924            .send_data_packet_with_flags(cx, &payload, self.sdu, 0, TNS_DATA_FLAGS_END_OF_REQUEST)
3925            .await?;
3926        Ok(())
3927    }
3928
3929    /// Wait for the next CQN notification record pushed by the EMON process and
3930    /// decode it. `read_timeout` bounds each underlying socket read so the
3931    /// background receive loop can poll a shutdown flag between reads (the DB
3932    /// never sends an end-of-stream marker; teardown unblocks the loop). The
3933    /// reference blocks forever and is unblocked by a forced socket close; the
3934    /// bounded read achieves the same clean teardown without a cross-thread
3935    /// socket close, and never hangs.
3936    ///
3937    /// Records may span several pushed packets, so this chains packets through
3938    /// `notification_buffer` exactly like the reference `ReadBuffer`.
3939    pub async fn recv_notification(
3940        &mut self,
3941        cx: &Cx,
3942        namespace: u32,
3943        public_qos: u32,
3944        read_timeout: Duration,
3945    ) -> Result<NotificationOutcome> {
3946        observe_cancellation_between_round_trips(cx)?;
3947        let db_name = self.descriptor.service_name.clone();
3948        loop {
3949            observe_cancellation_between_round_trips(cx)?;
3950            // consume the leading OAC message-type byte once
3951            if !self.notification_header_consumed {
3952                if self.notification_buffer.is_empty() {
3953                    match self.read_one_notification_packet(read_timeout).await? {
3954                        PacketRead::Appended => continue,
3955                        PacketRead::TimedOut => return Ok(NotificationOutcome::TimedOut),
3956                        PacketRead::Closed => return Ok(NotificationOutcome::Closed),
3957                    }
3958                }
3959                let consumed = check_notification_header_with_limits(
3960                    &self.notification_buffer,
3961                    self.protocol_limits,
3962                )?;
3963                self.notification_buffer.drain(..consumed);
3964                self.notification_header_consumed = true;
3965            }
3966            // try to decode one full record from the buffered bytes
3967            if !self.notification_buffer.is_empty() {
3968                if let Some((record, consumed)) = try_parse_oac_record_with_limits(
3969                    &self.notification_buffer,
3970                    namespace,
3971                    public_qos,
3972                    Some(&db_name),
3973                    self.protocol_limits,
3974                )? {
3975                    self.notification_buffer.drain(..consumed);
3976                    return Ok(NotificationOutcome::Record(record));
3977                }
3978            }
3979            // need more bytes: read another pushed packet (bounded)
3980            match self.read_one_notification_packet(read_timeout).await? {
3981                PacketRead::Appended => {}
3982                // a timeout mid-record is unusual; surface it so the caller can
3983                // re-check the shutdown flag and resume reading
3984                PacketRead::TimedOut => return Ok(NotificationOutcome::TimedOut),
3985                PacketRead::Closed => return Ok(NotificationOutcome::Closed),
3986            }
3987        }
3988    }
3989
3990    /// Reads one DATA packet from the emon socket (bounded by `read_timeout`)
3991    /// and appends its TTC payload (after the 2-byte data flags) to
3992    /// `notification_buffer`. Reports a timeout (so the caller can poll its
3993    /// shutdown flag) or a closed/errored socket distinctly. Non-DATA packets
3994    /// (markers, disconnect) end the stream.
3995    async fn read_one_notification_packet(&mut self, read_timeout: Duration) -> Result<PacketRead> {
3996        let read = self.core.read_packet(PacketLengthWidth::Large32);
3997        let packet = match time::timeout(time::wall_now(), read_timeout, read).await {
3998            Ok(Ok(packet)) => packet,
3999            // socket closed / errored (incl. force-close on teardown): end the
4000            // stream cleanly, mirroring the reference's swallowed read error
4001            Ok(Err(_)) => return Ok(PacketRead::Closed),
4002            Err(_) => return Ok(PacketRead::TimedOut),
4003        };
4004        if packet.packet_type != TNS_PACKET_TYPE_DATA {
4005            return Ok(PacketRead::Closed);
4006        }
4007        let Some((_data_flags, payload)) = packet.payload.split_at_checked(2) else {
4008            return Ok(PacketRead::Closed);
4009        };
4010        self.notification_buffer.extend_from_slice(payload);
4011        Ok(PacketRead::Appended)
4012    }
4013
4014    /// Ping with an upper bound on the round trip, used by pool health
4015    /// checks (reference pings under `ping_timeout`).
4016    pub async fn ping_with_timeout(&mut self, cx: &Cx, timeout_ms: u32) -> Result<()> {
4017        if timeout_ms == 0 {
4018            return self.ping(cx).await;
4019        }
4020        let deadline = QueryDeadline::from_timeout(Duration::from_millis(u64::from(timeout_ms)));
4021        match deadline.run(self.ping(cx)).await {
4022            Ok(result) => result,
4023            Err(DeadlineExpiry::BeforeStart) => self.reject_before_operation_start(cx, timeout_ms),
4024            // Previously this returned bare CallTimeout without even sending a
4025            // BREAK, leaving the half-sent ping round trip on the wire to poison
4026            // the next reuse. Break + drain like every other timeout path.
4027            Err(DeadlineExpiry::InFlight) => self.recover_from_call_timeout(cx, timeout_ms).await,
4028        }
4029    }
4030
4031    /// Commit the current transaction. DML on a connection is not durable
4032    /// until committed.
4033    pub async fn commit(&mut self, cx: &Cx) -> Result<()> {
4034        let _span = obs_span!("oracledb.commit");
4035        self.send_function(cx, TNS_FUNC_COMMIT).await?;
4036        // a commit ends any active sessionless transaction on the server
4037        // (reference clears `_sessionless_data` via the SYNC piggyback)
4038        self.sessionless_data = None;
4039        Ok(())
4040    }
4041
4042    /// Roll back the current transaction, discarding uncommitted DML.
4043    pub async fn rollback(&mut self, cx: &Cx) -> Result<()> {
4044        let _span = obs_span!("oracledb.rollback");
4045        self.send_function(cx, TNS_FUNC_ROLLBACK).await?;
4046        self.sessionless_data = None;
4047        Ok(())
4048    }
4049
4050    /// Enable server-side `DBMS_OUTPUT` buffering for this session. `buffer_bytes`
4051    /// caps the server buffer; `None` requests an unbounded buffer
4052    /// (`DBMS_OUTPUT.ENABLE(NULL)`). Call once before running the PL/SQL whose
4053    /// output you want, then [`read_dbms_output`](Self::read_dbms_output).
4054    pub async fn enable_dbms_output(&mut self, cx: &Cx, buffer_bytes: Option<u32>) -> Result<()> {
4055        // `buffer_bytes` is a u32 we own (never untrusted text), so inlining it
4056        // as a numeric literal is injection-safe and avoids an extra IN bind.
4057        let arg = buffer_bytes.map_or_else(|| "null".to_string(), |n| n.to_string());
4058        self.execute_query_with_bind_rows_and_options_core(
4059            cx,
4060            &format!("begin dbms_output.enable({arg}); end;"),
4061            0,
4062            &[],
4063            ExecuteOptions::default(),
4064        )
4065        .await?;
4066        Ok(())
4067    }
4068
4069    /// Read buffered `DBMS_OUTPUT` from this session via the canonical
4070    /// `GET_LINE(:line, :status)` loop, bounded by `max_lines` and `max_chars`.
4071    /// Stops cleanly when the server reports no more lines (`status != 0`) or
4072    /// when a bound is reached (setting [`DbmsOutput::truncated`]). Output is
4073    /// captured from this exact connection/session. python-oracledb leaves this
4074    /// loop to the caller; this centralizes it.
4075    pub async fn read_dbms_output(
4076        &mut self,
4077        cx: &Cx,
4078        max_lines: usize,
4079        max_chars: usize,
4080    ) -> Result<DbmsOutput> {
4081        // ORA_TYPE_SIZE_NUMBER is crate-private upstream; 22 is the NUMBER size.
4082        const NUMBER_SIZE: u32 = 22;
4083        let mut out = DbmsOutput::default();
4084        loop {
4085            observe_cancellation_between_round_trips(cx)?;
4086            let binds = [vec![
4087                BindValue::Output {
4088                    ora_type_num: oracledb_protocol::thin::ORA_TYPE_NUM_VARCHAR,
4089                    csfrm: oracledb_protocol::thin::CS_FORM_IMPLICIT,
4090                    buffer_size: 32767,
4091                },
4092                BindValue::Output {
4093                    ora_type_num: oracledb_protocol::thin::ORA_TYPE_NUM_NUMBER,
4094                    csfrm: 0,
4095                    buffer_size: NUMBER_SIZE,
4096                },
4097            ]];
4098            let res = self
4099                .execute_query_with_bind_rows_and_options_core(
4100                    cx,
4101                    "begin dbms_output.get_line(:1, :2); end;",
4102                    0,
4103                    &binds,
4104                    ExecuteOptions::default(),
4105                )
4106                .await?;
4107            // OUT values come back in bind order: [0] = line, [1] = status.
4108            let status = res
4109                .out_values
4110                .get(1)
4111                .and_then(|(_, v)| v.as_ref())
4112                .and_then(oracledb_protocol::thin::QueryValue::as_i64)
4113                .unwrap_or(1);
4114            if status != 0 {
4115                break; // no more lines buffered
4116            }
4117            let line = res
4118                .out_values
4119                .first()
4120                .and_then(|(_, v)| v.as_ref())
4121                .and_then(oracledb_protocol::thin::QueryValue::as_text)
4122                .unwrap_or("")
4123                .to_string();
4124            let chars = line.chars().count();
4125            // A real line is in hand. If it would cross a bound, stop and report
4126            // truncation: this line is past the bound and is discarded (it is part
4127            // of the acknowledged-truncated remainder). Checking after the read —
4128            // rather than before — keeps `truncated` exact: output that ends right
4129            // at `max_lines` reports `truncated == false`, not a false positive.
4130            if out.lines.len() >= max_lines || out.char_count + chars > max_chars {
4131                out.truncated = true;
4132                break;
4133            }
4134            out.char_count += chars;
4135            out.lines.push(line);
4136            out.line_count += 1;
4137        }
4138        Ok(out)
4139    }
4140
4141    /// Begins (`flags = TPC_TXN_FLAGS_NEW`) or resumes
4142    /// (`flags = TPC_TXN_FLAGS_RESUME`) a sessionless transaction. With
4143    /// `defer_round_trip = false` the request is sent immediately; with `true`
4144    /// it is queued as a piggyback on the next execute (reference
4145    /// impl/thin/connection.pyx `_start_sessionless_transaction`).
4146    async fn start_sessionless_transaction(
4147        &mut self,
4148        cx: &Cx,
4149        transaction_id: &[u8],
4150        timeout: u32,
4151        flags: u32,
4152        defer_round_trip: bool,
4153    ) -> Result<()> {
4154        if self.sessionless_data.is_some() {
4155            return Err(Error::SessionlessTransaction(
4156                SessionlessError::AlreadyActive,
4157            ));
4158        }
4159        let data = SessionlessData {
4160            transaction_id: transaction_id.to_vec(),
4161            timeout,
4162            operation: TNS_TPC_TXN_START,
4163            flags,
4164            piggyback_pending: defer_round_trip,
4165            started_on_server: false,
4166        };
4167        if defer_round_trip {
4168            // queue the begin/resume to ride on the next execute
4169            self.sessionless_data = Some(data);
4170            return Ok(());
4171        }
4172        // send the begin/resume immediately
4173        observe_cancellation_between_round_trips(cx)?;
4174        self.ensure_clean_before_request().await?;
4175        let seq_num = next_ttc_sequence(&mut self.ttc_seq_num);
4176        let payload = build_tpc_txn_switch_payload_with_seq(
4177            seq_num,
4178            0,
4179            data.operation,
4180            data.flags | TPC_TXN_FLAGS_SESSIONLESS,
4181            data.timeout,
4182            Some(transaction_id),
4183        );
4184        self.core.send_data_packet(cx, &payload, self.sdu).await?;
4185        // Classic-aware read (pre-23ai has no END_OF_RESPONSE framing); bead
4186        // rust-oracledb-eyp7.
4187        let capabilities = self.capabilities;
4188        let limits = self.protocol_limits;
4189        let response = self
4190            .read_response_cancellable(cx, !self.supports_end_of_response, |bytes| {
4191                response_complete(&parse_tpc_txn_switch_response_with_limits(
4192                    bytes,
4193                    capabilities,
4194                    limits,
4195                ))
4196            })
4197            .await?;
4198        let state = self.note_parse(parse_tpc_txn_switch_response_with_limits(
4199            &response,
4200            self.capabilities,
4201            self.protocol_limits,
4202        ))?;
4203        self.sessionless_data = Some(data);
4204        self.apply_sessionless_state(state);
4205        Ok(())
4206    }
4207
4208    /// Begins a new sessionless transaction (reference
4209    /// `begin_sessionless_transaction`).
4210    pub async fn begin_sessionless_transaction(
4211        &mut self,
4212        cx: &Cx,
4213        transaction_id: &[u8],
4214        timeout: u32,
4215        defer_round_trip: bool,
4216    ) -> Result<()> {
4217        self.start_sessionless_transaction(
4218            cx,
4219            transaction_id,
4220            timeout,
4221            TPC_TXN_FLAGS_NEW,
4222            defer_round_trip,
4223        )
4224        .await
4225    }
4226
4227    /// Resumes an existing sessionless transaction (reference
4228    /// `resume_sessionless_transaction`).
4229    pub async fn resume_sessionless_transaction(
4230        &mut self,
4231        cx: &Cx,
4232        transaction_id: &[u8],
4233        timeout: u32,
4234        defer_round_trip: bool,
4235    ) -> Result<()> {
4236        self.start_sessionless_transaction(
4237            cx,
4238            transaction_id,
4239            timeout,
4240            TPC_TXN_FLAGS_RESUME,
4241            defer_round_trip,
4242        )
4243        .await
4244    }
4245
4246    /// Suspends the active sessionless transaction immediately (reference
4247    /// `suspend_sessionless_transaction`).
4248    pub async fn suspend_sessionless_transaction(&mut self, cx: &Cx) -> Result<()> {
4249        match &self.sessionless_data {
4250            None => return Err(Error::SessionlessTransaction(SessionlessError::Inactive)),
4251            Some(data) if data.started_on_server => {
4252                return Err(Error::SessionlessTransaction(
4253                    SessionlessError::DifferingMethods,
4254                ));
4255            }
4256            Some(_) => {}
4257        }
4258        observe_cancellation_between_round_trips(cx)?;
4259        self.ensure_clean_before_request().await?;
4260        let seq_num = next_ttc_sequence(&mut self.ttc_seq_num);
4261        let payload = build_tpc_txn_switch_payload_with_seq(
4262            seq_num,
4263            0,
4264            TNS_TPC_TXN_DETACH,
4265            TPC_TXN_FLAGS_SESSIONLESS,
4266            0,
4267            None,
4268        );
4269        self.core.send_data_packet(cx, &payload, self.sdu).await?;
4270        // Classic-aware read (pre-23ai has no END_OF_RESPONSE framing); bead
4271        // rust-oracledb-eyp7.
4272        let capabilities = self.capabilities;
4273        let limits = self.protocol_limits;
4274        let response = self
4275            .read_response_cancellable(cx, !self.supports_end_of_response, |bytes| {
4276                response_complete(&parse_tpc_txn_switch_response_with_limits(
4277                    bytes,
4278                    capabilities,
4279                    limits,
4280                ))
4281            })
4282            .await?;
4283        let state = self.note_parse(parse_tpc_txn_switch_response_with_limits(
4284            &response,
4285            self.capabilities,
4286            self.protocol_limits,
4287        ))?;
4288        // a suspend always clears the active transaction locally; the server's
4289        // SYNC piggyback confirms it (reference clears `_sessionless_data`)
4290        self.sessionless_data = None;
4291        self.apply_sessionless_state(state);
4292        Ok(())
4293    }
4294
4295    /// Run a TPC transaction-switch (func 103) round trip and capture its
4296    /// response. Shared by `tpc_begin` (START) and `tpc_end` (DETACH).
4297    async fn tpc_switch_round_trip(
4298        &mut self,
4299        cx: &Cx,
4300        operation: u32,
4301        flags: u32,
4302        timeout: u32,
4303        xid: Option<&TpcXid<'_>>,
4304        context: Option<&[u8]>,
4305    ) -> Result<TpcSwitchResponse> {
4306        observe_cancellation_between_round_trips(cx)?;
4307        self.ensure_clean_before_request().await?;
4308        let seq_num = next_ttc_sequence(&mut self.ttc_seq_num);
4309        let payload = build_tpc_switch_payload_with_seq_and_version(
4310            seq_num,
4311            operation,
4312            flags,
4313            timeout,
4314            xid,
4315            context,
4316            self.capabilities.ttc_field_version,
4317        );
4318        self.core.send_data_packet(cx, &payload, self.sdu).await?;
4319        // Classic-aware read (pre-23ai has no END_OF_RESPONSE framing); bead
4320        // rust-oracledb-eyp7.
4321        let capabilities = self.capabilities;
4322        let limits = self.protocol_limits;
4323        let response = self
4324            .read_response_cancellable(cx, !self.supports_end_of_response, |bytes| {
4325                response_complete(&parse_tpc_switch_response_with_limits(
4326                    bytes,
4327                    capabilities,
4328                    limits,
4329                ))
4330            })
4331            .await?;
4332        self.note_parse(parse_tpc_switch_response_with_limits(
4333            &response,
4334            self.capabilities,
4335            self.protocol_limits,
4336        ))
4337    }
4338
4339    /// Run a TPC change-state (func 104) round trip and capture its response.
4340    /// Shared by `tpc_prepare` (PREPARE), `tpc_commit` (COMMIT) and
4341    /// `tpc_rollback` (ABORT).
4342    async fn tpc_change_state_round_trip(
4343        &mut self,
4344        cx: &Cx,
4345        operation: u32,
4346        requested_state: u32,
4347        xid: Option<&TpcXid<'_>>,
4348        context: Option<&[u8]>,
4349    ) -> Result<TpcChangeStateResponse> {
4350        observe_cancellation_between_round_trips(cx)?;
4351        self.ensure_clean_before_request().await?;
4352        let seq_num = next_ttc_sequence(&mut self.ttc_seq_num);
4353        let payload = build_tpc_change_state_payload_with_seq_and_version(
4354            seq_num,
4355            operation,
4356            requested_state,
4357            0,
4358            xid,
4359            context,
4360            self.capabilities.ttc_field_version,
4361        );
4362        self.core.send_data_packet(cx, &payload, self.sdu).await?;
4363        // Classic-aware read (pre-23ai has no END_OF_RESPONSE framing); bead
4364        // rust-oracledb-eyp7.
4365        let capabilities = self.capabilities;
4366        let limits = self.protocol_limits;
4367        let response = self
4368            .read_response_cancellable(cx, !self.supports_end_of_response, |bytes| {
4369                response_complete(&parse_tpc_change_state_response_with_limits(
4370                    bytes,
4371                    capabilities,
4372                    limits,
4373                ))
4374            })
4375            .await?;
4376        self.note_parse(parse_tpc_change_state_response_with_limits(
4377            &response,
4378            self.capabilities,
4379            self.protocol_limits,
4380        ))
4381    }
4382
4383    /// Begin (or resume/promote, via `flags`) an XA global transaction
4384    /// (reference impl/thin/connection.pyx `tpc_begin`). The server returns a
4385    /// transaction context which is captured for the subsequent
4386    /// end/prepare/commit/rollback round trips.
4387    pub async fn tpc_begin(
4388        &mut self,
4389        cx: &Cx,
4390        format_id: u32,
4391        global_transaction_id: &[u8],
4392        branch_qualifier: &[u8],
4393        flags: u32,
4394        timeout: u32,
4395    ) -> Result<()> {
4396        let xid = TpcXid {
4397            format_id,
4398            global_transaction_id,
4399            branch_qualifier,
4400        };
4401        let response = self
4402            .tpc_switch_round_trip(cx, TNS_TPC_TXN_START, flags, timeout, Some(&xid), None)
4403            .await?;
4404        self.transaction_context = Some(response.context);
4405        self.txn_in_progress = response.txn_in_progress;
4406        Ok(())
4407    }
4408
4409    /// End (detach) an XA global transaction branch (reference `tpc_end`). The
4410    /// retained transaction context is echoed back; `xid` is `None` to detach
4411    /// the implicit current transaction. The context is cleared afterwards.
4412    pub async fn tpc_end(
4413        &mut self,
4414        cx: &Cx,
4415        xid: Option<(u32, &[u8], &[u8])>,
4416        flags: u32,
4417    ) -> Result<()> {
4418        let xid = xid.map(|(format_id, gtid, bqual)| TpcXid {
4419            format_id,
4420            global_transaction_id: gtid,
4421            branch_qualifier: bqual,
4422        });
4423        let context = self.transaction_context.clone();
4424        let response = self
4425            .tpc_switch_round_trip(
4426                cx,
4427                TNS_TPC_TXN_DETACH,
4428                flags,
4429                0,
4430                xid.as_ref(),
4431                context.as_deref(),
4432            )
4433            .await?;
4434        self.txn_in_progress = response.txn_in_progress;
4435        self.transaction_context = None;
4436        Ok(())
4437    }
4438
4439    /// Prepare an XA global transaction for commit (reference `tpc_prepare`).
4440    /// Returns `true` when the transaction requires a commit, `false` when it
4441    /// is read-only; an unexpected out state raises DPY-5010.
4442    pub async fn tpc_prepare(&mut self, cx: &Cx, xid: Option<(u32, &[u8], &[u8])>) -> Result<bool> {
4443        let xid = xid.map(|(format_id, gtid, bqual)| TpcXid {
4444            format_id,
4445            global_transaction_id: gtid,
4446            branch_qualifier: bqual,
4447        });
4448        let context = self.transaction_context.clone();
4449        let response = self
4450            .tpc_change_state_round_trip(
4451                cx,
4452                TNS_TPC_TXN_PREPARE,
4453                TNS_TPC_TXN_STATE_PREPARE,
4454                xid.as_ref(),
4455                context.as_deref(),
4456            )
4457            .await?;
4458        self.txn_in_progress = response.txn_in_progress;
4459        match response.state {
4460            TNS_TPC_TXN_STATE_REQUIRES_COMMIT => Ok(true),
4461            TNS_TPC_TXN_STATE_READ_ONLY => Ok(false),
4462            other => Err(Error::UnknownTransactionState(other)),
4463        }
4464    }
4465
4466    /// Commit an XA global transaction (reference `tpc_commit`). `one_phase`
4467    /// requests a single-phase (read-only) commit; two-phase requests a
4468    /// committed state and expects the server to return FORGOTTEN. The retained
4469    /// context is sent and cleared. An unexpected out state raises DPY-5010.
4470    pub async fn tpc_commit(
4471        &mut self,
4472        cx: &Cx,
4473        xid: Option<(u32, &[u8], &[u8])>,
4474        one_phase: bool,
4475    ) -> Result<()> {
4476        let xid = xid.map(|(format_id, gtid, bqual)| TpcXid {
4477            format_id,
4478            global_transaction_id: gtid,
4479            branch_qualifier: bqual,
4480        });
4481        let requested_state = if one_phase {
4482            TNS_TPC_TXN_STATE_READ_ONLY
4483        } else {
4484            TNS_TPC_TXN_STATE_COMMITTED
4485        };
4486        let context = self.transaction_context.clone();
4487        let response = self
4488            .tpc_change_state_round_trip(
4489                cx,
4490                TNS_TPC_TXN_COMMIT,
4491                requested_state,
4492                xid.as_ref(),
4493                context.as_deref(),
4494            )
4495            .await?;
4496        self.txn_in_progress = response.txn_in_progress;
4497        // reference `_check_tpc_commit_state`: one-phase must be READ_ONLY or
4498        // COMMITTED; two-phase must be FORGOTTEN.
4499        let state = response.state;
4500        let ok = if one_phase {
4501            state == TNS_TPC_TXN_STATE_READ_ONLY || state == TNS_TPC_TXN_STATE_COMMITTED
4502        } else {
4503            state == TNS_TPC_TXN_STATE_FORGOTTEN
4504        };
4505        if !ok {
4506            return Err(Error::UnknownTransactionState(state));
4507        }
4508        self.transaction_context = None;
4509        Ok(())
4510    }
4511
4512    /// Roll back an XA global transaction (reference `tpc_rollback`). The
4513    /// retained context is sent; the server is expected to return ABORTED. An
4514    /// unexpected out state raises DPY-5010.
4515    pub async fn tpc_rollback(&mut self, cx: &Cx, xid: Option<(u32, &[u8], &[u8])>) -> Result<()> {
4516        let xid = xid.map(|(format_id, gtid, bqual)| TpcXid {
4517            format_id,
4518            global_transaction_id: gtid,
4519            branch_qualifier: bqual,
4520        });
4521        let context = self.transaction_context.clone();
4522        let response = self
4523            .tpc_change_state_round_trip(
4524                cx,
4525                TNS_TPC_TXN_ABORT,
4526                TNS_TPC_TXN_STATE_ABORTED,
4527                xid.as_ref(),
4528                context.as_deref(),
4529            )
4530            .await?;
4531        self.txn_in_progress = response.txn_in_progress;
4532        if response.state != TNS_TPC_TXN_STATE_ABORTED {
4533            return Err(Error::UnknownTransactionState(response.state));
4534        }
4535        Ok(())
4536    }
4537
4538    /// Whether a server-side transaction is in progress (reference
4539    /// `get_transaction_in_progress` -> `_txn_in_progress`).
4540    pub fn transaction_in_progress(&self) -> bool {
4541        self.txn_in_progress
4542    }
4543
4544    /// Validate that a `suspend_on_success` request is legal and fold the
4545    /// post-detach into the pending sessionless piggyback (reference
4546    /// execute.pyx `_handle_sessionless_suspend`). Called by the cursor execute
4547    /// path before building the execute message.
4548    pub fn prepare_sessionless_suspend_on_success(&mut self) -> Result<()> {
4549        match &mut self.sessionless_data {
4550            None => Err(Error::SessionlessTransaction(SessionlessError::Inactive)),
4551            Some(data) if data.started_on_server => Err(Error::SessionlessTransaction(
4552                SessionlessError::DifferingMethods,
4553            )),
4554            Some(data) => {
4555                if data.piggyback_pending {
4556                    data.operation |= TNS_TPC_TXN_POST_DETACH;
4557                } else {
4558                    data.operation = TNS_TPC_TXN_POST_DETACH;
4559                    data.flags = TPC_TXN_FLAGS_SESSIONLESS;
4560                    data.piggyback_pending = true;
4561                }
4562                Ok(())
4563            }
4564        }
4565    }
4566
4567    /// Take the pending sessionless piggyback bytes (if any) to prepend to the
4568    /// next execute payload, mirroring the close-cursors piggyback flow. The
4569    /// piggyback's sequence number is consumed from the connection counter so
4570    /// it precedes the execute's own sequence number.
4571    fn take_sessionless_piggyback(&mut self) -> Option<Vec<u8>> {
4572        let data = self.sessionless_data.as_mut()?;
4573        if !data.piggyback_pending {
4574            return None;
4575        }
4576        data.piggyback_pending = false;
4577        let xid = if data.operation & TNS_TPC_TXN_START != 0 {
4578            Some(data.transaction_id.clone())
4579        } else {
4580            None
4581        };
4582        let flags = data.flags | TPC_TXN_FLAGS_SESSIONLESS;
4583        let operation = data.operation;
4584        let timeout = data.timeout;
4585        let seq_num = next_ttc_sequence(&mut self.ttc_seq_num);
4586        Some(build_sessionless_piggyback(
4587            seq_num,
4588            0,
4589            operation,
4590            flags,
4591            timeout,
4592            xid.as_deref(),
4593        ))
4594    }
4595
4596    /// Apply a sessionless state update reported by the server (via the SYNC
4597    /// piggyback) to the connection's tracked state (reference
4598    /// `_update_sessionless_txn_state`).
4599    fn apply_sessionless_state(&mut self, state: Option<SessionlessTxnState>) {
4600        match state {
4601            // transaction ended/suspended on the server (reference clears
4602            // `_sessionless_data` and sets `_txn_in_progress = False`,
4603            // base.pyx:152/161)
4604            Some(SessionlessTxnState::Unset) => {
4605                self.sessionless_data = None;
4606                self.txn_in_progress = false;
4607            }
4608            // transaction started/resumed (reference replaces `_sessionless_data`
4609            // with a fresh `_SessionlessData`). This also covers a transaction
4610            // started via DBMS_TRANSACTION on the server, where no client-side
4611            // data existed yet: the server SET carries `started_on_server` so a
4612            // later client suspend/resume correctly raises DPY-3034.
4613            Some(SessionlessTxnState::Set { started_on_server }) => {
4614                self.txn_in_progress = true;
4615                match self.sessionless_data.as_mut() {
4616                    Some(data) => {
4617                        data.started_on_server = started_on_server;
4618                        data.piggyback_pending = false;
4619                    }
4620                    None => {
4621                        self.sessionless_data = Some(SessionlessData {
4622                            transaction_id: Vec::new(),
4623                            timeout: 0,
4624                            operation: TNS_TPC_TXN_START,
4625                            flags: 0,
4626                            piggyback_pending: false,
4627                            started_on_server,
4628                        });
4629                    }
4630                }
4631            }
4632            None => {}
4633        }
4634    }
4635
4636    #[allow(dead_code)]
4637    async fn execute_query_collect_core(
4638        &mut self,
4639        cx: &Cx,
4640        sql: &str,
4641        prefetch_rows: u32,
4642    ) -> Result<QueryResult> {
4643        let mut result = self
4644            .execute_query_with_bind_rows_and_options_core(
4645                cx,
4646                sql,
4647                prefetch_rows,
4648                &[],
4649                ExecuteOptions::default(),
4650            )
4651            .await?;
4652        if !columns_require_define(&result.columns) || result.cursor_id == 0 {
4653            return Ok(result);
4654        }
4655        // When the open server cursor already streamed rows inline (an active
4656        // define on a re-execute), those rows are authoritative; keep them.
4657        if !result.rows.is_empty() {
4658            return Ok(result);
4659        }
4660        let cursor_id = result.cursor_id;
4661        let columns = result.columns.clone();
4662        let fetched_result = self
4663            .define_and_fetch_rows_with_columns(cx, cursor_id, prefetch_rows.max(1), &columns, None)
4664            .await;
4665        let fetched = self.close_cursor_on_error(cursor_id, fetched_result)?;
4666        result.rows = fetched.rows;
4667        result.more_rows = fetched.more_rows;
4668        if !fetched.columns.is_empty() {
4669            result.columns = fetched.columns;
4670        }
4671        if result.cursor_id == 0 {
4672            result.cursor_id = cursor_id;
4673        }
4674        Ok(result)
4675    }
4676
4677    /// True when `sql` already has an open server cursor in the statement cache.
4678    /// The Arrow fetch entry points use this to tell a COLD query (freshly
4679    /// parsed cursor, no server-side define yet) from a WARM one (a cached cursor
4680    /// whose client-side define was established by an earlier fetch and persists
4681    /// server-side) — see [`Self::establish_cold_define`].
4682    #[cfg(feature = "arrow")]
4683    pub(crate) fn statement_has_cached_cursor(&self, sql: &str) -> bool {
4684        self.statement_cache
4685            .iter()
4686            .any(|entry| entry.sql == sql && entry.cursor_id != 0)
4687    }
4688
4689    /// Establishes the client-side define for a COLD define-requiring query
4690    /// (`VECTOR` / native `JSON` / `CLOB` / `BLOB`) so the first fetch on the
4691    /// freshly parsed cursor carries the define. Such columns come back from the
4692    /// execute as describe-only metadata (an empty first page); the server only
4693    /// streams their values once a define-fetch has told it the client's buffer
4694    /// shape. The row query paths ([`Self::query_with`],
4695    /// [`Self::execute_query_collect_core`]) already do this; the Arrow fetch
4696    /// paths ran a plain fetch instead and desynced ("invalid ub8 length") on a
4697    /// cold `VECTOR` (bead a4-0mk).
4698    ///
4699    /// `warm` (from [`Self::statement_has_cached_cursor`], captured BEFORE the
4700    /// execute) skips this: a warm cursor already carries the server-side define
4701    /// from an earlier fetch, so a plain fetch is correct — this keeps the
4702    /// already-working warm Arrow path byte-identical. Non-define-requiring
4703    /// queries (the scalar columnar fast path) and executes that streamed rows
4704    /// inline are also no-ops.
4705    #[cfg(feature = "arrow")]
4706    pub(crate) async fn establish_cold_define(
4707        &mut self,
4708        cx: &Cx,
4709        warm: bool,
4710        result: &mut QueryResult,
4711        prefetch_rows: u32,
4712    ) -> Result<()> {
4713        if warm
4714            || result.cursor_id == 0
4715            || !result.rows.is_empty()
4716            || !columns_require_define(&result.columns)
4717        {
4718            return Ok(());
4719        }
4720        let cursor_id = result.cursor_id;
4721        let columns = result.columns.clone();
4722        let fetched_result = self
4723            .define_and_fetch_rows_with_columns(cx, cursor_id, prefetch_rows.max(1), &columns, None)
4724            .await;
4725        let fetched = self.close_cursor_on_error(cursor_id, fetched_result)?;
4726        result.rows = fetched.rows;
4727        result.more_rows = fetched.more_rows;
4728        if !fetched.columns.is_empty() {
4729            result.columns = fetched.columns;
4730        }
4731        if result.cursor_id == 0 {
4732            result.cursor_id = cursor_id;
4733        }
4734        Ok(())
4735    }
4736
4737    /// Ergonomic query: bind typed Rust values positionally and return a lazy
4738    /// [`Rows`] facade. `params` is anything that converts into [`Params`]: a tuple
4739    /// `(40, "alice")`, a homogeneous array `[1, 2, 3]`, a raw
4740    /// `Vec<BindValue>`, or the named [`params!`](crate::params) form:
4741    ///
4742    /// ```no_run
4743    /// # use oracledb::Connection;
4744    /// # use asupersync::Cx;
4745    /// # async fn demo(conn: &mut Connection, cx: &Cx) -> Result<(), oracledb::Error> {
4746    /// let positional = conn.query(cx, "select :1 + :2 from dual", (40, 2)).await?
4747    ///     .collect(cx)
4748    ///     .await?;
4749    /// let named = conn
4750    ///     .query(cx, "select :a + :b from dual", oracledb::params!{ ":a" => 40, ":b" => 2 })
4751    ///     .await?
4752    ///     .collect(cx)
4753    ///     .await?;
4754    /// # let _ = (positional, named); Ok(()) }
4755    /// ```
4756    ///
4757    /// This is sugar over [`Self::query_with`]; the arraysize defaults to 100
4758    /// rows and define-requiring cells are materialized by default.
4759    pub async fn query<'p>(
4760        &mut self,
4761        cx: &Cx,
4762        sql: &str,
4763        params: impl Into<crate::Params<'p>>,
4764    ) -> Result<Rows<'_>> {
4765        self.query_with(cx, Query::owned_sql(sql.to_string()).bind(params))
4766            .await
4767    }
4768
4769    /// Run a query that must return exactly one row.
4770    pub async fn query_one<'p>(
4771        &mut self,
4772        cx: &Cx,
4773        sql: &str,
4774        params: impl Into<crate::Params<'p>>,
4775    ) -> Result<Row> {
4776        let mut rows = self
4777            .query_with(
4778                cx,
4779                Query::owned_sql(sql.to_string())
4780                    .bind(params)
4781                    .arraysize(NonZeroU32::new(2).expect("two is non-zero"))
4782                    .prefetch(2),
4783            )
4784            .await?;
4785        rows.materialize_for_cardinality(cx).await?;
4786        rows.one()
4787    }
4788
4789    /// Run a query that may return zero or one row.
4790    pub async fn query_opt<'p>(
4791        &mut self,
4792        cx: &Cx,
4793        sql: &str,
4794        params: impl Into<crate::Params<'p>>,
4795    ) -> Result<Option<Row>> {
4796        let mut rows = self
4797            .query_with(
4798                cx,
4799                Query::owned_sql(sql.to_string())
4800                    .bind(params)
4801                    .arraysize(NonZeroU32::new(2).expect("two is non-zero"))
4802                    .prefetch(2),
4803            )
4804            .await?;
4805        rows.materialize_for_cardinality(cx).await?;
4806        rows.opt()
4807    }
4808
4809    /// Run a query and eagerly drain every fetch batch.
4810    pub async fn query_all<'p>(
4811        &mut self,
4812        cx: &Cx,
4813        sql: &str,
4814        params: impl Into<crate::Params<'p>>,
4815    ) -> Result<Vec<Row>> {
4816        self.query(cx, sql, params).await?.collect(cx).await
4817    }
4818
4819    /// Run a query described by a [`Query`] builder and return a lazy row
4820    /// facade for the first batch plus continuation fetches.
4821    pub async fn query_with<'conn, 'q>(
4822        &'conn mut self,
4823        cx: &Cx,
4824        query: Query<'q>,
4825    ) -> Result<Rows<'conn>> {
4826        let Query {
4827            sql,
4828            params,
4829            arraysize,
4830            prefetch,
4831            prefetch_set: _,
4832            materialize_lobs,
4833            scrollable,
4834            timeout,
4835        } = query;
4836        let sql_owned = sql.into_owned();
4837        let binds = crate::sql_convert::resolve_params(&sql_owned, params)?;
4838        let bind_rows = if binds.is_empty() {
4839            Vec::new()
4840        } else {
4841            vec![binds]
4842        };
4843        let exec_options = ExecuteOptions::default().with_scrollable(scrollable);
4844        let deadline = QueryDeadline::new(cx, timeout);
4845        let mut result = match deadline
4846            .run(self.execute_query_with_bind_rows_and_options_core(
4847                cx,
4848                &sql_owned,
4849                prefetch,
4850                &bind_rows,
4851                exec_options,
4852            ))
4853            .await
4854        {
4855            Ok(result) => result?,
4856            Err(DeadlineExpiry::BeforeStart) => {
4857                return self.reject_before_operation_start(cx, deadline.timeout_ms());
4858            }
4859            Err(DeadlineExpiry::InFlight) => {
4860                return self
4861                    .recover_from_call_timeout(cx, deadline.timeout_ms())
4862                    .await
4863            }
4864        };
4865        if materialize_lobs
4866            && columns_require_define(&result.columns)
4867            && result.cursor_id != 0
4868            && result.rows.is_empty()
4869        {
4870            let cursor_id = result.cursor_id;
4871            let columns = result.columns.clone();
4872            let fetched = match deadline
4873                .run(self.define_and_fetch_rows_with_columns(
4874                    cx,
4875                    cursor_id,
4876                    prefetch.max(1),
4877                    &columns,
4878                    None,
4879                ))
4880                .await
4881            {
4882                Ok(result) => self.close_cursor_on_error(cursor_id, result)?,
4883                Err(DeadlineExpiry::BeforeStart) => {
4884                    self.release_cursor(cursor_id);
4885                    return self.reject_before_operation_start(cx, deadline.timeout_ms());
4886                }
4887                Err(DeadlineExpiry::InFlight) => {
4888                    let recovered = self
4889                        .recover_from_call_timeout(cx, deadline.timeout_ms())
4890                        .await;
4891                    self.close_cursor(cursor_id);
4892                    return recovered;
4893                }
4894            };
4895            result.rows = fetched.rows;
4896            result.more_rows = fetched.more_rows;
4897            if !fetched.columns.is_empty() {
4898                result.columns = fetched.columns;
4899            }
4900            if result.cursor_id == 0 {
4901                result.cursor_id = cursor_id;
4902            }
4903        }
4904        Ok(Rows::from_result(
4905            self, sql_owned, arraysize, deadline, scrollable, result,
4906        ))
4907    }
4908
4909    /// Execute DML, DDL, or PL/SQL with a single bind row.
4910    pub async fn execute<'p>(
4911        &mut self,
4912        cx: &Cx,
4913        sql: &str,
4914        params: impl Into<crate::Params<'p>>,
4915    ) -> Result<ExecuteOutcome> {
4916        self.execute_with(cx, Execute::owned_sql(sql.to_string()).bind(params))
4917            .await
4918    }
4919
4920    /// Execute DML, DDL, or PL/SQL described by an [`Execute`] builder.
4921    pub async fn execute_with<'e>(
4922        &mut self,
4923        cx: &Cx,
4924        execute: Execute<'e>,
4925    ) -> Result<ExecuteOutcome> {
4926        let deadline = QueryDeadline::new(cx, execute.timeout_duration());
4927        self.execute_with_deadline(cx, execute, deadline).await
4928    }
4929
4930    async fn execute_with_deadline<'e>(
4931        &mut self,
4932        cx: &Cx,
4933        execute: Execute<'e>,
4934        deadline: QueryDeadline,
4935    ) -> Result<ExecuteOutcome> {
4936        let Execute {
4937            sql,
4938            params,
4939            timeout: _,
4940            options,
4941        } = execute;
4942        let sql_owned = sql.into_owned();
4943        let binds = crate::sql_convert::resolve_params(&sql_owned, params)?;
4944        let bind_rows = if binds.is_empty() {
4945            Vec::new()
4946        } else {
4947            vec![binds]
4948        };
4949        let result = match deadline
4950            .run(self.execute_query_with_bind_rows_and_options_core(
4951                cx, &sql_owned, 0, &bind_rows, options,
4952            ))
4953            .await
4954        {
4955            Ok(result) => result?,
4956            Err(DeadlineExpiry::BeforeStart) => {
4957                return self.reject_before_operation_start(cx, deadline.timeout_ms());
4958            }
4959            Err(DeadlineExpiry::InFlight) => {
4960                return self
4961                    .recover_from_call_timeout(cx, deadline.timeout_ms())
4962                    .await
4963            }
4964        };
4965        Ok(ExecuteOutcome::from_query_result(result))
4966    }
4967
4968    /// Execute `sql` once for each bind row in a single array-DML operation.
4969    pub async fn execute_many<'b>(
4970        &mut self,
4971        cx: &Cx,
4972        sql: &str,
4973        rows: impl Into<crate::BatchRows<'b>>,
4974    ) -> Result<BatchOutcome> {
4975        self.execute_many_with(cx, Batch::owned_sql(sql.to_string(), rows))
4976            .await
4977    }
4978
4979    /// Execute array DML described by a [`Batch`] builder.
4980    pub async fn execute_many_with<'b>(
4981        &mut self,
4982        cx: &Cx,
4983        batch: Batch<'b>,
4984    ) -> Result<BatchOutcome> {
4985        let Batch {
4986            sql,
4987            rows,
4988            timeout,
4989            options,
4990        } = batch;
4991        rows.validate_rectangular()?;
4992        if rows.is_empty() {
4993            return Ok(BatchOutcome::empty(options.arraydmlrowcounts()));
4994        }
4995        let sql_owned = sql.into_owned();
4996        let deadline = QueryDeadline::new(cx, timeout);
4997        let result = match deadline
4998            .run(self.execute_query_with_bind_rows_and_options_core(
4999                cx,
5000                &sql_owned,
5001                0,
5002                rows.as_slice(),
5003                options,
5004            ))
5005            .await
5006        {
5007            Ok(result) => result?,
5008            Err(DeadlineExpiry::BeforeStart) => {
5009                return self.reject_before_operation_start(cx, deadline.timeout_ms());
5010            }
5011            Err(DeadlineExpiry::InFlight) => {
5012                return self
5013                    .recover_from_call_timeout(cx, deadline.timeout_ms())
5014                    .await
5015            }
5016        };
5017        Ok(BatchOutcome::from_query_result(result))
5018    }
5019
5020    /// Register a query against an existing CQN subscription.
5021    pub async fn register_query<'r>(
5022        &mut self,
5023        cx: &Cx,
5024        registration: Registration<'r>,
5025    ) -> Result<RegistrationOutcome> {
5026        let Registration {
5027            sql,
5028            params,
5029            registration_id,
5030            timeout,
5031        } = registration;
5032        let sql_owned = sql.into_owned();
5033        let binds = crate::sql_convert::resolve_params(&sql_owned, params)?;
5034        let bind_rows = if binds.is_empty() {
5035            Vec::new()
5036        } else {
5037            vec![binds]
5038        };
5039        let exec_options = ExecuteOptions::default().with_registration_id(registration_id);
5040        let deadline = QueryDeadline::new(cx, timeout);
5041        let result = match deadline
5042            .run(self.execute_query_with_bind_rows_and_options_core(
5043                cx,
5044                &sql_owned,
5045                0,
5046                &bind_rows,
5047                exec_options,
5048            ))
5049            .await
5050        {
5051            Ok(result) => result?,
5052            Err(DeadlineExpiry::BeforeStart) => {
5053                return self.reject_before_operation_start(cx, deadline.timeout_ms());
5054            }
5055            Err(DeadlineExpiry::InFlight) => {
5056                return self
5057                    .recover_from_call_timeout(cx, deadline.timeout_ms())
5058                    .await
5059            }
5060        };
5061        Ok(RegistrationOutcome::from_query_result(result))
5062    }
5063
5064    pub(crate) async fn execute_query_with_binds_core(
5065        &mut self,
5066        cx: &Cx,
5067        sql: &str,
5068        prefetch_rows: u32,
5069        binds: &[BindValue],
5070    ) -> Result<QueryResult> {
5071        let bind_rows = if binds.is_empty() {
5072            Vec::new()
5073        } else {
5074            vec![binds.to_vec()]
5075        };
5076        self.execute_query_with_bind_rows_and_options_core(
5077            cx,
5078            sql,
5079            prefetch_rows,
5080            &bind_rows,
5081            ExecuteOptions::default(),
5082        )
5083        .await
5084    }
5085
5086    pub(crate) async fn execute_query_with_bind_rows_and_options_core(
5087        &mut self,
5088        cx: &Cx,
5089        sql: &str,
5090        prefetch_rows: u32,
5091        bind_rows: &[Vec<BindValue>],
5092        exec_options: ExecuteOptions,
5093    ) -> Result<QueryResult> {
5094        match self
5095            .execute_query_with_bind_rows_options_adjusted(
5096                cx,
5097                sql,
5098                prefetch_rows,
5099                bind_rows,
5100                exec_options,
5101            )
5102            .await
5103        {
5104            // a query re-executed against an open server cursor whose select
5105            // list changed since it was parsed reports ORA-00932 (inconsistent
5106            // data types) or ORA-01007 (variable not in select list); the
5107            // reference clears the cursor and retries once with a full parse
5108            // (impl/thin/messages/base.pyx:1199-1213). The failing adjusted
5109            // call has already evicted the stale cursor from the statement
5110            // cache, so the retry re-parses from scratch.
5111            Err(err) if refetch_retry_applies(&err) && statement_is_query(sql) => {
5112                observe_cancellation_between_round_trips(cx)?;
5113                // also drop any retained by-SQL fetch metadata used by the
5114                // older refetch path so the retry rebuilds it
5115                self.forget_fetch_metadata(sql);
5116                self.execute_query_with_bind_rows_options_adjusted(
5117                    cx,
5118                    sql,
5119                    prefetch_rows,
5120                    bind_rows,
5121                    exec_options,
5122                )
5123                .await
5124            }
5125            other => other,
5126        }
5127    }
5128
5129    async fn execute_query_with_bind_rows_options_adjusted(
5130        &mut self,
5131        cx: &Cx,
5132        sql: &str,
5133        prefetch_rows: u32,
5134        bind_rows: &[Vec<BindValue>],
5135        exec_options: ExecuteOptions,
5136    ) -> Result<QueryResult> {
5137        // Bind/execute round-trip span (feature-gated, zero-cost when off).
5138        // Carries the SQL digest, the bind count (binds per row — NEVER any bind
5139        // value), and the executemany row count; `db.rows_fetched` is filled
5140        // after the response.
5141        let _span = obs_span!(
5142            "oracledb.execute",
5143            db.statement = %crate::obs::sql_digest(sql),
5144            db.bind_count = bind_rows.first().map_or(0, Vec::len) as u64,
5145            db.bind_rows = bind_rows.len() as u64,
5146            db.rows_fetched = tracing::field::Empty,
5147        );
5148        observe_cancellation_between_round_trips(cx)?;
5149        // A parse-only describe carries no binds (the wire bind count is zero),
5150        // and a scroll continuation re-uses an already-bound cursor; neither
5151        // should be shape-validated. The raw validator only rejects ragged
5152        // array-DML rows — see `validate_bind_rows_shape`.
5153        if !exec_options.scroll_operation() && !exec_options.parse_only() {
5154            crate::sql_convert::validate_bind_rows_shape(sql, bind_rows)?;
5155            // First-execute type validation: array DML binds one type per column,
5156            // so a batch row whose value type disagrees with the type the first
5157            // typed row established would be silently coerced by the server into a
5158            // cryptic ORA error. Reject it up front with a precise typed error
5159            // (reference DPY-2006). A single-row execute always passes.
5160            crate::sql_convert::validate_bind_rows_types(bind_rows)?;
5161        }
5162        // If a prior cancellable round trip was dropped mid-read, break + drain
5163        // the stranded call before issuing this execute (Scope cancel-on-drop).
5164        self.ensure_clean_before_request().await?;
5165        let mut exec_options = exec_options.with_max_string_size(self.capabilities.max_string_size);
5166        // a `suspend_on_success` execute folds a post-detach into the pending
5167        // sessionless piggyback; validate (DPY-3034/3036) before any wire work
5168        // (reference execute.pyx `_handle_sessionless_suspend`)
5169        if exec_options.suspend_on_success() {
5170            self.prepare_sessionless_suspend_on_success()?;
5171        }
5172        let use_cache = exec_options.cache_statement() && !exec_options.parse_only();
5173        // Whether the cursor produced by this execute may be returned to the
5174        // statement cache (reference `Statement._return_to_cache`). A statement
5175        // that had to be copied because the cached cursor was in use is NOT
5176        // returnable: returning it would evict the still-live original from the
5177        // cache and reset its fetch position (ORA-01002).
5178        let mut is_copy = false;
5179        // Bind TYPE shape of this execute: a cached cursor is only reused when
5180        // the shape it was parsed/bound with is still compatible, otherwise it
5181        // is dropped and this execute re-parses with the new bind metadata
5182        // (bead rust-oracledb-ilel: ORA-01722 on a NUMBER->TEXT rebind).
5183        let bind_shape = bind_type_shape(bind_rows);
5184        if exec_options.cursor_id() == 0 && !exec_options.parse_only() {
5185            if use_cache {
5186                if self.statement_is_in_use(sql) {
5187                    // cached cursor busy: this execute parses a fresh (copy)
5188                    // cursor that must not be returned to the cache
5189                    is_copy = true;
5190                } else if let Some(cursor_id) = self.statement_cache_get(sql, &bind_shape) {
5191                    exec_options = exec_options.with_cursor_id(cursor_id);
5192                }
5193            } else if let Some(cursor_id) = self.statement_cache_take(sql, &bind_shape) {
5194                // reference pops the statement from the cache even when
5195                // cache_statement=False, reusing its open cursor once
5196                exec_options = exec_options.with_cursor_id(cursor_id);
5197            }
5198        }
5199        // Re-executing an open cursor whose columns require a client-side define
5200        // (VECTOR) must suppress server-side prefetch (reference
5201        // `stmt._no_prefetch`, set once during describe in messages/base.pyx
5202        // 1159-1164 and persisted on the cached statement). Otherwise the
5203        // re-execute prefetches the row inline and exhausts the cursor before
5204        // the define-fetch runs, raising ORA-01002 on the next fetch.
5205        if exec_options.cursor_id() != 0 && statement_is_query(sql) {
5206            if let Some(columns) = self.cursor_columns.get(&exec_options.cursor_id()) {
5207                if columns.iter().any(|column| {
5208                    column.ora_type_num() == oracledb_protocol::thin::ORA_TYPE_NUM_VECTOR
5209                }) {
5210                    exec_options = exec_options.with_no_prefetch(true);
5211                }
5212            }
5213        }
5214        let piggyback = self.take_close_cursors_piggyback();
5215        if piggyback.is_none() {
5216            let has_ref_cursor_output = bind_rows.iter().any(|row| {
5217                row.iter().any(|value| {
5218                    matches!(
5219                        value,
5220                        BindValue::Output {
5221                            ora_type_num: oracledb_protocol::thin::ORA_TYPE_NUM_CURSOR,
5222                            ..
5223                        }
5224                    )
5225                })
5226            });
5227            if has_ref_cursor_output {
5228                // python-oracledb reserves this sequence slot for a
5229                // close-cursor piggyback.
5230                let _ = next_ttc_sequence(&mut self.ttc_seq_num);
5231            }
5232        }
5233        // a deferred begin/resume or a folded-in suspend-on-success rides as a
5234        // sessionless piggyback prepended to this execute (reference
5235        // messages/base.pyx `_write_sessionless_piggyback`); its sequence number
5236        // is consumed before the execute's, after the close-cursors piggyback's.
5237        self.protocol_limits.check_batch_rows(bind_rows.len())?;
5238        if let Some(first_row) = bind_rows.first() {
5239            self.protocol_limits.check_binds(first_row.len())?;
5240        }
5241        let sessionless_piggyback = self.take_sessionless_piggyback();
5242        let seq_num = next_ttc_sequence(&mut self.ttc_seq_num);
5243        let mut payload = build_execute_payload_with_bind_rows_and_options_with_seq(
5244            sql,
5245            prefetch_rows,
5246            seq_num,
5247            statement_is_query(sql),
5248            bind_rows,
5249            exec_options,
5250            self.capabilities.ttc_field_version,
5251        )?;
5252        if let Some(piggyback_bytes) = sessionless_piggyback {
5253            let mut combined = piggyback_bytes;
5254            combined.extend_from_slice(&payload);
5255            payload = combined;
5256        }
5257        if let Some(mut piggyback_bytes) = piggyback {
5258            piggyback_bytes.extend_from_slice(&payload);
5259            payload = piggyback_bytes;
5260        }
5261        trace_query_bytes("EXECUTE query payload", &payload);
5262        self.core.send_data_packet(cx, &payload, self.sdu).await?;
5263        let known_columns = if exec_options.cursor_id() != 0 {
5264            self.cursor_columns
5265                .get(&exec_options.cursor_id())
5266                .cloned()
5267                .unwrap_or_default()
5268        } else {
5269            Vec::new()
5270        };
5271        // Read under a cancel-on-drop guard: a dropped execute future arms the
5272        // next operation's break + drain. The classic probe is the same parse
5273        // this site runs below, with its result discarded.
5274        let capabilities = self.capabilities;
5275        let limits = self.protocol_limits;
5276        let first_bind_row = bind_rows.first().map(Vec::as_slice).unwrap_or(&[]);
5277        let classic = !self.supports_end_of_response;
5278        let response = self
5279            .read_flushing_out_binds_cancellable(cx, classic, |bytes| {
5280                response_complete(&parse_query_response_with_binds_options_columns_and_limits(
5281                    bytes,
5282                    capabilities,
5283                    first_bind_row,
5284                    exec_options,
5285                    &known_columns,
5286                    limits,
5287                ))
5288            })
5289            .await?;
5290        trace_query_bytes("EXECUTE query response", &response);
5291        let parsed = parse_query_response_with_binds_options_columns_and_limits(
5292            &response,
5293            self.capabilities,
5294            bind_rows.first().map(Vec::as_slice).unwrap_or(&[]),
5295            exec_options,
5296            &known_columns,
5297            self.protocol_limits,
5298        );
5299        match self.note_parse(parsed) {
5300            Ok(result) => {
5301                if result.cursor_id != 0
5302                    && !result.rows.is_empty()
5303                    && columns_have_lob_prefetch_fields(&result.columns)
5304                {
5305                    self.lob_prefetch_cursors.insert(result.cursor_id);
5306                }
5307                // a deferred begin/resume or a suspend-on-success reports its
5308                // outcome through the response's SYNC piggyback
5309                self.apply_sessionless_state(result.sessionless_txn_state);
5310                // refresh the transaction-in-progress flag from the wire
5311                // end-of-call status (reference protocol.pyx
5312                // `_process_call_status`); leave unchanged if the response
5313                // carried no STATUS message.
5314                if let Some(txn_in_progress) = result.txn_in_progress {
5315                    self.txn_in_progress = txn_in_progress;
5316                }
5317                if is_copy {
5318                    // a copied cursor is never returned to the statement cache;
5319                    // it is closed when its owning cursor releases it (reference
5320                    // `_return_to_cache = False` -> `_add_cursor_to_close`).
5321                    if result.cursor_id != 0 {
5322                        self.copied_cursors.insert(result.cursor_id);
5323                    }
5324                } else if use_cache {
5325                    self.statement_cache_put(sql, result.cursor_id, bind_shape);
5326                }
5327                // Mark the open query cursor as in use so a concurrent execute
5328                // of the same SQL on another cursor of this connection does not
5329                // reuse it (and reset its server-side fetch position). Released
5330                // by `release_cursor` when the owning cursor closes or
5331                // re-prepares (reference `Statement._in_use`). Only query
5332                // cursors hold a fetch position vulnerable to ORA-01002.
5333                if result.cursor_id != 0 && statement_is_query(sql) && !exec_options.parse_only() {
5334                    self.in_use_cursors.insert(result.cursor_id);
5335                }
5336                // A cursor passed as an IN REF CURSOR bind may be closed
5337                // server-side by the called PL/SQL (e.g. `close a_cursor`); its
5338                // cached cursor_id is then invalid. Drop any statement-cache
5339                // entry pointing at a bound cursor_id so the next execute on
5340                // that cursor re-parses with a fresh one instead of reusing the
5341                // closed one (ORA-01001). Test 1315 / 5815.
5342                self.invalidate_bound_ref_cursors(bind_rows);
5343                self.remember_cursor_columns(&result);
5344                obs_record!(_span, db.rows_fetched = result.rows.len() as u64);
5345                if exec_options.parse_only() {
5346                    return Ok(result);
5347                }
5348                self.apply_refetch_metadata(cx, sql, result, prefetch_rows.max(2))
5349                    .await
5350            }
5351            Err(err) => {
5352                // drop the cached cursor so the next execute re-parses
5353                // (reference base.pyx:1186-1189 clear_cursor on errors)
5354                if use_cache {
5355                    self.statement_cache_invalidate(sql, exec_options.cursor_id());
5356                }
5357                Err(err)
5358            }
5359        }
5360    }
5361
5362    /// Low-level raw execute: run `sql` once per bind row with explicit
5363    /// [`ExecuteOptions`] and an optional per-call timeout, returning the first
5364    /// fetch batch as a raw [`QueryResult`] (columns, `cursor_id`, `more_rows`,
5365    /// `rows`, OUT/RETURNING binds, batch errors, array DML row counts).
5366    ///
5367    /// This is the execute-side counterpart to the retained low-level fetch
5368    /// primitives ([`Self::fetch_rows`],
5369    /// [`Self::define_and_fetch_rows_with_columns`], [`Self::scroll_cursor`],
5370    /// [`Self::fetch_cursor`]): the four operation families
5371    /// ([`Self::query`]/[`Self::execute`]/[`Self::execute_many`]) are the
5372    /// ergonomic surface built *over* this, and project the `QueryResult` into
5373    /// the curated [`Rows`]/[`ExecuteOutcome`]/[`BatchOutcome`] outcomes. Use
5374    /// `execute_raw` only when you need the unprojected wire result — for
5375    /// example a statement-type-agnostic caller that decides query-vs-DML from
5376    /// `result.columns`, a parse-only describe (`exec_options.with_parse_only`),
5377    /// or per-bind-row OUT/RETURNING aggregation. Prefer the families for
5378    /// ordinary application code.
5379    ///
5380    /// `bind_rows` is positional array DML: each inner `Vec<BindValue>` is one
5381    /// bind row applied in a single round trip (an empty slice runs `sql` once
5382    /// with no binds). `prefetch_rows` is the requested first-batch size; rows
5383    /// beyond the first batch (when [`QueryResult::more_rows`] is set) are
5384    /// fetched with the cursor primitives above. `timeout_ms` of `Some(n)` with
5385    /// `n > 0` bounds the round trip with a BREAK→drain→[`Error::CallTimeout`]
5386    /// recovery that leaves the session usable; `None`/`Some(0)` runs untimed.
5387    pub async fn execute_raw(
5388        &mut self,
5389        cx: &Cx,
5390        sql: &str,
5391        prefetch_rows: u32,
5392        bind_rows: &[Vec<BindValue>],
5393        exec_options: ExecuteOptions,
5394        timeout_ms: Option<u32>,
5395    ) -> Result<QueryResult> {
5396        self.execute_query_with_bind_rows_options_call_timeout(
5397            cx,
5398            sql,
5399            prefetch_rows,
5400            bind_rows,
5401            exec_options,
5402            timeout_ms,
5403        )
5404        .await
5405    }
5406
5407    pub(crate) async fn execute_query_with_bind_rows_options_call_timeout(
5408        &mut self,
5409        cx: &Cx,
5410        sql: &str,
5411        prefetch_rows: u32,
5412        bind_rows: &[Vec<BindValue>],
5413        exec_options: ExecuteOptions,
5414        timeout_ms: Option<u32>,
5415    ) -> Result<QueryResult> {
5416        let Some(timeout_ms) = timeout_ms.filter(|value| *value > 0) else {
5417            return self
5418                .execute_query_with_bind_rows_and_options_core(
5419                    cx,
5420                    sql,
5421                    prefetch_rows,
5422                    bind_rows,
5423                    exec_options,
5424                )
5425                .await;
5426        };
5427        let deadline = QueryDeadline::from_timeout(Duration::from_millis(u64::from(timeout_ms)));
5428        match deadline
5429            .run(self.execute_query_with_bind_rows_and_options_core(
5430                cx,
5431                sql,
5432                prefetch_rows,
5433                bind_rows,
5434                exec_options,
5435            ))
5436            .await
5437        {
5438            Ok(result) => result,
5439            Err(DeadlineExpiry::BeforeStart) => self.reject_before_operation_start(cx, timeout_ms),
5440            Err(DeadlineExpiry::InFlight) => self.recover_from_call_timeout(cx, timeout_ms).await,
5441        }
5442    }
5443
5444    /// If a previous cancellable fetch future was dropped mid-read (its
5445    /// `CancelDrainGuard` moved the recovery phase to `BreakSent`), break +
5446    /// drain the stranded server call now — before this round trip sends its own
5447    /// request — so the leftover bytes / still-running call cannot poison this
5448    /// response. A failed drain marks the connection dead and surfaces
5449    /// [`Error::ConnectionClosed`].
5450    ///
5451    /// A bare [`fetch_rows_request`](Self::fetch_rows_request) whose paired
5452    /// `fetch_rows_ref_response` is never consumed (the caller abandons the
5453    /// speculative page without dropping a response future to fire the guard)
5454    /// leaves the phase `InFlight` with a stranded response on the wire. Treat
5455    /// that the same as a dropped fetch: move it to `BreakSent` so the drain
5456    /// below reclaims the wire instead of wedging the connection on every
5457    /// subsequent operation. This is only ever reached from the *start* of the
5458    /// next operation — `fetch_rows_ref_response`, the one path that legitimately
5459    /// reads its own `InFlight` response, never calls this.
5460    async fn ensure_clean_before_request(&mut self) -> Result<()> {
5461        if self.core.recovery.phase() == SessionRecoveryPhase::InFlight {
5462            self.core.recovery.mark_break_required();
5463        }
5464        if !self.core.recovery.begin_pending_drain()? {
5465            return Ok(());
5466        }
5467        match self
5468            .core
5469            .cancel_and_drain_wire(BREAK_DRAIN_RECOVERY_TIMEOUT)
5470        {
5471            Ok(()) => {
5472                self.core.recovery.finish_drain_ready();
5473                Ok(())
5474            }
5475            Err(err) => {
5476                self.core.recovery.mark_dead();
5477                self.dead = true;
5478                Err(err)
5479            }
5480        }
5481    }
5482
5483    /// Convert Asupersync's transport-level signal for a context cancelled
5484    /// while a socket read was pending into the driver's public cancellation
5485    /// error. The [`CancelDrainGuard`] deliberately remains armed on this path:
5486    /// the request is already on the wire, so the next connection owner must
5487    /// break and drain it before reusing the session.
5488    ///
5489    /// A real OS `Interrupted` error is left untouched. Asupersync uses that
5490    /// same `io::ErrorKind` for cancellation-aware TCP reads, but marks its own
5491    /// cancellation with the exact payload `"cancelled"`
5492    /// (`asupersync::net::tcp::stream` poll paths). That marker is set at the
5493    /// instant asupersync observes the cancel, so it is the DETERMINISTIC signal:
5494    /// keying off it (or an already-observable `cx.checkpoint()`) instead of the
5495    /// checkpoint alone closes the interrupt-vs-checkpoint race where the read
5496    /// future resolves before the cancel flag is visible to a *later*
5497    /// `checkpoint()` on this thread — which otherwise mistyped a real
5498    /// cancellation as a raw `Io(Interrupted)`. A genuine OS `EINTR` (a different
5499    /// message, no pending cancel) is still left untouched.
5500    fn in_flight_read_cancellation_error(&mut self, cx: &Cx, err: &Error) -> Option<Error> {
5501        let Error::Io(io_error) = err else {
5502            return None;
5503        };
5504        if io_error.kind() != std::io::ErrorKind::Interrupted
5505            || !(is_asupersync_cancellation(io_error) || cx.checkpoint().is_err())
5506        {
5507            return None;
5508        }
5509
5510        let timeout_ms = cx
5511            .budget()
5512            .remaining_time(time::wall_now())
5513            .map(duration_to_millis_saturating)
5514            .unwrap_or(0);
5515        let disposition = cancel_disposition(cx.cancel_reason());
5516        if disposition == CancelDisposition::Close {
5517            // Shutdown/resource-loss cancellation is not reusable even though
5518            // the guard would otherwise schedule a deferred drain.
5519            self.core.recovery.mark_dead();
5520            self.dead = true;
5521        }
5522        Some(disposition.into_error(timeout_ms))
5523    }
5524
5525    /// Read one TTC response under a `CancelDrainGuard`: if THIS read future is
5526    /// dropped mid-flight (the fetch was cancelled / raced), the guard moves the
5527    /// recovery phase to `BreakSent` so the next operation breaks + drains the
5528    /// stranded call. A normal completion disarms the guard, so the uncancelled
5529    /// path costs nothing beyond an `Arc::clone`.
5530    async fn read_response_cancellable(
5531        &mut self,
5532        cx: &Cx,
5533        classic: bool,
5534        probe: impl Fn(&[u8]) -> bool,
5535    ) -> Result<Vec<u8>> {
5536        // Clone the Arc so the guard owns a handle independent of the `&mut self`
5537        // read borrow (the two touch disjoint state but the borrow checker can't
5538        // prove it across the guard's lifetime).
5539        let recovery = Arc::clone(&self.core.recovery);
5540        let mut guard = CancelDrainGuard::arm(recovery)?;
5541        let response = match self
5542            .core
5543            .read_data_response_probed(cx, classic, probe)
5544            .await
5545        {
5546            Ok(response) => response,
5547            Err(err) => {
5548                if let Some(cancelled) = self.in_flight_read_cancellation_error(cx, &err) {
5549                    return Err(cancelled);
5550                }
5551                return Err(err);
5552            }
5553        };
5554        guard.disarm();
5555        Ok(response)
5556    }
5557
5558    /// Read one END_OF_RESPONSE-delimited pipeline response with the same
5559    /// cancel-on-drop bookkeeping as every ordinary request response. Pipeline
5560    /// writes are followed by multiple response boundaries; a stalled boundary
5561    /// must therefore arm recovery before the inactivity timeout reaches the
5562    /// caller, or the next request could consume a stranded pipeline response.
5563    async fn read_pipeline_response_cancellable(&mut self, cx: &Cx) -> Result<DataResponse> {
5564        let recovery = Arc::clone(&self.core.recovery);
5565        let mut guard = CancelDrainGuard::arm(recovery)?;
5566        let response = match self.core.read_data_response_boundary(cx, true).await {
5567            Ok(response) => response,
5568            Err(err) => {
5569                if let Some(cancelled) = self.in_flight_read_cancellation_error(cx, &err) {
5570                    return Err(cancelled);
5571                }
5572                return Err(err);
5573            }
5574        };
5575        guard.disarm();
5576        Ok(response)
5577    }
5578
5579    /// [`Self::read_response_cancellable`] for the bind/execute path, which reads
5580    /// via [`read_data_response_flushing_out_binds`] (it answers FLUSH_OUT_BINDS
5581    /// requests). Same cancel-on-drop semantics: a dropped execute future arms
5582    /// the next operation's break + drain.
5583    async fn read_flushing_out_binds_cancellable(
5584        &mut self,
5585        cx: &Cx,
5586        classic: bool,
5587        probe: impl Fn(&[u8]) -> bool,
5588    ) -> Result<Vec<u8>> {
5589        let recovery = Arc::clone(&self.core.recovery);
5590        let mut guard = CancelDrainGuard::arm(recovery)?;
5591        let response = match self
5592            .core
5593            .read_data_response_flushing_out_binds_probed(cx, self.sdu, classic, probe)
5594            .await
5595        {
5596            Ok(response) => response,
5597            Err(err) => {
5598                if let Some(cancelled) = self.in_flight_read_cancellation_error(cx, &err) {
5599                    return Err(cancelled);
5600                }
5601                return Err(err);
5602            }
5603        };
5604        guard.disarm();
5605        Ok(response)
5606    }
5607
5608    pub async fn fetch_rows(
5609        &mut self,
5610        cx: &Cx,
5611        cursor_id: u32,
5612        arraysize: u32,
5613        previous_row: Option<&[Option<oracledb_protocol::thin::QueryValue>]>,
5614    ) -> Result<QueryResult> {
5615        self.fetch_rows_with_columns(cx, cursor_id, arraysize, &[], previous_row)
5616            .await
5617    }
5618
5619    pub async fn fetch_rows_with_columns(
5620        &mut self,
5621        cx: &Cx,
5622        cursor_id: u32,
5623        arraysize: u32,
5624        known_columns: &[ColumnMetadata],
5625        previous_row: Option<&[Option<oracledb_protocol::thin::QueryValue>]>,
5626    ) -> Result<QueryResult> {
5627        // Fetch round-trip span (feature-gated, zero-cost when off). Carries the
5628        // cursor id and the requested arraysize; `db.rows_fetched` is filled
5629        // after the response.
5630        let _span = obs_span!(
5631            "oracledb.fetch",
5632            db.cursor_id = cursor_id as u64,
5633            db.arraysize = arraysize as u64,
5634            db.rows_fetched = tracing::field::Empty,
5635        );
5636        observe_cancellation_between_round_trips(cx)?;
5637        // If a prior fetch future was cancelled mid-read, break + drain the
5638        // stranded call before issuing this fetch (Scope-based cancel-on-drop).
5639        self.ensure_clean_before_request().await?;
5640        let columns = self
5641            .cursor_columns
5642            .get(&cursor_id)
5643            .cloned()
5644            .unwrap_or_else(|| known_columns.to_vec());
5645        let lob_prefetch = self.lob_prefetch_cursors.contains(&cursor_id);
5646        let seq_num = next_ttc_sequence(&mut self.ttc_seq_num);
5647        let payload = if lob_prefetch {
5648            build_define_fetch_payload_with_seq(
5649                cursor_id,
5650                arraysize,
5651                seq_num,
5652                &columns,
5653                self.capabilities.ttc_field_version,
5654            )?
5655        } else {
5656            build_fetch_payload_with_seq(
5657                cursor_id,
5658                arraysize,
5659                seq_num,
5660                self.capabilities.ttc_field_version,
5661            )
5662        };
5663        trace_query_bytes("FETCH payload", &payload);
5664        self.core.send_data_packet(cx, &payload, self.sdu).await?;
5665        // Read under a cancel-on-drop guard: if THIS fetch future is dropped
5666        // mid-read, the next operation will break + drain the stranded call.
5667        // The classic probe is the same parse this site runs below, with its
5668        // result discarded.
5669        let capabilities = self.capabilities;
5670        let limits = self.protocol_limits;
5671        let classic = !self.supports_end_of_response;
5672        let profile = fetch_profile::enabled();
5673        let read_start = profile.then(time::wall_now);
5674        let response = self
5675            .read_response_cancellable(cx, classic, |bytes| {
5676                response_complete(&if lob_prefetch {
5677                    parse_define_fetch_response_with_context_and_limits(
5678                        bytes,
5679                        capabilities,
5680                        &columns,
5681                        previous_row,
5682                        limits,
5683                    )
5684                } else {
5685                    parse_fetch_response_with_context_and_limits(
5686                        bytes,
5687                        capabilities,
5688                        &columns,
5689                        previous_row,
5690                        limits,
5691                    )
5692                })
5693            })
5694            .await?;
5695        if let Some(start) = read_start {
5696            fetch_profile::add_read(time::wall_now().duration_since(start));
5697        }
5698        trace_query_bytes("FETCH response", &response);
5699        let decode_start = profile.then(time::wall_now);
5700        let parsed = if lob_prefetch {
5701            parse_define_fetch_response_with_context_and_limits(
5702                &response,
5703                self.capabilities,
5704                &columns,
5705                previous_row,
5706                self.protocol_limits,
5707            )
5708        } else {
5709            parse_fetch_response_with_context_and_limits(
5710                &response,
5711                self.capabilities,
5712                &columns,
5713                previous_row,
5714                self.protocol_limits,
5715            )
5716        };
5717        if let Some(start) = decode_start {
5718            fetch_profile::add_decode(time::wall_now().duration_since(start));
5719        }
5720        let result = self.note_parse(parsed)?;
5721        obs_record!(_span, db.rows_fetched = result.rows.len() as u64);
5722        self.remember_cursor_columns(&result);
5723        Ok(result)
5724    }
5725
5726    /// Zero-copy companion to [`fetch_rows`](Self::fetch_rows): fetch one batch
5727    /// of rows from an open server cursor and return a
5728    /// [`BorrowedFetchResult`]
5729    /// whose rows borrow the response buffer (no per-cell allocation for the
5730    /// common scalar case). Iterate the rows with
5731    /// [`BorrowedRowBatch::for_each_row_ref`](oracledb_protocol::thin::BorrowedRowBatch::for_each_row_ref).
5732    ///
5733    /// This is additive: the owned [`fetch_rows`](Self::fetch_rows) path is
5734    /// unchanged. Prefer [`for_each_row_ref`](Self::for_each_row_ref) for the
5735    /// common "execute and drain" case; this lower-level method exists for
5736    /// callers that page manually.
5737    pub async fn fetch_rows_ref(
5738        &mut self,
5739        cx: &Cx,
5740        cursor_id: u32,
5741        arraysize: u32,
5742        previous_row: Option<&[Option<oracledb_protocol::thin::QueryValue>]>,
5743    ) -> Result<BorrowedFetchResult> {
5744        observe_cancellation_between_round_trips(cx)?;
5745        self.ensure_clean_before_request().await?;
5746        let columns = self
5747            .cursor_columns
5748            .get(&cursor_id)
5749            .cloned()
5750            .unwrap_or_default();
5751        let lob_prefetch = self.lob_prefetch_cursors.contains(&cursor_id);
5752        let seq_num = next_ttc_sequence(&mut self.ttc_seq_num);
5753        let payload = if lob_prefetch {
5754            build_define_fetch_payload_with_seq(
5755                cursor_id,
5756                arraysize,
5757                seq_num,
5758                &columns,
5759                self.capabilities.ttc_field_version,
5760            )?
5761        } else {
5762            build_fetch_payload_with_seq(
5763                cursor_id,
5764                arraysize,
5765                seq_num,
5766                self.capabilities.ttc_field_version,
5767            )
5768        };
5769        trace_query_bytes("FETCH payload", &payload);
5770        self.core.send_data_packet(cx, &payload, self.sdu).await?;
5771        // The classic probe runs the same borrowed parsers this site uses
5772        // below; the borrowed result is dropped inside the closure, so no
5773        // borrow of the probe bytes escapes.
5774        let capabilities = self.capabilities;
5775        let limits = self.protocol_limits;
5776        let classic = !self.supports_end_of_response;
5777        let profile = fetch_profile::enabled();
5778        let read_start = profile.then(time::wall_now);
5779        let response = self
5780            .read_response_cancellable(cx, classic, |bytes| {
5781                if lob_prefetch {
5782                    response_complete(&parse_define_fetch_response_borrowed_with_limits(
5783                        bytes,
5784                        capabilities,
5785                        &columns,
5786                        previous_row,
5787                        limits,
5788                    ))
5789                } else {
5790                    response_complete(&parse_query_response_borrowed_with_limits(
5791                        bytes,
5792                        capabilities,
5793                        &columns,
5794                        previous_row,
5795                        limits,
5796                    ))
5797                }
5798            })
5799            .await?;
5800        if let Some(start) = read_start {
5801            fetch_profile::add_read(time::wall_now().duration_since(start));
5802        }
5803        trace_query_bytes("FETCH response", &response);
5804        let decode_start = profile.then(time::wall_now);
5805        let parsed = if lob_prefetch {
5806            parse_define_fetch_response_borrowed_with_limits(
5807                &response,
5808                self.capabilities,
5809                &columns,
5810                previous_row,
5811                self.protocol_limits,
5812            )
5813        } else {
5814            parse_query_response_borrowed_with_limits(
5815                &response,
5816                self.capabilities,
5817                &columns,
5818                previous_row,
5819                self.protocol_limits,
5820            )
5821        };
5822        if let Some(start) = decode_start {
5823            fetch_profile::add_decode(time::wall_now().duration_since(start));
5824        }
5825        let result = self.note_parse(parsed)?;
5826        // Mirror the owned `fetch_rows` path: if the server re-described the
5827        // cursor mid-paging (the type-change refetch path emits DESCRIBE_INFO),
5828        // persist the adjusted column list under this cursor id so subsequent
5829        // pages decode with the new schema. Keyed on the known `cursor_id`
5830        // (the response's own cursor_id is 0 on an ordinary fetch).
5831        if cursor_id != 0 && !result.batch.columns().is_empty() {
5832            self.cursor_columns
5833                .insert(cursor_id, result.batch.columns().to_vec());
5834        }
5835        Ok(result)
5836    }
5837
5838    /// Send a FETCH request for the next page on an open cursor **without**
5839    /// reading its response — the *request* half of the speculative next-page
5840    /// prefetch that overlaps page K+1's wire round trip with page K's decode
5841    /// (bead rust-oracledb-xad / 3oi). Pair it with
5842    /// [`fetch_rows_ref_response`](Self::fetch_rows_ref_response), which reads +
5843    /// decodes the outstanding page.
5844    ///
5845    /// ## Cancellation safety
5846    ///
5847    /// Issuing this request leaves a server response in flight on the wire. So
5848    /// it moves the recovery phase to `InFlight` for the entire window until
5849    /// that response is consumed: if the owning future is dropped while the
5850    /// response is in flight — whether during the prior page's decode (no read
5851    /// yet) or during the response read itself — the next operation breaks +
5852    /// drains the stranded page before issuing its own request (the same
5853    /// machinery that protects a dropped fetch, see `CancelDrainGuard`). A
5854    /// request can only be issued from a clean wire boundary, so it first drains
5855    /// any *previously* stranded call.
5856    pub async fn fetch_rows_request(
5857        &mut self,
5858        cx: &Cx,
5859        cursor_id: u32,
5860        arraysize: u32,
5861    ) -> Result<()> {
5862        observe_cancellation_between_round_trips(cx)?;
5863        // A request must start from a clean boundary: if a prior cancelled op
5864        // left a drain pending, break + drain it first.
5865        self.ensure_clean_before_request().await?;
5866        let columns = self
5867            .cursor_columns
5868            .get(&cursor_id)
5869            .cloned()
5870            .unwrap_or_default();
5871        let lob_prefetch = self.lob_prefetch_cursors.contains(&cursor_id);
5872        let seq_num = next_ttc_sequence(&mut self.ttc_seq_num);
5873        let payload = if lob_prefetch {
5874            build_define_fetch_payload_with_seq(
5875                cursor_id,
5876                arraysize,
5877                seq_num,
5878                &columns,
5879                self.capabilities.ttc_field_version,
5880            )?
5881        } else {
5882            build_fetch_payload_with_seq(
5883                cursor_id,
5884                arraysize,
5885                seq_num,
5886                self.capabilities.ttc_field_version,
5887            )
5888        };
5889        trace_query_bytes("FETCH payload (prefetch)", &payload);
5890        self.core.recovery.begin_operation()?;
5891        match self.core.send_data_packet(cx, &payload, self.sdu).await {
5892            Ok(()) => Ok(()),
5893            Err(err) => {
5894                self.core.recovery.mark_dead();
5895                self.dead = true;
5896                Err(err)
5897            }
5898        }?;
5899        // A speculative response is now outstanding: the recovery phase remains
5900        // InFlight for the WHOLE window until it is consumed by
5901        // `fetch_rows_ref_response`, so a drop anywhere in {decode prior page,
5902        // read this response} cleans it up before the next op runs.
5903        Ok(())
5904    }
5905
5906    /// Read + decode the response to a FETCH request previously issued by
5907    /// [`fetch_rows_request`](Self::fetch_rows_request) — the *response* half of
5908    /// the speculative next-page prefetch. Returns the same
5909    /// [`BorrowedFetchResult`] as [`fetch_rows_ref`](Self::fetch_rows_ref).
5910    ///
5911    /// Must be called exactly once per `fetch_rows_request`, with the request's
5912    /// `cursor_id` and the `previous_row` seed for duplicate-column decoding
5913    /// (the last row of the prior page — known by now because the prior page is
5914    /// fully decoded). The read runs under a `CancelDrainGuard` (a mid-read
5915    /// drop re-arms the pending drain); a clean read disarms the
5916    /// `fetch_rows_request` arming, leaving no stranded page.
5917    pub async fn fetch_rows_ref_response(
5918        &mut self,
5919        cx: &Cx,
5920        cursor_id: u32,
5921        previous_row: Option<&[Option<oracledb_protocol::thin::QueryValue>]>,
5922    ) -> Result<BorrowedFetchResult> {
5923        observe_cancellation_between_round_trips(cx)?;
5924        // Read under the cancel-on-drop guard. NOTE: do NOT
5925        // `ensure_clean_before_request` here — the InFlight phase is set by our
5926        // own `fetch_rows_request` and marks the response we are about to read
5927        // legitimately, not a stranded call to discard.
5928        let columns = self
5929            .cursor_columns
5930            .get(&cursor_id)
5931            .cloned()
5932            .unwrap_or_default();
5933        let lob_prefetch = self.lob_prefetch_cursors.contains(&cursor_id);
5934        // The classic probe runs the same borrowed parsers this site uses
5935        // below; the borrowed result is dropped inside the closure.
5936        let capabilities = self.capabilities;
5937        let limits = self.protocol_limits;
5938        let classic = !self.supports_end_of_response;
5939        let profile = fetch_profile::enabled();
5940        let read_start = profile.then(time::wall_now);
5941        let response = self
5942            .read_response_cancellable(cx, classic, |bytes| {
5943                if lob_prefetch {
5944                    response_complete(&parse_define_fetch_response_borrowed_with_limits(
5945                        bytes,
5946                        capabilities,
5947                        &columns,
5948                        previous_row,
5949                        limits,
5950                    ))
5951                } else {
5952                    response_complete(&parse_query_response_borrowed_with_limits(
5953                        bytes,
5954                        capabilities,
5955                        &columns,
5956                        previous_row,
5957                        limits,
5958                    ))
5959                }
5960            })
5961            .await?;
5962        if let Some(start) = read_start {
5963            fetch_profile::add_read(time::wall_now().duration_since(start));
5964        }
5965        trace_query_bytes("FETCH response (prefetch)", &response);
5966        let decode_start = profile.then(time::wall_now);
5967        let parsed = if lob_prefetch {
5968            parse_define_fetch_response_borrowed_with_limits(
5969                &response,
5970                self.capabilities,
5971                &columns,
5972                previous_row,
5973                self.protocol_limits,
5974            )
5975        } else {
5976            parse_query_response_borrowed_with_limits(
5977                &response,
5978                self.capabilities,
5979                &columns,
5980                previous_row,
5981                self.protocol_limits,
5982            )
5983        };
5984        if let Some(start) = decode_start {
5985            fetch_profile::add_decode(time::wall_now().duration_since(start));
5986        }
5987        let result = self.note_parse(parsed)?;
5988        if cursor_id != 0 && !result.batch.columns().is_empty() {
5989            self.cursor_columns
5990                .insert(cursor_id, result.batch.columns().to_vec());
5991        }
5992        Ok(result)
5993    }
5994
5995    /// Execute `sql` and drive every fetched row through `callback` as a slice
5996    /// of borrowed [`QueryValueRef`] —
5997    /// the zero-copy fetch fast path. Scalar cells (Text / Number / Raw /
5998    /// Boolean / Interval / DateTime) borrow the fetch buffer directly, so a
5999    /// Rust consumer iterating a wide many-row result pays ~0 allocations per
6000    /// cell, in contrast to the owned [`execute_raw`](Self::execute_raw) +
6001    /// [`fetch_rows`](Self::fetch_rows) path which materializes a `String` /
6002    /// `Vec<u8>` per scalar cell of every row.
6003    ///
6004    /// The `&[Option<QueryValueRef>]` row slice is valid only for the duration
6005    /// of each `callback` call — it borrows the batch buffer and cannot escape.
6006    /// Use [`QueryValueRef::to_owned_value`](oracledb_protocol::thin::QueryValueRef::to_owned_value)
6007    /// to keep a value past the call. Cold cells (LOB / Cursor / Object / Vector
6008    /// / JSON / non-UTF-8 / ROWID) surface as `QueryValueRef::Owned`.
6009    ///
6010    /// Pages through the cursor with the given `arraysize` until the server
6011    /// reports no more rows, releasing the server cursor back to the statement
6012    /// cache when done. The owned fetch path is untouched.
6013    pub async fn for_each_row_ref<F>(
6014        &mut self,
6015        cx: &Cx,
6016        sql: &str,
6017        arraysize: u32,
6018        mut callback: F,
6019    ) -> Result<()>
6020    where
6021        F: FnMut(&[Option<QueryValueRef<'_>>]) -> Result<()>,
6022    {
6023        // Streaming-query span (feature-gated, zero-cost when off). Carries the
6024        // SQL digest and the arraysize (the prefetch fill target); filled after
6025        // the loop with the total rows streamed, the number of paged fetch round
6026        // trips, and the max prefetch look-ahead depth. This path keeps a single
6027        // page in flight, so the look-ahead (queue) depth is 0 or 1 — the
6028        // backpressure signal; rows/sec is the operator's db.rows_streamed over
6029        // the span duration. NEVER row data.
6030        let _stream_span = obs_span!(
6031            "oracledb.stream",
6032            db.statement = %crate::obs::sql_digest(sql),
6033            db.arraysize = arraysize as u64,
6034            db.rows_streamed = tracing::field::Empty,
6035            db.pages_fetched = tracing::field::Empty,
6036            db.prefetch_inflight_max = tracing::field::Empty,
6037        );
6038        #[cfg(feature = "tracing")]
6039        let mut rows_streamed: u64 = 0;
6040        #[cfg(feature = "tracing")]
6041        let mut pages_fetched: u64 = 0;
6042        #[cfg(feature = "tracing")]
6043        let mut prefetch_inflight_max: u64 = 0;
6044
6045        // First round trip: EXECUTE + first fetch batch (owned), to obtain the
6046        // open cursor id and column metadata. The first batch's rows are decoded
6047        // borrowed by re-parsing nothing — instead we capture them from the
6048        // owned result below. To keep the borrowed guarantee for the first batch
6049        // too, we re-fetch borrowed pages from the cursor.
6050        let first = self
6051            .execute_query_with_bind_rows_and_options_core(
6052                cx,
6053                sql,
6054                arraysize,
6055                &[],
6056                ExecuteOptions::default(),
6057            )
6058            .await?;
6059        let cursor_id = first.cursor_id;
6060        let mut cursor = BorrowedStreamCursorGuard::new(self, cursor_id);
6061        if cursor_id != 0 && columns_have_lob_prefetch_fields(&first.columns) {
6062            cursor.connection().lob_prefetch_cursors.insert(cursor_id);
6063        }
6064
6065        // Emit the first (owned) batch's rows as borrowed refs over owned values.
6066        // The first execute round trip already materialized them; surfacing them
6067        // through QueryValueRef::Owned keeps the callback's type uniform without
6068        // a second round trip for batch one.
6069        for row in &first.rows {
6070            let refs: Vec<Option<QueryValueRef<'_>>> = row
6071                .iter()
6072                .map(|cell| cell.as_ref().map(QueryValueRef::Owned))
6073                .collect();
6074            callback(&refs)?;
6075        }
6076        #[cfg(feature = "tracing")]
6077        {
6078            rows_streamed += first.rows.len() as u64;
6079        }
6080
6081        let mut more_rows = first.more_rows;
6082        let mut previous_row: Option<Vec<Option<oracledb_protocol::thin::QueryValue>>> =
6083            first.rows.last().cloned();
6084
6085        // Speculative next-page prefetch (bead xad / 3oi): overlap the wire round
6086        // trip of page K+1 with the CPU decode of page K. The trick is that a
6087        // FETCH *request* needs only the cursor id + arraysize (not page K's
6088        // rows), while `previous_row` — page K's last row, the duplicate-column
6089        // seed — is needed only when *decoding* K+1, by which point K is done.
6090        //
6091        // So the loop keeps one page of look-ahead outstanding on the wire:
6092        //   1. request page K+1   (just the send; arms the prefetch drain guard)
6093        //   2. read+decode page K (callback) — overlaps with K+1 in flight
6094        //   3. read+decode page K+1 next iteration, immediately request K+2, ...
6095        //
6096        // Cancellation: `fetch_rows_request` leaves the recovery phase InFlight
6097        // until the response is consumed, so a drop anywhere in this loop
6098        // (including inside the callback, with a page in flight) leaves the
6099        // stranded page to be broken + drained by the next op on this
6100        // connection. Decode stays on this task, so the borrowed batch buffer
6101        // never crosses a thread/await boundary that outlives it — the
6102        // borrowed-fetch guarantee is preserved and `#![forbid(unsafe_code)]`
6103        // holds.
6104
6105        // Prime the pipeline: request the first paged batch ahead of decoding.
6106        if more_rows && cursor_id != 0 {
6107            cursor
6108                .connection()
6109                .fetch_rows_request(cx, cursor_id, arraysize)
6110                .await?;
6111            #[cfg(feature = "tracing")]
6112            {
6113                // One page is now outstanding on the wire (look-ahead depth 1).
6114                prefetch_inflight_max = 1;
6115            }
6116        }
6117
6118        while more_rows && cursor_id != 0 {
6119            // Read + decode the page whose request is already in flight.
6120            let result = cursor
6121                .connection()
6122                .fetch_rows_ref_response(cx, cursor_id, previous_row.as_deref())
6123                .await?;
6124            let next_more = result.more_rows;
6125
6126            // Speculatively request the NEXT page BEFORE running the callback, so
6127            // its round trip overlaps this page's decode + the callback's work.
6128            // The request needs no data from `result`, so `result`'s buffer stays
6129            // alive and untouched across this send.
6130            if next_more {
6131                cursor
6132                    .connection()
6133                    .fetch_rows_request(cx, cursor_id, arraysize)
6134                    .await?;
6135            }
6136
6137            // Snapshot ONLY the last row of the page as the next page's
6138            // duplicate-column seed; materializing every row to owned (the old
6139            // behaviour) would defeat the zero-copy fast path. `row_count()` is
6140            // the `for_each_row_ref` iteration count (both are `row_starts.len()`),
6141            // so this captures exactly the row the old "overwrite every iteration"
6142            // logic left in `last_owned`. Seed correctness is covered by the
6143            // duplicate-CLOB compression regression in `live_borrowed_fetch`.
6144            let row_count = result.batch.row_count();
6145            #[cfg(feature = "tracing")]
6146            {
6147                pages_fetched += 1;
6148                rows_streamed += row_count as u64;
6149                if next_more {
6150                    prefetch_inflight_max = prefetch_inflight_max.max(1);
6151                }
6152            }
6153            let mut last_owned: Option<Vec<Option<oracledb_protocol::thin::QueryValue>>> = None;
6154            let mut row_idx = 0usize;
6155            result.batch.for_each_row_ref(|row| {
6156                if row_idx + 1 == row_count {
6157                    last_owned = Some(
6158                        row.iter()
6159                            .map(|cell| cell.map(|v| v.to_owned_value()))
6160                            .collect(),
6161                    );
6162                }
6163                row_idx += 1;
6164                callback(row)
6165            })?;
6166            if let Some(last) = last_owned {
6167                previous_row = Some(last);
6168            }
6169            more_rows = next_more;
6170        }
6171
6172        obs_record!(_stream_span, db.rows_streamed = rows_streamed);
6173        obs_record!(_stream_span, db.pages_fetched = pages_fetched);
6174        obs_record!(
6175            _stream_span,
6176            db.prefetch_inflight_max = prefetch_inflight_max
6177        );
6178        cursor.release();
6179        Ok(())
6180    }
6181
6182    pub async fn define_and_fetch_rows_with_columns(
6183        &mut self,
6184        cx: &Cx,
6185        cursor_id: u32,
6186        arraysize: u32,
6187        define_columns: &[ColumnMetadata],
6188        previous_row: Option<&[Option<oracledb_protocol::thin::QueryValue>]>,
6189    ) -> Result<QueryResult> {
6190        observe_cancellation_between_round_trips(cx)?;
6191        self.ensure_clean_before_request().await?;
6192        let seq_num = next_ttc_sequence(&mut self.ttc_seq_num);
6193        let payload = build_define_fetch_payload_with_seq(
6194            cursor_id,
6195            arraysize,
6196            seq_num,
6197            define_columns,
6198            self.capabilities.ttc_field_version,
6199        )?;
6200        trace_query_bytes("DEFINE FETCH payload", &payload);
6201        self.core.send_data_packet(cx, &payload, self.sdu).await?;
6202        let capabilities = self.capabilities;
6203        let limits = self.protocol_limits;
6204        let response = self
6205            .read_response_cancellable(cx, !self.supports_end_of_response, |bytes| {
6206                response_complete(&parse_define_fetch_response_with_context_and_limits(
6207                    bytes,
6208                    capabilities,
6209                    define_columns,
6210                    previous_row,
6211                    limits,
6212                ))
6213            })
6214            .await?;
6215        trace_query_bytes("DEFINE FETCH response", &response);
6216        let result = parse_define_fetch_response_with_context_and_limits(
6217            &response,
6218            self.capabilities,
6219            define_columns,
6220            previous_row,
6221            self.protocol_limits,
6222        )
6223        .map_err(Error::from)?;
6224        if columns_have_lob_prefetch_fields(define_columns) {
6225            self.lob_prefetch_cursors.insert(cursor_id);
6226            if result.cursor_id != 0 {
6227                self.lob_prefetch_cursors.insert(result.cursor_id);
6228            }
6229        } else {
6230            self.lob_prefetch_cursors.remove(&cursor_id);
6231            if result.cursor_id != 0 {
6232                self.lob_prefetch_cursors.remove(&result.cursor_id);
6233            }
6234        }
6235        self.cursor_columns
6236            .insert(cursor_id, define_columns.to_vec());
6237        self.remember_cursor_columns(&result);
6238        Ok(result)
6239    }
6240
6241    /// Fetch up to `max_rows` rows from a returned REF CURSOR — a
6242    /// `QueryValue::Cursor` OUT value, or an entry of
6243    /// [`QueryResult::implicit_resultsets`]. A returned cursor is
6244    /// self-describing (its column metadata travels with it as
6245    /// `CursorValue::columns`), so no manual cursor-id handling is required.
6246    /// The child result set's column metadata is on the returned
6247    /// [`QueryResult::columns`]; fetching is bounded by `max_rows`. Like the
6248    /// reference, the nested cursor's id is not independently closed — the
6249    /// server owns its lifecycle as part of the parent statement (reference
6250    /// `_add_cursor_to_close` skips nested cursors). python-oracledb makes you
6251    /// drive the cursor lifecycle by hand; this does it.
6252    pub async fn fetch_cursor(
6253        &mut self,
6254        cx: &Cx,
6255        cursor: &oracledb_protocol::thin::CursorValue,
6256        max_rows: usize,
6257    ) -> Result<QueryResult> {
6258        // Cap the per-fetch array size, but never ask the server for more rows
6259        // than the caller wants: a small `max_rows` must not drag a full batch
6260        // off the wire. (`.max(1)` keeps the degenerate `max_rows == 0` define
6261        // fetch valid; the result is still truncated to 0 below.)
6262        const ARRAYSIZE: usize = 100;
6263        let fetch_size =
6264            |fetched: usize| -> u32 { max_rows.saturating_sub(fetched).clamp(1, ARRAYSIZE) as u32 };
6265        let mut rows: Vec<Vec<Option<oracledb_protocol::thin::QueryValue>>> = Vec::new();
6266        // First fetch is a DEFINE-FETCH: it establishes the column buffers for
6267        // the cursor and returns the first batch.
6268        let mut batch = self
6269            .define_and_fetch_rows_with_columns(
6270                cx,
6271                cursor.cursor_id,
6272                fetch_size(0),
6273                &cursor.columns,
6274                None,
6275            )
6276            .await?;
6277        let mut more = batch.more_rows;
6278        let mut cid = if batch.cursor_id != 0 {
6279            batch.cursor_id
6280        } else {
6281            cursor.cursor_id
6282        };
6283        rows.append(&mut batch.rows);
6284        // Continuation fetches until the cursor drains or the bound is reached.
6285        while more && cid != 0 && rows.len() < max_rows {
6286            observe_cancellation_between_round_trips(cx)?;
6287            let previous_row = rows.last().cloned();
6288            let mut next = self
6289                .fetch_rows_with_columns(
6290                    cx,
6291                    cid,
6292                    fetch_size(rows.len()),
6293                    &cursor.columns,
6294                    previous_row.as_deref(),
6295                )
6296                .await?;
6297            more = next.more_rows;
6298            if next.cursor_id != 0 {
6299                cid = next.cursor_id;
6300            }
6301            rows.append(&mut next.rows);
6302        }
6303        rows.truncate(max_rows);
6304        self.release_cursor(cid);
6305        Ok(QueryResult {
6306            columns: cursor.columns.clone(),
6307            rows,
6308            ..Default::default()
6309        })
6310    }
6311
6312    /// Fetch the metadata for an Oracle ADT type from the data dictionary. For an
6313    /// *object* type, returns its attributes in declaration order (`ALL_TYPE_ATTRS`),
6314    /// each with its Oracle type name and whether it is itself a nested object
6315    /// type. For a *collection* type (VARRAY / nested table), returns its element
6316    /// metadata in `collection_element` (`ALL_COLL_TYPES`). Pair with
6317    /// [`decode_object`] to turn a returned `QueryValue::Object` into structured
6318    /// values. `schema`/`type_name` are matched case-insensitively.
6319    pub async fn describe_object_type(
6320        &mut self,
6321        cx: &Cx,
6322        schema: &str,
6323        type_name: &str,
6324    ) -> Result<ObjectType> {
6325        let schema = schema.to_ascii_uppercase();
6326        let name = type_name.to_ascii_uppercase();
6327        let binds = || {
6328            vec![
6329                oracledb_protocol::thin::BindValue::Text(schema.clone()),
6330                oracledb_protocol::thin::BindValue::Text(name.clone()),
6331            ]
6332        };
6333        let row_text =
6334            |row: &[Option<oracledb_protocol::thin::QueryValue>], i: usize| -> Option<String> {
6335                match row.get(i) {
6336                    Some(Some(v)) => {
6337                        oracledb_protocol::thin::QueryValue::as_text(v).map(str::to_string)
6338                    }
6339                    _ => None,
6340                }
6341            };
6342
6343        // A collection type (VARRAY / nested table) is described by its element
6344        // type, not by attributes — check ALL_COLL_TYPES first.
6345        let coll = self
6346            .execute_query_with_binds_core(
6347                cx,
6348                "select elem_type_name, elem_type_owner from all_coll_types \
6349                 where owner = :1 and type_name = :2",
6350                10,
6351                &binds(),
6352            )
6353            .await?;
6354        if let Some(row) = coll.rows.first() {
6355            return Ok(ObjectType {
6356                schema,
6357                name,
6358                attributes: Vec::new(),
6359                collection_element: Some(CollectionElement {
6360                    type_name: row_text(row, 0).unwrap_or_default(),
6361                    type_owner: row_text(row, 1),
6362                }),
6363            });
6364        }
6365
6366        // High prefetch so every attribute row arrives in one batch. Oracle caps
6367        // a type at 1000 attributes, so this never truncates the metadata.
6368        let res = self
6369            .execute_query_with_binds_core(
6370                cx,
6371                "select attr_name, attr_type_name, attr_type_owner from all_type_attrs \
6372                 where owner = :1 and type_name = :2 order by attr_no",
6373                1000,
6374                &binds(),
6375            )
6376            .await?;
6377        let attributes: Vec<ObjectAttribute> = res
6378            .rows
6379            .iter()
6380            .map(|row| ObjectAttribute {
6381                name: row_text(row, 0).unwrap_or_default(),
6382                type_name: row_text(row, 1).unwrap_or_default(),
6383                type_owner: row_text(row, 2),
6384            })
6385            .collect();
6386        if attributes.is_empty() {
6387            return Err(Error::Protocol(
6388                oracledb_protocol::ProtocolError::UnsupportedFeature(
6389                    "object type not found or has no attributes",
6390                ),
6391            ));
6392        }
6393        Ok(ObjectType {
6394            schema,
6395            name,
6396            attributes,
6397            collection_element: None,
6398        })
6399    }
6400
6401    /// Sends a scroll request on an open scrollable cursor and returns the
6402    /// repositioned buffer (reference `_create_scroll_message` +
6403    /// `_post_process_scroll`). The caller computes the orientation/position;
6404    /// `arraysize` is the prefetch/iteration count used for the fetch.
6405    pub async fn scroll_cursor(
6406        &mut self,
6407        cx: &Cx,
6408        sql: &str,
6409        cursor_id: u32,
6410        arraysize: u32,
6411        fetch_orientation: u32,
6412        fetch_pos: u32,
6413    ) -> Result<QueryResult> {
6414        observe_cancellation_between_round_trips(cx)?;
6415        self.ensure_clean_before_request().await?;
6416        let exec_options = ExecuteOptions::default()
6417            .with_max_string_size(self.capabilities.max_string_size)
6418            .with_cursor_id(cursor_id)
6419            .with_scrollable(true)
6420            .with_scroll_operation(true)
6421            .with_fetch_orientation(fetch_orientation)
6422            .with_fetch_pos(fetch_pos)
6423            .with_cache_statement(false);
6424        let piggyback = self.take_close_cursors_piggyback();
6425        let seq_num = next_ttc_sequence(&mut self.ttc_seq_num);
6426        let mut payload = build_execute_payload_with_bind_rows_and_options_with_seq(
6427            sql,
6428            arraysize,
6429            seq_num,
6430            true,
6431            &[],
6432            exec_options,
6433            self.capabilities.ttc_field_version,
6434        )?;
6435        if let Some(mut piggyback_bytes) = piggyback {
6436            piggyback_bytes.extend_from_slice(&payload);
6437            payload = piggyback_bytes;
6438        }
6439        trace_query_bytes("SCROLL payload", &payload);
6440        self.core.send_data_packet(cx, &payload, self.sdu).await?;
6441        let known_columns = self
6442            .cursor_columns
6443            .get(&cursor_id)
6444            .cloned()
6445            .unwrap_or_default();
6446        // The classic probe is the same parse this site runs below, with its
6447        // result discarded (a scroll is an execute-shaped round trip).
6448        let capabilities = self.capabilities;
6449        let limits = self.protocol_limits;
6450        let response = self
6451            .read_flushing_out_binds_cancellable(cx, !self.supports_end_of_response, |bytes| {
6452                response_complete(&parse_query_response_with_binds_options_columns_and_limits(
6453                    bytes,
6454                    capabilities,
6455                    &[],
6456                    exec_options,
6457                    &known_columns,
6458                    limits,
6459                ))
6460            })
6461            .await?;
6462        trace_query_bytes("SCROLL response", &response);
6463        let parsed = parse_query_response_with_binds_options_columns_and_limits(
6464            &response,
6465            self.capabilities,
6466            &[],
6467            exec_options,
6468            &known_columns,
6469            self.protocol_limits,
6470        );
6471        let result = self.note_parse(parsed)?;
6472        self.remember_cursor_columns(&result);
6473        Ok(result)
6474    }
6475
6476    /// Read up to `amount` units from the LOB identified by `locator`,
6477    /// starting at 1-based `offset`. The `locator` comes from a
6478    /// [`QueryValue::Lob`](protocol::thin::QueryValue::Lob) cell. The returned
6479    /// bytes are the raw LOB content in the column's character-set form; decode
6480    /// CLOB/NCLOB text with
6481    /// [`decode_lob_text`](protocol::thin::decode_lob_text).
6482    pub async fn read_lob(
6483        &mut self,
6484        cx: &Cx,
6485        locator: &[u8],
6486        offset: u64,
6487        amount: u64,
6488    ) -> Result<LobReadResult> {
6489        // LOB read span (feature-gated, zero-cost when off). Carries the offset
6490        // and requested amount — never the locator bytes or the LOB data.
6491        let _span = obs_span!(
6492            "oracledb.lob",
6493            db.operation = "read",
6494            db.lob_offset = offset,
6495            db.lob_amount = amount,
6496        );
6497        observe_cancellation_between_round_trips(cx)?;
6498        self.ensure_clean_before_request().await?;
6499        let seq_num = next_ttc_sequence(&mut self.ttc_seq_num);
6500        let payload = build_lob_read_payload_with_seq(
6501            locator,
6502            offset,
6503            amount,
6504            seq_num,
6505            self.capabilities.ttc_field_version,
6506        )?;
6507        trace_query_bytes("LOB READ payload", &payload);
6508        self.core.send_data_packet(cx, &payload, self.sdu).await?;
6509        let capabilities = self.capabilities;
6510        let limits = self.protocol_limits;
6511        let response = self
6512            .read_response_cancellable(cx, !self.supports_end_of_response, |bytes| {
6513                response_complete(&parse_lob_read_response_with_limits(
6514                    bytes,
6515                    capabilities,
6516                    locator,
6517                    limits,
6518                ))
6519            })
6520            .await?;
6521        trace_query_bytes("LOB READ response", &response);
6522        self.note_parse(parse_lob_read_response_with_limits(
6523            &response,
6524            self.capabilities,
6525            locator,
6526            self.protocol_limits,
6527        ))
6528    }
6529
6530    pub async fn read_lob_with_timeout(
6531        &mut self,
6532        cx: &Cx,
6533        locator: &[u8],
6534        offset: u64,
6535        amount: u64,
6536        timeout_ms: Option<u32>,
6537    ) -> Result<LobReadResult> {
6538        self.read_lob_call_timeout(cx, locator, offset, amount, timeout_ms)
6539            .await
6540    }
6541
6542    /// Enqueues a single AQ message (FUNC 121), returning the assigned 16-byte
6543    /// message id. The TTC round-trip mirrors `read_lob`.
6544    pub async fn aq_enq_one(
6545        &mut self,
6546        cx: &Cx,
6547        queue: &AqQueueDesc,
6548        props: &AqMsgProps,
6549        enq_options: &AqEnqOptions,
6550    ) -> Result<Option<Vec<u8>>> {
6551        observe_cancellation_between_round_trips(cx)?;
6552        self.ensure_clean_before_request().await?;
6553        self.protocol_limits.check_frame_bytes(queue.name.len())?;
6554        self.protocol_limits
6555            .check_frame_bytes(queue.payload_toid.len())?;
6556        let seq_num = next_ttc_sequence(&mut self.ttc_seq_num);
6557        let payload = build_aq_enq_payload(
6558            queue,
6559            props,
6560            enq_options,
6561            seq_num,
6562            self.capabilities.ttc_field_version,
6563            self.supports_oson_long_fnames(),
6564        )?;
6565        trace_query_bytes("AQ ENQ payload", &payload);
6566        self.core.send_data_packet(cx, &payload, self.sdu).await?;
6567        // Classic-aware read (pre-23ai has no END_OF_RESPONSE framing); bead
6568        // rust-oracledb-eyp7.
6569        let capabilities = self.capabilities;
6570        let limits = self.protocol_limits;
6571        let response = self
6572            .read_response_cancellable(cx, !self.supports_end_of_response, |bytes| {
6573                response_complete(&parse_aq_enq_response_with_limits(
6574                    bytes,
6575                    capabilities,
6576                    limits,
6577                ))
6578            })
6579            .await?;
6580        trace_query_bytes("AQ ENQ response", &response);
6581        self.note_parse(parse_aq_enq_response_with_limits(
6582            &response,
6583            self.capabilities,
6584            self.protocol_limits,
6585        ))
6586    }
6587
6588    /// Dequeues a single AQ message (FUNC 122). Returns `None` when the queue is
6589    /// empty (ORA-25228 cleared server-side).
6590    pub async fn aq_deq_one(
6591        &mut self,
6592        cx: &Cx,
6593        queue: &AqQueueDesc,
6594        deq_options: &AqDeqOptions,
6595    ) -> Result<AqDeqResult> {
6596        observe_cancellation_between_round_trips(cx)?;
6597        self.ensure_clean_before_request().await?;
6598        self.protocol_limits.check_frame_bytes(queue.name.len())?;
6599        self.protocol_limits
6600            .check_frame_bytes(queue.payload_toid.len())?;
6601        let seq_num = next_ttc_sequence(&mut self.ttc_seq_num);
6602        let payload = build_aq_deq_payload(
6603            queue,
6604            deq_options,
6605            seq_num,
6606            self.capabilities.ttc_field_version,
6607        )?;
6608        trace_query_bytes("AQ DEQ payload", &payload);
6609        self.core.send_data_packet(cx, &payload, self.sdu).await?;
6610        // Classic-aware read (pre-23ai has no END_OF_RESPONSE framing); bead
6611        // rust-oracledb-eyp7.
6612        let capabilities = self.capabilities;
6613        let limits = self.protocol_limits;
6614        let response = self
6615            .read_response_cancellable(cx, !self.supports_end_of_response, |bytes| {
6616                response_complete(&parse_aq_deq_response_with_limits(
6617                    bytes,
6618                    capabilities,
6619                    &queue.kind,
6620                    limits,
6621                ))
6622            })
6623            .await?;
6624        trace_query_bytes("AQ DEQ response", &response);
6625        self.note_parse(parse_aq_deq_response_with_limits(
6626            &response,
6627            self.capabilities,
6628            &queue.kind,
6629            self.protocol_limits,
6630        ))
6631    }
6632
6633    /// Enqueues many AQ messages in one array round-trip (FUNC 145, op=ENQ),
6634    /// returning the assigned msgid per input message in order.
6635    pub async fn aq_enq_many(
6636        &mut self,
6637        cx: &Cx,
6638        queue: &AqQueueDesc,
6639        props_list: &[AqMsgProps],
6640        enq_options: &AqEnqOptions,
6641    ) -> Result<Vec<Vec<u8>>> {
6642        observe_cancellation_between_round_trips(cx)?;
6643        self.ensure_clean_before_request().await?;
6644        self.protocol_limits.check_batch_rows(props_list.len())?;
6645        self.protocol_limits.check_frame_bytes(queue.name.len())?;
6646        self.protocol_limits
6647            .check_frame_bytes(queue.payload_toid.len())?;
6648        let seq_num = next_ttc_sequence(&mut self.ttc_seq_num);
6649        let payload = build_aq_array_enq_payload(
6650            queue,
6651            props_list,
6652            enq_options,
6653            seq_num,
6654            self.capabilities.ttc_field_version,
6655            self.supports_oson_long_fnames(),
6656        )?;
6657        trace_query_bytes("AQ ARRAY ENQ payload", &payload);
6658        self.core.send_data_packet(cx, &payload, self.sdu).await?;
6659        // Classic-aware read (pre-23ai has no END_OF_RESPONSE framing); bead
6660        // rust-oracledb-eyp7.
6661        let capabilities = self.capabilities;
6662        let limits = self.protocol_limits;
6663        let response = self
6664            .read_response_cancellable(cx, !self.supports_end_of_response, |bytes| {
6665                response_complete(&parse_aq_array_response_with_limits(
6666                    bytes,
6667                    capabilities,
6668                    TNS_AQ_ARRAY_ENQ,
6669                    props_list.len() as u32,
6670                    &queue.kind,
6671                    limits,
6672                ))
6673            })
6674            .await?;
6675        trace_query_bytes("AQ ARRAY ENQ response", &response);
6676        let result: AqArrayResult = self.note_parse(parse_aq_array_response_with_limits(
6677            &response,
6678            self.capabilities,
6679            TNS_AQ_ARRAY_ENQ,
6680            props_list.len() as u32,
6681            &queue.kind,
6682            self.protocol_limits,
6683        ))?;
6684        Ok(result.enq_msgids)
6685    }
6686
6687    /// Dequeues up to `max_num_messages` AQ messages in one array round-trip
6688    /// (FUNC 145, op=DEQ). Returns the dequeued messages (empty when none).
6689    pub async fn aq_deq_many(
6690        &mut self,
6691        cx: &Cx,
6692        queue: &AqQueueDesc,
6693        deq_options: &AqDeqOptions,
6694        max_num_messages: u32,
6695    ) -> Result<Vec<oracledb_protocol::thin::aq::AqDeqMessage>> {
6696        observe_cancellation_between_round_trips(cx)?;
6697        self.ensure_clean_before_request().await?;
6698        self.protocol_limits
6699            .check_batch_rows(max_num_messages as usize)?;
6700        self.protocol_limits.check_frame_bytes(queue.name.len())?;
6701        self.protocol_limits
6702            .check_frame_bytes(queue.payload_toid.len())?;
6703        let seq_num = next_ttc_sequence(&mut self.ttc_seq_num);
6704        let payload = build_aq_array_deq_payload(
6705            queue,
6706            deq_options,
6707            max_num_messages,
6708            seq_num,
6709            self.capabilities.ttc_field_version,
6710        )?;
6711        trace_query_bytes("AQ ARRAY DEQ payload", &payload);
6712        self.core.send_data_packet(cx, &payload, self.sdu).await?;
6713        // Classic-aware read (pre-23ai has no END_OF_RESPONSE framing); bead
6714        // rust-oracledb-eyp7.
6715        let capabilities = self.capabilities;
6716        let limits = self.protocol_limits;
6717        let response = self
6718            .read_response_cancellable(cx, !self.supports_end_of_response, |bytes| {
6719                response_complete(&parse_aq_array_response_with_limits(
6720                    bytes,
6721                    capabilities,
6722                    TNS_AQ_ARRAY_DEQ,
6723                    max_num_messages,
6724                    &queue.kind,
6725                    limits,
6726                ))
6727            })
6728            .await?;
6729        trace_query_bytes("AQ ARRAY DEQ response", &response);
6730        let result: AqArrayResult = self.note_parse(parse_aq_array_response_with_limits(
6731            &response,
6732            self.capabilities,
6733            TNS_AQ_ARRAY_DEQ,
6734            max_num_messages,
6735            &queue.kind,
6736            self.protocol_limits,
6737        ))?;
6738        Ok(result.deq_messages)
6739    }
6740
6741    pub async fn create_temp_lob(
6742        &mut self,
6743        cx: &Cx,
6744        ora_type_num: u8,
6745        csfrm: u8,
6746    ) -> Result<LobReadResult> {
6747        observe_cancellation_between_round_trips(cx)?;
6748        self.ensure_clean_before_request().await?;
6749        let seq_num = next_ttc_sequence(&mut self.ttc_seq_num);
6750        let payload = build_lob_create_temp_payload_with_seq(
6751            ora_type_num,
6752            csfrm,
6753            seq_num,
6754            self.capabilities.ttc_field_version,
6755        )?;
6756        trace_query_bytes("LOB CREATE TEMP payload", &payload);
6757        self.core.send_data_packet(cx, &payload, self.sdu).await?;
6758        let capabilities = self.capabilities;
6759        let limits = self.protocol_limits;
6760        let response = self
6761            .read_response_cancellable(cx, !self.supports_end_of_response, |bytes| {
6762                response_complete(&parse_lob_create_temp_response_with_limits(
6763                    bytes,
6764                    capabilities,
6765                    limits,
6766                ))
6767            })
6768            .await?;
6769        trace_query_bytes("LOB CREATE TEMP response", &response);
6770        self.note_parse(parse_lob_create_temp_response_with_limits(
6771            &response,
6772            self.capabilities,
6773            self.protocol_limits,
6774        ))
6775    }
6776
6777    pub async fn write_lob(
6778        &mut self,
6779        cx: &Cx,
6780        locator: &[u8],
6781        offset: u64,
6782        data: &[u8],
6783    ) -> Result<LobReadResult> {
6784        // LOB write span (feature-gated, zero-cost when off). Carries the offset
6785        // and the byte count written — never the locator bytes or the LOB data.
6786        let _span = obs_span!(
6787            "oracledb.lob",
6788            db.operation = "write",
6789            db.lob_offset = offset,
6790            db.lob_bytes = data.len() as u64,
6791        );
6792        observe_cancellation_between_round_trips(cx)?;
6793        self.ensure_clean_before_request().await?;
6794        self.protocol_limits.check_frame_bytes(locator.len())?;
6795        self.protocol_limits.check_frame_bytes(data.len())?;
6796        let seq_num = next_ttc_sequence(&mut self.ttc_seq_num);
6797        let payload = build_lob_write_payload_with_seq(
6798            locator,
6799            offset,
6800            data,
6801            seq_num,
6802            self.capabilities.ttc_field_version,
6803        )?;
6804        trace_query_bytes("LOB WRITE payload", &payload);
6805        self.core.send_data_packet(cx, &payload, self.sdu).await?;
6806        let capabilities = self.capabilities;
6807        let limits = self.protocol_limits;
6808        let response = self
6809            .read_response_cancellable(cx, !self.supports_end_of_response, |bytes| {
6810                response_complete(&parse_lob_write_response_with_limits(
6811                    bytes,
6812                    capabilities,
6813                    locator,
6814                    limits,
6815                ))
6816            })
6817            .await?;
6818        trace_query_bytes("LOB WRITE response", &response);
6819        self.note_parse(parse_lob_write_response_with_limits(
6820            &response,
6821            self.capabilities,
6822            locator,
6823            self.protocol_limits,
6824        ))
6825    }
6826
6827    pub async fn write_lob_with_timeout(
6828        &mut self,
6829        cx: &Cx,
6830        locator: &[u8],
6831        offset: u64,
6832        data: &[u8],
6833        timeout_ms: Option<u32>,
6834    ) -> Result<LobReadResult> {
6835        self.write_lob_call_timeout(cx, locator, offset, data, timeout_ms)
6836            .await
6837    }
6838
6839    pub async fn trim_lob(
6840        &mut self,
6841        cx: &Cx,
6842        locator: &[u8],
6843        new_size: u64,
6844    ) -> Result<LobReadResult> {
6845        observe_cancellation_between_round_trips(cx)?;
6846        self.ensure_clean_before_request().await?;
6847        self.protocol_limits.check_frame_bytes(locator.len())?;
6848        let seq_num = next_ttc_sequence(&mut self.ttc_seq_num);
6849        let payload = build_lob_trim_payload_with_seq(
6850            locator,
6851            new_size,
6852            seq_num,
6853            self.capabilities.ttc_field_version,
6854        )?;
6855        trace_query_bytes("LOB TRIM payload", &payload);
6856        self.core.send_data_packet(cx, &payload, self.sdu).await?;
6857        let capabilities = self.capabilities;
6858        let limits = self.protocol_limits;
6859        let response = self
6860            .read_response_cancellable(cx, !self.supports_end_of_response, |bytes| {
6861                response_complete(&parse_lob_trim_response_with_limits(
6862                    bytes,
6863                    capabilities,
6864                    locator,
6865                    limits,
6866                ))
6867            })
6868            .await?;
6869        trace_query_bytes("LOB TRIM response", &response);
6870        self.note_parse(parse_lob_trim_response_with_limits(
6871            &response,
6872            self.capabilities,
6873            locator,
6874            self.protocol_limits,
6875        ))
6876    }
6877
6878    pub async fn trim_lob_with_timeout(
6879        &mut self,
6880        cx: &Cx,
6881        locator: &[u8],
6882        new_size: u64,
6883        timeout_ms: Option<u32>,
6884    ) -> Result<LobReadResult> {
6885        self.trim_lob_call_timeout(cx, locator, new_size, timeout_ms)
6886            .await
6887    }
6888
6889    pub async fn free_temp_lobs(&mut self, cx: &Cx, locators: &[Vec<u8>]) -> Result<()> {
6890        observe_cancellation_between_round_trips(cx)?;
6891        if locators.is_empty() {
6892            return Ok(());
6893        }
6894        self.ensure_clean_before_request().await?;
6895        self.protocol_limits.check_lob_chunks(locators.len())?;
6896        let returned_parameter_len = locators.iter().try_fold(0usize, |total, locator| {
6897            self.protocol_limits.check_frame_bytes(locator.len())?;
6898            total.checked_add(locator.len()).ok_or(
6899                oracledb_protocol::ProtocolError::ResourceLimit {
6900                    limit: "frame_bytes",
6901                    observed: usize::MAX,
6902                    maximum: self.protocol_limits.max_frame_bytes,
6903                },
6904            )
6905        })?;
6906        self.protocol_limits
6907            .check_frame_bytes(returned_parameter_len)?;
6908        let seq_num = next_ttc_sequence(&mut self.ttc_seq_num);
6909        let payload = build_lob_free_temp_payload_with_seq(
6910            locators,
6911            seq_num,
6912            self.capabilities.ttc_field_version,
6913        )?;
6914        trace_query_bytes("LOB FREE TEMP payload", &payload);
6915        self.core.send_data_packet(cx, &payload, self.sdu).await?;
6916        let capabilities = self.capabilities;
6917        let limits = self.protocol_limits;
6918        let response = self
6919            .read_response_cancellable(cx, !self.supports_end_of_response, |bytes| {
6920                response_complete(&parse_lob_free_temp_response_with_limits(
6921                    bytes,
6922                    capabilities,
6923                    returned_parameter_len,
6924                    limits,
6925                ))
6926            })
6927            .await?;
6928        trace_query_bytes("LOB FREE TEMP response", &response);
6929        self.note_parse(parse_lob_free_temp_response_with_limits(
6930            &response,
6931            self.capabilities,
6932            returned_parameter_len,
6933            self.protocol_limits,
6934        ))
6935    }
6936
6937    pub async fn free_temp_lobs_with_timeout(
6938        &mut self,
6939        cx: &Cx,
6940        locators: &[Vec<u8>],
6941        timeout_ms: Option<u32>,
6942    ) -> Result<()> {
6943        self.free_temp_lobs_call_timeout(cx, locators, timeout_ms)
6944            .await
6945    }
6946
6947    #[allow(dead_code)]
6948    async fn execute_query_call_timeout(
6949        &mut self,
6950        cx: &Cx,
6951        sql: &str,
6952        prefetch_rows: u32,
6953        timeout_ms: Option<u32>,
6954    ) -> Result<QueryResult> {
6955        let Some(timeout_ms) = timeout_ms.filter(|value| *value > 0) else {
6956            return self
6957                .execute_query_with_bind_rows_and_options_core(
6958                    cx,
6959                    sql,
6960                    prefetch_rows,
6961                    &[],
6962                    ExecuteOptions::default(),
6963                )
6964                .await;
6965        };
6966        let deadline = QueryDeadline::from_timeout(Duration::from_millis(u64::from(timeout_ms)));
6967        match deadline
6968            .run(self.execute_query_with_bind_rows_and_options_core(
6969                cx,
6970                sql,
6971                prefetch_rows,
6972                &[],
6973                ExecuteOptions::default(),
6974            ))
6975            .await
6976        {
6977            Ok(result) => result,
6978            Err(DeadlineExpiry::BeforeStart) => self.reject_before_operation_start(cx, timeout_ms),
6979            Err(DeadlineExpiry::InFlight) => self.recover_from_call_timeout(cx, timeout_ms).await,
6980        }
6981    }
6982
6983    async fn execute_query_with_binds_call_timeout(
6984        &mut self,
6985        cx: &Cx,
6986        sql: &str,
6987        prefetch_rows: u32,
6988        binds: &[BindValue],
6989        timeout_ms: Option<u32>,
6990    ) -> Result<QueryResult> {
6991        let Some(timeout_ms) = timeout_ms.filter(|value| *value > 0) else {
6992            return self
6993                .execute_query_with_binds_core(cx, sql, prefetch_rows, binds)
6994                .await;
6995        };
6996        let deadline = QueryDeadline::from_timeout(Duration::from_millis(u64::from(timeout_ms)));
6997        match deadline
6998            .run(self.execute_query_with_binds_core(cx, sql, prefetch_rows, binds))
6999            .await
7000        {
7001            Ok(result) => result,
7002            Err(DeadlineExpiry::BeforeStart) => self.reject_before_operation_start(cx, timeout_ms),
7003            Err(DeadlineExpiry::InFlight) => self.recover_from_call_timeout(cx, timeout_ms).await,
7004        }
7005    }
7006
7007    #[allow(dead_code)]
7008    async fn execute_query_with_bind_rows_call_timeout(
7009        &mut self,
7010        cx: &Cx,
7011        sql: &str,
7012        prefetch_rows: u32,
7013        bind_rows: &[Vec<BindValue>],
7014        timeout_ms: Option<u32>,
7015    ) -> Result<QueryResult> {
7016        let Some(timeout_ms) = timeout_ms.filter(|value| *value > 0) else {
7017            return self
7018                .execute_query_with_bind_rows_and_options_core(
7019                    cx,
7020                    sql,
7021                    prefetch_rows,
7022                    bind_rows,
7023                    ExecuteOptions::default(),
7024                )
7025                .await;
7026        };
7027        let deadline = QueryDeadline::from_timeout(Duration::from_millis(u64::from(timeout_ms)));
7028        match deadline
7029            .run(self.execute_query_with_bind_rows_and_options_core(
7030                cx,
7031                sql,
7032                prefetch_rows,
7033                bind_rows,
7034                ExecuteOptions::default(),
7035            ))
7036            .await
7037        {
7038            Ok(result) => result,
7039            Err(DeadlineExpiry::BeforeStart) => self.reject_before_operation_start(cx, timeout_ms),
7040            Err(DeadlineExpiry::InFlight) => self.recover_from_call_timeout(cx, timeout_ms).await,
7041        }
7042    }
7043
7044    async fn read_lob_call_timeout(
7045        &mut self,
7046        cx: &Cx,
7047        locator: &[u8],
7048        offset: u64,
7049        amount: u64,
7050        timeout_ms: Option<u32>,
7051    ) -> Result<LobReadResult> {
7052        let Some(timeout_ms) = timeout_ms.filter(|value| *value > 0) else {
7053            return self.read_lob(cx, locator, offset, amount).await;
7054        };
7055        let deadline = QueryDeadline::from_timeout(Duration::from_millis(u64::from(timeout_ms)));
7056        match deadline
7057            .run(self.read_lob(cx, locator, offset, amount))
7058            .await
7059        {
7060            Ok(result) => result,
7061            Err(DeadlineExpiry::BeforeStart) => self.reject_before_operation_start(cx, timeout_ms),
7062            Err(DeadlineExpiry::InFlight) => self.recover_from_call_timeout(cx, timeout_ms).await,
7063        }
7064    }
7065
7066    async fn write_lob_call_timeout(
7067        &mut self,
7068        cx: &Cx,
7069        locator: &[u8],
7070        offset: u64,
7071        data: &[u8],
7072        timeout_ms: Option<u32>,
7073    ) -> Result<LobReadResult> {
7074        let Some(timeout_ms) = timeout_ms.filter(|value| *value > 0) else {
7075            return self.write_lob(cx, locator, offset, data).await;
7076        };
7077        let deadline = QueryDeadline::from_timeout(Duration::from_millis(u64::from(timeout_ms)));
7078        match deadline
7079            .run(self.write_lob(cx, locator, offset, data))
7080            .await
7081        {
7082            Ok(result) => result,
7083            Err(DeadlineExpiry::BeforeStart) => self.reject_before_operation_start(cx, timeout_ms),
7084            Err(DeadlineExpiry::InFlight) => self.recover_from_call_timeout(cx, timeout_ms).await,
7085        }
7086    }
7087
7088    async fn trim_lob_call_timeout(
7089        &mut self,
7090        cx: &Cx,
7091        locator: &[u8],
7092        new_size: u64,
7093        timeout_ms: Option<u32>,
7094    ) -> Result<LobReadResult> {
7095        let Some(timeout_ms) = timeout_ms.filter(|value| *value > 0) else {
7096            return self.trim_lob(cx, locator, new_size).await;
7097        };
7098        let deadline = QueryDeadline::from_timeout(Duration::from_millis(u64::from(timeout_ms)));
7099        match deadline.run(self.trim_lob(cx, locator, new_size)).await {
7100            Ok(result) => result,
7101            Err(DeadlineExpiry::BeforeStart) => self.reject_before_operation_start(cx, timeout_ms),
7102            Err(DeadlineExpiry::InFlight) => self.recover_from_call_timeout(cx, timeout_ms).await,
7103        }
7104    }
7105
7106    async fn free_temp_lobs_call_timeout(
7107        &mut self,
7108        cx: &Cx,
7109        locators: &[Vec<u8>],
7110        timeout_ms: Option<u32>,
7111    ) -> Result<()> {
7112        let Some(timeout_ms) = timeout_ms.filter(|value| *value > 0) else {
7113            return self.free_temp_lobs(cx, locators).await;
7114        };
7115        let deadline = QueryDeadline::from_timeout(Duration::from_millis(u64::from(timeout_ms)));
7116        match deadline.run(self.free_temp_lobs(cx, locators)).await {
7117            Ok(result) => result,
7118            Err(DeadlineExpiry::BeforeStart) => self.reject_before_operation_start(cx, timeout_ms),
7119            Err(DeadlineExpiry::InFlight) => self.recover_from_call_timeout(cx, timeout_ms).await,
7120        }
7121    }
7122
7123    /// Sends a direct path prepare (TTC function 128) for the given table and
7124    /// returns the server column metadata plus the direct path cursor id.
7125    pub async fn direct_path_prepare(
7126        &mut self,
7127        cx: &Cx,
7128        schema_name: &str,
7129        table_name: &str,
7130        column_names: &[String],
7131    ) -> Result<oracledb_protocol::dpl::DirectPathPrepareResult> {
7132        observe_cancellation_between_round_trips(cx)?;
7133        self.ensure_clean_before_request().await?;
7134        self.protocol_limits.check_columns(column_names.len())?;
7135        let seq_num = next_ttc_sequence(&mut self.ttc_seq_num);
7136        let payload = oracledb_protocol::dpl::build_direct_path_prepare_payload_with_version(
7137            schema_name,
7138            table_name,
7139            column_names,
7140            seq_num,
7141            self.capabilities.ttc_field_version,
7142        )?;
7143        trace_query_bytes("DIRECT PATH PREPARE payload", &payload);
7144        self.core.send_data_packet(cx, &payload, self.sdu).await?;
7145        // Classic-aware read (pre-23ai has no END_OF_RESPONSE framing); bead
7146        // rust-oracledb-eyp7.
7147        let capabilities = self.capabilities;
7148        let limits = self.protocol_limits;
7149        let response = self
7150            .read_response_cancellable(cx, !self.supports_end_of_response, |bytes| {
7151                response_complete(
7152                    &oracledb_protocol::dpl::parse_direct_path_prepare_response_with_limits(
7153                        bytes,
7154                        capabilities,
7155                        limits,
7156                    ),
7157                )
7158            })
7159            .await?;
7160        trace_query_bytes("DIRECT PATH PREPARE response", &response);
7161        oracledb_protocol::dpl::parse_direct_path_prepare_response_with_limits(
7162            &response,
7163            self.capabilities,
7164            self.protocol_limits,
7165        )
7166        .map_err(Error::from)
7167    }
7168
7169    /// Sends one direct path load stream message (TTC function 129).
7170    pub async fn direct_path_load_stream(
7171        &mut self,
7172        cx: &Cx,
7173        cursor_id: u16,
7174        stream: &oracledb_protocol::dpl::DirectPathStream,
7175    ) -> Result<()> {
7176        observe_cancellation_between_round_trips(cx)?;
7177        self.ensure_clean_before_request().await?;
7178        let seq_num = next_ttc_sequence(&mut self.ttc_seq_num);
7179        let payload = oracledb_protocol::dpl::build_direct_path_load_stream_payload_with_version(
7180            cursor_id,
7181            stream,
7182            seq_num,
7183            self.capabilities.ttc_field_version,
7184        )?;
7185        trace_query_bytes("DIRECT PATH LOAD STREAM payload", &payload);
7186        self.core.send_data_packet(cx, &payload, self.sdu).await?;
7187        // Classic-aware read (pre-23ai has no END_OF_RESPONSE framing); bead
7188        // rust-oracledb-eyp7.
7189        let capabilities = self.capabilities;
7190        let limits = self.protocol_limits;
7191        let response = self
7192            .read_response_cancellable(cx, !self.supports_end_of_response, |bytes| {
7193                response_complete(
7194                    &oracledb_protocol::dpl::parse_direct_path_simple_response_with_limits(
7195                        bytes,
7196                        capabilities,
7197                        limits,
7198                    ),
7199                )
7200            })
7201            .await?;
7202        trace_query_bytes("DIRECT PATH LOAD STREAM response", &response);
7203        oracledb_protocol::dpl::parse_direct_path_simple_response_with_limits(
7204            &response,
7205            self.capabilities,
7206            self.protocol_limits,
7207        )
7208        .map_err(Error::from)
7209    }
7210
7211    /// Sends a direct path op message (TTC function 130).
7212    /// [`oracledb_protocol::dpl::TNS_DP_OP_FINISH`] commits the load
7213    /// server-side; [`oracledb_protocol::dpl::TNS_DP_OP_ABORT`] discards it.
7214    pub async fn direct_path_op(&mut self, cx: &Cx, cursor_id: u16, op_code: u32) -> Result<()> {
7215        observe_cancellation_between_round_trips(cx)?;
7216        self.ensure_clean_before_request().await?;
7217        let seq_num = next_ttc_sequence(&mut self.ttc_seq_num);
7218        let payload = oracledb_protocol::dpl::build_direct_path_op_payload_with_version(
7219            cursor_id,
7220            op_code,
7221            seq_num,
7222            self.capabilities.ttc_field_version,
7223        );
7224        trace_query_bytes("DIRECT PATH OP payload", &payload);
7225        self.core.send_data_packet(cx, &payload, self.sdu).await?;
7226        // Classic-aware read (pre-23ai has no END_OF_RESPONSE framing); bead
7227        // rust-oracledb-eyp7.
7228        let capabilities = self.capabilities;
7229        let limits = self.protocol_limits;
7230        let response = self
7231            .read_response_cancellable(cx, !self.supports_end_of_response, |bytes| {
7232                response_complete(
7233                    &oracledb_protocol::dpl::parse_direct_path_simple_response_with_limits(
7234                        bytes,
7235                        capabilities,
7236                        limits,
7237                    ),
7238                )
7239            })
7240            .await?;
7241        trace_query_bytes("DIRECT PATH OP response", &response);
7242        oracledb_protocol::dpl::parse_direct_path_simple_response_with_limits(
7243            &response,
7244            self.capabilities,
7245            self.protocol_limits,
7246        )
7247        .map_err(Error::from)
7248    }
7249
7250    /// Loads `rows` into `schema_name.table_name` via the direct path load
7251    /// interface, mirroring the reference driver loop
7252    /// (impl/thin/connection.pyx `direct_path_load`): prepare, stream batches
7253    /// of `batch_size` rows, then FINISH (which commits) or ABORT on error.
7254    /// The op message is always sent, even when streaming fails, so the
7255    /// session is never left wedged.
7256    pub async fn direct_path_load(
7257        &mut self,
7258        cx: &Cx,
7259        schema_name: &str,
7260        table_name: &str,
7261        column_names: &[String],
7262        rows: &[Vec<oracledb_protocol::dpl::DirectPathColumnValue>],
7263        batch_size: u32,
7264    ) -> Result<()> {
7265        let prepare = self
7266            .direct_path_prepare(cx, schema_name, table_name, column_names)
7267            .await?;
7268        let load_result = self
7269            .direct_path_load_batches(cx, &prepare, rows, batch_size)
7270            .await;
7271        let op_code = if load_result.is_ok() {
7272            oracledb_protocol::dpl::TNS_DP_OP_FINISH
7273        } else {
7274            oracledb_protocol::dpl::TNS_DP_OP_ABORT
7275        };
7276        let op_result = self.direct_path_op(cx, prepare.cursor_id, op_code).await;
7277        load_result?;
7278        op_result
7279    }
7280
7281    /// Loads pre-converted rows against an already-prepared direct path cursor,
7282    /// then sends the FINISH (or ABORT on failure) op. Lets a caller convert its
7283    /// data using the prepared `column_metadata` without a second PREPARE round
7284    /// trip (so the reference round-trip count holds).
7285    pub async fn direct_path_load_prepared(
7286        &mut self,
7287        cx: &Cx,
7288        prepare: &oracledb_protocol::dpl::DirectPathPrepareResult,
7289        rows: &[Vec<oracledb_protocol::dpl::DirectPathColumnValue>],
7290        batch_size: u32,
7291    ) -> Result<()> {
7292        let load_result = self
7293            .direct_path_load_batches(cx, prepare, rows, batch_size)
7294            .await;
7295        let op_code = if load_result.is_ok() {
7296            oracledb_protocol::dpl::TNS_DP_OP_FINISH
7297        } else {
7298            oracledb_protocol::dpl::TNS_DP_OP_ABORT
7299        };
7300        let op_result = self.direct_path_op(cx, prepare.cursor_id, op_code).await;
7301        load_result?;
7302        op_result
7303    }
7304
7305    async fn direct_path_load_batches(
7306        &mut self,
7307        cx: &Cx,
7308        prepare: &oracledb_protocol::dpl::DirectPathPrepareResult,
7309        rows: &[Vec<oracledb_protocol::dpl::DirectPathColumnValue>],
7310        batch_size: u32,
7311    ) -> Result<()> {
7312        // Verify all row widths match the column metadata before sending
7313        // anything (reference `_verify_metadata` rejects a width mismatch before
7314        // the first stream message). `batch_size` is a chunking *upper bound*,
7315        // not a row count: `BatchLoadState` clamps it to the data length per
7316        // batch (`calculate_num_rows_in_batch`), and the reference imposes no
7317        // cap on `batch_size` (its default is the `2**32 - 1` "all rows"
7318        // sentinel; oversized data is piece-streamed). So it must NOT be
7319        // limit-checked here — doing so spuriously raised "protocol resource
7320        // limit exceeded" for the default sentinel. The genuine per-execute row
7321        // caps remain on the array-DML / object paths, which pass real counts.
7322        for row in rows {
7323            if row.len() != prepare.column_metadata.len() {
7324                return Err(oracledb_protocol::ProtocolError::TtcDecode(
7325                    "direct path row width does not match column metadata",
7326                )
7327                .into());
7328            }
7329        }
7330        let mut state =
7331            oracledb_protocol::dpl::BatchLoadState::for_rows(rows.len() as u64, batch_size)?;
7332        // 1-based running row counter across batches for error messages
7333        let mut row_num: u64 = 1;
7334        while !state.is_done() {
7335            observe_cancellation_between_round_trips(cx)?;
7336            let start = usize::try_from(state.offset()).map_err(|_| {
7337                oracledb_protocol::ProtocolError::TtcDecode("direct path offset overflow")
7338            })?;
7339            let end = start + state.num_rows() as usize;
7340            let stream = oracledb_protocol::dpl::encode_direct_path_rows(
7341                &prepare.column_metadata,
7342                &rows[start..end],
7343                row_num,
7344            )?;
7345            row_num += (end - start) as u64;
7346            self.direct_path_load_stream(cx, prepare.cursor_id, &stream)
7347                .await?;
7348            state.next_batch();
7349        }
7350        Ok(())
7351    }
7352
7353    /// On a call timeout, send a BREAK and drain the server's in-flight
7354    /// response so the wire stream is left clean and the connection stays
7355    /// reusable — the parity-faithful recovery python-oracledb performs before
7356    /// raising `DPY-4024` (`_break_external` + `_receive_packet`/`_reset`,
7357    /// protocol.pyx:449-451). Delegates to [`break_and_drain_wire`].
7358    ///
7359    /// `Ok(())` means the drain succeeded and the connection is usable; the
7360    /// caller then returns [`Error::CallTimeout`]. If the drain fails (a second
7361    /// timeout or a wire error), the connection is marked [`Self::dead`] and the
7362    /// returned [`Error::ConnectionClosed`] is propagated instead — mirroring
7363    /// the reference's disconnect-on-second-timeout (protocol.pyx:454-458).
7364    async fn break_and_drain(&mut self) -> Result<()> {
7365        self.core.recovery.begin_drain_after_break()?;
7366        match self.core.break_and_drain_wire(BREAK_DRAIN_RECOVERY_TIMEOUT) {
7367            Ok(()) => {
7368                self.core.recovery.finish_drain_ready();
7369                Ok(())
7370            }
7371            Err(err) => {
7372                // Recovery failed: the stream is poisoned, the connection is
7373                // dead. Pools must discard it (see `is_dead` / `is_connection_lost`).
7374                self.core.recovery.mark_dead();
7375                self.dead = true;
7376                Err(err)
7377            }
7378        }
7379    }
7380
7381    /// Reject an operation whose deadline elapsed before its future was ever
7382    /// polled. No request bytes can exist, so sending BREAK here would corrupt
7383    /// an idle session; structured context cancellation still determines the
7384    /// public error and whether the connection remains reusable.
7385    pub(crate) fn reject_before_operation_start<T>(
7386        &mut self,
7387        cx: &Cx,
7388        timeout_ms: u32,
7389    ) -> Result<T> {
7390        let disposition = cx
7391            .cancel_reason()
7392            .map(|reason| CancelDisposition::from_kind(reason.kind))
7393            .unwrap_or(CancelDisposition::Timeout);
7394        if disposition == CancelDisposition::Close {
7395            self.core.recovery.mark_dead();
7396            self.dead = true;
7397        }
7398        Err(disposition.into_error(timeout_ms))
7399    }
7400
7401    /// Common tail for every `*_call_timeout` arm: the in-flight operation hit
7402    /// its deadline (the user's `call_timeout`) or the caller's `Cx` was
7403    /// cancelled, so break + drain the wire and then surface the right error.
7404    ///
7405    /// The drain is unconditional — whatever the reason, the cancelled call's
7406    /// in-flight bytes must be cleared off the socket before the connection can
7407    /// be reused or discarded cleanly. After a clean drain we branch on the
7408    /// asupersync [`CancelKind`] (via [`CancelDisposition`]) to flatten to the
7409    /// right public error:
7410    ///
7411    /// * **No `Cx` cancel recorded** (a pure `call_timeout` deadline): the
7412    ///   classic [`Error::CallTimeout`] (`DPY-4024`) — the session survives and
7413    ///   the error is connection-reusable + retryable.
7414    /// * **Timeout/deadline/quota cancel**: same — [`Error::CallTimeout`].
7415    /// * **Shutdown / resource-loss cancel** ([`CancelDisposition::Close`]):
7416    ///   even though the drain succeeded, the runtime is going away, so the
7417    ///   connection is marked **dead** and [`Error::ConnectionClosed`] is
7418    ///   surfaced — the caller must discard it.
7419    /// * **Explicit / topological cancel** ([`CancelDisposition::Cancel`]): the
7420    ///   distinct [`Error::Cancelled`] (`ORA-01013`), connection-reusable.
7421    ///
7422    /// On a **failed** drain the connection is already dead and
7423    /// [`Error::ConnectionClosed`] (`DPY-4011`) is propagated regardless of the
7424    /// cancel kind. Always returns `Err`, so it composes as the `Err(_)` branch
7425    /// of the timeout `match`.
7426    pub(crate) async fn recover_from_call_timeout<T>(
7427        &mut self,
7428        cx: &Cx,
7429        timeout_ms: u32,
7430    ) -> Result<T> {
7431        match self.break_and_drain().await {
7432            Ok(()) => {
7433                // This arm is reached because a deadline elapsed. If the `Cx`
7434                // also carries a structured cancel, its kind drives the
7435                // disposition; otherwise (no recorded cancel) it is a pure
7436                // `call_timeout` deadline, which is the `Timeout` disposition —
7437                // NOT the generic `Cancel` fallback used at the between-round-
7438                // trip checkpoint boundary.
7439                let disposition = cx
7440                    .cancel_reason()
7441                    .map(|reason| CancelDisposition::from_kind(reason.kind))
7442                    .unwrap_or(CancelDisposition::Timeout);
7443                if disposition == CancelDisposition::Close {
7444                    // Drain left the wire clean, but a runtime shutdown means the
7445                    // connection must not be handed back to the pool.
7446                    self.core.recovery.mark_dead();
7447                    self.dead = true;
7448                }
7449                Err(disposition.into_error(timeout_ms))
7450            }
7451            Err(closed) => Err(closed),
7452        }
7453    }
7454
7455    /// Cleans the wire after a two-thread cancel: a [`CancelHandle`] on another
7456    /// thread already sent the BREAK while this thread was blocked in a query, so
7457    /// the socket now holds the full multi-stage cancel response. Drains it with
7458    /// the SAME machinery the call-timeout path uses ([`drain_cancel_wire`] ->
7459    /// [`drain_break_response_recovery`]) — the cancelled call's in-flight DATA
7460    /// response, the break-ack MARKER, the RESET handshake, and the trailing
7461    /// ORA-01013 — leaving the connection clean and reusable.
7462    ///
7463    /// Before this used the proper drain it ran a single `read_data_response`
7464    /// that stopped at the in-flight response's end-of-response boundary, leaking
7465    /// the MARKER + ORA-01013 into the socket where the NEXT operation misread
7466    /// them (bead rust-oracledb-wnz). A failed drain marks the connection dead
7467    /// and surfaces [`Error::ConnectionClosed`].
7468    async fn drain_cancel_response(&mut self) -> Result<()> {
7469        self.core.recovery.begin_drain_after_break()?;
7470        match self.core.drain_cancel_wire(BREAK_DRAIN_RECOVERY_TIMEOUT) {
7471            Ok(()) => {
7472                self.core.recovery.finish_drain_ready();
7473                Ok(())
7474            }
7475            Err(err) => {
7476                self.core.recovery.mark_dead();
7477                self.dead = true;
7478                Err(err)
7479            }
7480        }
7481    }
7482
7483    /// Explicitly cancel the in-flight operation on this connection and leave the
7484    /// connection in a clean, reusable state.
7485    ///
7486    /// This sends a BREAK to the server and then **drains** the entire cancel
7487    /// response (any in-flight DATA response of the cancelled call, the break-ack
7488    /// MARKER, the RESET handshake, and the trailing `ORA-01013`) so the wire is
7489    /// left at a clean message boundary — exactly the recovery python-oracledb
7490    /// performs in `Connection.cancel()` (`_break_external()` + `_reset()`,
7491    /// protocol.pyx:533-557). The reference would send an out-of-band urgent-TCP
7492    /// break when `supports_oob` is negotiated and fall back to this in-band
7493    /// BREAK marker otherwise (protocol.pyx:56-69); asupersync's transport does
7494    /// not expose `MSG_OOB`, so the portable in-band path is always taken (the
7495    /// server handles it identically — see [`Self::supports_oob`]).
7496    ///
7497    /// On success the connection is **usable for the next operation** (the cancel
7498    /// mirrors `DPY-4024` semantics: the session is alive, the wire is clean).
7499    /// `Ok(())` means the cancel completed and the connection survives. If the
7500    /// drain fails (a second timeout or a wire error) the connection is marked
7501    /// dead and [`Error::ConnectionClosed`] is returned instead.
7502    ///
7503    /// Unlike [`Self::cancel_handle`] (which only fires a bare BREAK from another
7504    /// thread, leaving the owner-side drain to private recovery machinery), this
7505    /// is the single-call, self-contained cancel: break **and** drain in one
7506    /// place.
7507    pub async fn cancel(&mut self, _cx: &Cx) -> Result<()> {
7508        self.core.recovery.begin_drain_after_break()?;
7509        match self
7510            .core
7511            .cancel_and_drain_wire(BREAK_DRAIN_RECOVERY_TIMEOUT)
7512        {
7513            Ok(()) => {
7514                self.core.recovery.finish_drain_ready();
7515                Ok(())
7516            }
7517            Err(err) => {
7518                self.core.recovery.mark_dead();
7519                self.dead = true;
7520                Err(err)
7521            }
7522        }
7523    }
7524
7525    /// Whether the server negotiated out-of-band (urgent-TCP) break support at
7526    /// accept time (`protocol_options & TNS_GSO_CAN_RECV_ATTENTION`, the
7527    /// reference `Capabilities.supports_oob`, capabilities.pyx:120). This driver
7528    /// always uses the in-band BREAK marker for [`Self::cancel`] regardless,
7529    /// because asupersync's `TcpStream` does not expose `send(MSG_OOB)`; the bit
7530    /// is surfaced for diagnostics and parity with the reference capability
7531    /// negotiation. The server accepts the in-band break on every connection.
7532    pub fn supports_oob(&self) -> bool {
7533        self.supports_oob
7534    }
7535
7536    fn remember_cursor_columns(&mut self, result: &QueryResult) {
7537        if result.cursor_id != 0 && !result.columns.is_empty() {
7538            // On a statement-cache hit the same cursor re-executes with identical
7539            // columns, so the map already holds an equal value. Cloning the
7540            // `Vec<ColumnMetadata>` (and each column's `name`/object/domain
7541            // `String`) every call would be pure waste on that hot path. Skip the
7542            // clone when the cached value already matches; the map ends with the
7543            // same content either way (behavior-preserving). The equality check is
7544            // a cheap field compare versus the String allocations a clone makes.
7545            if self.cursor_columns.get(&result.cursor_id) == Some(&result.columns) {
7546                return;
7547            }
7548            self.cursor_columns
7549                .insert(result.cursor_id, result.columns.clone());
7550        }
7551    }
7552
7553    /// Retains the fetch metadata of the most recent execution of `sql`,
7554    /// evicting the oldest entry beyond the cap (reference retains this on
7555    /// the cached Statement object, impl/thin/statement.pyx:300-310).
7556    fn remember_fetch_metadata(&mut self, sql: &str, columns: &[ColumnMetadata]) {
7557        const FETCH_METADATA_RETENTION_CAP: usize = 100;
7558        if !self.fetch_metadata_by_sql.contains_key(sql) {
7559            if self.fetch_metadata_order.len() >= FETCH_METADATA_RETENTION_CAP {
7560                if let Some(oldest) = self.fetch_metadata_order.pop_front() {
7561                    self.fetch_metadata_by_sql.remove(&oldest);
7562                }
7563            }
7564            self.fetch_metadata_order.push_back(sql.to_string());
7565        }
7566        self.fetch_metadata_by_sql
7567            .insert(sql.to_string(), columns.to_vec());
7568    }
7569
7570    /// Drops the retained fetch metadata for `sql` (the reference clears the
7571    /// cached statement's cursor before a type-change retry,
7572    /// impl/thin/messages/base.pyx:1206-1213). Returns whether an entry
7573    /// existed.
7574    fn forget_fetch_metadata(&mut self, sql: &str) -> bool {
7575        if self.fetch_metadata_by_sql.remove(sql).is_some() {
7576            self.fetch_metadata_order.retain(|entry| entry != sql);
7577            return true;
7578        }
7579        false
7580    }
7581
7582    /// Applies the re-execute type-change rule: when the retained fetch
7583    /// metadata for this SQL says a column previously fetched as char/raw is
7584    /// now described as CLOB/BLOB, re-define the cursor so the data streams
7585    /// as LONG/LONG RAW (reference _adjust_metadata + _requires_define,
7586    /// impl/thin/messages/base.pyx:820-845, 1148-1158).
7587    async fn apply_refetch_metadata(
7588        &mut self,
7589        cx: &Cx,
7590        sql: &str,
7591        mut result: QueryResult,
7592        arraysize: u32,
7593    ) -> Result<QueryResult> {
7594        if result.columns.is_empty() {
7595            return Ok(result);
7596        }
7597        // Cross-connection statement-shape observation (bead a4-8pp): record the
7598        // freshly-described shape in the shared cache. If another connection
7599        // changed the shape since it was last seen (a concurrent DDL), self-heal
7600        // by dropping THIS connection's retained per-SQL fetch metadata so the
7601        // adjust below cannot re-define the fresh columns toward the now-stale
7602        // shape. The decode itself always uses the live `result.columns`, so it
7603        // is never stale; the cache only forces a re-describe when the shape
7604        // drifted. Self-heal only ever invalidates (heals down), never loosens.
7605        if self.shape_cache.observe(sql, &result.columns).self_healed {
7606            self.forget_fetch_metadata(sql);
7607        }
7608        if let Some(previous_columns) = self.fetch_metadata_by_sql.get(sql) {
7609            let mut adjusted = result.columns.clone();
7610            let mut any_adjusted = false;
7611            for (index, column) in adjusted.iter_mut().enumerate() {
7612                if let Some(previous) = previous_columns.get(index) {
7613                    any_adjusted |= adjust_refetch_metadata(previous, column);
7614                }
7615            }
7616            if any_adjusted && result.cursor_id != 0 {
7617                observe_cancellation_between_round_trips(cx)?;
7618                let cursor_id = result.cursor_id;
7619                let redefined_result = self
7620                    .define_and_fetch_rows_with_columns(
7621                        cx,
7622                        cursor_id,
7623                        arraysize.max(1),
7624                        &adjusted,
7625                        None,
7626                    )
7627                    .await;
7628                let mut redefined = self.close_cursor_on_error(cursor_id, redefined_result)?;
7629                if redefined.columns.is_empty() {
7630                    redefined.columns = adjusted;
7631                }
7632                if redefined.cursor_id == 0 {
7633                    redefined.cursor_id = cursor_id;
7634                }
7635                result = redefined;
7636            }
7637        }
7638        self.remember_fetch_metadata(sql, &result.columns);
7639        Ok(result)
7640    }
7641
7642    /// Looks up an open server cursor for the SQL text, refreshing its LRU
7643    /// position (reference `_statement_cache.get_statement`). A cached cursor
7644    /// that is currently `_in_use` by another live cursor is NOT handed out:
7645    /// the reference makes a `stmt.copy()` (fresh cursor id) in that case, so
7646    /// concurrent cursors over identical SQL each drive their own server
7647    /// cursor and cannot reset each other's fetch position (ORA-01002). We
7648    /// model the copy by returning `None`, which forces a fresh PARSE.
7649    ///
7650    /// A cached cursor whose recorded bind TYPE shape is incompatible with
7651    /// `bind_shape` is dropped (queued for the close-cursors piggyback) and
7652    /// `None` is returned, forcing a fresh PARSE with the new bind metadata:
7653    /// re-executing it would make the server coerce the new values through
7654    /// the stale parsed types (bead rust-oracledb-ilel, ORA-01722).
7655    fn statement_cache_get(&mut self, sql: &str, bind_shape: &[BindShapeSlot]) -> Option<u32> {
7656        let index = self
7657            .statement_cache
7658            .iter()
7659            .position(|entry| entry.sql == sql)?;
7660        let cursor_id = self.statement_cache[index].cursor_id;
7661        if cursor_id != 0 && self.in_use_cursors.contains(&cursor_id) {
7662            return None;
7663        }
7664        if !bind_shape_is_compatible(&self.statement_cache[index].bind_shape, bind_shape) {
7665            self.statement_cache_invalidate(sql, cursor_id);
7666            return None;
7667        }
7668        let entry = self.statement_cache.remove(index);
7669        self.statement_cache.push(entry);
7670        Some(cursor_id)
7671    }
7672
7673    /// Removes and returns the open cursor for the SQL text; used when the
7674    /// caller requested `cache_statement=False` but the statement is still
7675    /// present from an earlier cached execution (reference `_get_statement`
7676    /// pops from the cache unconditionally). A bind-shape mismatch drops the
7677    /// cursor instead of handing it out (same rule as
7678    /// [`Self::statement_cache_get`]).
7679    fn statement_cache_take(&mut self, sql: &str, bind_shape: &[BindShapeSlot]) -> Option<u32> {
7680        let index = self
7681            .statement_cache
7682            .iter()
7683            .position(|entry| entry.sql == sql)?;
7684        if !bind_shape_is_compatible(&self.statement_cache[index].bind_shape, bind_shape) {
7685            let cursor_id = self.statement_cache[index].cursor_id;
7686            self.statement_cache_invalidate(sql, cursor_id);
7687            return None;
7688        }
7689        Some(self.statement_cache.remove(index).cursor_id)
7690    }
7691
7692    /// Stores/updates the open cursor for the SQL text along with the bind
7693    /// TYPE shape it was bound with, evicting the least recently used entry
7694    /// into the close-cursors piggyback queue (reference
7695    /// `_statement_cache.return_statement`).
7696    fn statement_cache_put(&mut self, sql: &str, cursor_id: u32, bind_shape: Vec<BindShapeSlot>) {
7697        let to_close = statement_cache_insert(
7698            &mut self.statement_cache,
7699            self.statement_cache_size,
7700            sql,
7701            cursor_id,
7702            bind_shape,
7703        );
7704        for cursor_id in &to_close {
7705            self.lob_prefetch_cursors.remove(cursor_id);
7706            self.cursor_columns.remove(cursor_id);
7707        }
7708        self.cursors_to_close.extend(to_close);
7709    }
7710
7711    /// Drops any statement-cache entry whose open cursor was passed as an IN
7712    /// REF CURSOR bind in `bind_rows`. The called PL/SQL may have closed the
7713    /// cursor server-side, leaving the cached cursor_id invalid; clearing the
7714    /// entry forces a re-parse on the next execute of that SQL rather than
7715    /// reusing the closed cursor (ORA-01001). Test 1315 / 5815.
7716    fn invalidate_bound_ref_cursors(&mut self, bind_rows: &[Vec<BindValue>]) {
7717        for row in bind_rows {
7718            for value in row {
7719                if let BindValue::Cursor { cursor_id } = value {
7720                    if *cursor_id == 0 {
7721                        continue;
7722                    }
7723                    self.statement_cache
7724                        .retain(|entry| entry.cursor_id != *cursor_id);
7725                    self.cursor_columns.remove(cursor_id);
7726                    self.lob_prefetch_cursors.remove(cursor_id);
7727                }
7728            }
7729        }
7730    }
7731
7732    /// Releases a server cursor id previously marked in use by an executing
7733    /// query cursor (reference `_return_statement` clearing `Statement._in_use`).
7734    /// Called when the owning cursor closes or re-prepares; once released the
7735    /// cached cursor may be reused by the next execute of the same SQL. The
7736    /// cursor id stays in the statement cache (the open server cursor is kept
7737    /// for reuse, mirroring `_return_to_cache`).
7738    pub fn release_cursor(&mut self, cursor_id: u32) {
7739        if cursor_id == 0 {
7740            return;
7741        }
7742        self.in_use_cursors.remove(&cursor_id);
7743        // A copied cursor (parsed because the cached statement was busy) is not
7744        // kept open: queue it for the close-cursors piggyback now that its
7745        // owning cursor is done with it (reference `_add_cursor_to_close`).
7746        if self.copied_cursors.remove(&cursor_id) {
7747            self.cursors_to_close.push(cursor_id);
7748            self.cursor_columns.remove(&cursor_id);
7749            self.lob_prefetch_cursors.remove(&cursor_id);
7750        }
7751    }
7752
7753    /// Apply the fail-closed lifecycle rule for an operation on an already-open
7754    /// cursor: a successful result keeps ownership unchanged, while an error
7755    /// retires the cursor because its server-side validity is no longer proven.
7756    /// Callers that prove an operation never started must use `release_cursor`
7757    /// directly instead, preserving a valid cached cursor for reuse.
7758    pub(crate) fn close_cursor_on_error<T>(
7759        &mut self,
7760        cursor_id: u32,
7761        result: Result<T>,
7762    ) -> Result<T> {
7763        if result.is_err() {
7764            self.close_cursor(cursor_id);
7765        }
7766        result
7767    }
7768
7769    /// Queue an open server cursor to be closed on the next round trip
7770    /// (reference `_add_cursor_to_close`). Unlike [`Self::release_cursor`],
7771    /// which returns a cached cursor to the statement cache for reuse, this
7772    /// drops the cursor entirely: its id is sent in the close-cursors piggyback
7773    /// that rides the next execute, any statement-cache entry pointing at the
7774    /// id is evicted, and its retained describe metadata is forgotten. Use this
7775    /// for a non-cached cursor (for example one opened by [`Self::execute_raw`])
7776    /// once its result is fully consumed, or for any cursor whose validity was
7777    /// lost after a failed operation. A cursor id of `0` is ignored.
7778    pub fn close_cursor(&mut self, cursor_id: u32) {
7779        if cursor_id == 0 {
7780            return;
7781        }
7782        self.statement_cache
7783            .retain(|entry| entry.cursor_id != cursor_id);
7784        self.in_use_cursors.remove(&cursor_id);
7785        self.copied_cursors.remove(&cursor_id);
7786        self.cursor_columns.remove(&cursor_id);
7787        self.lob_prefetch_cursors.remove(&cursor_id);
7788        if !self.cursors_to_close.contains(&cursor_id) {
7789            self.cursors_to_close.push(cursor_id);
7790        }
7791    }
7792
7793    /// Returns true when the SQL text has a cached open cursor that is
7794    /// currently in use by another live cursor (reference `Statement._in_use`
7795    /// checked in `get_statement`).
7796    fn statement_is_in_use(&self, sql: &str) -> bool {
7797        self.statement_cache
7798            .iter()
7799            .find(|entry| entry.sql == sql)
7800            .is_some_and(|entry| {
7801                entry.cursor_id != 0 && self.in_use_cursors.contains(&entry.cursor_id)
7802            })
7803    }
7804
7805    /// Drops the cached cursor for the SQL text after a server error so the
7806    /// next execute re-parses (reference `_statement_cache.clear_cursor`).
7807    fn statement_cache_invalidate(&mut self, sql: &str, cursor_id: u32) {
7808        if let Some(index) = self
7809            .statement_cache
7810            .iter()
7811            .position(|entry| entry.sql == sql)
7812        {
7813            self.statement_cache.remove(index);
7814        }
7815        if cursor_id != 0 {
7816            self.cursors_to_close.push(cursor_id);
7817            self.cursor_columns.remove(&cursor_id);
7818            self.lob_prefetch_cursors.remove(&cursor_id);
7819            self.in_use_cursors.remove(&cursor_id);
7820            self.copied_cursors.remove(&cursor_id);
7821        }
7822    }
7823
7824    /// Builds the close-cursors piggyback bytes for any queued cursor ids;
7825    /// the piggyback consumes its own TTC sequence number.
7826    fn take_close_cursors_piggyback(&mut self) -> Option<Vec<u8>> {
7827        if self.cursors_to_close.is_empty() {
7828            return None;
7829        }
7830        let cursor_ids = std::mem::take(&mut self.cursors_to_close);
7831        let seq_num = next_ttc_sequence(&mut self.ttc_seq_num);
7832        Some(oracledb_protocol::thin::build_close_cursors_piggyback(
7833            &cursor_ids,
7834            seq_num,
7835            self.capabilities.ttc_field_version,
7836        ))
7837    }
7838
7839    /// Log off and close the connection, consuming it. Any uncommitted
7840    /// transaction is rolled back by the server.
7841    pub async fn close(mut self, cx: &Cx) -> Result<()> {
7842        observe_cancellation_between_round_trips(cx)?;
7843        match time::timeout(time::wall_now(), Duration::from_secs(5), self.rollback(cx)).await {
7844            Ok(result) => result?,
7845            Err(_) => {
7846                let eof = encode_packet(
7847                    TNS_PACKET_TYPE_DATA,
7848                    0,
7849                    Some(oracledb_protocol::thin::TNS_DATA_FLAGS_EOF),
7850                    &[],
7851                    PacketLengthWidth::Large32,
7852                )?;
7853                let _ = self.core.write_all(cx, &eof).await;
7854                let _ = self.core.shutdown_write(cx).await;
7855                return Ok(());
7856            }
7857        }
7858        self.ensure_clean_before_request().await?;
7859        let seq_num = next_ttc_sequence(&mut self.ttc_seq_num);
7860        self.core
7861            .send_data_packet(
7862                cx,
7863                &build_function_payload_with_seq(
7864                    TNS_FUNC_LOGOFF,
7865                    seq_num,
7866                    self.capabilities.ttc_field_version,
7867                ),
7868                self.sdu,
7869            )
7870            .await?;
7871        let capabilities = self.capabilities;
7872        let limits = self.protocol_limits;
7873        self.core
7874            .finish_session_close(cx, !self.supports_end_of_response, |bytes| {
7875                response_complete(&parse_plain_function_response_with_limits(
7876                    bytes,
7877                    capabilities,
7878                    limits,
7879                ))
7880            })
7881            .await
7882    }
7883
7884    /// Runs a batch of operations as a true wire pipeline (single round trip):
7885    /// every request is written before anything is read, then the N+1
7886    /// boundary-delimited responses (one per operation plus the end-pipeline
7887    /// response) are returned as raw TTC payloads in token order. Mirrors the
7888    /// reference flow (impl/thin/connection.pyx `run_pipeline_with_pipelining`
7889    /// and protocol.pyx `end_pipeline`):
7890    ///
7891    /// * the first message is prefixed with the begin-pipeline piggyback and
7892    ///   its first packet carries TNS_DATA_FLAGS_BEGIN_PIPELINE,
7893    /// * each operation message carries token 1..N and its final packet
7894    ///   carries TNS_DATA_FLAGS_END_OF_REQUEST,
7895    /// * the end-pipeline message (function 200) closes the batch,
7896    /// * marker packets received while reading pipeline responses are dropped
7897    ///   without sending a reset (packet.pyx:346-370),
7898    /// * responses are read for every operation even after a server error --
7899    ///   the server answers each message in both pipeline modes, so callers
7900    ///   parse per-operation payloads and decide error semantics.
7901    pub async fn run_pipeline(
7902        &mut self,
7903        cx: &Cx,
7904        requests: &[PipelineRequest],
7905        continue_on_error: bool,
7906    ) -> Result<Vec<Vec<u8>>> {
7907        // Pipelining is defined in terms of END_OF_RESPONSE boundary framing
7908        // (impl/thin/capabilities.pyx:126-130); a pre-23ai server that did not
7909        // negotiate it cannot delimit the N+1 pipelined responses, so fail
7910        // closed instead of hanging on the first boundary read.
7911        if !self.supports_end_of_response {
7912            return Err(Error::Protocol(
7913                oracledb_protocol::ProtocolError::UnsupportedFeature(
7914                    "pipelining requires END_OF_RESPONSE framing, which this server \
7915                     did not negotiate (requires Oracle Database 23ai or later)",
7916                ),
7917            ));
7918        }
7919        observe_cancellation_between_round_trips(cx)?;
7920        if requests.is_empty() {
7921            return Ok(Vec::new());
7922        }
7923        self.ensure_clean_before_request().await?;
7924        self.protocol_limits
7925            .check_length_prefixed_elements(requests.len())?;
7926        let pipeline_mode = if continue_on_error {
7927            TNS_PIPELINE_MODE_CONTINUE_ON_ERROR
7928        } else {
7929            TNS_PIPELINE_MODE_ABORT_ON_ERROR
7930        };
7931        for (index, request) in requests.iter().enumerate() {
7932            let token_num = index as u64 + 1;
7933            let mut payload = Vec::new();
7934            let mut first_packet_flags = 0u16;
7935            if index == 0 {
7936                // Flush any pending close-cursors piggyback on the first op so
7937                // server cursors retired since the last round trip (evicted from
7938                // the statement cache or released by a closed cursor) are
7939                // actually closed — otherwise a sequence of pipelines that open
7940                // query cursors leaks them server-side (ORA-01000). The
7941                // reference likewise rides queued closes as a piggyback on the
7942                // next message; it is consumed before the begin-pipeline
7943                // piggyback's sequence number, matching the ordinary execute
7944                // path where the close-cursors piggyback is prepended first.
7945                if let Some(close_piggyback) = self.take_close_cursors_piggyback() {
7946                    payload.extend_from_slice(&close_piggyback);
7947                }
7948                let piggyback_seq = next_ttc_sequence(&mut self.ttc_seq_num);
7949                payload.extend_from_slice(&build_begin_pipeline_piggyback(
7950                    piggyback_seq,
7951                    token_num,
7952                    pipeline_mode,
7953                ));
7954                first_packet_flags |= TNS_DATA_FLAGS_BEGIN_PIPELINE;
7955            }
7956            let seq_num = next_ttc_sequence(&mut self.ttc_seq_num);
7957            match request {
7958                PipelineRequest::Execute {
7959                    sql,
7960                    bind_rows,
7961                    prefetch_rows,
7962                } => {
7963                    self.protocol_limits.check_batch_rows(bind_rows.len())?;
7964                    if let Some(first_row) = bind_rows.first() {
7965                        self.protocol_limits.check_binds(first_row.len())?;
7966                    }
7967                    payload.extend_from_slice(
7968                        &build_execute_payload_with_bind_rows_and_options_with_seq(
7969                            sql,
7970                            *prefetch_rows,
7971                            seq_num,
7972                            statement_is_query(sql),
7973                            bind_rows,
7974                            ExecuteOptions::default()
7975                                .with_token_num(token_num)
7976                                .with_max_string_size(self.capabilities.max_string_size),
7977                            self.capabilities.ttc_field_version,
7978                        )?,
7979                    );
7980                }
7981                PipelineRequest::Commit => {
7982                    payload.extend_from_slice(&build_function_payload_with_seq_and_token(
7983                        TNS_FUNC_COMMIT,
7984                        seq_num,
7985                        token_num,
7986                        self.capabilities.ttc_field_version,
7987                    ));
7988                }
7989            }
7990            trace_query_bytes("PIPELINE op payload", &payload);
7991            self.core
7992                .send_data_packet_with_flags(
7993                    cx,
7994                    &payload,
7995                    self.sdu,
7996                    first_packet_flags,
7997                    TNS_DATA_FLAGS_END_OF_REQUEST,
7998                )
7999                .await?;
8000        }
8001        let seq_num = next_ttc_sequence(&mut self.ttc_seq_num);
8002        let end_payload = build_end_pipeline_payload_with_seq(seq_num);
8003        trace_query_bytes("PIPELINE end payload", &end_payload);
8004        self.core
8005            .send_data_packet(cx, &end_payload, self.sdu)
8006            .await?;
8007        let mut responses = Vec::with_capacity(requests.len() + 1);
8008        for _ in 0..=requests.len() {
8009            let response = match self.read_pipeline_response_cancellable(cx).await {
8010                Ok(response) => response,
8011                Err(Error::CallTimeout(timeout_ms)) => {
8012                    return self.recover_from_call_timeout(cx, timeout_ms).await;
8013                }
8014                Err(err) => return Err(err),
8015            };
8016            trace_query_bytes("PIPELINE response", &response.payload);
8017            responses.push(response.payload);
8018        }
8019        Ok(responses)
8020    }
8021
8022    /// Runs a batch as a true single-round-trip pipeline (like [`Self::run_pipeline`])
8023    /// and decodes each per-operation response into a [`QueryResult`], reusing
8024    /// the same `parse_query_response_*` decoders the ordinary execute path uses
8025    /// — no result-layer reimplementation. The end-pipeline response (the N+1th
8026    /// raw payload) is consumed for framing but not returned.
8027    ///
8028    /// Each operation is decoded with its own bind row and prefetch (carried by
8029    /// [`PipelineRequest::Execute`]); the returned vector has one entry per
8030    /// request, in token order. A per-operation server error is captured as
8031    /// `Err` for that slot rather than aborting the batch, so the caller can
8032    /// implement both abort-on-error and continue-on-error semantics over the
8033    /// decoded results (the wire batch already ran to completion — the server
8034    /// answers every message in both pipeline modes).
8035    ///
8036    /// The connection's `txn_in_progress` flag is refreshed from the last
8037    /// successfully decoded operation that carried an end-of-call STATUS, so a
8038    /// pipeline ending in commit/DML leaves the flag consistent with the
8039    /// sequential path (test_7614). No extra round trips are issued here: a
8040    /// query whose rows did not all fit in the prefetch returns its open
8041    /// `cursor_id` + `columns` + `more_rows` in the [`QueryResult`] so the
8042    /// caller can finish the fetch over the ordinary public cursor API, exactly
8043    /// as the reference `_complete_pipeline_op` does.
8044    pub async fn run_pipeline_decoded(
8045        &mut self,
8046        cx: &Cx,
8047        requests: &[PipelineRequest],
8048        continue_on_error: bool,
8049    ) -> Result<Vec<Result<QueryResult>>> {
8050        let raw = self.run_pipeline(cx, requests, continue_on_error).await?;
8051        // raw has requests.len() + 1 entries (the last is the end-pipeline
8052        // response, consumed for framing only).
8053        let mut decoded = Vec::with_capacity(requests.len());
8054        for (index, request) in requests.iter().enumerate() {
8055            let payload = &raw[index];
8056            let outcome = match request {
8057                PipelineRequest::Commit => {
8058                    // A commit op answers with a plain function response; decode
8059                    // it the same way the standalone commit path does so the
8060                    // txn-in-progress bit is sampled identically.
8061                    match parse_plain_function_response_with_limits(
8062                        payload,
8063                        self.capabilities,
8064                        self.protocol_limits,
8065                    ) {
8066                        Ok(txn_in_progress) => Ok(QueryResult {
8067                            txn_in_progress: Some(txn_in_progress),
8068                            ..QueryResult::default()
8069                        }),
8070                        Err(err) => Err(Error::Protocol(err)),
8071                    }
8072                }
8073                PipelineRequest::Execute { sql, bind_rows, .. } => {
8074                    parse_query_response_with_binds_options_columns_and_limits(
8075                        payload,
8076                        self.capabilities,
8077                        bind_rows.first().map(Vec::as_slice).unwrap_or(&[]),
8078                        ExecuteOptions::default(),
8079                        &[],
8080                        self.protocol_limits,
8081                    )
8082                    .map_err(Error::Protocol)
8083                    .inspect(|result| {
8084                        // Track open query cursors so a later op or a follow-up
8085                        // fetch on this connection does not collide with them.
8086                        self.remember_cursor_columns(result);
8087                        if result.cursor_id != 0 && statement_is_query(sql) {
8088                            self.in_use_cursors.insert(result.cursor_id);
8089                        }
8090                    })
8091                }
8092            };
8093            // Refresh txn-in-progress from any op that carried a STATUS message,
8094            // mirroring the sequential per-op execute bookkeeping.
8095            if let Ok(result) = &outcome {
8096                if let Some(txn_in_progress) = result.txn_in_progress {
8097                    self.txn_in_progress = txn_in_progress;
8098                }
8099            }
8100            decoded.push(outcome);
8101        }
8102        Ok(decoded)
8103    }
8104
8105    async fn send_function(&mut self, cx: &Cx, function_code: u8) -> Result<()> {
8106        observe_cancellation_between_round_trips(cx)?;
8107        self.ensure_clean_before_request().await?;
8108        let seq_num = next_ttc_sequence(&mut self.ttc_seq_num);
8109        self.core
8110            .send_data_packet(
8111                cx,
8112                &build_function_payload_with_seq(
8113                    function_code,
8114                    seq_num,
8115                    self.capabilities.ttc_field_version,
8116                ),
8117                self.sdu,
8118            )
8119            .await?;
8120        let capabilities = self.capabilities;
8121        let limits = self.protocol_limits;
8122        let response = self
8123            .read_response_cancellable(cx, !self.supports_end_of_response, |bytes| {
8124                response_complete(&parse_plain_function_response_with_limits(
8125                    bytes,
8126                    capabilities,
8127                    limits,
8128                ))
8129            })
8130            .await?;
8131        // Surface server errors (e.g. ORA-01012 after a killed session) that
8132        // arrive on plain function round trips; pool ping health checks and
8133        // commit/rollback depend on these not being silently swallowed. The
8134        // returned bit refreshes `txn_in_progress` from the wire end-of-call
8135        // status (reference protocol.pyx `_process_call_status`).
8136        let txn_in_progress = self.note_parse(parse_plain_function_response_with_limits(
8137            &response,
8138            self.capabilities,
8139            self.protocol_limits,
8140        ))?;
8141        self.txn_in_progress = txn_in_progress;
8142        Ok(())
8143    }
8144
8145    /// Mark CLOB/BLOB result columns that actually hold JSON.
8146    ///
8147    /// A column described over the wire as CLOB or BLOB can be a JSON column
8148    /// (`IS JSON` storage); the fetch metadata does not say so directly. For each
8149    /// such candidate this runs a catalog probe against `ALL_JSON_COLUMNS` in the
8150    /// current schema and flips `is_json` when the column is registered as JSON.
8151    /// Columns already flagged JSON, non-LOB columns, and unnamed (expression)
8152    /// columns are skipped. `timeout_ms` bounds each probe (the call timeout).
8153    ///
8154    /// The reference fires the same `ALL_JSON_COLUMNS` lookup after describing a
8155    /// LOB result column so JSON-in-LOB values decode correctly.
8156    pub async fn supplement_json_column_metadata(
8157        &mut self,
8158        cx: &Cx,
8159        columns: &mut [ColumnMetadata],
8160        timeout_ms: Option<u32>,
8161    ) -> Result<()> {
8162        let candidates = json_lob_probe_candidates(columns);
8163        if candidates.is_empty() {
8164            return Ok(());
8165        }
8166        for (index, column_name) in candidates {
8167            let result = self
8168                .execute_query_with_binds_call_timeout(
8169                    cx,
8170                    "select 1 \
8171                     from all_json_columns \
8172                     where owner = sys_context('USERENV', 'CURRENT_SCHEMA') \
8173                       and column_name = :1",
8174                    1,
8175                    &[BindValue::Text(column_name)],
8176                    timeout_ms,
8177                )
8178                .await?;
8179            if !result.rows.is_empty() {
8180                columns[index] = columns[index].clone().with_is_json(true);
8181            }
8182        }
8183        Ok(())
8184    }
8185}
8186
8187impl CancelHandle {
8188    /// Request cancellation of the connection operation currently in flight.
8189    ///
8190    /// The blocking facade for synchronous callers is [`Self::cancel_blocking`];
8191    /// Rust cannot overload that zero-argument wrapper with this `&Cx` form.
8192    ///
8193    /// This is request-only: it sends the BREAK marker and records that recovery
8194    /// is pending, but the connection owner remains responsible for draining the
8195    /// cancel response and reconciling the session back to Ready or Dead.
8196    pub async fn cancel(&mut self, cx: &Cx) -> Result<()> {
8197        observe_cancellation_between_round_trips(cx)?;
8198        if !self.should_send_break_request()? {
8199            return Ok(());
8200        }
8201        let mut write = lock_write(cx, &self.write).await?;
8202        if !self.should_send_break_request()? {
8203            return Ok(());
8204        }
8205        match send_marker(&mut *write, TNS_MARKER_TYPE_BREAK).await {
8206            Ok(()) => self.recovery.mark_break_sent(),
8207            Err(err) => {
8208                self.recovery.mark_dead();
8209                Err(err)
8210            }
8211        }
8212    }
8213
8214    /// Send the request-only BREAK needed by a local recovery path without
8215    /// consulting the operation context.
8216    ///
8217    /// A caller can arrive here precisely because that context has already
8218    /// been cancelled. Recovery must still get the server to stop the
8219    /// in-flight operation before its owner can drain the multi-packet cancel
8220    /// response and safely hand the connection back. A recovery thread owns
8221    /// the bounded marker write so async I/O cannot observe the caller's
8222    /// cancelled ambient context.
8223    pub(crate) fn cancel_for_recovery(&mut self) -> Result<()> {
8224        if !self.should_send_break_request()? {
8225            return Ok(());
8226        }
8227        match crate::recovery::send_break_without_current_cx(
8228            &self.write,
8229            BREAK_DRAIN_RECOVERY_TIMEOUT,
8230        ) {
8231            Ok(()) => self.recovery.mark_break_sent(),
8232            Err(err) => {
8233                self.recovery.mark_dead();
8234                Err(err)
8235            }
8236        }
8237    }
8238
8239    fn should_send_break_request(&self) -> Result<bool> {
8240        match self.recovery.phase() {
8241            SessionRecoveryPhase::Dead => {
8242                Err(Error::ConnectionClosed("connection is closed".into()))
8243            }
8244            SessionRecoveryPhase::BreakSent | SessionRecoveryPhase::Draining => Ok(false),
8245            SessionRecoveryPhase::Ready | SessionRecoveryPhase::InFlight => Ok(true),
8246        }
8247    }
8248
8249    /// Blocking facade for synchronous callers.
8250    pub fn cancel_blocking(&mut self) -> Result<()> {
8251        let runtime = build_io_runtime()?;
8252        runtime.block_on(async {
8253            let cx = Cx::current()
8254                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8255            self.cancel(&cx).await
8256        })
8257    }
8258}
8259
8260/// Synchronous facade over [`Connection`].
8261///
8262/// Each associated function spins up a private single-threaded Asupersync
8263/// runtime, drives the corresponding async [`Connection`] method to
8264/// completion, and blocks the calling thread until it returns. The functions
8265/// take a `&mut Connection` (returned by [`BlockingConnection::connect`]) so a
8266/// connection can be reused across calls. This is the simplest way to use the
8267/// driver from ordinary synchronous Rust.
8268///
8269/// ```no_run
8270/// use oracledb::{BlockingConnection, ConnectOptions};
8271/// use oracledb::protocol::ClientIdentity;
8272///
8273/// # fn main() -> Result<(), oracledb::Error> {
8274/// let identity = ClientIdentity::new("svc", "host", "user", "term", "rust-oracledb")?;
8275/// let mut conn = BlockingConnection::connect(
8276///     ConnectOptions::new("dbhost:1521/FREEPDB1", "app", "pw", identity),
8277/// )?;
8278/// let row = BlockingConnection::query_one(&mut conn, "select 1 from dual", ())?;
8279/// let value: i64 = row.get(0)?;
8280/// assert_eq!(value, 1);
8281/// BlockingConnection::close(conn)?;
8282/// # Ok(())
8283/// # }
8284/// ```
8285pub struct BlockingConnection;
8286
8287impl BlockingConnection {
8288    /// Open a connection synchronously. See [`Connection::connect`].
8289    pub fn connect(options: ConnectOptions) -> Result<Connection> {
8290        let runtime = build_io_runtime()?;
8291        runtime.block_on(async {
8292            let cx = Cx::current()
8293                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8294            Connection::connect(&cx, options).await
8295        })
8296    }
8297
8298    pub fn ping(connection: &mut Connection) -> Result<()> {
8299        let runtime = build_io_runtime()?;
8300        runtime.block_on(async {
8301            let cx = Cx::current()
8302                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8303            connection.ping(&cx).await
8304        })
8305    }
8306
8307    pub fn ping_with_timeout(connection: &mut Connection, timeout_ms: u32) -> Result<()> {
8308        let runtime = build_io_runtime()?;
8309        runtime.block_on(async {
8310            let cx = Cx::current()
8311                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8312            connection.ping_with_timeout(&cx, timeout_ms).await
8313        })
8314    }
8315
8316    pub fn change_password(
8317        connection: &mut Connection,
8318        old_password: &str,
8319        new_password: &str,
8320    ) -> Result<()> {
8321        let runtime = build_io_runtime()?;
8322        runtime.block_on(async {
8323            let cx = Cx::current()
8324                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8325            connection
8326                .change_password(&cx, old_password, new_password)
8327                .await
8328        })
8329    }
8330
8331    /// Blocking wrapper for [`Connection::cancel`].
8332    pub fn cancel(connection: &mut Connection) -> Result<()> {
8333        block_on_io(|cx| async move { connection.cancel(&cx).await })
8334    }
8335
8336    pub fn commit(connection: &mut Connection) -> Result<()> {
8337        let runtime = build_io_runtime()?;
8338        runtime.block_on(async {
8339            let cx = Cx::current()
8340                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8341            connection.commit(&cx).await
8342        })
8343    }
8344
8345    /// Register a CQN subscription (FUNC 125, opcode 1). See
8346    /// [`Connection::subscribe_register`].
8347    #[allow(clippy::too_many_arguments)]
8348    pub fn subscribe_register(
8349        connection: &mut Connection,
8350        namespace: u32,
8351        name: Option<&str>,
8352        public_qos: u32,
8353        operations: u32,
8354        timeout: u32,
8355        grouping_class: u8,
8356        grouping_value: u32,
8357        grouping_type: u8,
8358    ) -> Result<SubscribeResult> {
8359        let runtime = build_io_runtime()?;
8360        runtime.block_on(async {
8361            let cx = Cx::current()
8362                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8363            connection
8364                .subscribe_register(
8365                    &cx,
8366                    namespace,
8367                    name,
8368                    public_qos,
8369                    operations,
8370                    timeout,
8371                    grouping_class,
8372                    grouping_value,
8373                    grouping_type,
8374                )
8375                .await
8376        })
8377    }
8378
8379    /// Unregister a CQN subscription (FUNC 125, opcode 2). See
8380    /// [`Connection::subscribe_unregister`].
8381    #[allow(clippy::too_many_arguments)]
8382    pub fn subscribe_unregister(
8383        connection: &mut Connection,
8384        registration_id: u64,
8385        client_id: &[u8],
8386        namespace: u32,
8387        name: Option<&str>,
8388        public_qos: u32,
8389        operations: u32,
8390        timeout: u32,
8391        grouping_class: u8,
8392        grouping_value: u32,
8393        grouping_type: u8,
8394    ) -> Result<()> {
8395        let runtime = build_io_runtime()?;
8396        runtime.block_on(async {
8397            let cx = Cx::current()
8398                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8399            connection
8400                .subscribe_unregister(
8401                    &cx,
8402                    registration_id,
8403                    client_id,
8404                    namespace,
8405                    name,
8406                    public_qos,
8407                    operations,
8408                    timeout,
8409                    grouping_class,
8410                    grouping_value,
8411                    grouping_type,
8412                )
8413                .await
8414        })
8415    }
8416
8417    /// Send the blocking CQN NOTIFY registration message. See
8418    /// [`Connection::notify_register`].
8419    pub fn notify_register(connection: &mut Connection, client_id: &[u8]) -> Result<()> {
8420        block_on_io(|cx| async move { connection.notify_register(&cx, client_id).await })
8421    }
8422
8423    /// Blocking wrapper for [`Connection::recv_notification`].
8424    pub fn recv_notification(
8425        connection: &mut Connection,
8426        namespace: u32,
8427        public_qos: u32,
8428        read_timeout: Duration,
8429    ) -> Result<NotificationOutcome> {
8430        block_on_io(|cx| async move {
8431            connection
8432                .recv_notification(&cx, namespace, public_qos, read_timeout)
8433                .await
8434        })
8435    }
8436
8437    /// Blocking wrapper for [`Connection::register_query`].
8438    pub fn register_query<'r>(
8439        connection: &mut Connection,
8440        registration: Registration<'r>,
8441    ) -> Result<RegistrationOutcome> {
8442        block_on_io(|cx| async move { connection.register_query(&cx, registration).await })
8443    }
8444
8445    pub fn rollback(connection: &mut Connection) -> Result<()> {
8446        let runtime = build_io_runtime()?;
8447        runtime.block_on(async {
8448            let cx = Cx::current()
8449                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8450            connection.rollback(&cx).await
8451        })
8452    }
8453
8454    /// Blocking wrapper for [`Connection::enable_dbms_output`].
8455    pub fn enable_dbms_output(
8456        connection: &mut Connection,
8457        buffer_bytes: Option<u32>,
8458    ) -> Result<()> {
8459        let runtime = build_io_runtime()?;
8460        runtime.block_on(async {
8461            let cx = Cx::current()
8462                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8463            connection.enable_dbms_output(&cx, buffer_bytes).await
8464        })
8465    }
8466
8467    /// Blocking wrapper for [`Connection::read_dbms_output`].
8468    pub fn read_dbms_output(
8469        connection: &mut Connection,
8470        max_lines: usize,
8471        max_chars: usize,
8472    ) -> Result<DbmsOutput> {
8473        let runtime = build_io_runtime()?;
8474        runtime.block_on(async {
8475            let cx = Cx::current()
8476                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8477            connection.read_dbms_output(&cx, max_lines, max_chars).await
8478        })
8479    }
8480
8481    /// Blocking wrapper for [`Connection::fetch_cursor`].
8482    pub fn fetch_cursor(
8483        connection: &mut Connection,
8484        cursor: &oracledb_protocol::thin::CursorValue,
8485        max_rows: usize,
8486    ) -> Result<QueryResult> {
8487        let runtime = build_io_runtime()?;
8488        runtime.block_on(async {
8489            let cx = Cx::current()
8490                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8491            connection.fetch_cursor(&cx, cursor, max_rows).await
8492        })
8493    }
8494
8495    /// Blocking wrapper for [`Connection::describe_object_type`].
8496    pub fn describe_object_type(
8497        connection: &mut Connection,
8498        schema: &str,
8499        type_name: &str,
8500    ) -> Result<ObjectType> {
8501        let runtime = build_io_runtime()?;
8502        runtime.block_on(async {
8503            let cx = Cx::current()
8504                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8505            connection
8506                .describe_object_type(&cx, schema, type_name)
8507                .await
8508        })
8509    }
8510
8511    pub fn begin_sessionless_transaction(
8512        connection: &mut Connection,
8513        transaction_id: &[u8],
8514        timeout: u32,
8515        defer_round_trip: bool,
8516    ) -> Result<()> {
8517        let runtime = build_io_runtime()?;
8518        runtime.block_on(async {
8519            let cx = Cx::current()
8520                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8521            connection
8522                .begin_sessionless_transaction(&cx, transaction_id, timeout, defer_round_trip)
8523                .await
8524        })
8525    }
8526
8527    pub fn resume_sessionless_transaction(
8528        connection: &mut Connection,
8529        transaction_id: &[u8],
8530        timeout: u32,
8531        defer_round_trip: bool,
8532    ) -> Result<()> {
8533        let runtime = build_io_runtime()?;
8534        runtime.block_on(async {
8535            let cx = Cx::current()
8536                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8537            connection
8538                .resume_sessionless_transaction(&cx, transaction_id, timeout, defer_round_trip)
8539                .await
8540        })
8541    }
8542
8543    pub fn suspend_sessionless_transaction(connection: &mut Connection) -> Result<()> {
8544        let runtime = build_io_runtime()?;
8545        runtime.block_on(async {
8546            let cx = Cx::current()
8547                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8548            connection.suspend_sessionless_transaction(&cx).await
8549        })
8550    }
8551
8552    #[allow(clippy::too_many_arguments)]
8553    pub fn tpc_begin(
8554        connection: &mut Connection,
8555        format_id: u32,
8556        global_transaction_id: &[u8],
8557        branch_qualifier: &[u8],
8558        flags: u32,
8559        timeout: u32,
8560    ) -> Result<()> {
8561        let runtime = build_io_runtime()?;
8562        runtime.block_on(async {
8563            let cx = Cx::current()
8564                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8565            connection
8566                .tpc_begin(
8567                    &cx,
8568                    format_id,
8569                    global_transaction_id,
8570                    branch_qualifier,
8571                    flags,
8572                    timeout,
8573                )
8574                .await
8575        })
8576    }
8577
8578    pub fn tpc_end(
8579        connection: &mut Connection,
8580        xid: Option<(u32, &[u8], &[u8])>,
8581        flags: u32,
8582    ) -> Result<()> {
8583        let runtime = build_io_runtime()?;
8584        runtime.block_on(async {
8585            let cx = Cx::current()
8586                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8587            connection.tpc_end(&cx, xid, flags).await
8588        })
8589    }
8590
8591    pub fn tpc_prepare(
8592        connection: &mut Connection,
8593        xid: Option<(u32, &[u8], &[u8])>,
8594    ) -> Result<bool> {
8595        let runtime = build_io_runtime()?;
8596        runtime.block_on(async {
8597            let cx = Cx::current()
8598                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8599            connection.tpc_prepare(&cx, xid).await
8600        })
8601    }
8602
8603    pub fn tpc_commit(
8604        connection: &mut Connection,
8605        xid: Option<(u32, &[u8], &[u8])>,
8606        one_phase: bool,
8607    ) -> Result<()> {
8608        let runtime = build_io_runtime()?;
8609        runtime.block_on(async {
8610            let cx = Cx::current()
8611                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8612            connection.tpc_commit(&cx, xid, one_phase).await
8613        })
8614    }
8615
8616    pub fn tpc_rollback(
8617        connection: &mut Connection,
8618        xid: Option<(u32, &[u8], &[u8])>,
8619    ) -> Result<()> {
8620        let runtime = build_io_runtime()?;
8621        runtime.block_on(async {
8622            let cx = Cx::current()
8623                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8624            connection.tpc_rollback(&cx, xid).await
8625        })
8626    }
8627
8628    /// Blocking wrapper for [`Connection::query`]: bind typed Rust values
8629    /// positionally or by name, then return a blocking lazy row facade.
8630    pub fn query<'conn, 'p>(
8631        connection: &'conn mut Connection,
8632        sql: &str,
8633        params: impl Into<crate::Params<'p>>,
8634    ) -> Result<BlockingRows<'conn>> {
8635        block_on_io(|cx| async move {
8636            connection
8637                .query(&cx, sql, params)
8638                .await
8639                .map(BlockingRows::new)
8640        })
8641    }
8642
8643    /// Blocking wrapper for [`Connection::query_one`].
8644    pub fn query_one<'p>(
8645        connection: &mut Connection,
8646        sql: &str,
8647        params: impl Into<crate::Params<'p>>,
8648    ) -> Result<Row> {
8649        block_on_io(|cx| async move { connection.query_one(&cx, sql, params).await })
8650    }
8651
8652    /// Blocking wrapper for [`Connection::query_opt`].
8653    pub fn query_opt<'p>(
8654        connection: &mut Connection,
8655        sql: &str,
8656        params: impl Into<crate::Params<'p>>,
8657    ) -> Result<Option<Row>> {
8658        block_on_io(|cx| async move { connection.query_opt(&cx, sql, params).await })
8659    }
8660
8661    /// Blocking wrapper for [`Connection::query_all`].
8662    pub fn query_all<'p>(
8663        connection: &mut Connection,
8664        sql: &str,
8665        params: impl Into<crate::Params<'p>>,
8666    ) -> Result<Vec<Row>> {
8667        block_on_io(|cx| async move { connection.query_all(&cx, sql, params).await })
8668    }
8669
8670    /// Blocking wrapper for [`Connection::query_with`].
8671    pub fn query_with<'conn, 'q>(
8672        connection: &'conn mut Connection,
8673        query: Query<'q>,
8674    ) -> Result<BlockingRows<'conn>> {
8675        block_on_io(|cx| async move {
8676            connection
8677                .query_with(&cx, query)
8678                .await
8679                .map(BlockingRows::new)
8680        })
8681    }
8682
8683    /// Blocking wrapper for [`Connection::execute`].
8684    pub fn execute<'p>(
8685        connection: &mut Connection,
8686        sql: &str,
8687        params: impl Into<crate::Params<'p>>,
8688    ) -> Result<ExecuteOutcome> {
8689        block_on_io(|cx| async move { connection.execute(&cx, sql, params).await })
8690    }
8691
8692    /// Blocking wrapper for [`Connection::execute_with`].
8693    pub fn execute_with<'e>(
8694        connection: &mut Connection,
8695        execute: Execute<'e>,
8696    ) -> Result<ExecuteOutcome> {
8697        block_on_io(|cx| async move { connection.execute_with(&cx, execute).await })
8698    }
8699
8700    /// Blocking wrapper for [`Connection::execute_many`].
8701    pub fn execute_many<'b>(
8702        connection: &mut Connection,
8703        sql: &str,
8704        rows: impl Into<crate::BatchRows<'b>>,
8705    ) -> Result<BatchOutcome> {
8706        block_on_io(|cx| async move { connection.execute_many(&cx, sql, rows).await })
8707    }
8708
8709    /// Blocking wrapper for [`Connection::execute_many_with`].
8710    pub fn execute_many_with<'b>(
8711        connection: &mut Connection,
8712        batch: Batch<'b>,
8713    ) -> Result<BatchOutcome> {
8714        block_on_io(|cx| async move { connection.execute_many_with(&cx, batch).await })
8715    }
8716
8717    /// Blocking wrapper for [`Connection::execute_raw`]: the low-level raw
8718    /// execute primitive returning the unprojected [`QueryResult`], the
8719    /// execute-side counterpart to the retained blocking fetch primitives
8720    /// ([`Self::fetch_rows`], [`Self::define_and_fetch_rows_with_columns`],
8721    /// [`Self::scroll_cursor`], [`Self::fetch_cursor`]). Prefer the blocking
8722    /// operation families ([`Self::query`]/[`Self::execute`]/[`Self::execute_many`])
8723    /// for ordinary code; reach for `execute_raw` only when you need the raw
8724    /// wire result (statement-type-agnostic dispatch, parse-only describe, or
8725    /// per-bind-row OUT/RETURNING aggregation).
8726    pub fn execute_raw(
8727        connection: &mut Connection,
8728        sql: &str,
8729        prefetch_rows: u32,
8730        bind_rows: &[Vec<BindValue>],
8731        exec_options: ExecuteOptions,
8732        timeout_ms: Option<u32>,
8733    ) -> Result<QueryResult> {
8734        let runtime = build_io_runtime()?;
8735        runtime.block_on(async {
8736            let cx = Cx::current()
8737                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8738            connection
8739                .execute_raw(&cx, sql, prefetch_rows, bind_rows, exec_options, timeout_ms)
8740                .await
8741        })
8742    }
8743
8744    pub fn fetch_rows(
8745        connection: &mut Connection,
8746        cursor_id: u32,
8747        arraysize: u32,
8748        previous_row: Option<&[Option<oracledb_protocol::thin::QueryValue>]>,
8749    ) -> Result<QueryResult> {
8750        let runtime = build_io_runtime()?;
8751        runtime.block_on(async {
8752            let cx = Cx::current()
8753                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8754            connection
8755                .fetch_rows(&cx, cursor_id, arraysize, previous_row)
8756                .await
8757        })
8758    }
8759
8760    pub fn fetch_rows_with_columns(
8761        connection: &mut Connection,
8762        cursor_id: u32,
8763        arraysize: u32,
8764        known_columns: &[ColumnMetadata],
8765        previous_row: Option<&[Option<oracledb_protocol::thin::QueryValue>]>,
8766    ) -> Result<QueryResult> {
8767        let runtime = build_io_runtime()?;
8768        runtime.block_on(async {
8769            let cx = Cx::current()
8770                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8771            connection
8772                .fetch_rows_with_columns(&cx, cursor_id, arraysize, known_columns, previous_row)
8773                .await
8774        })
8775    }
8776
8777    pub fn define_and_fetch_rows_with_columns(
8778        connection: &mut Connection,
8779        cursor_id: u32,
8780        arraysize: u32,
8781        define_columns: &[ColumnMetadata],
8782        previous_row: Option<&[Option<oracledb_protocol::thin::QueryValue>]>,
8783    ) -> Result<QueryResult> {
8784        let runtime = build_io_runtime()?;
8785        runtime.block_on(async {
8786            let cx = Cx::current()
8787                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8788            connection
8789                .define_and_fetch_rows_with_columns(
8790                    &cx,
8791                    cursor_id,
8792                    arraysize,
8793                    define_columns,
8794                    previous_row,
8795                )
8796                .await
8797        })
8798    }
8799
8800    pub fn scroll_cursor(
8801        connection: &mut Connection,
8802        sql: &str,
8803        cursor_id: u32,
8804        arraysize: u32,
8805        fetch_orientation: u32,
8806        fetch_pos: u32,
8807    ) -> Result<QueryResult> {
8808        let runtime = build_io_runtime()?;
8809        runtime.block_on(async {
8810            let cx = Cx::current()
8811                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8812            connection
8813                .scroll_cursor(&cx, sql, cursor_id, arraysize, fetch_orientation, fetch_pos)
8814                .await
8815        })
8816    }
8817
8818    pub fn read_lob(
8819        connection: &mut Connection,
8820        locator: &[u8],
8821        offset: u64,
8822        amount: u64,
8823    ) -> Result<LobReadResult> {
8824        let runtime = build_io_runtime()?;
8825        runtime.block_on(async {
8826            let cx = Cx::current()
8827                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8828            connection.read_lob(&cx, locator, offset, amount).await
8829        })
8830    }
8831
8832    pub fn read_lob_with_timeout(
8833        connection: &mut Connection,
8834        locator: &[u8],
8835        offset: u64,
8836        amount: u64,
8837        timeout_ms: Option<u32>,
8838    ) -> Result<LobReadResult> {
8839        let runtime = build_io_runtime()?;
8840        runtime.block_on(async {
8841            let cx = Cx::current()
8842                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8843            connection
8844                .read_lob_call_timeout(&cx, locator, offset, amount, timeout_ms)
8845                .await
8846        })
8847    }
8848
8849    pub fn aq_enq_one(
8850        connection: &mut Connection,
8851        queue: &AqQueueDesc,
8852        props: &AqMsgProps,
8853        enq_options: &AqEnqOptions,
8854    ) -> Result<Option<Vec<u8>>> {
8855        let runtime = build_io_runtime()?;
8856        runtime.block_on(async {
8857            let cx = Cx::current()
8858                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8859            connection.aq_enq_one(&cx, queue, props, enq_options).await
8860        })
8861    }
8862
8863    pub fn aq_deq_one(
8864        connection: &mut Connection,
8865        queue: &AqQueueDesc,
8866        deq_options: &AqDeqOptions,
8867    ) -> Result<AqDeqResult> {
8868        let runtime = build_io_runtime()?;
8869        runtime.block_on(async {
8870            let cx = Cx::current()
8871                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8872            connection.aq_deq_one(&cx, queue, deq_options).await
8873        })
8874    }
8875
8876    pub fn aq_enq_many(
8877        connection: &mut Connection,
8878        queue: &AqQueueDesc,
8879        props_list: &[AqMsgProps],
8880        enq_options: &AqEnqOptions,
8881    ) -> Result<Vec<Vec<u8>>> {
8882        let runtime = build_io_runtime()?;
8883        runtime.block_on(async {
8884            let cx = Cx::current()
8885                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8886            connection
8887                .aq_enq_many(&cx, queue, props_list, enq_options)
8888                .await
8889        })
8890    }
8891
8892    pub fn aq_deq_many(
8893        connection: &mut Connection,
8894        queue: &AqQueueDesc,
8895        deq_options: &AqDeqOptions,
8896        max_num_messages: u32,
8897    ) -> Result<Vec<oracledb_protocol::thin::aq::AqDeqMessage>> {
8898        let runtime = build_io_runtime()?;
8899        runtime.block_on(async {
8900            let cx = Cx::current()
8901                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8902            connection
8903                .aq_deq_many(&cx, queue, deq_options, max_num_messages)
8904                .await
8905        })
8906    }
8907
8908    pub fn create_temp_lob(
8909        connection: &mut Connection,
8910        ora_type_num: u8,
8911        csfrm: u8,
8912    ) -> Result<LobReadResult> {
8913        let runtime = build_io_runtime()?;
8914        runtime.block_on(async {
8915            let cx = Cx::current()
8916                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8917            connection.create_temp_lob(&cx, ora_type_num, csfrm).await
8918        })
8919    }
8920
8921    pub fn write_lob(
8922        connection: &mut Connection,
8923        locator: &[u8],
8924        offset: u64,
8925        data: &[u8],
8926    ) -> Result<LobReadResult> {
8927        let runtime = build_io_runtime()?;
8928        runtime.block_on(async {
8929            let cx = Cx::current()
8930                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8931            connection.write_lob(&cx, locator, offset, data).await
8932        })
8933    }
8934
8935    pub fn trim_lob(
8936        connection: &mut Connection,
8937        locator: &[u8],
8938        new_size: u64,
8939    ) -> Result<LobReadResult> {
8940        let runtime = build_io_runtime()?;
8941        runtime.block_on(async {
8942            let cx = Cx::current()
8943                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8944            connection.trim_lob(&cx, locator, new_size).await
8945        })
8946    }
8947
8948    pub fn write_lob_with_timeout(
8949        connection: &mut Connection,
8950        locator: &[u8],
8951        offset: u64,
8952        data: &[u8],
8953        timeout_ms: Option<u32>,
8954    ) -> Result<LobReadResult> {
8955        let runtime = build_io_runtime()?;
8956        runtime.block_on(async {
8957            let cx = Cx::current()
8958                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8959            connection
8960                .write_lob_call_timeout(&cx, locator, offset, data, timeout_ms)
8961                .await
8962        })
8963    }
8964
8965    pub fn trim_lob_with_timeout(
8966        connection: &mut Connection,
8967        locator: &[u8],
8968        new_size: u64,
8969        timeout_ms: Option<u32>,
8970    ) -> Result<LobReadResult> {
8971        let runtime = build_io_runtime()?;
8972        runtime.block_on(async {
8973            let cx = Cx::current()
8974                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8975            connection
8976                .trim_lob_call_timeout(&cx, locator, new_size, timeout_ms)
8977                .await
8978        })
8979    }
8980
8981    pub fn free_temp_lobs(connection: &mut Connection, locators: &[Vec<u8>]) -> Result<()> {
8982        let runtime = build_io_runtime()?;
8983        runtime.block_on(async {
8984            let cx = Cx::current()
8985                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8986            connection.free_temp_lobs(&cx, locators).await
8987        })
8988    }
8989
8990    pub fn free_temp_lobs_with_timeout(
8991        connection: &mut Connection,
8992        locators: &[Vec<u8>],
8993        timeout_ms: Option<u32>,
8994    ) -> Result<()> {
8995        let runtime = build_io_runtime()?;
8996        runtime.block_on(async {
8997            let cx = Cx::current()
8998                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
8999            connection
9000                .free_temp_lobs_call_timeout(&cx, locators, timeout_ms)
9001                .await
9002        })
9003    }
9004
9005    pub fn direct_path_load(
9006        connection: &mut Connection,
9007        schema_name: &str,
9008        table_name: &str,
9009        column_names: &[String],
9010        rows: &[Vec<oracledb_protocol::dpl::DirectPathColumnValue>],
9011        batch_size: u32,
9012    ) -> Result<()> {
9013        let runtime = build_io_runtime()?;
9014        runtime.block_on(async {
9015            let cx = Cx::current()
9016                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
9017            connection
9018                .direct_path_load(&cx, schema_name, table_name, column_names, rows, batch_size)
9019                .await
9020        })
9021    }
9022
9023    pub fn run_pipeline(
9024        connection: &mut Connection,
9025        requests: &[PipelineRequest],
9026        continue_on_error: bool,
9027    ) -> Result<Vec<Vec<u8>>> {
9028        let runtime = build_io_runtime()?;
9029        runtime.block_on(async {
9030            let cx = Cx::current()
9031                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
9032            connection
9033                .run_pipeline(&cx, requests, continue_on_error)
9034                .await
9035        })
9036    }
9037
9038    pub fn run_pipeline_decoded(
9039        connection: &mut Connection,
9040        requests: &[PipelineRequest],
9041        continue_on_error: bool,
9042    ) -> Result<Vec<Result<QueryResult>>> {
9043        let runtime = build_io_runtime()?;
9044        runtime.block_on(async {
9045            let cx = Cx::current()
9046                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
9047            connection
9048                .run_pipeline_decoded(&cx, requests, continue_on_error)
9049                .await
9050        })
9051    }
9052
9053    pub fn direct_path_prepare(
9054        connection: &mut Connection,
9055        schema_name: &str,
9056        table_name: &str,
9057        column_names: &[String],
9058    ) -> Result<oracledb_protocol::dpl::DirectPathPrepareResult> {
9059        let runtime = build_io_runtime()?;
9060        runtime.block_on(async {
9061            let cx = Cx::current()
9062                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
9063            connection
9064                .direct_path_prepare(&cx, schema_name, table_name, column_names)
9065                .await
9066        })
9067    }
9068
9069    pub fn direct_path_load_prepared(
9070        connection: &mut Connection,
9071        prepare: &oracledb_protocol::dpl::DirectPathPrepareResult,
9072        rows: &[Vec<oracledb_protocol::dpl::DirectPathColumnValue>],
9073        batch_size: u32,
9074    ) -> Result<()> {
9075        let runtime = build_io_runtime()?;
9076        runtime.block_on(async {
9077            let cx = Cx::current()
9078                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
9079            connection
9080                .direct_path_load_prepared(&cx, prepare, rows, batch_size)
9081                .await
9082        })
9083    }
9084
9085    #[doc(hidden)]
9086    pub fn __pyshim_drain_cancel_response(connection: &mut Connection) -> Result<()> {
9087        let runtime = build_io_runtime()?;
9088        runtime.block_on(async { connection.drain_cancel_response().await })
9089    }
9090
9091    /// Reconcile recovery after a blocking pyshim call received the complete
9092    /// ORA-01013 response triggered by a concurrent [`CancelHandle`].
9093    ///
9094    /// This is intentionally hidden: the caller must have parsed the complete
9095    /// TTC response as a structured ORA-01013 before invoking it. At that point
9096    /// `read_response_cancellable` has completed and disarmed its read guard,
9097    /// so there is nothing left to drain; only the handle's `BreakSent` phase
9098    /// remains. Resetting that phase makes the clean connection reusable.
9099    #[doc(hidden)]
9100    pub fn __pyshim_reconcile_completed_cancel_response(connection: &mut Connection) -> Result<()> {
9101        match connection.core.recovery.phase() {
9102            SessionRecoveryPhase::Ready => Ok(()),
9103            SessionRecoveryPhase::BreakSent => {
9104                connection.core.recovery.finish_drain_ready();
9105                Ok(())
9106            }
9107            SessionRecoveryPhase::InFlight | SessionRecoveryPhase::Draining => Err(
9108                Error::ConnectionClosed("cancel response is not complete".into()),
9109            ),
9110            SessionRecoveryPhase::Dead => {
9111                Err(Error::ConnectionClosed("connection is closed".into()))
9112            }
9113        }
9114    }
9115
9116    /// Blocking wrapper for [`Connection::supplement_json_column_metadata`].
9117    pub fn supplement_json_column_metadata(
9118        connection: &mut Connection,
9119        columns: &mut [ColumnMetadata],
9120        timeout_ms: Option<u32>,
9121    ) -> Result<()> {
9122        let runtime = build_io_runtime()?;
9123        runtime.block_on(async {
9124            let cx = Cx::current()
9125                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
9126            connection
9127                .supplement_json_column_metadata(&cx, columns, timeout_ms)
9128                .await
9129        })
9130    }
9131
9132    pub fn close(connection: Connection) -> Result<()> {
9133        let runtime = build_io_runtime()?;
9134        runtime.block_on(async {
9135            let cx = Cx::current()
9136                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
9137            connection.close(&cx).await
9138        })
9139    }
9140}
9141
9142/// Construct a fresh single-threaded Asupersync runtime with a native reactor.
9143///
9144/// This is the heavy path: it creates an epoll reactor and spawns a worker OS
9145/// thread. It is only called once per thread by [`io_runtime`], which caches
9146/// the result; callers should use [`build_io_runtime`] (the cached accessor)
9147/// rather than this directly.
9148fn new_io_runtime() -> Result<Runtime> {
9149    let reactor = reactor::create_reactor()?;
9150    RuntimeBuilder::current_thread()
9151        .with_reactor(reactor)
9152        .build()
9153        .map_err(|err| Error::Runtime(err.to_string()))
9154}
9155
9156/// Build a fresh single-thread asupersync runtime owned by a connection pool.
9157///
9158/// Unlike [`build_io_runtime`], which returns a thread-local runtime reused for
9159/// every blocking-facade call on a thread, the pool needs a *persistent, owned*
9160/// runtime whose worker thread hosts the region-owned reaper task for the pool's
9161/// whole lifetime. Each call returns a brand-new runtime; the pool stores it and
9162/// drops it (shutting the reaper down) when the last pool handle is dropped.
9163///
9164/// The worker thread carries the `oracledb-pool-bg` name prefix (the same name
9165/// the old detached worker used) so it is identifiable in stack dumps and so the
9166/// pool's threads are distinguishable from the shared blocking-facade runtime's.
9167pub(crate) fn new_pool_runtime() -> Result<Runtime> {
9168    let reactor = reactor::create_reactor()?;
9169    RuntimeBuilder::current_thread()
9170        .with_reactor(reactor)
9171        .thread_name_prefix("oracledb-pool-bg")
9172        .build()
9173        .map_err(|err| Error::Runtime(err.to_string()))
9174}
9175
9176thread_local! {
9177    /// One blocking-facade runtime per calling thread, built lazily on first
9178    /// use and reused for every subsequent `BlockingConnection` /
9179    /// `CancelHandle` call on that thread.
9180    ///
9181    /// The previous behaviour built a brand-new runtime — a fresh epoll reactor
9182    /// plus a worker OS thread that is spawned and immediately joined — on every
9183    /// single call. For the synchronous facade, which the PyO3 shim drives for
9184    /// every suite operation, that fixed per-call cost dominated cheap
9185    /// operations like `select 1 from dual`. Caching the runtime per thread
9186    /// removes that overhead from every call after the first.
9187    ///
9188    /// Correctness is preserved: each `Runtime::block_on` still installs a fresh
9189    /// request-scoped `Cx` (with `Budget::INFINITE`) and runtime/Cx guards for
9190    /// the duration of the polled future, so cancellation and context semantics
9191    /// are unchanged. The connection's socket re-registers (`rearm`) with the
9192    /// persistent reactor on each call exactly as Asupersync's owned TCP halves
9193    /// are designed to; this is strictly less work than dropping and rebuilding
9194    /// a reactor every call. The runtime is current-thread, so it never crosses
9195    /// threads, and it lives for the thread's lifetime.
9196    static IO_RUNTIME: std::cell::RefCell<Option<Runtime>> =
9197        const { std::cell::RefCell::new(None) };
9198}
9199
9200/// Return this thread's cached blocking-facade runtime, building it on first
9201/// use. The returned `Runtime` is a cheap `Arc`-backed clone of the cached
9202/// instance; cloning does not spawn threads or create reactors. Behaviourally
9203/// equivalent to constructing a runtime per call, minus the per-call build cost.
9204fn build_io_runtime() -> Result<Runtime> {
9205    IO_RUNTIME.with(|slot| {
9206        if let Some(runtime) = slot.borrow().as_ref() {
9207            return Ok(runtime.clone());
9208        }
9209        let runtime = new_io_runtime()?;
9210        *slot.borrow_mut() = Some(runtime.clone());
9211        Ok(runtime)
9212    })
9213}
9214
9215/// Run a blocking-facade operation on this thread's cached I/O runtime,
9216/// passing it the ambient [`Cx`] installed by [`Runtime::block_on`].
9217pub(crate) fn block_on_io<F, Fut, T>(operation: F) -> Result<T>
9218where
9219    F: FnOnce(Cx) -> Fut,
9220    Fut: std::future::Future<Output = Result<T>>,
9221{
9222    let runtime = build_io_runtime()?;
9223    runtime.block_on(async {
9224        let cx = Cx::current()
9225            .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
9226        operation(cx).await
9227    })
9228}
9229
9230/// Runs a connection future to completion on a blocking runtime, passing it the
9231/// ambient [`Cx`] (shared shape of the `BlockingConnection` wrappers).
9232#[cfg(feature = "arrow")]
9233pub(crate) fn block_on_connection<F, Fut, T>(operation: F) -> Result<T>
9234where
9235    F: FnOnce(Cx) -> Fut,
9236    Fut: std::future::Future<Output = Result<T>>,
9237{
9238    block_on_io(operation)
9239}
9240
9241#[derive(Clone, Debug, Eq, PartialEq)]
9242struct IncomingPacket {
9243    packet_type: u8,
9244    packet_flags: u8,
9245    payload: Vec<u8>,
9246}
9247
9248async fn lock_write<W>(
9249    cx: &Cx,
9250    write: &Arc<AsyncMutex<W>>,
9251) -> Result<asupersync::sync::OwnedMutexGuard<W>>
9252where
9253    W: AsyncWrite + std::fmt::Debug + Unpin,
9254{
9255    // Borrowed mutex guards have been !Send since asupersync 0.3.5 because their
9256    // thread-local lock-order state must be released on the acquiring thread.
9257    // A write may await I/O, so retain the connection's Send future contract
9258    // with an Arc-backed owned guard while preserving write serialization.
9259    asupersync::sync::OwnedMutexGuard::lock(Arc::clone(write), cx)
9260        .await
9261        .map_err(|err| Error::Runtime(err.to_string()))
9262}
9263
9264async fn write_all_shared<W>(cx: &Cx, write: &Arc<AsyncMutex<W>>, packet: &[u8]) -> Result<()>
9265where
9266    W: AsyncWrite + std::fmt::Debug + Unpin,
9267{
9268    let mut guard = lock_write(cx, write).await?;
9269    guard.write_all(packet).await?;
9270    guard.flush().await?;
9271    Ok(())
9272}
9273
9274async fn shutdown_write_shared<W>(cx: &Cx, write: &Arc<AsyncMutex<W>>) -> Result<()>
9275where
9276    W: AsyncWrite + std::fmt::Debug + Unpin,
9277{
9278    let mut guard = lock_write(cx, write).await?;
9279    guard.shutdown().await?;
9280    Ok(())
9281}
9282
9283async fn send_data_packet_shared<W>(
9284    cx: &Cx,
9285    write: &Arc<AsyncMutex<W>>,
9286    payload: &[u8],
9287    sdu: usize,
9288) -> Result<()>
9289where
9290    W: AsyncWrite + std::fmt::Debug + Unpin,
9291{
9292    let mut guard = lock_write(cx, write).await?;
9293    send_data_packet(&mut *guard, payload, sdu).await
9294}
9295
9296async fn send_data_packet_shared_with_width<W>(
9297    cx: &Cx,
9298    write: &Arc<AsyncMutex<W>>,
9299    payload: &[u8],
9300    sdu: usize,
9301    width: PacketLengthWidth,
9302) -> Result<()>
9303where
9304    W: AsyncWrite + std::fmt::Debug + Unpin,
9305{
9306    let mut guard = lock_write(cx, write).await?;
9307    send_data_packet_with_flags_and_width(&mut *guard, payload, sdu, 0, 0, width).await
9308}
9309
9310async fn send_data_packet_shared_with_flags<W>(
9311    cx: &Cx,
9312    write: &Arc<AsyncMutex<W>>,
9313    payload: &[u8],
9314    sdu: usize,
9315    first_packet_flags: u16,
9316    last_packet_flags: u16,
9317) -> Result<()>
9318where
9319    W: AsyncWrite + std::fmt::Debug + Unpin,
9320{
9321    let mut guard = lock_write(cx, write).await?;
9322    send_data_packet_with_flags(
9323        &mut *guard,
9324        payload,
9325        sdu,
9326        first_packet_flags,
9327        last_packet_flags,
9328    )
9329    .await
9330}
9331
9332async fn send_marker_shared<W>(cx: &Cx, write: &Arc<AsyncMutex<W>>, marker_type: u8) -> Result<()>
9333where
9334    W: AsyncWrite + std::fmt::Debug + Unpin,
9335{
9336    let mut guard = lock_write(cx, write).await?;
9337    send_marker(&mut *guard, marker_type).await
9338}
9339
9340fn lock_write_for_recovery<W>(
9341    write: &Arc<AsyncMutex<W>>,
9342) -> Result<asupersync::sync::OwnedMutexGuard<W>>
9343where
9344    W: AsyncWrite + std::fmt::Debug + Unpin,
9345{
9346    asupersync::sync::OwnedMutexGuard::try_lock(Arc::clone(write)).map_err(|err| match err {
9347        asupersync::sync::TryLockError::Locked => Error::ConnectionClosed(
9348            "write lock unavailable while recovering from cancellation".into(),
9349        ),
9350        asupersync::sync::TryLockError::Poisoned => {
9351            Error::ConnectionClosed("write lock poisoned while recovering from cancellation".into())
9352        }
9353    })
9354}
9355
9356pub(crate) async fn send_marker_recovery<W>(
9357    write: &Arc<AsyncMutex<W>>,
9358    marker_type: u8,
9359) -> Result<()>
9360where
9361    W: AsyncWrite + std::fmt::Debug + Unpin,
9362{
9363    let mut guard = lock_write_for_recovery(write)?;
9364    send_marker(&mut *guard, marker_type).await
9365}
9366
9367async fn send_data_packet<W>(stream: &mut W, payload: &[u8], sdu: usize) -> Result<()>
9368where
9369    W: AsyncWrite + Unpin,
9370{
9371    send_data_packet_with_flags(stream, payload, sdu, 0, 0).await
9372}
9373
9374/// Sends a TTC payload as one or more data packets, applying
9375/// `first_packet_flags` to the first packet and `last_packet_flags` to the
9376/// last (combined when the payload fits a single packet) -- the WriteBuffer
9377/// `_data_flags` semantics the pipeline framing relies on (BEGIN_PIPELINE on
9378/// the packet carrying the begin piggyback, END_OF_REQUEST on a message's
9379/// final packet).
9380async fn send_data_packet_with_flags<W>(
9381    stream: &mut W,
9382    payload: &[u8],
9383    sdu: usize,
9384    first_packet_flags: u16,
9385    last_packet_flags: u16,
9386) -> Result<()>
9387where
9388    W: AsyncWrite + Unpin,
9389{
9390    send_data_packet_with_flags_and_width(
9391        stream,
9392        payload,
9393        sdu,
9394        first_packet_flags,
9395        last_packet_flags,
9396        PacketLengthWidth::Large32,
9397    )
9398    .await
9399}
9400
9401async fn send_data_packet_with_flags_and_width<W>(
9402    stream: &mut W,
9403    payload: &[u8],
9404    sdu: usize,
9405    first_packet_flags: u16,
9406    last_packet_flags: u16,
9407    width: PacketLengthWidth,
9408) -> Result<()>
9409where
9410    W: AsyncWrite + Unpin,
9411{
9412    let max_payload = sdu.saturating_sub(TNS_DATA_PACKET_OVERHEAD).max(1);
9413    let chunk_count = payload.chunks(max_payload).len();
9414    for (index, chunk) in payload.chunks(max_payload).enumerate() {
9415        let mut flags = 0u16;
9416        if index == 0 {
9417            flags |= first_packet_flags;
9418        }
9419        if index + 1 == chunk_count {
9420            flags |= last_packet_flags;
9421        }
9422        let packet = encode_packet(TNS_PACKET_TYPE_DATA, 0, Some(flags), chunk, width)?;
9423        stream.write_all(&packet).await?;
9424    }
9425    // The full request payload is now written to the socket. A cancellation that
9426    // lands on the trailing flush (asupersync's `poll_flush` also returns
9427    // `Io(Interrupted,"cancelled")`) has NOT lost the request — the bytes are
9428    // already on the wire, and flush is a formality on an already-buffered
9429    // socket. Swallow that specific cancellation so the round-trip proceeds to
9430    // its cancellation-aware read, which surfaces the typed `Error::Cancelled`
9431    // and arms break-and-drain recovery. Without this, a cancel racing the flush
9432    // leaked a raw `Io(Interrupted)` from the write path, bypassing the read-side
9433    // cancellation typing entirely. Any other flush error still propagates.
9434    match stream.flush().await {
9435        Ok(()) => {}
9436        Err(err) if is_asupersync_cancellation(&err) => {}
9437        Err(err) => return Err(err.into()),
9438    }
9439    Ok(())
9440}
9441
9442struct DataResponse {
9443    payload: Vec<u8>,
9444    flush_out_binds: bool,
9445}
9446
9447#[cfg(test)]
9448async fn read_data_response<R, W>(
9449    read: &mut R,
9450    cx: &Cx,
9451    write: &Arc<AsyncMutex<W>>,
9452) -> Result<Vec<u8>>
9453where
9454    R: AsyncRead + Unpin,
9455    W: AsyncWrite + std::fmt::Debug + Unpin,
9456{
9457    read_data_response_with_limits(read, cx, write, ProtocolLimits::DEFAULT).await
9458}
9459
9460async fn read_data_response_with_limits<R, W>(
9461    read: &mut R,
9462    cx: &Cx,
9463    write: &Arc<AsyncMutex<W>>,
9464    limits: ProtocolLimits,
9465) -> Result<Vec<u8>>
9466where
9467    R: AsyncRead + Unpin,
9468    W: AsyncWrite + std::fmt::Debug + Unpin,
9469{
9470    Ok(
9471        read_data_response_boundary_with_limits(read, cx, write, false, limits)
9472            .await?
9473            .payload,
9474    )
9475}
9476
9477const ASUPERSYNC_TLS_MISSING_CLOSE_NOTIFY: &str = "tls connection closed without close_notify";
9478
9479#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9480enum SessionCloseDisposition {
9481    ResponseComplete,
9482    PeerHardClosed,
9483}
9484
9485fn is_missing_tls_close_notify(error: &std::io::Error) -> bool {
9486    error.kind() == std::io::ErrorKind::UnexpectedEof
9487        && error.to_string() == ASUPERSYNC_TLS_MISSING_CLOSE_NOTIFY
9488}
9489
9490/// Read adapter for the one terminal LOGOFF response.
9491///
9492/// It converts Asupersync's exact missing-`close_notify` signal into ordinary
9493/// EOF only when no application byte has yet been delivered through the
9494/// adapter. The surrounding response reader then reports its usual short-read
9495/// error, which [`read_session_close_response_with_limits`] recognizes via the
9496/// flag below. Once even one byte has arrived, the signal passes through
9497/// unchanged so a partial TNS header or payload can never look like a clean
9498/// session close.
9499struct SessionCloseRead<'a, R> {
9500    inner: &'a mut R,
9501    application_bytes: usize,
9502    missing_close_notify_at_boundary: bool,
9503}
9504
9505impl<'a, R> SessionCloseRead<'a, R> {
9506    fn new(inner: &'a mut R) -> Self {
9507        Self {
9508            inner,
9509            application_bytes: 0,
9510            missing_close_notify_at_boundary: false,
9511        }
9512    }
9513}
9514
9515impl<R> AsyncRead for SessionCloseRead<'_, R>
9516where
9517    R: AsyncRead + Unpin,
9518{
9519    fn poll_read(
9520        self: std::pin::Pin<&mut Self>,
9521        context: &mut std::task::Context<'_>,
9522        buffer: &mut asupersync::io::ReadBuf<'_>,
9523    ) -> std::task::Poll<std::io::Result<()>> {
9524        let this = self.get_mut();
9525        let filled_before = buffer.filled().len();
9526        match std::pin::Pin::new(&mut *this.inner).poll_read(context, buffer) {
9527            std::task::Poll::Ready(Ok(())) => {
9528                this.application_bytes += buffer.filled().len() - filled_before;
9529                std::task::Poll::Ready(Ok(()))
9530            }
9531            std::task::Poll::Ready(Err(error))
9532                if this.application_bytes == 0 && is_missing_tls_close_notify(&error) =>
9533            {
9534                this.missing_close_notify_at_boundary = true;
9535                std::task::Poll::Ready(Ok(()))
9536            }
9537            other => other,
9538        }
9539    }
9540}
9541
9542/// Read the terminal LOGOFF response while accepting Oracle's bare TCP close
9543/// at the empty response boundary. Every ordinary operation, including the
9544/// pre-ACCEPT connect phase, continues to use the strict readers above/below.
9545async fn read_session_close_response_with_limits<R, W, P>(
9546    read: &mut R,
9547    cx: &Cx,
9548    write: &Arc<AsyncMutex<W>>,
9549    classic: bool,
9550    probe: &P,
9551    limits: ProtocolLimits,
9552) -> Result<SessionCloseDisposition>
9553where
9554    R: AsyncRead + Unpin,
9555    W: AsyncWrite + std::fmt::Debug + Unpin,
9556    P: Fn(&[u8]) -> bool,
9557{
9558    let mut close_read = SessionCloseRead::new(read);
9559    let result = if classic {
9560        read_classic_data_response_probed_with_limits(&mut close_read, cx, write, probe, limits)
9561            .await
9562            .map(|_| SessionCloseDisposition::ResponseComplete)
9563    } else {
9564        read_data_response_with_limits(&mut close_read, cx, write, limits)
9565            .await
9566            .map(|_| SessionCloseDisposition::ResponseComplete)
9567    };
9568    match result {
9569        Err(_) if close_read.missing_close_notify_at_boundary => {
9570            Ok(SessionCloseDisposition::PeerHardClosed)
9571        }
9572        other => other,
9573    }
9574}
9575
9576/// Accumulates DATA packets for one classic (pre-END_OF_RESPONSE)
9577/// connect-phase response until the payload's terminal message is complete.
9578/// Used only for the pre-23ai protocol-negotiation / data-types / auth round
9579/// trips, where END_OF_RESPONSE framing is not negotiated and completion is
9580/// message-driven (reference messages/base.pyx `Message.process`).
9581///
9582/// MARKER packets run the same reset dance as the post-connect readers: a
9583/// pre-23ai server answers a failed classic login (e.g. wrong password) with
9584/// a break MARKER *before* the ERROR response, so without the reset exchange
9585/// the caller would surface a misleading `UnexpectedPacket(MARKER)` instead
9586/// of the real ORA-01017 (reference packet.pyx: markers are handled uniformly
9587/// on every read, including the connect-phase auth round trips).
9588async fn read_classic_data_response_with_limits<R, W>(
9589    read: &mut R,
9590    cx: &Cx,
9591    write: &Arc<AsyncMutex<W>>,
9592    limits: ProtocolLimits,
9593) -> Result<Vec<u8>>
9594where
9595    R: AsyncRead + Unpin,
9596    W: AsyncWrite + std::fmt::Debug + Unpin,
9597{
9598    let mut response = Vec::new();
9599    let mut pending_packet: Option<IncomingPacket> = None;
9600    let mut after_reset = false;
9601    loop {
9602        let packet = match pending_packet.take() {
9603            Some(packet) => packet,
9604            None => read_packet_with_limits(read, PacketLengthWidth::Large32, limits).await?,
9605        };
9606        if packet.packet_type == TNS_PACKET_TYPE_MARKER {
9607            pending_packet =
9608                reset_after_marker_with_limits(read, cx, write, &packet, limits).await?;
9609            after_reset = true;
9610            continue;
9611        }
9612        if packet.packet_type != TNS_PACKET_TYPE_DATA {
9613            return Err(Error::UnexpectedPacket(packet.packet_type));
9614        }
9615        let payload =
9616            packet
9617                .payload
9618                .get(2..)
9619                .ok_or(oracledb_protocol::ProtocolError::TtcDecode(
9620                    "missing data packet flags",
9621                ))?;
9622        let combined = response.len().checked_add(payload.len()).ok_or(
9623            oracledb_protocol::ProtocolError::ResourceLimit {
9624                limit: "response_bytes",
9625                observed: usize::MAX,
9626                maximum: limits.max_response_bytes,
9627            },
9628        )?;
9629        limits.check_response_bytes(combined)?;
9630        response.extend_from_slice(payload);
9631        if (after_reset && post_reset_packet_ends_response(payload))
9632            || classic_connect_response_is_complete(&response, limits)?
9633        {
9634            return Ok(response);
9635        }
9636    }
9637}
9638
9639/// Probe predicate for classic (pre-END_OF_RESPONSE) response reassembly:
9640/// whether a parse attempt over the accumulated payload says the response is
9641/// complete. `TtcDecode` is the decoder's "ran out of bytes / short read"
9642/// error — the response needs more packets. ANY other outcome means the
9643/// parser consumed a full response: `Ok` is the happy path, a
9644/// `ServerError`/`ServerErrorInfo` means the terminal ERROR message was
9645/// reached (the caller's real parse will surface it), and structural errors
9646/// (`UnknownMessageType`, `ResourceLimit`, ...) are returned to the caller by
9647/// its real parse instead of hanging the read loop forever.
9648fn response_complete<T>(result: &oracledb_protocol::Result<T>) -> bool {
9649    !matches!(result, Err(oracledb_protocol::ProtocolError::TtcDecode(_)))
9650}
9651
9652/// Accumulates DATA packets for one classic (pre-END_OF_RESPONSE) post-connect
9653/// response, deciding completion with the caller's `probe` over the
9654/// accumulated payload (see
9655/// [`ConnectionCore::read_data_response_probed`]). MARKER packets run the
9656/// same reset dance as [`read_data_response_boundary_seeded`]; after a reset
9657/// the terminal-message-byte relaxation ([`post_reset_packet_ends_response`])
9658/// also ends the response, exactly like the flag-framed reader.
9659async fn read_classic_data_response_probed_with_limits<R, W, P>(
9660    read: &mut R,
9661    cx: &Cx,
9662    write: &Arc<AsyncMutex<W>>,
9663    probe: &P,
9664    limits: ProtocolLimits,
9665) -> Result<Vec<u8>>
9666where
9667    R: AsyncRead + Unpin,
9668    W: AsyncWrite + std::fmt::Debug + Unpin,
9669    P: Fn(&[u8]) -> bool,
9670{
9671    let mut response = Vec::new();
9672    read_classic_data_response_probed_into(read, cx, write, probe, limits, &mut response).await?;
9673    Ok(response)
9674}
9675
9676/// The reassembly loop of [`read_classic_data_response_probed_with_limits`],
9677/// appending onto an existing buffer so the flush-out-binds continuation can
9678/// probe the COMBINED payload (the parser always parses from the response
9679/// start).
9680async fn read_classic_data_response_probed_into<R, W, P>(
9681    read: &mut R,
9682    cx: &Cx,
9683    write: &Arc<AsyncMutex<W>>,
9684    probe: &P,
9685    limits: ProtocolLimits,
9686    response: &mut Vec<u8>,
9687) -> Result<()>
9688where
9689    R: AsyncRead + Unpin,
9690    W: AsyncWrite + std::fmt::Debug + Unpin,
9691    P: Fn(&[u8]) -> bool,
9692{
9693    let mut pending_packet: Option<IncomingPacket> = None;
9694    let mut after_reset = false;
9695    loop {
9696        let packet = match pending_packet.take() {
9697            Some(packet) => packet,
9698            None => read_packet_with_limits(read, PacketLengthWidth::Large32, limits).await?,
9699        };
9700        if packet.packet_type == TNS_PACKET_TYPE_MARKER {
9701            pending_packet =
9702                reset_after_marker_with_limits(read, cx, write, &packet, limits).await?;
9703            after_reset = true;
9704            continue;
9705        }
9706        if packet.packet_type != TNS_PACKET_TYPE_DATA {
9707            return Err(Error::UnexpectedPacket(packet.packet_type));
9708        }
9709        let payload =
9710            packet
9711                .payload
9712                .get(2..)
9713                .ok_or(oracledb_protocol::ProtocolError::TtcDecode(
9714                    "missing data packet flags",
9715                ))?;
9716        let combined = response.len().checked_add(payload.len()).ok_or(
9717            oracledb_protocol::ProtocolError::ResourceLimit {
9718                limit: "response_bytes",
9719                observed: usize::MAX,
9720                maximum: limits.max_response_bytes,
9721            },
9722        )?;
9723        limits.check_response_bytes(combined)?;
9724        response.extend_from_slice(payload);
9725        if (after_reset && post_reset_packet_ends_response(payload)) || probe(response) {
9726            return Ok(());
9727        }
9728    }
9729}
9730
9731/// Classic (pre-END_OF_RESPONSE) counterpart of
9732/// [`read_data_response_flushing_out_binds_with_limits`]: reads one probed
9733/// response and, while it ends at a FLUSH_OUT_BINDS request (terminal message
9734/// byte, same detection as the flag-framed path), answers it and keeps
9735/// accumulating — probing the combined payload — until the real response is
9736/// complete.
9737async fn read_classic_data_response_flushing_out_binds_probed_with_limits<R, W, P>(
9738    read: &mut R,
9739    cx: &Cx,
9740    write: &Arc<AsyncMutex<W>>,
9741    sdu: usize,
9742    probe: &P,
9743    limits: ProtocolLimits,
9744) -> Result<Vec<u8>>
9745where
9746    R: AsyncRead + Unpin,
9747    W: AsyncWrite + std::fmt::Debug + Unpin,
9748    P: Fn(&[u8]) -> bool,
9749{
9750    let mut payload = Vec::new();
9751    read_classic_data_response_probed_into(read, cx, write, probe, limits, &mut payload).await?;
9752    while matches!(payload.last(), Some(&TNS_MSG_TYPE_FLUSH_OUT_BINDS)) {
9753        observe_cancellation_between_round_trips(cx)?;
9754        payload.pop();
9755        send_data_packet_shared(cx, write, &[TNS_MSG_TYPE_FLUSH_OUT_BINDS], sdu).await?;
9756        read_classic_data_response_probed_into(read, cx, write, probe, limits, &mut payload)
9757            .await?;
9758    }
9759    Ok(payload)
9760}
9761
9762/// Upper bound on how long the post-break recovery drain may take before the
9763/// driver gives up and declares the connection dead. Mirrors the reference's
9764/// "second timeout while recovering" disconnect (protocol.pyx:454-458): the
9765/// first timeout was the user's `call_timeout`; this guards the *recovery*
9766/// read so a server that never answers the BREAK cannot hang the caller
9767/// forever. Reuses the same 5 s ceiling as [`Connection::drain_cancel_response`].
9768const BREAK_DRAIN_RECOVERY_TIMEOUT: Duration = Duration::from_secs(5);
9769
9770/// Sends a BREAK marker and then **drains** the server's entire break response
9771/// so the wire stream is left at a clean message boundary, exactly as
9772/// python-oracledb does on a call timeout (`_break_external()` then
9773/// `_receive_packet()` / `_reset()`, protocol.pyx:449-451, 507-557).
9774///
9775/// The break response is multi-stage and racy on the wire (confirmed by live
9776/// trace against Oracle 23/26ai): when the server is mid-call it may flush the
9777/// **in-flight response** of the timed-out call *first* — a complete DATA
9778/// response carrying its own end-of-response flag — and only *then* send the
9779/// break-acknowledge **MARKER**, the **RESET** handshake, and the **trailing
9780/// error packet** (ORA-01013 "user requested cancel"). A naive
9781/// `read_data_response` stops at the in-flight response's end-of-response and
9782/// leaves the MARKER + ORA-01013 in the socket, where the *next* operation
9783/// misreads them (it surfaces ORA-01013 / desyncs). The reference avoids this
9784/// because its `_reset()` is what clears `_break_in_progress`, and it always
9785/// runs `_reset()` (consuming the MARKER, the RESET marker, and the trailing
9786/// error packet) before the connection is considered recovered.
9787///
9788/// So this drain does NOT stop at the first end-of-response: it discards any
9789/// in-flight DATA responses until it meets the break-acknowledge MARKER, runs
9790/// the RESET dance via [`reset_after_marker`] (send RESET, discard packets until
9791/// the server RESET marker), and then consumes the trailing error response to
9792/// its end-of-response boundary. Everything read here is discarded — it is the
9793/// dead remains of the cancelled call, not a result for any caller.
9794///
9795/// On success (`Ok(())`) the stream is clean and the connection is reusable. If
9796/// the drain errors or its bounded *secondary* timeout fires, the wire could not
9797/// be left clean, so the connection must be discarded — the error is surfaced as
9798/// [`Error::ConnectionClosed`], which is [`Error::is_connection_lost`].
9799#[allow(dead_code)]
9800async fn break_and_drain_wire<R, W>(
9801    read: &mut R,
9802    write: &Arc<AsyncMutex<W>>,
9803    recovery_timeout: Duration,
9804) -> Result<()>
9805where
9806    R: AsyncRead + Unpin,
9807    W: AsyncWrite + std::fmt::Debug + Unpin,
9808{
9809    let result = time::timeout(
9810        time::wall_now(),
9811        recovery_timeout,
9812        break_and_drain_wire_unbounded(read, write),
9813    )
9814    .await
9815    .ok();
9816    classify_recovery_result(RecoveryWireAction::BreakAndDrain, result)
9817}
9818
9819async fn break_and_drain_wire_unbounded<R, W>(
9820    read: &mut R,
9821    write: &Arc<AsyncMutex<W>>,
9822) -> Result<()>
9823where
9824    R: AsyncRead + Unpin,
9825    W: AsyncWrite + std::fmt::Debug + Unpin,
9826{
9827    break_and_drain_wire_unbounded_with_limits(read, write, ProtocolLimits::DEFAULT, false).await
9828}
9829
9830async fn break_and_drain_wire_unbounded_with_limits<R, W>(
9831    read: &mut R,
9832    write: &Arc<AsyncMutex<W>>,
9833    limits: ProtocolLimits,
9834    classic: bool,
9835) -> Result<()>
9836where
9837    R: AsyncRead + Unpin,
9838    W: AsyncWrite + std::fmt::Debug + Unpin,
9839{
9840    // 1) Send the BREAK marker (reference `_break_external`).
9841    send_marker_recovery(write, TNS_MARKER_TYPE_BREAK)
9842        .await
9843        .map_err(|err| {
9844            Error::ConnectionClosed(format!(
9845                "failed to send break marker on call timeout: {err}"
9846            ))
9847        })?;
9848    // 2) Drain the whole break response.
9849    drain_break_response_recovery_with_limits(read, write, limits, classic).await
9850}
9851
9852/// Sends a BREAK and drains the server's cancel response so the wire is left at
9853/// a clean boundary, for an **explicit** user cancel (rather than a call
9854/// timeout). This is the wire half of [`Connection::cancel`].
9855///
9856/// The wire sequence a user cancel triggers is byte-for-byte the same as a call
9857/// timeout — python-oracledb routes both through `_break_external()` +
9858/// `_reset()` (`cancel()` is `connection.pyx:291` -> `_break_external`;
9859/// `protocol.pyx:533-557`). So this delegates straight to
9860/// [`break_and_drain_wire`] (no duplicated drain loop): the BREAK marker, the
9861/// discard of any in-flight DATA responses, the RESET handshake, and the
9862/// consumption of the trailing ORA-01013 error all happen there.
9863///
9864/// The reference would send an out-of-band (urgent-TCP) break first when
9865/// `supports_oob` is negotiated; when OOB is unavailable it falls back to this
9866/// in-band INTERRUPT/BREAK marker (`protocol.pyx:56-69`). Asupersync's
9867/// `TcpStream` does not expose `send(MSG_OOB)`, so we always take the in-band
9868/// path — which the reference itself uses on every platform where OOB is off
9869/// (e.g. Windows, or `disable_oob`), and which the server handles identically.
9870///
9871/// `Ok(())` means the wire is clean and the connection is reusable; the caller
9872/// surfaces [`Error::Cancelled`]. On a failed drain the error is
9873/// [`Error::ConnectionClosed`] and the connection must be discarded.
9874#[allow(dead_code)]
9875async fn cancel_and_drain_wire<R, W>(
9876    read: &mut R,
9877    write: &Arc<AsyncMutex<W>>,
9878    recovery_timeout: Duration,
9879) -> Result<()>
9880where
9881    R: AsyncRead + Unpin,
9882    W: AsyncWrite + std::fmt::Debug + Unpin,
9883{
9884    break_and_drain_wire(read, write, recovery_timeout).await
9885}
9886
9887/// Drains the server's cancel response **without** sending a BREAK first.
9888///
9889/// Used by the two-thread cancel path: a [`CancelHandle`] on a *separate* thread
9890/// has already sent the BREAK marker while the main thread was blocked inside a
9891/// query round trip. The wire now carries the same multi-stage cancel response a
9892/// timeout break would (the cancelled call's in-flight DATA response, the
9893/// break-ack MARKER, the RESET handshake, and the trailing ORA-01013), so this
9894/// reuses [`drain_break_response_recovery`] — the SAME drain
9895/// `break_and_drain_wire` runs — under the same bounded recovery timeout. It
9896/// just omits the `send_marker` BREAK that the handle thread already issued;
9897/// sending a second BREAK here would inject an extra marker the server answers
9898/// with an extra reset, desyncing the reused connection.
9899///
9900/// `Ok(())` leaves the wire clean and the connection reusable. A drain error or
9901/// a secondary timeout yields [`Error::ConnectionClosed`] (the connection must
9902/// be discarded), matching the break+drain failure semantics.
9903#[allow(dead_code)]
9904async fn drain_cancel_wire<R, W>(
9905    read: &mut R,
9906    write: &Arc<AsyncMutex<W>>,
9907    recovery_timeout: Duration,
9908) -> Result<()>
9909where
9910    R: AsyncRead + Unpin,
9911    W: AsyncWrite + std::fmt::Debug + Unpin,
9912{
9913    let result = time::timeout(
9914        time::wall_now(),
9915        recovery_timeout,
9916        drain_cancel_wire_unbounded(read, write),
9917    )
9918    .await
9919    .ok();
9920    classify_recovery_result(RecoveryWireAction::DrainCancel, result)
9921}
9922
9923async fn drain_cancel_wire_unbounded<R, W>(read: &mut R, write: &Arc<AsyncMutex<W>>) -> Result<()>
9924where
9925    R: AsyncRead + Unpin,
9926    W: AsyncWrite + std::fmt::Debug + Unpin,
9927{
9928    drain_cancel_wire_unbounded_with_limits(read, write, ProtocolLimits::DEFAULT, false).await
9929}
9930
9931async fn drain_cancel_wire_unbounded_with_limits<R, W>(
9932    read: &mut R,
9933    write: &Arc<AsyncMutex<W>>,
9934    limits: ProtocolLimits,
9935    classic: bool,
9936) -> Result<()>
9937where
9938    R: AsyncRead + Unpin,
9939    W: AsyncWrite + std::fmt::Debug + Unpin,
9940{
9941    drain_break_response_recovery_with_limits(read, write, limits, classic).await
9942}
9943
9944/// Drop-guard that marks a connection's recovery phase `BreakSent` if a
9945/// cancellable round-trip read future is **dropped while still in flight**.
9946///
9947/// This is the Scope-based cancel-on-drop half of the cancellation story: when a
9948/// fetch/execute future is raced by a `select!` or a `time::timeout` and the
9949/// losing branch is dropped, the request has already gone out but its response
9950/// is still arriving (or the server is still mid-call). Dropping the future ends
9951/// the `&mut Connection` borrow but leaves those bytes / that running call on the
9952/// wire. The guard's `Drop` records in the single recovery owner that the next
9953/// operation must first send a BREAK and drain (via [`cancel_and_drain_wire`])
9954/// before issuing its own request — so a cancelled fetch never poisons the
9955/// stream for the next one.
9956///
9957/// A read that completes normally calls `CancelDrainGuard::disarm` first, so
9958/// the common (uncancelled) path never arms the flag and pays nothing.
9959struct CancelDrainGuard {
9960    recovery: Arc<SessionRecovery>,
9961    armed: bool,
9962}
9963
9964impl CancelDrainGuard {
9965    /// Mark the response as in flight for the duration of a cancellable read.
9966    fn arm(recovery: Arc<SessionRecovery>) -> Result<Self> {
9967        recovery.begin_or_adopt_operation()?;
9968        Ok(Self {
9969            recovery,
9970            armed: true,
9971        })
9972    }
9973
9974    /// Disarm the guard after the read completed normally, so its `Drop` is a
9975    /// no-op and the next operation does not needlessly break + drain.
9976    fn disarm(&mut self) {
9977        self.recovery.complete_operation();
9978        self.armed = false;
9979    }
9980}
9981
9982impl Drop for CancelDrainGuard {
9983    fn drop(&mut self) {
9984        if self.armed {
9985            // The future was dropped mid-read (cancelled): tell the next
9986            // operation to break + drain the stranded server call first.
9987            self.recovery.mark_break_required();
9988        }
9989    }
9990}
9991
9992/// Reads and discards the full server response to a BREAK: any in-flight DATA
9993/// response(s) of the cancelled call, then the break-acknowledge MARKER, the
9994/// RESET handshake, and the trailing error packet — leaving the reader at a
9995/// clean boundary. See [`break_and_drain_wire`] for why stopping at the first
9996/// end-of-response is insufficient.
9997#[cfg(test)]
9998#[allow(dead_code)]
9999async fn drain_break_response_recovery<R, W>(read: &mut R, write: &Arc<AsyncMutex<W>>) -> Result<()>
10000where
10001    R: AsyncRead + Unpin,
10002    W: AsyncWrite + std::fmt::Debug + Unpin,
10003{
10004    drain_break_response_recovery_with_limits(read, write, ProtocolLimits::DEFAULT, false).await
10005}
10006
10007async fn drain_break_response_recovery_with_limits<R, W>(
10008    read: &mut R,
10009    write: &Arc<AsyncMutex<W>>,
10010    limits: ProtocolLimits,
10011    classic: bool,
10012) -> Result<()>
10013where
10014    R: AsyncRead + Unpin,
10015    W: AsyncWrite + std::fmt::Debug + Unpin,
10016{
10017    // Phase A: discard whole DATA responses until the break-acknowledge MARKER.
10018    // The server flushes the cancelled call's in-flight response first; each is
10019    // a complete DATA response (its own end-of-response) that we drop on the
10020    // floor. The MARKER is what drives the RESET handshake.
10021    let initial_marker = loop {
10022        let packet = read_packet_with_limits(read, PacketLengthWidth::Large32, limits).await?;
10023        match packet.packet_type {
10024            TNS_PACKET_TYPE_MARKER => break packet,
10025            TNS_PACKET_TYPE_DATA => {
10026                trace_connect_bytes("BREAK drain: discarded in-flight packet", &packet.payload);
10027                continue;
10028            }
10029            other => {
10030                // Packet-layer byte (header offset 4), NOT a TTC message
10031                // type: name the TNS packet type so triage is not steered
10032                // toward the application layer (bead
10033                // rust-oracledb-pre23ai-connect-z47u.3).
10034                return Err(Error::UnexpectedPacket(other));
10035            }
10036        }
10037    };
10038
10039    // Phase B: run the RESET dance (send RESET, discard packets until the server
10040    // RESET marker). `reset_after_marker` returns the first non-marker packet
10041    // after the RESET confirmation, if any — that is the head of the trailing
10042    // error response (ORA-01013).
10043    let pending =
10044        reset_after_marker_recovery_with_limits(read, write, &initial_marker, limits).await?;
10045
10046    // Phase C: consume the trailing error response to its end-of-response
10047    // boundary and discard it. Reuses the same boundary loop the normal read
10048    // path uses, seeded with the packet `reset_after_marker` already pulled.
10049    let trailing = read_data_response_boundary_from_recovery_with_limits(
10050        read, write, pending, limits, classic,
10051    )
10052    .await?;
10053    trace_connect_bytes("BREAK drain: trailing error response", &trailing.payload);
10054    Ok(())
10055}
10056
10057#[cfg(test)]
10058async fn read_data_response_flushing_out_binds<R, W>(
10059    read: &mut R,
10060    cx: &Cx,
10061    write: &Arc<AsyncMutex<W>>,
10062    sdu: usize,
10063) -> Result<Vec<u8>>
10064where
10065    R: AsyncRead + Unpin,
10066    W: AsyncWrite + std::fmt::Debug + Unpin,
10067{
10068    read_data_response_flushing_out_binds_with_limits(read, cx, write, sdu, ProtocolLimits::DEFAULT)
10069        .await
10070}
10071
10072async fn read_data_response_flushing_out_binds_with_limits<R, W>(
10073    read: &mut R,
10074    cx: &Cx,
10075    write: &Arc<AsyncMutex<W>>,
10076    sdu: usize,
10077    limits: ProtocolLimits,
10078) -> Result<Vec<u8>>
10079where
10080    R: AsyncRead + Unpin,
10081    W: AsyncWrite + std::fmt::Debug + Unpin,
10082{
10083    let mut response =
10084        read_data_response_boundary_with_limits(read, cx, write, false, limits).await?;
10085    let mut payload = response.payload;
10086    while response.flush_out_binds {
10087        observe_cancellation_between_round_trips(cx)?;
10088        if matches!(payload.last(), Some(&TNS_MSG_TYPE_FLUSH_OUT_BINDS)) {
10089            payload.pop();
10090        }
10091        send_data_packet_shared(cx, write, &[TNS_MSG_TYPE_FLUSH_OUT_BINDS], sdu).await?;
10092        response = read_data_response_boundary_with_limits(read, cx, write, false, limits).await?;
10093        let combined = payload.len().checked_add(response.payload.len()).ok_or(
10094            oracledb_protocol::ProtocolError::ResourceLimit {
10095                limit: "response_bytes",
10096                observed: usize::MAX,
10097                maximum: limits.max_response_bytes,
10098            },
10099        )?;
10100        limits.check_response_bytes(combined)?;
10101        payload.extend_from_slice(&response.payload);
10102    }
10103    Ok(payload)
10104}
10105
10106/// Returns whether this DATA packet carries the end of the TTC response, given
10107/// the packet's 2-byte data flags and its post-flags payload.
10108///
10109/// This mirrors the reference `Packet.has_end_of_response`
10110/// (impl/thin/packet.pyx:58-73). The end of a response is signalled either by
10111/// the `END_OF_RESPONSE` / `EOF` data flag, or by a trailing
10112/// `TNS_MSG_TYPE_END_OF_RESPONSE` (29 / 0x1d) byte that arrives **as its own
10113/// minimal packet** -- a packet whose entire post-flags payload is exactly that
10114/// one byte (reference condition `packet_size == PACKET_HEADER_SIZE + 3`).
10115///
10116/// The size guard is load-bearing for multi-packet wide-row results. Without it,
10117/// any DATA packet whose payload merely *happens to end* in byte 0x1d -- an
10118/// utterly ordinary value inside a NUMBER mantissa, a length prefix, or a text
10119/// byte -- would be misread as the end of the response. A wide (e.g. 20-column
10120/// NUMBER/VARCHAR2) single fetch of ~1500+ rows spans several network packets,
10121/// and a mid-stream packet boundary lands on a 0x1d byte often enough that the
10122/// reassembly loop terminated early, truncating the buffer. The TTC decoder then
10123/// mis-framed the continuation, surfacing as "encoded NUMBER too long" /
10124/// "truncated TTC payload" (bead rust-oracledb-n2s).
10125fn data_packet_ends_response(flags: u16, payload: &[u8]) -> bool {
10126    if flags
10127        & (oracledb_protocol::thin::TNS_DATA_FLAGS_END_OF_RESPONSE
10128            | oracledb_protocol::thin::TNS_DATA_FLAGS_EOF)
10129        != 0
10130    {
10131        return true;
10132    }
10133    // Fallback for servers that did not negotiate END_OF_RESPONSE framing: a
10134    // response that is a single minimal packet whose entire post-flags payload
10135    // is just the END_OF_RESPONSE marker, or just the FLUSH_OUT_BINDS marker
10136    // (which the reference also treats as end_of_response,
10137    // messages/base.pyx:1267-1269). The exact-length match is the load-bearing
10138    // guard: a multi-packet body packet that merely *ends* in one of these bytes
10139    // must NOT terminate the response.
10140    payload == [TNS_MSG_TYPE_END_OF_RESPONSE] || payload == [TNS_MSG_TYPE_FLUSH_OUT_BINDS]
10141}
10142
10143/// Whether a DATA packet read **after a RESET dance** ends the response, based
10144/// on its terminal TTC message byte alone.
10145///
10146/// After a BREAK/RESET the server stops using request-boundary framing: the
10147/// trailing packets of the break-recovery response do NOT carry the
10148/// `END_OF_RESPONSE` data flag (confirmed by live wire trace against Oracle
10149/// 23ai on the DML-RETURNING error path, test_1600 test_1612 / ORA-12899). The
10150/// server instead sends message-byte-framed packets — e.g. a `FLUSH_OUT_BINDS`
10151/// *request* (a DATA packet ending in byte 0x13) that expects a
10152/// `FLUSH_OUT_BINDS` reply, then the real error packet. The reference detects
10153/// the boundary while *processing* the message (`TNS_MSG_TYPE_FLUSH_OUT_BINDS`
10154/// and `TNS_MSG_TYPE_END_OF_RESPONSE` both set `end_of_response`,
10155/// messages/base.pyx:1267-1269), because its `_check_request_boundary` is off
10156/// for post-reset packets (protocol.pyx:896-906).
10157///
10158/// Unlike [`data_packet_ends_response`], this does NOT require the marker byte
10159/// to be the packet's sole byte — the FLUSH_OUT_BINDS request arrives as
10160/// `07 00 00 13`, the marker as the *last* byte. That is safe here precisely
10161/// because it is gated on the post-reset context, which carries no multi-packet
10162/// wide-row body (the bead rust-oracledb-n2s false-positive only arises on the
10163/// normal request-boundary-framed read path, which never sets `after_reset`).
10164fn post_reset_packet_ends_response(payload: &[u8]) -> bool {
10165    matches!(
10166        payload.last(),
10167        Some(&TNS_MSG_TYPE_FLUSH_OUT_BINDS) | Some(&TNS_MSG_TYPE_END_OF_RESPONSE)
10168    )
10169}
10170
10171/// Reads one boundary-delimited TTC response. While `in_pipeline` is set,
10172/// marker packets are silently dropped instead of triggering the
10173/// send-reset/await-reset dance -- the reference does the same while reading
10174/// pipelined responses (packet.pyx:346-370, protocol.pyx:889-906), since the
10175/// server emits a marker alongside an in-pipeline error without expecting a
10176/// reset exchange.
10177#[cfg(test)]
10178#[allow(dead_code)]
10179async fn read_data_response_boundary<R, W>(
10180    read: &mut R,
10181    cx: &Cx,
10182    write: &Arc<AsyncMutex<W>>,
10183    in_pipeline: bool,
10184) -> Result<DataResponse>
10185where
10186    R: AsyncRead + Unpin,
10187    W: AsyncWrite + std::fmt::Debug + Unpin,
10188{
10189    read_data_response_boundary_with_limits(read, cx, write, in_pipeline, ProtocolLimits::DEFAULT)
10190        .await
10191}
10192
10193async fn read_data_response_boundary_with_limits<R, W>(
10194    read: &mut R,
10195    cx: &Cx,
10196    write: &Arc<AsyncMutex<W>>,
10197    in_pipeline: bool,
10198    limits: ProtocolLimits,
10199) -> Result<DataResponse>
10200where
10201    R: AsyncRead + Unpin,
10202    W: AsyncWrite + std::fmt::Debug + Unpin,
10203{
10204    // Normal (23ai / END_OF_RESPONSE-framed) read path: never classic.
10205    read_data_response_boundary_seeded(read, Some(cx), write, in_pipeline, None, limits, false)
10206        .await
10207}
10208
10209/// Like [`read_data_response_boundary`] but seeds the reassembly loop with an
10210/// already-read `seed` packet (e.g. the trailing packet `reset_after_marker`
10211/// pulled past a RESET marker) before reading more from the wire. Used by the
10212/// break-drain path to consume the trailing error response. Always runs the
10213/// non-pipeline (reset-handling) variant.
10214#[cfg(test)]
10215#[allow(dead_code)]
10216async fn read_data_response_boundary_from_recovery<R, W>(
10217    read: &mut R,
10218    write: &Arc<AsyncMutex<W>>,
10219    seed: Option<IncomingPacket>,
10220) -> Result<DataResponse>
10221where
10222    R: AsyncRead + Unpin,
10223    W: AsyncWrite + std::fmt::Debug + Unpin,
10224{
10225    read_data_response_boundary_from_recovery_with_limits(
10226        read,
10227        write,
10228        seed,
10229        ProtocolLimits::DEFAULT,
10230        false,
10231    )
10232    .await
10233}
10234
10235async fn read_data_response_boundary_from_recovery_with_limits<R, W>(
10236    read: &mut R,
10237    write: &Arc<AsyncMutex<W>>,
10238    seed: Option<IncomingPacket>,
10239    limits: ProtocolLimits,
10240    classic: bool,
10241) -> Result<DataResponse>
10242where
10243    R: AsyncRead + Unpin,
10244    W: AsyncWrite + std::fmt::Debug + Unpin,
10245{
10246    read_data_response_boundary_seeded(read, None, write, false, seed, limits, classic).await
10247}
10248
10249async fn read_data_response_boundary_seeded<R, W>(
10250    read: &mut R,
10251    cx: Option<&Cx>,
10252    write: &Arc<AsyncMutex<W>>,
10253    in_pipeline: bool,
10254    seed: Option<IncomingPacket>,
10255    limits: ProtocolLimits,
10256    classic: bool,
10257) -> Result<DataResponse>
10258where
10259    R: AsyncRead + Unpin,
10260    W: AsyncWrite + std::fmt::Debug + Unpin,
10261{
10262    let mut response = Vec::new();
10263    let mut pending_packet = seed;
10264    // Set once this loop has run a RESET dance (reference `_reset`). After a
10265    // RESET the server stops using request-boundary (END_OF_RESPONSE data flag)
10266    // framing for the trailing error response: it sends message-byte-framed
10267    // packets, exactly like the reference's `message.process()` after
10268    // `_reset()`, where `_check_request_boundary` is off (protocol.pyx:819-821,
10269    // 896-906). So a post-reset packet whose payload ends in a terminal message
10270    // byte (FLUSH_OUT_BINDS / END_OF_RESPONSE) ends the response even without
10271    // the data flag. This relaxation is gated on `after_reset` so the wide-row
10272    // false-positive guard (bead rust-oracledb-n2s) on the normal framing path
10273    // is left entirely intact.
10274    let mut after_reset = false;
10275    loop {
10276        let packet = match pending_packet.take() {
10277            Some(packet) => packet,
10278            None => read_packet_with_limits(read, PacketLengthWidth::Large32, limits).await?,
10279        };
10280        if packet.packet_type == TNS_PACKET_TYPE_MARKER {
10281            if in_pipeline {
10282                trace_connect_bytes("MARKER packet skipped in pipeline", &packet.payload);
10283                continue;
10284            }
10285            pending_packet = match cx {
10286                Some(cx) => {
10287                    reset_after_marker_with_limits(read, cx, write, &packet, limits).await?
10288                }
10289                None => {
10290                    reset_after_marker_recovery_with_limits(read, write, &packet, limits).await?
10291                }
10292            };
10293            after_reset = true;
10294            continue;
10295        }
10296        if packet.packet_type != TNS_PACKET_TYPE_DATA {
10297            // Packet-layer byte (header offset 4), NOT a TTC message type:
10298            // name the TNS packet type so triage is not steered toward the
10299            // application layer (bead rust-oracledb-pre23ai-connect-z47u.3).
10300            return Err(Error::UnexpectedPacket(packet.packet_type));
10301        }
10302        let (data_flags, payload) = packet.payload.split_at_checked(2).ok_or(
10303            oracledb_protocol::ProtocolError::TtcDecode("missing data packet flags"),
10304        )?;
10305        let flags = u16::from_be_bytes(
10306            data_flags
10307                .try_into()
10308                .map_err(|_| oracledb_protocol::ProtocolError::TtcDecode("invalid flags"))?,
10309        );
10310        let ends = data_packet_ends_response(flags, payload)
10311            || (after_reset && post_reset_packet_ends_response(payload));
10312        // Single-packet passthrough (bead rust-oracledb-0n0): when the whole
10313        // response is ONE DATA packet (nothing accumulated yet AND this packet
10314        // ends the response), move the packet's owned buffer into `response` and
10315        // strip the 2 flag bytes in place, instead of allocating a fresh Vec and
10316        // copying the entire payload into it. The flag-strip is preserved (drain
10317        // removes the same 2 leading bytes), the end-of-response decision is the
10318        // exact same `ends` computed above, and the FLUSH_OUT_BINDS terminal-byte
10319        // detection below sees an identical byte stream. The multi-packet path is
10320        // unchanged (it must reassemble, so it extends).
10321        if ends && response.is_empty() {
10322            limits.check_response_bytes(payload.len())?;
10323            response = packet.payload;
10324            response.drain(..2);
10325            break;
10326        }
10327        let combined = response.len().checked_add(payload.len()).ok_or(
10328            oracledb_protocol::ProtocolError::ResourceLimit {
10329                limit: "response_bytes",
10330                observed: usize::MAX,
10331                maximum: limits.max_response_bytes,
10332            },
10333        )?;
10334        limits.check_response_bytes(combined)?;
10335        response.extend_from_slice(payload);
10336        if ends {
10337            break;
10338        }
10339        // Classic (pre-END_OF_RESPONSE) recovery framing: on a server that never
10340        // negotiated END_OF_RESPONSE (protocol < 319, i.e. everything before
10341        // 23ai), the trailing error response after a BREAK/RESET carries neither
10342        // the END_OF_RESPONSE data flag nor a terminal marker byte -- it ends at
10343        // its terminal TTC message (ERROR/STATUS). Decide completion by parsing
10344        // the accumulated stream, exactly like the classic connect-phase reader,
10345        // so the recovery drain does not block until its secondary timeout and
10346        // surface a spurious ConnectionClosed instead of the real CallTimeout /
10347        // Cancelled (bead rust-oracledb-99xu). Gated on `classic` so the 23ai
10348        // flag-framed path (and its wide-row false-positive guard) is untouched.
10349        if classic && classic_connect_response_is_complete(&response, limits).unwrap_or(false) {
10350            break;
10351        }
10352    }
10353    // A flush-out-binds response ends with the FLUSH_OUT_BINDS message byte
10354    // (reference messages/base.pyx:1267-1269, which also sets end_of_response).
10355    // Detect it from the terminal message byte of the fully reassembled stream
10356    // rather than mid-stream, so an ordinary data byte 0x13 at a packet boundary
10357    // is never mistaken for it.
10358    let flush_out_binds = matches!(response.last(), Some(&TNS_MSG_TYPE_FLUSH_OUT_BINDS));
10359    Ok(DataResponse {
10360        payload: response,
10361        flush_out_binds,
10362    })
10363}
10364
10365const TNS_PACKET_TYPE_MARKER: u8 = 12;
10366const TNS_MARKER_TYPE_BREAK: u8 = 1;
10367const TNS_MARKER_TYPE_RESET: u8 = 2;
10368
10369#[cfg(test)]
10370#[allow(dead_code)]
10371async fn reset_after_marker_recovery<R, W>(
10372    read: &mut R,
10373    write: &Arc<AsyncMutex<W>>,
10374    initial_marker: &IncomingPacket,
10375) -> Result<Option<IncomingPacket>>
10376where
10377    R: AsyncRead + Unpin,
10378    W: AsyncWrite + std::fmt::Debug + Unpin,
10379{
10380    reset_after_marker_recovery_with_limits(read, write, initial_marker, ProtocolLimits::DEFAULT)
10381        .await
10382}
10383
10384async fn reset_after_marker_recovery_with_limits<R, W>(
10385    read: &mut R,
10386    write: &Arc<AsyncMutex<W>>,
10387    initial_marker: &IncomingPacket,
10388    limits: ProtocolLimits,
10389) -> Result<Option<IncomingPacket>>
10390where
10391    R: AsyncRead + Unpin,
10392    W: AsyncWrite + std::fmt::Debug + Unpin,
10393{
10394    trace_connect_bytes("MARKER packet", &initial_marker.payload);
10395    send_marker_recovery(write, TNS_MARKER_TYPE_RESET).await?;
10396    drain_reset_markers_with_limits(read, limits).await
10397}
10398
10399#[cfg(test)]
10400#[allow(dead_code)]
10401async fn reset_after_marker<R, W>(
10402    read: &mut R,
10403    cx: &Cx,
10404    write: &Arc<AsyncMutex<W>>,
10405    initial_marker: &IncomingPacket,
10406) -> Result<Option<IncomingPacket>>
10407where
10408    R: AsyncRead + Unpin,
10409    W: AsyncWrite + std::fmt::Debug + Unpin,
10410{
10411    reset_after_marker_with_limits(read, cx, write, initial_marker, ProtocolLimits::DEFAULT).await
10412}
10413
10414async fn reset_after_marker_with_limits<R, W>(
10415    read: &mut R,
10416    cx: &Cx,
10417    write: &Arc<AsyncMutex<W>>,
10418    initial_marker: &IncomingPacket,
10419    limits: ProtocolLimits,
10420) -> Result<Option<IncomingPacket>>
10421where
10422    R: AsyncRead + Unpin,
10423    W: AsyncWrite + std::fmt::Debug + Unpin,
10424{
10425    trace_connect_bytes("MARKER packet", &initial_marker.payload);
10426    send_marker_shared(cx, write, TNS_MARKER_TYPE_RESET).await?;
10427    drain_reset_markers_with_limits(read, limits).await
10428}
10429
10430#[cfg(test)]
10431#[allow(dead_code)]
10432async fn drain_reset_markers<R>(read: &mut R) -> Result<Option<IncomingPacket>>
10433where
10434    R: AsyncRead + Unpin,
10435{
10436    drain_reset_markers_with_limits(read, ProtocolLimits::DEFAULT).await
10437}
10438
10439async fn drain_reset_markers_with_limits<R>(
10440    read: &mut R,
10441    limits: ProtocolLimits,
10442) -> Result<Option<IncomingPacket>>
10443where
10444    R: AsyncRead + Unpin,
10445{
10446    // Drain the RESET handshake: consume EVERY trailing marker packet — the
10447    // RESET acknowledgement AND any additional markers the server sends after
10448    // it (a documented variant: reference _reset's second loop,
10449    // protocol.pyx:554-556, "some servers send multiple reset markers"). Return
10450    // the first NON-marker packet (the trailing error/data packet) so the caller
10451    // is seeded with it. Returning early on the first RESET marker would leave a
10452    // following marker in the stream, which the caller mis-reads as a fresh
10453    // break and answers with a DUPLICATE RESET, poisoning a reused connection
10454    // (bead rust-oracledb-yhz). Exactly one RESET is ever sent, here.
10455    loop {
10456        let packet = read_packet_with_limits(read, PacketLengthWidth::Large32, limits).await?;
10457        if packet.packet_type != TNS_PACKET_TYPE_MARKER {
10458            return Ok(Some(packet));
10459        }
10460        trace_connect_bytes("MARKER reset response", &packet.payload);
10461    }
10462}
10463
10464async fn send_marker<W>(stream: &mut W, marker_type: u8) -> Result<()>
10465where
10466    W: AsyncWrite + Unpin,
10467{
10468    let packet = encode_packet(
10469        TNS_PACKET_TYPE_MARKER,
10470        0,
10471        None,
10472        &[1, 0, marker_type],
10473        PacketLengthWidth::Large32,
10474    )?;
10475    trace_connect_bytes("send MARKER", &packet);
10476    stream.write_all(&packet).await?;
10477    stream.flush().await?;
10478    Ok(())
10479}
10480
10481#[cfg(test)]
10482#[allow(dead_code)]
10483async fn read_packet<R>(stream: &mut R, width: PacketLengthWidth) -> Result<IncomingPacket>
10484where
10485    R: AsyncRead + Unpin,
10486{
10487    read_packet_with_limits(stream, width, ProtocolLimits::DEFAULT).await
10488}
10489
10490/// Derives the TCP keepalive idle interval from a DSN `EXPIRE_TIME` (minutes,
10491/// reference net.pyx). `0` (the default) disables keepalive; any positive value
10492/// enables it with that many minutes of socket idle time before the first probe,
10493/// so a half-open/dead peer is detected instead of wedging a later read (GH#14).
10494fn keepalive_idle_from_expire_time(expire_time_minutes: u32) -> Option<Duration> {
10495    (expire_time_minutes > 0).then(|| Duration::from_secs(u64::from(expire_time_minutes) * 60))
10496}
10497
10498/// Marker carried through `std::io::Error` only while a read has made no
10499/// progress for its configured inactivity window.
10500#[derive(Debug)]
10501struct InactivityReadExpired(u32);
10502
10503impl std::fmt::Display for InactivityReadExpired {
10504    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
10505        write!(formatter, "read made no progress for {} ms", self.0)
10506    }
10507}
10508
10509impl std::error::Error for InactivityReadExpired {}
10510
10511/// An [`AsyncRead`] adapter whose deadline resets whenever its inner reader
10512/// supplies one or more bytes. This is intentionally below packet framing so a
10513/// large, continuously advancing response is not treated as a stalled read.
10514struct InactivityRead<'a, R> {
10515    inner: &'a mut R,
10516    timeout: Option<Duration>,
10517    idle_sleep: Option<std::pin::Pin<Box<time::Sleep>>>,
10518}
10519
10520impl<'a, R> InactivityRead<'a, R> {
10521    fn new(inner: &'a mut R, timeout: Option<Duration>) -> Self {
10522        Self {
10523            inner,
10524            timeout,
10525            idle_sleep: None,
10526        }
10527    }
10528}
10529
10530impl<R> AsyncRead for InactivityRead<'_, R>
10531where
10532    R: AsyncRead + Unpin,
10533{
10534    fn poll_read(
10535        self: std::pin::Pin<&mut Self>,
10536        context: &mut std::task::Context<'_>,
10537        buffer: &mut asupersync::io::ReadBuf<'_>,
10538    ) -> std::task::Poll<std::io::Result<()>> {
10539        let this = self.get_mut();
10540        let filled_before = buffer.filled().len();
10541        match std::pin::Pin::new(&mut *this.inner).poll_read(context, buffer) {
10542            std::task::Poll::Ready(Ok(())) => {
10543                if buffer.filled().len() > filled_before {
10544                    this.idle_sleep = None;
10545                }
10546                std::task::Poll::Ready(Ok(()))
10547            }
10548            std::task::Poll::Ready(Err(error)) => std::task::Poll::Ready(Err(error)),
10549            std::task::Poll::Pending => {
10550                let Some(timeout) = this.timeout else {
10551                    return std::task::Poll::Pending;
10552                };
10553                let sleep = this
10554                    .idle_sleep
10555                    .get_or_insert_with(|| Box::pin(time::sleep(time::wall_now(), timeout)));
10556                if sleep.as_mut().poll(context).is_ready() {
10557                    std::task::Poll::Ready(Err(std::io::Error::new(
10558                        std::io::ErrorKind::TimedOut,
10559                        InactivityReadExpired(duration_to_millis_saturating(timeout)),
10560                    )))
10561                } else {
10562                    std::task::Poll::Pending
10563                }
10564            }
10565        }
10566    }
10567}
10568
10569fn map_inactivity_timeout<U>(result: Result<U>) -> Result<U> {
10570    match result {
10571        Err(Error::Io(error)) => {
10572            let timeout = error
10573                .get_ref()
10574                .and_then(|source| source.downcast_ref::<InactivityReadExpired>())
10575                .map(|marker| marker.0);
10576            match timeout {
10577                Some(timeout) => Err(Error::CallTimeout(timeout)),
10578                None => Err(Error::Io(error)),
10579            }
10580        }
10581        other => other,
10582    }
10583}
10584
10585async fn read_packet_with_limits<R>(
10586    stream: &mut R,
10587    width: PacketLengthWidth,
10588    limits: ProtocolLimits,
10589) -> Result<IncomingPacket>
10590where
10591    R: AsyncRead + Unpin,
10592{
10593    let mut header = [0u8; 8];
10594    stream.read_exact(&mut header).await?;
10595    let [len0, len1, len2, len3, packet_type, packet_flags, _, _] = header;
10596    let declared = match width {
10597        PacketLengthWidth::Legacy16 => usize::from(u16::from_be_bytes([len0, len1])),
10598        PacketLengthWidth::Large32 => {
10599            usize::try_from(u32::from_be_bytes([len0, len1, len2, len3])).unwrap_or(usize::MAX)
10600        }
10601    };
10602    if declared < header.len() {
10603        return Err(oracledb_protocol::ProtocolError::InvalidPacketLength {
10604            length: declared,
10605            minimum: header.len(),
10606        }
10607        .into());
10608    }
10609    limits.check_packet_bytes(declared)?;
10610    let mut payload = vec![0u8; declared - header.len()];
10611    stream.read_exact(&mut payload).await?;
10612    Ok(IncomingPacket {
10613        packet_type,
10614        packet_flags,
10615        payload,
10616    })
10617}
10618
10619/// The endpoint a listener REDIRECT points at, split out of the redirect
10620/// data (reference protocol.pyx `_connect_phase_one` redirect branch).
10621#[derive(Clone, Debug, Eq, PartialEq)]
10622struct RedirectTarget {
10623    /// Host to reconnect the transport to.
10624    host: String,
10625    /// Port to reconnect the transport to.
10626    port: u16,
10627    /// The connect data to send in the CONNECT packet on the redirected
10628    /// connection (the part of the redirect data after the NUL separator).
10629    connect_data: String,
10630}
10631
10632/// Splits a REDIRECT packet payload into the declared redirect-data length
10633/// (u16be prefix) and the data bytes carried inline in this packet. The
10634/// inline bytes may be shorter than the declared length (the remainder then
10635/// arrives in follow-up packets, reference ConnectMessage.process
10636/// `wait_for_packets_sync`) or longer (trailing bytes beyond the declared
10637/// length are ignored).
10638fn redirect_payload_prefix(payload: &[u8]) -> Result<(usize, &[u8])> {
10639    let Some((length_bytes, inline)) = payload.split_first_chunk::<2>() else {
10640        return Err(Error::InvalidRedirectData(format!(
10641            "REDIRECT packet payload of {} byte(s) is too short for the u16 redirect-data length",
10642            payload.len()
10643        )));
10644    };
10645    let declared = usize::from(u16::from_be_bytes(*length_bytes));
10646    let take = inline.len().min(declared);
10647    Ok((declared, &inline[..take]))
10648}
10649
10650/// Assembles the full redirect data starting from the first REDIRECT packet's
10651/// payload, reading follow-up packets from the same connection while the
10652/// declared length is not yet satisfied (the listener may send the length in
10653/// one packet and the data in the next). Follow-up packets must be REDIRECT
10654/// or DATA packets; anything else fails closed as an unexpected packet.
10655async fn read_redirect_data(core: &mut DriverCore, first_payload: &[u8]) -> Result<String> {
10656    let (declared, inline) = redirect_payload_prefix(first_payload)?;
10657    let mut data = inline.to_vec();
10658    while data.len() < declared {
10659        let packet = core.read_packet(PacketLengthWidth::Legacy16).await?;
10660        if packet.packet_type != TNS_PACKET_TYPE_REDIRECT
10661            && packet.packet_type != TNS_PACKET_TYPE_DATA
10662        {
10663            return Err(Error::UnexpectedPacket(packet.packet_type));
10664        }
10665        if packet.payload.is_empty() {
10666            return Err(Error::InvalidRedirectData(format!(
10667                "listener stopped short of the declared redirect data \
10668                 ({} of {declared} byte(s) received)",
10669                data.len()
10670            )));
10671        }
10672        data.extend_from_slice(&packet.payload);
10673    }
10674    data.truncate(declared);
10675    String::from_utf8(data)
10676        .map_err(|_| Error::InvalidRedirectData("redirect data is not valid UTF-8".to_string()))
10677}
10678
10679/// Parses assembled redirect data (`"<address>\0<connect data>"`) into the
10680/// target endpoint, enforcing that the redirect keeps the original transport
10681/// protocol: a `tcps` connect is never silently downgraded to plain `tcp`
10682/// (and a mid-connect `tcp` -> `tcps` upgrade is not supported). The address
10683/// part is a TNS address/descriptor fragment, e.g.
10684/// `(ADDRESS=(PROTOCOL=tcp)(HOST=dispatcher)(PORT=1621))` (reference parses
10685/// it with `ConnectParamsImpl._parse_connect_string` and takes the first
10686/// address). NOTE: when the original connect is `tcps`, the redirect address
10687/// must say `PROTOCOL=tcps` explicitly — an omitted protocol parses as plain
10688/// `tcp` and is refused as a downgrade (fail closed; the reference instead
10689/// ignores the redirect protocol entirely and keeps its original transport).
10690fn parse_redirect_target(
10691    redirect_data: &str,
10692    original_protocol: NetProtocol,
10693) -> Result<RedirectTarget> {
10694    let Some((address_part, connect_data)) = redirect_data.split_once('\0') else {
10695        return Err(Error::InvalidRedirectData(
10696            "missing NUL separator between the redirect address and its connect data".to_string(),
10697        ));
10698    };
10699    let descriptor = connectstring_parse(address_part).map_err(|err| {
10700        Error::InvalidRedirectData(format!("unparseable redirect address: {err}"))
10701    })?;
10702    let address = descriptor
10703        .as_ref()
10704        .and_then(|descriptor| descriptor.first_address())
10705        .ok_or_else(|| {
10706            Error::InvalidRedirectData("redirect address defines no usable endpoint".to_string())
10707        })?;
10708    let host = address
10709        .host
10710        .clone()
10711        .ok_or_else(|| Error::InvalidRedirectData("redirect address has no HOST".to_string()))?;
10712    let target_protocol: NetProtocol = address.protocol.into();
10713    if target_protocol.is_tls() != original_protocol.is_tls() {
10714        return Err(Error::RedirectUnsupported);
10715    }
10716    Ok(RedirectTarget {
10717        host,
10718        port: address.port,
10719        connect_data: connect_data.to_string(),
10720    })
10721}
10722
10723/// [`oracledb_protocol::net::connectstring::parse`] under a driver-error
10724/// signature (used by the redirect-target parser above).
10725fn connectstring_parse(
10726    input: &str,
10727) -> std::result::Result<
10728    Option<oracledb_protocol::net::connectstring::Descriptor>,
10729    oracledb_protocol::ProtocolError,
10730> {
10731    oracledb_protocol::net::connectstring::parse(input)
10732}
10733
10734fn transport_connect_timeout_duration(seconds: f64) -> Duration {
10735    let seconds = if seconds.is_finite() && seconds > 0.0 {
10736        seconds
10737    } else {
10738        20.0
10739    };
10740    Duration::from_secs_f64(seconds.max(0.001))
10741}
10742
10743/// Resolve the fail-closed DN-match setting from the parsed descriptor and
10744/// explicit connection options. Either side opting out disables only identity
10745/// matching; certificate-chain validation remains mandatory.
10746fn effective_ssl_server_dn_match(description: &Description, options: &ConnectOptions) -> bool {
10747    description.security.ssl_server_dn_match && options.ssl_server_dn_match
10748}
10749
10750/// The SDU (session data unit, in bytes) advertised in the CONNECT packet
10751/// (F1, bead `rust-oracledb-clvm`). Precedence: an explicit
10752/// [`ConnectOptions::with_sdu`] value wins; otherwise a DSN-parsed `(SDU=...)`
10753/// (`Description::sdu`, already clamped by the connect-string parser to
10754/// 512..=2_097_152) is honoured; otherwise the shared 8192 default. The caller
10755/// clamps the result to the 16-bit wire field (the classic CONNECT-packet SDU
10756/// ceiling of 65535); the server negotiates the effective SDU down from the
10757/// advertised value in its ACCEPT.
10758fn resolve_effective_sdu(options_sdu: u16, description: &Description) -> u32 {
10759    const BUILDER_DEFAULT_SDU: u16 = 8192;
10760    if options_sdu != BUILDER_DEFAULT_SDU {
10761        u32::from(options_sdu)
10762    } else if description.sdu != DSN_DEFAULT_SDU {
10763        description.sdu
10764    } else {
10765        u32::from(BUILDER_DEFAULT_SDU)
10766    }
10767}
10768
10769/// One transport endpoint to try during multi-address failover (F2).
10770#[derive(Clone, Debug, PartialEq, Eq)]
10771struct ConnectAddress {
10772    host: String,
10773    port: u16,
10774}
10775
10776/// A failure that can only occur after deterministic connection configuration
10777/// has completed and one concrete transport address is being established.
10778///
10779/// The type deliberately has no configuration/auth/wallet variants: every
10780/// value is therefore safe to aggregate and retry against the next address.
10781/// This construction-level boundary prevents a public [`Error::Tls`] produced
10782/// while building rustls configuration from being mistaken for a transient TLS
10783/// handshake failure.
10784#[derive(Debug)]
10785enum TransportEstablishmentError {
10786    Io(std::io::Error),
10787    TlsHandshake(tls::TlsHandshakeError),
10788}
10789
10790impl TransportEstablishmentError {
10791    fn is_terminal(&self) -> bool {
10792        match self {
10793            Self::Io(_) => false,
10794            Self::TlsHandshake(error) => error.is_terminal(),
10795        }
10796    }
10797
10798    fn into_driver_error(self) -> Error {
10799        match self {
10800            Self::Io(error) => Error::Io(error),
10801            Self::TlsHandshake(error) => error.into_driver_error(),
10802        }
10803    }
10804}
10805
10806impl std::fmt::Display for TransportEstablishmentError {
10807    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
10808        match self {
10809            Self::Io(error) => write!(f, "I/O error: {error}"),
10810            Self::TlsHandshake(error) => write!(f, "{error}"),
10811        }
10812    }
10813}
10814
10815impl From<std::io::Error> for TransportEstablishmentError {
10816    fn from(error: std::io::Error) -> Self {
10817        Self::Io(error)
10818    }
10819}
10820
10821impl From<tls::TlsHandshakeError> for TransportEstablishmentError {
10822    fn from(error: tls::TlsHandshakeError) -> Self {
10823        Self::TlsHandshake(error)
10824    }
10825}
10826
10827/// Build the ordered list of transport endpoints to try for a (possibly
10828/// multi-address) connect descriptor (F2, bead `rust-oracledb-clvm`),
10829/// restricted to the primary transport `protocol`. Honours `LOAD_BALANCE`
10830/// (shuffle within the balanced scope) and `FAILOVER=OFF` (only the first
10831/// address of that list). Addresses without a host, or whose protocol differs
10832/// from the primary, are skipped. The first entry equals the endpoint
10833/// [`EasyConnect::parse`] selected as primary, so a single-address descriptor
10834/// behaves exactly as before.
10835fn resolve_connect_addresses(
10836    descriptor: &Descriptor,
10837    protocol: NetProtocol,
10838) -> Vec<ConnectAddress> {
10839    let mut out: Vec<ConnectAddress> = Vec::new();
10840    let mut descriptions: Vec<&Description> = descriptor.descriptions.iter().collect();
10841    if descriptor.load_balance {
10842        shuffle_in_place(&mut descriptions);
10843    }
10844    for description in descriptions {
10845        let mut lists: Vec<&AddressList> = description.address_lists.iter().collect();
10846        if description.load_balance {
10847            shuffle_in_place(&mut lists);
10848        }
10849        for list in lists {
10850            let mut addresses: Vec<&Address> = list
10851                .addresses
10852                .iter()
10853                .filter(|address| {
10854                    address.host.is_some() && NetProtocol::from(address.protocol) == protocol
10855                })
10856                .collect();
10857            if list.load_balance || description.load_balance {
10858                shuffle_in_place(&mut addresses);
10859            }
10860            let limit = if list.failover {
10861                addresses.len()
10862            } else {
10863                addresses.len().min(1)
10864            };
10865            for address in addresses.into_iter().take(limit) {
10866                if let Some(host) = &address.host {
10867                    out.push(ConnectAddress {
10868                        host: host.clone(),
10869                        port: address.port,
10870                    });
10871                }
10872            }
10873        }
10874    }
10875    out
10876}
10877
10878/// In-place Fisher–Yates shuffle seeded from the wall clock. `LOAD_BALANCE`
10879/// only needs unpredictability across connects, not cryptographic randomness,
10880/// so this stays dependency-free (no `rand` in the driver crate).
10881fn shuffle_in_place<T>(items: &mut [T]) {
10882    if items.len() < 2 {
10883        return;
10884    }
10885    let mut state = shuffle_seed();
10886    for i in (1..items.len()).rev() {
10887        // xorshift64*
10888        state ^= state << 13;
10889        state ^= state >> 7;
10890        state ^= state << 17;
10891        let j = (state % (i as u64 + 1)) as usize;
10892        items.swap(i, j);
10893    }
10894}
10895
10896fn shuffle_seed() -> u64 {
10897    use std::time::{SystemTime, UNIX_EPOCH};
10898    let nanos = SystemTime::now()
10899        .duration_since(UNIX_EPOCH)
10900        .map(|d| d.as_nanos() as u64)
10901        .unwrap_or(0x9E37_79B9_7F4A_7C15);
10902    // Mix and force a non-zero (odd) seed so xorshift never degenerates.
10903    (nanos ^ nanos.rotate_left(32).wrapping_mul(0x2545_F491_4F6C_DD1D)) | 1
10904}
10905
10906/// Builds the listener connect descriptor, optionally injecting `(SERVER=emon)`
10907/// into `CONNECT_DATA` (between `SERVICE_NAME` and `CID`, matching the golden
10908/// emon connect packet). The reference sets `description.server_type = "emon"`
10909/// for the background CQN connection (subscr.pyx:70-73).
10910fn listener_connect_descriptor_with_server(
10911    descriptor: &EasyConnect,
10912    description: &Description,
10913    identity: &ClientIdentity,
10914    server_type_emon: bool,
10915    token_auth: bool,
10916    ssl_server_dn_match: bool,
10917    ssl_server_cert_dn: Option<&str>,
10918) -> String {
10919    let address = descriptor_address_clause(descriptor);
10920    let connect_data = listener_connect_data_clause(description, identity, server_type_emon);
10921    let security = descriptor_security_clause(
10922        descriptor.protocol,
10923        description,
10924        token_auth,
10925        ssl_server_dn_match,
10926        ssl_server_cert_dn,
10927    );
10928    format!("(DESCRIPTION={}{}{})", address, connect_data, security)
10929}
10930
10931fn auth_connect_descriptor(
10932    descriptor: &EasyConnect,
10933    description: &Description,
10934    token_auth: bool,
10935    ssl_server_dn_match: bool,
10936    ssl_server_cert_dn: Option<&str>,
10937) -> String {
10938    let address = descriptor_address_clause(descriptor);
10939    let connect_data = auth_connect_data_clause(description, descriptor);
10940    let security = descriptor_security_clause(
10941        descriptor.protocol,
10942        description,
10943        token_auth,
10944        ssl_server_dn_match,
10945        ssl_server_cert_dn,
10946    );
10947    format!("(DESCRIPTION={}{}{})", address, connect_data, security)
10948}
10949
10950fn descriptor_address_clause(descriptor: &EasyConnect) -> String {
10951    let protocol = match descriptor.protocol {
10952        NetProtocol::Tcp => "tcp",
10953        NetProtocol::Tcps => "tcps",
10954    };
10955    format!(
10956        "(ADDRESS=(PROTOCOL={})(HOST={})(PORT={}))",
10957        protocol, descriptor.host, descriptor.port
10958    )
10959}
10960
10961fn listener_connect_data_clause(
10962    description: &Description,
10963    identity: &ClientIdentity,
10964    server_type_emon: bool,
10965) -> String {
10966    let mut out = auth_connect_data_clause_from_service(description, None);
10967    if server_type_emon {
10968        out.push_str("(SERVER=emon)");
10969    } else if let Some(server_type) = description.connect_data.server_type {
10970        out.push_str("(SERVER=");
10971        out.push_str(server_type.as_str());
10972        out.push(')');
10973    }
10974    for (key, value) in &description.connect_data.extra {
10975        out.push('(');
10976        out.push_str(key);
10977        out.push('=');
10978        out.push_str(value);
10979        out.push(')');
10980    }
10981    out.push_str("(CID=(PROGRAM=");
10982    out.push_str(&identity.program);
10983    out.push_str(")(HOST=");
10984    out.push_str(&identity.machine);
10985    out.push_str(")(USER=");
10986    out.push_str(&identity.osuser);
10987    out.push_str(")))");
10988    out
10989}
10990
10991fn auth_connect_data_clause(description: &Description, descriptor: &EasyConnect) -> String {
10992    let mut out =
10993        auth_connect_data_clause_from_service(description, Some(&descriptor.service_name));
10994    if let Some(server_type) = description.connect_data.server_type {
10995        out.push_str("(SERVER=");
10996        out.push_str(server_type.as_str());
10997        out.push(')');
10998    }
10999    for (key, value) in &description.connect_data.extra {
11000        out.push('(');
11001        out.push_str(key);
11002        out.push('=');
11003        out.push_str(value);
11004        out.push(')');
11005    }
11006    out.push(')');
11007    out
11008}
11009
11010fn auth_connect_data_clause_from_service(
11011    description: &Description,
11012    fallback_service_name: Option<&str>,
11013) -> String {
11014    let service_name = description
11015        .connect_data
11016        .service_name
11017        .as_deref()
11018        .or(fallback_service_name)
11019        .unwrap_or("");
11020    format!("(CONNECT_DATA=(SERVICE_NAME={service_name})")
11021}
11022
11023fn descriptor_security_clause(
11024    protocol: NetProtocol,
11025    description: &Description,
11026    token_auth: bool,
11027    ssl_server_dn_match: bool,
11028    ssl_server_cert_dn: Option<&str>,
11029) -> String {
11030    let security = &description.security;
11031    if !protocol.is_tls()
11032        && !token_auth
11033        && ssl_server_dn_match
11034        && ssl_server_cert_dn.is_none()
11035        && security.extra.is_empty()
11036    {
11037        return String::new();
11038    }
11039
11040    let mut out = String::from("(SECURITY=");
11041    if protocol.is_tls() || !ssl_server_dn_match {
11042        out.push_str("(SSL_SERVER_DN_MATCH=");
11043        out.push_str(if ssl_server_dn_match { "ON" } else { "OFF" });
11044        out.push(')');
11045    }
11046    if let Some(cert_dn) = ssl_server_cert_dn {
11047        out.push_str("(SSL_SERVER_CERT_DN=");
11048        out.push_str(cert_dn);
11049        out.push(')');
11050    }
11051    for (key, value) in &security.extra {
11052        if key.eq_ignore_ascii_case("TOKEN_AUTH") {
11053            continue;
11054        }
11055        out.push('(');
11056        out.push_str(key);
11057        out.push('=');
11058        out.push_str(value);
11059        out.push(')');
11060    }
11061    if token_auth {
11062        out.push_str("(TOKEN_AUTH=OCI_TOKEN)");
11063    }
11064    out.push(')');
11065    out
11066}
11067
11068fn parse_session_u32(
11069    data: &std::collections::BTreeMap<String, String>,
11070    key: &'static str,
11071) -> Result<u32> {
11072    data.get(key)
11073        .ok_or(Error::MissingSessionField(key))?
11074        .parse::<u32>()
11075        .map_err(|_| Error::MissingSessionField(key))
11076}
11077
11078fn parse_session_u16(
11079    data: &std::collections::BTreeMap<String, String>,
11080    key: &'static str,
11081) -> Result<u16> {
11082    // Parse straight into the target width. Going through u64 and casting with
11083    // `as u16` silently wrapped, so AUTH_SERIAL_NUM=70000 was reported as 4464
11084    // and the connection carried a session serial the server never issued —
11085    // which is the number cancellation and session correlation are matched on
11086    // (finding DC7, bead oraclemcp-eng-program-bp8ia.7.11.3).
11087    data.get(key)
11088        .ok_or(Error::MissingSessionField(key))?
11089        .parse::<u16>()
11090        .map_err(|_| Error::MissingSessionField(key))
11091}
11092
11093/// Extract `db_unique_name` from the AUTH phase-two session data: the value of
11094/// `AUTH_SC_REAL_DBUNIQUE_NAME` ONLY (reference 16a57f1cbd58). This key is
11095/// DISTINCT from `AUTH_SC_DBUNIQUE_NAME` (which upstream maps to `db_name`), so
11096/// there is deliberately no fallback. `None` when the key is absent.
11097fn parse_db_unique_name(data: &std::collections::BTreeMap<String, String>) -> Option<String> {
11098    data.get("AUTH_SC_REAL_DBUNIQUE_NAME").cloned()
11099}
11100
11101fn next_ttc_sequence(seq_num: &mut u8) -> u8 {
11102    *seq_num = seq_num.wrapping_add(1);
11103    if *seq_num == 0 {
11104        *seq_num = 1;
11105    }
11106    *seq_num
11107}
11108
11109/// LRU statement-cache insert, bounded to `capacity`. Moves an existing entry
11110/// for `sql` to most-recently-used (replacing its cursor) and evicts the oldest
11111/// entries past `capacity`. Returns the cursor ids that should be closed (the
11112/// replaced cursor and any evicted ones). `capacity == 0` disables caching: the
11113/// freshly inserted cursor is itself evicted and returned for closing, so the
11114/// cache stays empty (python-oracledb `stmtcachesize=0`). A `cursor_id` of 0
11115/// (no server cursor) is never cached. Pure so it is unit-testable without a
11116/// live connection.
11117fn statement_cache_insert(
11118    cache: &mut Vec<CachedStatement>,
11119    capacity: usize,
11120    sql: &str,
11121    cursor_id: u32,
11122    mut bind_shape: Vec<BindShapeSlot>,
11123) -> Vec<u32> {
11124    let mut to_close = Vec::new();
11125    if cursor_id == 0 {
11126        return to_close;
11127    }
11128    if let Some(index) = cache.iter().position(|entry| entry.sql == sql) {
11129        let old = cache.remove(index);
11130        if old.cursor_id != 0 && old.cursor_id != cursor_id {
11131            to_close.push(old.cursor_id);
11132        } else if old.cursor_id == cursor_id && old.bind_shape.len() == bind_shape.len() {
11133            // Re-execution of the SAME open cursor: an untyped-NULL bind is
11134            // written with placeholder VARCHAR metadata and does not disturb
11135            // the type the cursor was parsed with, so the previous concrete
11136            // slot is inherited instead of downgrading it to the placeholder.
11137            for (slot, old_slot) in bind_shape.iter_mut().zip(old.bind_shape) {
11138                if slot.untyped_null {
11139                    *slot = old_slot;
11140                }
11141            }
11142        }
11143    }
11144    cache.push(CachedStatement {
11145        sql: sql.to_string(),
11146        cursor_id,
11147        bind_shape,
11148    });
11149    while cache.len() > capacity {
11150        let evicted = cache.remove(0);
11151        if evicted.cursor_id != 0 {
11152            to_close.push(evicted.cursor_id);
11153        }
11154    }
11155    to_close
11156}
11157
11158/// One statement-cache entry: the open server cursor for a SQL text and the
11159/// bind TYPE shape it was last bound/parsed with (see
11160/// [`Connection::statement_cache`] and bead rust-oracledb-ilel).
11161#[derive(Clone, Debug, PartialEq, Eq)]
11162struct CachedStatement {
11163    sql: String,
11164    cursor_id: u32,
11165    bind_shape: Vec<BindShapeSlot>,
11166}
11167
11168/// Per-position bind TYPE shape used to decide whether an open cached cursor
11169/// may be re-executed with the current binds. The server resolves bind
11170/// conversions against the metadata the cursor was PARSED with, not the
11171/// metadata sent on a re-execute: rebinding a different type through a cached
11172/// cursor makes the server coerce through the stale type (ORA-01722 when text
11173/// rides a NUMBER-parsed cursor). python-oracledb tracks the same change via
11174/// `Statement._binds_changed` (thin/statement.pyx `_set_var`).
11175#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11176struct BindShapeSlot {
11177    /// Bind type folded to its interchangeable family: CHAR/VARCHAR/LONG all
11178    /// map to VARCHAR and RAW/LONG_RAW to RAW (mirroring the wire writer's
11179    /// `bind_metadata_types_are_compatible`), so a pure SIZE change never
11180    /// invalidates the cursor — only a TYPE change does.
11181    family: u8,
11182    /// Character-set form: distinguishes NCHAR text from implicit-charset
11183    /// text (python-oracledb `_set_var` also compares `csfrm`).
11184    csfrm: u8,
11185    /// True when every row's value at this position is an untyped NULL. Such
11186    /// a bind is written with placeholder VARCHAR metadata and converts to
11187    /// any parsed type server-side, so it is compatible with any cached slot.
11188    untyped_null: bool,
11189}
11190
11191/// Folds a wire bind type into its statement-cache compatibility family (the
11192/// same classes the metadata writer merges across bind rows).
11193fn bind_type_family(ora_type_num: u8) -> u8 {
11194    use oracledb_protocol::thin::{
11195        ORA_TYPE_NUM_CHAR, ORA_TYPE_NUM_LONG, ORA_TYPE_NUM_LONG_RAW, ORA_TYPE_NUM_RAW,
11196        ORA_TYPE_NUM_VARCHAR,
11197    };
11198    match ora_type_num {
11199        ORA_TYPE_NUM_CHAR | ORA_TYPE_NUM_LONG => ORA_TYPE_NUM_VARCHAR,
11200        ORA_TYPE_NUM_LONG_RAW => ORA_TYPE_NUM_RAW,
11201        other => other,
11202    }
11203}
11204
11205/// Computes the bind TYPE shape of an execute's bind rows. Mirrors the wire
11206/// metadata writer's inference (`write_bind_metadata_for_rows`): the first
11207/// non-NULL value in a column determines its type; a column that is untyped
11208/// NULL in every row is a wildcard slot.
11209fn bind_type_shape(bind_rows: &[Vec<BindValue>]) -> Vec<BindShapeSlot> {
11210    use oracledb_protocol::thin::{bind_value_type_info, CS_FORM_IMPLICIT, ORA_TYPE_NUM_VARCHAR};
11211    let Some(first_row) = bind_rows.first() else {
11212        return Vec::new();
11213    };
11214    (0..first_row.len())
11215        .map(|index| {
11216            let info = bind_rows
11217                .iter()
11218                .find_map(|row| row.get(index).and_then(bind_value_type_info));
11219            match info {
11220                Some(info) => BindShapeSlot {
11221                    family: bind_type_family(info.ora_type_num),
11222                    csfrm: info.csfrm,
11223                    untyped_null: false,
11224                },
11225                None => BindShapeSlot {
11226                    family: ORA_TYPE_NUM_VARCHAR,
11227                    csfrm: CS_FORM_IMPLICIT,
11228                    untyped_null: true,
11229                },
11230            }
11231        })
11232        .collect()
11233}
11234
11235/// True when a cached cursor bound with `cached` may be re-executed with
11236/// binds of shape `new`: every position must keep its type family + charset
11237/// form, except that an untyped NULL in the NEW binds matches anything (it is
11238/// written as a placeholder and null-converts to any parsed type).
11239fn bind_shape_is_compatible(cached: &[BindShapeSlot], new: &[BindShapeSlot]) -> bool {
11240    cached.len() == new.len()
11241        && cached.iter().zip(new).all(|(cached, new)| {
11242            new.untyped_null || (cached.family == new.family && cached.csfrm == new.csfrm)
11243        })
11244}
11245
11246/// Returns whether Oracle will execute `sql` on its row-producing path.
11247///
11248/// A leading `WITH` does not determine that by itself: Oracle permits a CTE
11249/// list before `SELECT` and before DML. Walk each complete CTE definition, then
11250/// inspect the first statement keyword after the list. Syntax we cannot
11251/// confidently walk stays on the non-query path.
11252fn statement_is_query(sql: &str) -> bool {
11253    let mut cursor = SqlStatementCursor::new(sql);
11254    match cursor.next_keyword() {
11255        Some(keyword) if keyword.eq_ignore_ascii_case("select") => true,
11256        Some(keyword) if keyword.eq_ignore_ascii_case("with") => cursor
11257            .cte_statement_keyword()
11258            .is_some_and(|keyword| keyword.eq_ignore_ascii_case("select")),
11259        _ => false,
11260    }
11261}
11262
11263/// A deliberately narrow SQL cursor for query-path selection. It understands
11264/// comments, quoted strings/identifiers, balanced parentheses, and the CTE
11265/// grammar prefix; it is not a general SQL parser.
11266struct SqlStatementCursor<'a> {
11267    bytes: &'a [u8],
11268    index: usize,
11269}
11270
11271impl<'a> SqlStatementCursor<'a> {
11272    fn new(sql: &'a str) -> Self {
11273        Self {
11274            bytes: sql.as_bytes(),
11275            index: 0,
11276        }
11277    }
11278
11279    fn next_keyword(&mut self) -> Option<&'a str> {
11280        self.skip_trivia();
11281        let start = self.index;
11282        while self
11283            .bytes
11284            .get(self.index)
11285            .is_some_and(u8::is_ascii_alphabetic)
11286        {
11287            self.index += 1;
11288        }
11289        (start != self.index)
11290            .then(|| std::str::from_utf8(&self.bytes[start..self.index]).ok())
11291            .flatten()
11292    }
11293
11294    /// Return the statement keyword after one or more conventional CTEs.
11295    ///
11296    /// This accepts only `name [(columns)] AS (subquery)` definitions. A
11297    /// `WITH FUNCTION` or malformed `WITH` is intentionally not interpreted
11298    /// as a query, because treating it as one could route a non-query through
11299    /// the fetch path.
11300    fn cte_statement_keyword(&mut self) -> Option<&'a str> {
11301        loop {
11302            if !self.consume_identifier() {
11303                return None;
11304            }
11305            self.skip_trivia();
11306            if self.peek() == Some(b'(') && !self.consume_parentheses() {
11307                return None;
11308            }
11309            if !self.consume_keyword("as") || !self.consume_parentheses() {
11310                return None;
11311            }
11312            self.skip_trivia();
11313            if self.peek() == Some(b',') {
11314                self.index += 1;
11315                continue;
11316            }
11317            return self.next_keyword();
11318        }
11319    }
11320
11321    fn consume_keyword(&mut self, expected: &str) -> bool {
11322        self.next_keyword()
11323            .is_some_and(|keyword| keyword.eq_ignore_ascii_case(expected))
11324    }
11325
11326    fn consume_identifier(&mut self) -> bool {
11327        self.skip_trivia();
11328        if self.peek() == Some(b'\"') {
11329            return self.consume_quoted_identifier();
11330        }
11331        let start = self.index;
11332        if !self.peek().is_some_and(|byte| byte.is_ascii_alphabetic()) {
11333            return false;
11334        }
11335        while self
11336            .bytes
11337            .get(self.index)
11338            .is_some_and(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'$' | b'#'))
11339        {
11340            self.index += 1;
11341        }
11342        start != self.index
11343    }
11344
11345    fn consume_parentheses(&mut self) -> bool {
11346        self.skip_trivia();
11347        if self.peek() != Some(b'(') {
11348            return false;
11349        }
11350        let mut depth = 0_u32;
11351        while let Some(byte) = self.peek() {
11352            match byte {
11353                b'\'' => {
11354                    if !self.consume_single_quoted() {
11355                        return false;
11356                    }
11357                }
11358                b'\"' => {
11359                    if !self.consume_quoted_identifier() {
11360                        return false;
11361                    }
11362                }
11363                b'q' | b'Q' if self.bytes.get(self.index + 1) == Some(&b'\'') => {
11364                    if !self.consume_q_quoted() {
11365                        return false;
11366                    }
11367                }
11368                b'-' if self.bytes.get(self.index + 1) == Some(&b'-') => {
11369                    self.skip_line_comment();
11370                }
11371                b'/' if self.bytes.get(self.index + 1) == Some(&b'*') => {
11372                    if !self.skip_block_comment() {
11373                        return false;
11374                    }
11375                }
11376                b'(' => {
11377                    depth += 1;
11378                    self.index += 1;
11379                }
11380                b')' => {
11381                    if depth == 0 {
11382                        return false;
11383                    }
11384                    depth -= 1;
11385                    self.index += 1;
11386                    if depth == 0 {
11387                        return true;
11388                    }
11389                }
11390                _ => self.index += 1,
11391            }
11392        }
11393        false
11394    }
11395
11396    fn skip_trivia(&mut self) {
11397        loop {
11398            while self.peek().is_some_and(|byte| byte.is_ascii_whitespace()) {
11399                self.index += 1;
11400            }
11401            match (self.peek(), self.bytes.get(self.index + 1)) {
11402                (Some(b'-'), Some(b'-')) => self.skip_line_comment(),
11403                (Some(b'/'), Some(b'*')) => {
11404                    if !self.skip_block_comment() {
11405                        return;
11406                    }
11407                }
11408                _ => return,
11409            }
11410        }
11411    }
11412
11413    fn consume_single_quoted(&mut self) -> bool {
11414        debug_assert_eq!(self.peek(), Some(b'\''));
11415        self.index += 1;
11416        while let Some(byte) = self.peek() {
11417            self.index += 1;
11418            if byte == b'\'' {
11419                if self.peek() == Some(b'\'') {
11420                    self.index += 1;
11421                } else {
11422                    return true;
11423                }
11424            }
11425        }
11426        false
11427    }
11428
11429    fn consume_quoted_identifier(&mut self) -> bool {
11430        debug_assert_eq!(self.peek(), Some(b'\"'));
11431        self.index += 1;
11432        while let Some(byte) = self.peek() {
11433            self.index += 1;
11434            if byte == b'\"' {
11435                if self.peek() == Some(b'\"') {
11436                    self.index += 1;
11437                } else {
11438                    return true;
11439                }
11440            }
11441        }
11442        false
11443    }
11444
11445    fn consume_q_quoted(&mut self) -> bool {
11446        debug_assert!(matches!(self.peek(), Some(b'q' | b'Q')));
11447        let Some(&opening) = self.bytes.get(self.index + 2) else {
11448            return false;
11449        };
11450        let closing = match opening {
11451            b'[' => b']',
11452            b'{' => b'}',
11453            b'(' => b')',
11454            b'<' => b'>',
11455            other => other,
11456        };
11457        self.index += 3;
11458        while self.index + 1 < self.bytes.len() {
11459            if self.bytes[self.index] == closing && self.bytes[self.index + 1] == b'\'' {
11460                self.index += 2;
11461                return true;
11462            }
11463            self.index += 1;
11464        }
11465        false
11466    }
11467
11468    fn skip_line_comment(&mut self) {
11469        debug_assert_eq!(
11470            self.bytes.get(self.index..self.index + 2),
11471            Some(b"--".as_slice())
11472        );
11473        self.index += 2;
11474        while self
11475            .peek()
11476            .is_some_and(|byte| !matches!(byte, b'\n' | b'\r'))
11477        {
11478            self.index += 1;
11479        }
11480    }
11481
11482    fn skip_block_comment(&mut self) -> bool {
11483        debug_assert_eq!(
11484            self.bytes.get(self.index..self.index + 2),
11485            Some(b"/*".as_slice())
11486        );
11487        self.index += 2;
11488        while self.index + 1 < self.bytes.len() {
11489            if self.bytes[self.index] == b'*' && self.bytes[self.index + 1] == b'/' {
11490                self.index += 2;
11491                return true;
11492            }
11493            self.index += 1;
11494        }
11495        false
11496    }
11497
11498    fn peek(&self) -> Option<u8> {
11499        self.bytes.get(self.index).copied()
11500    }
11501}
11502
11503/// True when any column needs a client-side define to stream its value:
11504/// `CLOB` / `BLOB` / `VECTOR` / native `JSON`. Such columns come back from the
11505/// initial execute as describe-only metadata; the value is delivered on a
11506/// follow-up define-fetch round trip (reference `statement._requires_define`).
11507fn columns_require_define(columns: &[ColumnMetadata]) -> bool {
11508    use oracledb_protocol::thin::{
11509        ORA_TYPE_NUM_BLOB, ORA_TYPE_NUM_CLOB, ORA_TYPE_NUM_JSON, ORA_TYPE_NUM_VECTOR,
11510    };
11511    columns.iter().any(|column| {
11512        matches!(
11513            column.ora_type_num(),
11514            ORA_TYPE_NUM_CLOB | ORA_TYPE_NUM_BLOB | ORA_TYPE_NUM_VECTOR | ORA_TYPE_NUM_JSON
11515        )
11516    })
11517}
11518
11519fn columns_have_lob_prefetch_fields(columns: &[ColumnMetadata]) -> bool {
11520    use oracledb_protocol::thin::{ORA_TYPE_NUM_BLOB, ORA_TYPE_NUM_CLOB};
11521    columns
11522        .iter()
11523        .any(|column| matches!(column.ora_type_num(), ORA_TYPE_NUM_CLOB | ORA_TYPE_NUM_BLOB))
11524}
11525
11526/// Columns that warrant an `ALL_JSON_COLUMNS` probe to learn whether a CLOB/BLOB
11527/// actually stores JSON: not already flagged JSON, of LOB type, and named (an
11528/// unnamed expression column cannot be looked up by name). Returns each
11529/// candidate's index paired with its upper-cased name (the catalog stores names
11530/// upper-cased) so the caller can flip `is_json` in place after the probe.
11531fn json_lob_probe_candidates(columns: &[ColumnMetadata]) -> Vec<(usize, String)> {
11532    use oracledb_protocol::thin::{ORA_TYPE_NUM_BLOB, ORA_TYPE_NUM_CLOB};
11533    columns
11534        .iter()
11535        .enumerate()
11536        .filter(|(_, metadata)| {
11537            !metadata.is_json()
11538                && matches!(
11539                    metadata.ora_type_num(),
11540                    ORA_TYPE_NUM_CLOB | ORA_TYPE_NUM_BLOB
11541                )
11542                && !metadata.name().is_empty()
11543        })
11544        .map(|(index, metadata)| (index, metadata.name().to_ascii_uppercase()))
11545        .collect()
11546}
11547
11548fn trace_connect_step(step: &'static str) {
11549    if std::env::var_os("ORACLEDB_TRACE_CONNECT").is_some() {
11550        eprintln!("oracledb::connect: {step}");
11551    }
11552}
11553
11554fn trace_connect_value(label: &'static str, value: &str) {
11555    if std::env::var_os("ORACLEDB_TRACE_CONNECT").is_some() {
11556        eprintln!("oracledb::connect: {label}: {value}");
11557    }
11558}
11559
11560fn trace_connect_bytes(label: &'static str, bytes: &[u8]) {
11561    if std::env::var_os("ORACLEDB_TRACE_CONNECT").is_some() {
11562        let mut hex = String::with_capacity(bytes.len() * 2);
11563        for byte in bytes {
11564            use std::fmt::Write as _;
11565            let _ = write!(&mut hex, "{byte:02x}");
11566        }
11567        eprintln!("oracledb::connect: {label} len={} hex={hex}", bytes.len());
11568    }
11569}
11570
11571fn trace_query_bytes(label: &'static str, bytes: &[u8]) {
11572    if std::env::var_os("ORACLEDB_TRACE_QUERY").is_some() {
11573        let mut hex = String::with_capacity(bytes.len() * 2);
11574        for byte in bytes {
11575            use std::fmt::Write as _;
11576            let _ = write!(&mut hex, "{byte:02x}");
11577        }
11578        eprintln!("oracledb::query: {label} len={} hex={hex}", bytes.len());
11579    }
11580}
11581
11582#[cfg(test)]
11583mod tests {
11584    use super::*;
11585    use asupersync::lab::{DporExplorer, ExplorerConfig, LabRuntime};
11586    use asupersync::types::{Budget, CancelKind, Time};
11587    use oracledb_protocol::thin::QueryValue;
11588    use std::future::{poll_fn, Future};
11589    use std::io::Read;
11590    use std::net::TcpListener;
11591    use std::pin::pin;
11592    use std::sync::atomic::{AtomicBool, Ordering};
11593    use std::task::{Poll, Waker};
11594    use std::thread;
11595    use std::time::{Duration, Instant};
11596
11597    #[test]
11598    fn statement_is_query_recognizes_select_after_cte_list() {
11599        for sql in [
11600            "WITH x AS (SELECT 1 AS id FROM dual) SELECT id FROM x",
11601            concat!(
11602                "WITH first_cte AS (SELECT 1 AS id FROM dual), ",
11603                "second_cte AS (SELECT id + 1 AS id FROM first_cte) ",
11604                "SELECT id FROM second_cte"
11605            ),
11606            concat!(
11607                "WITH outer_cte AS (",
11608                "WITH inner_cte AS (SELECT q'[)]' AS marker FROM dual) ",
11609                "SELECT marker FROM inner_cte",
11610                ") SELECT marker FROM outer_cte"
11611            ),
11612        ] {
11613            assert!(
11614                statement_is_query(sql),
11615                "CTE SELECT must use query path: {sql}"
11616            );
11617        }
11618    }
11619
11620    #[test]
11621    fn statement_is_query_rejects_cte_prefixed_dml_and_plsql() {
11622        for sql in [
11623            "WITH x AS (SELECT 1 AS id FROM dual) INSERT INTO cte_target (id) SELECT id FROM x",
11624            "WITH x AS (SELECT 1 AS id FROM dual) UPDATE cte_target SET id = id + 1",
11625            "WITH x AS (SELECT 1 AS id FROM dual) DELETE FROM cte_target WHERE id IN (SELECT id FROM x)",
11626            concat!(
11627                "WITH x AS (SELECT 1 AS id FROM dual) ",
11628                "MERGE INTO cte_target target USING x ON (target.id = x.id) ",
11629                "WHEN MATCHED THEN UPDATE SET target.id = x.id"
11630            ),
11631            concat!(
11632                "WITH FUNCTION answer RETURN NUMBER IS BEGIN RETURN 42; END; ",
11633                "SELECT answer FROM dual"
11634            ),
11635        ] {
11636            assert!(
11637                !statement_is_query(sql),
11638                "only SELECT after a conventional CTE list may use query path: {sql}"
11639            );
11640        }
11641    }
11642
11643    #[test]
11644    fn statement_is_query_skips_leading_whitespace_and_comments() {
11645        for sql in [
11646            " \n\t-- leading line comment\nWITH x AS (SELECT 1 FROM dual) SELECT * FROM x",
11647            "/* leading block comment */ WITH x AS (SELECT 1 FROM dual) SELECT * FROM x",
11648            "/* leading block comment */\nSELECT 1 FROM dual",
11649        ] {
11650            assert!(
11651                statement_is_query(sql),
11652                "comments must not hide a query: {sql}"
11653            );
11654        }
11655    }
11656
11657    // ---- bead rust-oracledb-clvm: DSN transport params (F1) + failover (F2) ----
11658
11659    /// Read the SDU advertised in a CONNECT-packet payload (the 4th `u16be`
11660    /// field; see `build_connect_packet_payload`).
11661    fn connect_packet_sdu(payload: &[u8]) -> u16 {
11662        u16::from_be_bytes([payload[6], payload[7]])
11663    }
11664
11665    #[test]
11666    fn f1_dsn_sdu_reaches_the_connect_config() {
11667        // A DSN-set SDU must reach the connect config when the structured
11668        // builder left SDU at its default (the common DSN-only case).
11669        let desc = EasyConnect::parse_descriptor(
11670            "(DESCRIPTION=(SDU=32768)(ADDRESS=(PROTOCOL=tcp)(HOST=h)(PORT=1521))\
11671             (CONNECT_DATA=(SERVICE_NAME=svc)))",
11672        )
11673        .expect("descriptor parses");
11674        let primary = desc.first_description();
11675        assert_eq!(primary.sdu, 32768, "parser must capture DSN SDU");
11676        // builder default (8192) => DSN SDU wins.
11677        let advertised =
11678            u16::try_from(resolve_effective_sdu(8192, primary)).expect("32768 fits u16");
11679        assert_eq!(advertised, 32768);
11680        // and it reaches the actual CONNECT packet bytes.
11681        let payload = build_connect_packet_payload(
11682            "(DESCRIPTION=(ADDRESS=(PROTOCOL=tcp)(HOST=h)(PORT=1521))\
11683             (CONNECT_DATA=(SERVICE_NAME=svc)))",
11684            advertised,
11685        )
11686        .expect("payload builds");
11687        assert_eq!(connect_packet_sdu(&payload), 32768);
11688    }
11689
11690    #[test]
11691    fn f1_effective_sdu_precedence() {
11692        let dsn = EasyConnect::parse_descriptor(
11693            "(DESCRIPTION=(SDU=16384)(ADDRESS=(PROTOCOL=tcp)(HOST=h)(PORT=1))\
11694             (CONNECT_DATA=(SERVICE_NAME=s)))",
11695        )
11696        .expect("parse");
11697        let desc = dsn.first_description();
11698        // explicit builder SDU wins over the DSN.
11699        assert_eq!(resolve_effective_sdu(4096, desc), 4096);
11700        // builder at default => DSN SDU used.
11701        assert_eq!(resolve_effective_sdu(8192, desc), 16384);
11702        // neither set => shared default.
11703        let plain = EasyConnect::parse_descriptor(
11704            "(DESCRIPTION=(ADDRESS=(PROTOCOL=tcp)(HOST=h)(PORT=1))(CONNECT_DATA=(SERVICE_NAME=s)))",
11705        )
11706        .expect("parse");
11707        assert_eq!(resolve_effective_sdu(8192, plain.first_description()), 8192);
11708    }
11709
11710    #[test]
11711    fn f1_dsn_transport_connect_timeout_reaches_the_deadline() {
11712        // A DSN-set transport_connect_timeout must drive the connect deadline,
11713        // not the hard-coded 20s.
11714        let desc = EasyConnect::parse_descriptor("h:1521/svc?transport_connect_timeout=3.5")
11715            .expect("EZConnect-Plus parses");
11716        let secs = desc.first_description().tcp_connect_timeout;
11717        assert!((secs - 3.5).abs() < 1e-9, "parser captured {secs}");
11718        let deadline = transport_connect_timeout_duration(secs);
11719        assert_eq!(deadline, Duration::from_secs_f64(3.5));
11720        // sanity: a descriptor without the param keeps the 20s default.
11721        let default_desc = EasyConnect::parse_descriptor("h:1521/svc").expect("parse");
11722        assert_eq!(
11723            transport_connect_timeout_duration(
11724                default_desc.first_description().tcp_connect_timeout
11725            ),
11726            Duration::from_secs(20)
11727        );
11728    }
11729
11730    /// GH#14: the TCP keepalive idle interval is derived from a DSN `EXPIRE_TIME`
11731    /// (minutes). `0`/absent disables it; a positive value maps to that many
11732    /// minutes of socket idle time.
11733    #[test]
11734    fn a11_keepalive_idle_derives_from_expire_time_minutes() {
11735        assert_eq!(keepalive_idle_from_expire_time(0), None);
11736        assert_eq!(
11737            keepalive_idle_from_expire_time(1),
11738            Some(Duration::from_secs(60))
11739        );
11740        assert_eq!(
11741            keepalive_idle_from_expire_time(5),
11742            Some(Duration::from_secs(300))
11743        );
11744        // Tie the derivation to the DSN parser: a descriptor `EXPIRE_TIME=2`
11745        // yields a 120s keepalive idle; the default descriptor disables it.
11746        let with_expire =
11747            EasyConnect::parse_descriptor("h:1521/svc?expire_time=2").expect("parse expire_time");
11748        assert_eq!(
11749            keepalive_idle_from_expire_time(with_expire.first_description().expire_time),
11750            Some(Duration::from_secs(120))
11751        );
11752        let default_desc = EasyConnect::parse_descriptor("h:1521/svc").expect("parse default");
11753        assert_eq!(
11754            keepalive_idle_from_expire_time(default_desc.first_description().expire_time),
11755            None
11756        );
11757    }
11758
11759    /// An `AsyncRead` that never yields a byte and never wakes — models a silent
11760    /// or half-open peer whose data (or FIN/RST) never arrives, so a naive read
11761    /// would hang forever.
11762    #[derive(Debug)]
11763    struct SilentRead;
11764
11765    impl asupersync::io::AsyncRead for SilentRead {
11766        fn poll_read(
11767            self: std::pin::Pin<&mut Self>,
11768            _cx: &mut std::task::Context<'_>,
11769            _buf: &mut asupersync::io::ReadBuf<'_>,
11770        ) -> std::task::Poll<std::io::Result<()>> {
11771            std::task::Poll::Pending
11772        }
11773    }
11774
11775    /// Emits one byte after each delay, modelling a slow large response that
11776    /// is continuously making progress rather than a stalled peer.
11777    struct ProgressRead {
11778        bytes: VecDeque<u8>,
11779        delay: Duration,
11780        sleep: Option<std::pin::Pin<Box<time::Sleep>>>,
11781    }
11782
11783    impl ProgressRead {
11784        fn new(bytes: impl IntoIterator<Item = u8>, delay: Duration) -> Self {
11785            Self {
11786                bytes: bytes.into_iter().collect(),
11787                delay,
11788                sleep: None,
11789            }
11790        }
11791    }
11792
11793    impl asupersync::io::AsyncRead for ProgressRead {
11794        fn poll_read(
11795            mut self: std::pin::Pin<&mut Self>,
11796            context: &mut std::task::Context<'_>,
11797            buffer: &mut asupersync::io::ReadBuf<'_>,
11798        ) -> std::task::Poll<std::io::Result<()>> {
11799            if self.bytes.is_empty() {
11800                return std::task::Poll::Ready(Ok(()));
11801            }
11802            let delay = self.delay;
11803            let sleep = self
11804                .sleep
11805                .get_or_insert_with(|| Box::pin(time::sleep(time::wall_now(), delay)));
11806            if sleep.as_mut().poll(context).is_pending() {
11807                return std::task::Poll::Pending;
11808            }
11809            self.sleep = None;
11810            buffer.put_slice(&[self.bytes.pop_front().expect("checked non-empty")]);
11811            std::task::Poll::Ready(Ok(()))
11812        }
11813    }
11814
11815    /// GH#14 / AC1+AC4+AC5: with an inactivity deadline set, a post-auth framing
11816    /// read against a silent server fails at ~the deadline with `CallTimeout`
11817    /// instead of hanging. Deterministic via the `SilentRead` mock transport.
11818    #[test]
11819    fn a11_inactivity_deadline_fires_on_a_silent_server() {
11820        let runtime = build_io_runtime().expect("io runtime");
11821        let elapsed = runtime.block_on(async {
11822            let mut source = SilentRead;
11823            let mut reader = InactivityRead::new(&mut source, Some(Duration::from_millis(200)));
11824            let started = std::time::Instant::now();
11825            let result = map_inactivity_timeout(
11826                read_packet_with_limits(
11827                    &mut reader,
11828                    PacketLengthWidth::Large32,
11829                    ProtocolLimits::DEFAULT,
11830                )
11831                .await,
11832            );
11833            let elapsed = started.elapsed();
11834            assert!(
11835                matches!(result, Err(Error::CallTimeout(_))),
11836                "expected CallTimeout, got {result:?}"
11837            );
11838            elapsed
11839        });
11840        // Fired promptly at ~200ms — nowhere near an unbounded hang. The bounds
11841        // are generous to stay robust on a loaded CI host.
11842        assert!(
11843            elapsed >= Duration::from_millis(150),
11844            "deadline fired too early ({elapsed:?}); it must honour the configured 200ms"
11845        );
11846        assert!(
11847            elapsed < Duration::from_secs(5),
11848            "deadline should fire promptly, took {elapsed:?}"
11849        );
11850    }
11851
11852    /// GH#14: continuous byte-level progress resets the idle timer. The full
11853    /// packet takes longer than the configured duration but every individual
11854    /// byte arrives sooner, so an operation deadline would fail this test while
11855    /// a true inactivity deadline succeeds.
11856    #[test]
11857    fn a11_inactivity_deadline_resets_after_every_read_progress() {
11858        let runtime = build_io_runtime().expect("io runtime");
11859        runtime.block_on(async {
11860            let packet = encode_packet(
11861                TNS_PACKET_TYPE_DATA,
11862                0,
11863                None,
11864                &[0x01, 0x02],
11865                PacketLengthWidth::Large32,
11866            )
11867            .expect("packet");
11868            let mut source = ProgressRead::new(packet, Duration::from_millis(35));
11869            let mut reader = InactivityRead::new(&mut source, Some(Duration::from_millis(100)));
11870            let started = std::time::Instant::now();
11871            let packet = map_inactivity_timeout(
11872                read_packet_with_limits(
11873                    &mut reader,
11874                    PacketLengthWidth::Large32,
11875                    ProtocolLimits::DEFAULT,
11876                )
11877                .await,
11878            )
11879            .expect("each byte arrived before the idle deadline");
11880            assert_eq!(packet.payload, vec![0x01, 0x02]);
11881            assert!(
11882                started.elapsed() > Duration::from_millis(250),
11883                "the whole packet must outlast one 100 ms inactivity window"
11884            );
11885        });
11886    }
11887
11888    #[test]
11889    fn f2_address_list_yields_all_addresses_in_order() {
11890        let desc = EasyConnect::parse_descriptor(
11891            "(DESCRIPTION=(ADDRESS_LIST=\
11892             (ADDRESS=(PROTOCOL=tcp)(HOST=primary)(PORT=1521))\
11893             (ADDRESS=(PROTOCOL=tcp)(HOST=standby)(PORT=1522)))\
11894             (CONNECT_DATA=(SERVICE_NAME=svc)))",
11895        )
11896        .expect("parse");
11897        let addrs = resolve_connect_addresses(&desc, NetProtocol::Tcp);
11898        assert_eq!(
11899            addrs,
11900            vec![
11901                ConnectAddress {
11902                    host: "primary".into(),
11903                    port: 1521
11904                },
11905                ConnectAddress {
11906                    host: "standby".into(),
11907                    port: 1522
11908                },
11909            ]
11910        );
11911    }
11912
11913    #[test]
11914    fn f2_failover_off_keeps_only_first_address() {
11915        let desc = EasyConnect::parse_descriptor(
11916            "(DESCRIPTION=(ADDRESS_LIST=(FAILOVER=OFF)\
11917             (ADDRESS=(PROTOCOL=tcp)(HOST=only)(PORT=1521))\
11918             (ADDRESS=(PROTOCOL=tcp)(HOST=nope)(PORT=1522)))\
11919             (CONNECT_DATA=(SERVICE_NAME=svc)))",
11920        )
11921        .expect("parse");
11922        let addrs = resolve_connect_addresses(&desc, NetProtocol::Tcp);
11923        assert_eq!(
11924            addrs,
11925            vec![ConnectAddress {
11926                host: "only".into(),
11927                port: 1521
11928            }]
11929        );
11930    }
11931
11932    #[test]
11933    fn f2_load_balance_preserves_address_set() {
11934        // LOAD_BALANCE may reorder, but every address must still be present.
11935        let desc = EasyConnect::parse_descriptor(
11936            "(DESCRIPTION=(ADDRESS_LIST=(LOAD_BALANCE=ON)\
11937             (ADDRESS=(PROTOCOL=tcp)(HOST=a)(PORT=1))\
11938             (ADDRESS=(PROTOCOL=tcp)(HOST=b)(PORT=2))\
11939             (ADDRESS=(PROTOCOL=tcp)(HOST=c)(PORT=3)))\
11940             (CONNECT_DATA=(SERVICE_NAME=svc)))",
11941        )
11942        .expect("parse");
11943        let mut addrs = resolve_connect_addresses(&desc, NetProtocol::Tcp);
11944        addrs.sort_by_key(|address| address.port);
11945        assert_eq!(
11946            addrs,
11947            vec![
11948                ConnectAddress {
11949                    host: "a".into(),
11950                    port: 1
11951                },
11952                ConnectAddress {
11953                    host: "b".into(),
11954                    port: 2
11955                },
11956                ConnectAddress {
11957                    host: "c".into(),
11958                    port: 3
11959                },
11960            ]
11961        );
11962    }
11963
11964    #[test]
11965    fn f2_only_primary_protocol_addresses_are_candidates() {
11966        // A mixed-protocol descriptor only yields the primary-protocol
11967        // endpoints (the transport/TLS setup is resolved for one protocol).
11968        let desc = EasyConnect::parse_descriptor(
11969            "(DESCRIPTION=(ADDRESS_LIST=\
11970             (ADDRESS=(PROTOCOL=tcp)(HOST=plain)(PORT=1521))\
11971             (ADDRESS=(PROTOCOL=tcps)(HOST=secure)(PORT=2484)))\
11972             (CONNECT_DATA=(SERVICE_NAME=svc)))",
11973        )
11974        .expect("parse");
11975        assert_eq!(
11976            resolve_connect_addresses(&desc, NetProtocol::Tcp),
11977            vec![ConnectAddress {
11978                host: "plain".into(),
11979                port: 1521
11980            }]
11981        );
11982        assert_eq!(
11983            resolve_connect_addresses(&desc, NetProtocol::Tcps),
11984            vec![ConnectAddress {
11985                host: "secure".into(),
11986                port: 2484
11987            }]
11988        );
11989    }
11990
11991    #[test]
11992    fn f2_transport_attempt_type_only_covers_io_and_tls_handshake() {
11993        // Only errors constructible inside `dial` can reach the failover loop.
11994        // Configuration/auth errors remain ordinary `Error` values and have no
11995        // conversion into this transport-attempt type.
11996        let io_failure = TransportEstablishmentError::from(std::io::Error::new(
11997            std::io::ErrorKind::ConnectionRefused,
11998            "refused",
11999        ));
12000        assert!(matches!(
12001            io_failure.into_driver_error(),
12002            Error::Io(error) if error.kind() == std::io::ErrorKind::ConnectionRefused
12003        ));
12004
12005        let tls_failure = TransportEstablishmentError::from(tls::TlsHandshakeError::new(
12006            "certificate rejected".to_string(),
12007        ));
12008        assert!(matches!(
12009            tls_failure.into_driver_error(),
12010            Error::Tls(detail) if detail == "TCPS handshake failed: certificate rejected"
12011        ));
12012    }
12013
12014    #[test]
12015    fn api_design_nothing_lost_map_covers_current_surface() {
12016        // `BindValue` / `QueryValue` are `#[non_exhaustive]`: the exhaustive
12017        // variant-name `match` that flags a newly-added variant now lives on the
12018        // types themselves (in oracledb-protocol), so this cross-crate test reads
12019        // through the stable `variant_name()` accessor instead of a fragile
12020        // external match. The compile-time tripwire is preserved where the enums
12021        // are defined.
12022        assert_eq!(BindValue::Null.variant_name(), "Null");
12023        assert_eq!(QueryValue::Text(String::new()).variant_name(), "Text");
12024
12025        let design = include_str!("../../../docs/API_DESIGN.md");
12026        for method in [
12027            "execute_query_for_registration",
12028            "execute_query",
12029            "execute_query_collect",
12030            "execute_query_with_timeout",
12031            "execute_query_with_binds",
12032            "execute_query_with_binds_and_timeout",
12033            "query",
12034            "query_named",
12035            "query_named_with_timeout",
12036            "execute_query_with_bind_rows",
12037            "execute_query_with_bind_rows_and_options",
12038            "execute_query_with_bind_rows_and_timeout",
12039            "execute_query_with_bind_rows_options_and_timeout",
12040        ] {
12041            assert!(design.contains(method), "API_DESIGN.md missing {method}");
12042        }
12043
12044        for group in 1..=24 {
12045            let marker = format!("C{group:02}");
12046            assert!(
12047                design.contains(&marker),
12048                "API_DESIGN.md missing capability group {marker}"
12049            );
12050        }
12051
12052        for field in [
12053            "batcherrors",
12054            "arraydmlrowcounts",
12055            "parse_only",
12056            "token_num",
12057            "cursor_id",
12058            "cache_statement",
12059            "scrollable",
12060            "fetch_orientation",
12061            "fetch_pos",
12062            "scroll_operation",
12063            "suspend_on_success",
12064            "no_prefetch",
12065            "registration_id",
12066            "max_string_size",
12067        ] {
12068            assert!(
12069                design.contains(field),
12070                "API_DESIGN.md missing ExecuteOptions field {field}"
12071            );
12072        }
12073
12074        let bind_variants = [
12075            "Null",
12076            "TypedNull",
12077            "Output",
12078            "ReturnOutput",
12079            "ObjectOutput",
12080            "ObjectInput",
12081            "Text",
12082            "Raw",
12083            "Lob",
12084            "Number",
12085            "BinaryInteger",
12086            "BinaryDouble",
12087            "BinaryFloat",
12088            "Boolean",
12089            "IntervalDS",
12090            "IntervalYM",
12091            "DateTime",
12092            "Timestamp",
12093            "TimestampTz",
12094            "Array",
12095            "Vector",
12096            "Json",
12097            "Cursor",
12098        ];
12099        assert_eq!(bind_variants.len(), 23);
12100        for variant in bind_variants {
12101            assert!(
12102                design.contains(&format!("`{variant}`")),
12103                "API_DESIGN.md missing BindValue::{variant}"
12104            );
12105        }
12106
12107        let query_variants = [
12108            "Text",
12109            "TextRaw",
12110            "Raw",
12111            "Rowid",
12112            "BinaryDouble",
12113            "IntervalDS",
12114            "IntervalYM",
12115            "Number",
12116            "Boolean",
12117            "Cursor",
12118            "DateTime",
12119            "TimestampTz",
12120            "Object",
12121            "Lob",
12122            "Vector",
12123            "Json",
12124            "Array",
12125        ];
12126        assert_eq!(query_variants.len(), 17);
12127        for variant in query_variants {
12128            assert!(
12129                design.contains(&format!("`{variant}`")),
12130                "API_DESIGN.md missing QueryValue::{variant}"
12131            );
12132        }
12133    }
12134
12135    #[test]
12136    fn migration_guide_covers_every_deprecated_method() {
12137        // No orphan removal: every old execute/query name that carried a
12138        // `#[deprecated(since = "0.3.0")]` shim and is now removed in
12139        // 0.5.0 must still appear in the user-facing 0.3.0 migration
12140        // guide, so an external consumer can always find the replacement.
12141        // These are exactly the names listed in API_DESIGN.md §8.
12142        let guide = include_str!("../../../docs/MIGRATING-0.3.md");
12143        for method in [
12144            "execute_query",
12145            "execute_query_collect",
12146            "execute_query_with_timeout",
12147            "execute_query_with_binds",
12148            "execute_query_with_binds_and_timeout",
12149            "query_named",
12150            "query_named_with_timeout",
12151            "execute_query_with_bind_rows",
12152            "execute_query_with_bind_rows_and_options",
12153            "execute_query_with_bind_rows_and_timeout",
12154            "execute_query_with_bind_rows_options_and_timeout",
12155            "execute_query_for_registration",
12156        ] {
12157            assert!(
12158                guide.contains(method),
12159                "MIGRATING-0.3.md missing deprecated method {method}"
12160            );
12161        }
12162        // The replacement families and builders must be documented too.
12163        for replacement in [
12164            "query_with",
12165            "execute_with",
12166            "execute_many",
12167            "execute_many_with",
12168            "register_query",
12169            "query_one",
12170            "query_opt",
12171            "query_all",
12172            "Query::timeout",
12173            "Execute::raw_options",
12174            "Batch::raw_options",
12175            "Registration::new",
12176            "execute_raw",
12177        ] {
12178            assert!(
12179                guide.contains(replacement),
12180                "MIGRATING-0.3.md missing replacement {replacement}"
12181            );
12182        }
12183        // The one-release removal window must be stated so external users know
12184        // the shims disappear in 0.5.0.
12185        assert!(
12186            guide.contains("0.5.0"),
12187            "MIGRATING-0.3.md must state the shims are removed in 0.5.0"
12188        );
12189    }
12190
12191    fn cache_entry(sql: &str, cursor_id: u32) -> CachedStatement {
12192        CachedStatement {
12193            sql: sql.into(),
12194            cursor_id,
12195            bind_shape: Vec::new(),
12196        }
12197    }
12198
12199    #[test]
12200    fn statement_cache_evicts_lru_past_capacity() {
12201        let mut cache = Vec::new();
12202        // capacity 2: a third distinct statement evicts the oldest (cursor 10).
12203        assert!(statement_cache_insert(&mut cache, 2, "a", 10, Vec::new()).is_empty());
12204        assert!(statement_cache_insert(&mut cache, 2, "b", 11, Vec::new()).is_empty());
12205        assert_eq!(
12206            statement_cache_insert(&mut cache, 2, "c", 12, Vec::new()),
12207            vec![10]
12208        );
12209        assert_eq!(
12210            cache,
12211            vec![cache_entry("b", 11), cache_entry("c", 12)],
12212            "LRU order retained"
12213        );
12214        // Re-inserting an existing SQL with a new cursor closes the old cursor
12215        // and moves it to most-recently-used; nothing is evicted.
12216        assert_eq!(
12217            statement_cache_insert(&mut cache, 2, "b", 99, Vec::new()),
12218            vec![11]
12219        );
12220        assert_eq!(cache, vec![cache_entry("c", 12), cache_entry("b", 99)]);
12221    }
12222
12223    #[test]
12224    fn statement_cache_size_zero_disables_caching() {
12225        let mut cache = Vec::new();
12226        // capacity 0: the freshly inserted cursor is itself evicted (queued for
12227        // close) and the cache stays empty — caching disabled.
12228        assert_eq!(
12229            statement_cache_insert(&mut cache, 0, "a", 10, Vec::new()),
12230            vec![10]
12231        );
12232        assert!(cache.is_empty(), "size 0 must never retain a statement");
12233        // A no-cursor (0) insert is never cached and closes nothing.
12234        assert!(statement_cache_insert(&mut cache, 5, "a", 0, Vec::new()).is_empty());
12235        assert!(cache.is_empty());
12236    }
12237
12238    fn shape_of(values: &[BindValue]) -> Vec<BindShapeSlot> {
12239        bind_type_shape(&[values.to_vec()])
12240    }
12241
12242    #[test]
12243    fn bind_type_shape_change_is_incompatible_but_size_change_is_not() {
12244        use oracledb_protocol::thin::ORA_TYPE_NUM_LONG;
12245        let number = shape_of(&[BindValue::Number("42".into())]);
12246        let short_text = shape_of(&[BindValue::Text("a".into())]);
12247        let long_text = shape_of(&[BindValue::Text("x".repeat(40_000))]);
12248        let raw = shape_of(&[BindValue::Raw(vec![1, 2, 3])]);
12249        // The repro from bead rust-oracledb-ilel: NUMBER -> TEXT must NOT
12250        // reuse the cached cursor (server-side ORA-01722), nor TEXT -> RAW.
12251        assert!(!bind_shape_is_compatible(&number, &short_text));
12252        assert!(!bind_shape_is_compatible(&short_text, &number));
12253        assert!(!bind_shape_is_compatible(&short_text, &raw));
12254        assert!(!bind_shape_is_compatible(&raw, &number));
12255        // Same type family: identical, and a pure size change stays
12256        // compatible (no re-parse on every string-length change).
12257        assert!(bind_shape_is_compatible(&number, &number));
12258        assert!(bind_shape_is_compatible(&short_text, &long_text));
12259        assert!(bind_shape_is_compatible(&long_text, &short_text));
12260        // CHAR/VARCHAR/LONG fold into one family (the wire writer merges them
12261        // via bind_metadata_types_are_compatible).
12262        let long_typed = shape_of(&[BindValue::TypedNull {
12263            ora_type_num: ORA_TYPE_NUM_LONG,
12264            csfrm: 1,
12265            buffer_size: 10,
12266        }]);
12267        assert!(bind_shape_is_compatible(&short_text, &long_typed));
12268        // Bind-count mismatch is never compatible.
12269        assert!(!bind_shape_is_compatible(
12270            &number,
12271            &shape_of(&[BindValue::Number("1".into()), BindValue::Number("2".into())])
12272        ));
12273    }
12274
12275    #[test]
12276    fn bind_type_shape_untyped_null_matches_any_cached_slot() {
12277        let number = shape_of(&[BindValue::Number("42".into())]);
12278        let null = shape_of(&[BindValue::Null]);
12279        assert!(null[0].untyped_null);
12280        // A NULL bind rides any cached cursor (written as a placeholder, the
12281        // value null-converts server-side)...
12282        assert!(bind_shape_is_compatible(&number, &null));
12283        // ...but a concrete NUMBER does not ride a cursor parsed with the
12284        // VARCHAR placeholder: re-parse for correct select-list typing.
12285        assert!(!bind_shape_is_compatible(&null, &number));
12286        // Text matches the placeholder family.
12287        assert!(bind_shape_is_compatible(
12288            &null,
12289            &shape_of(&[BindValue::Text("x".into())])
12290        ));
12291    }
12292
12293    #[test]
12294    fn bind_type_shape_infers_column_type_from_first_non_null_row() {
12295        // executemany with a leading NULL: the column type comes from the
12296        // first non-NULL row (same inference as the wire metadata writer).
12297        let rows = vec![
12298            vec![BindValue::Null],
12299            vec![BindValue::Number("7".into())],
12300            vec![BindValue::Null],
12301        ];
12302        let shape = bind_type_shape(&rows);
12303        assert!(!shape[0].untyped_null);
12304        assert_eq!(
12305            shape,
12306            shape_of(&[BindValue::Number("7".into())]),
12307            "inferred NUMBER column"
12308        );
12309        // All-NULL column stays a wildcard.
12310        let all_null = bind_type_shape(&[vec![BindValue::Null], vec![BindValue::Null]]);
12311        assert!(all_null[0].untyped_null);
12312    }
12313
12314    #[test]
12315    fn statement_cache_insert_inherits_concrete_slot_over_untyped_null() {
12316        // First execute binds NUMBER; a later re-execute of the SAME cursor
12317        // binds NULL. The stored shape must keep NUMBER (the cursor is still
12318        // parsed for NUMBER), so a following NUMBER execute reuses it.
12319        let number = shape_of(&[BindValue::Number("42".into())]);
12320        let null = shape_of(&[BindValue::Null]);
12321        let mut cache = Vec::new();
12322        assert!(statement_cache_insert(&mut cache, 5, "q", 10, number.clone()).is_empty());
12323        assert!(statement_cache_insert(&mut cache, 5, "q", 10, null).is_empty());
12324        assert_eq!(cache[0].bind_shape, number, "concrete slot inherited");
12325        // A REPLACED cursor (fresh parse) records the new shape verbatim.
12326        let text = shape_of(&[BindValue::Text("x".into())]);
12327        assert_eq!(
12328            statement_cache_insert(&mut cache, 5, "q", 11, text.clone()),
12329            vec![10]
12330        );
12331        assert_eq!(cache[0].bind_shape, text);
12332    }
12333
12334    /// This test previously asserted the wrap it was named for
12335    /// (`auth_serial_num_truncates_to_low_u16_instead_of_rejecting`), pinning
12336    /// 70000 -> 4464 as intended behaviour. A wrapped serial is a session
12337    /// identity the server never issued, so the contract is now rejection.
12338    #[test]
12339    fn auth_serial_num_out_of_u16_range_is_rejected_not_wrapped() {
12340        for out_of_range in ["65536", "70000", "4294967296", "-1"] {
12341            let mut data = BTreeMap::new();
12342            data.insert("AUTH_SERIAL_NUM".to_string(), out_of_range.to_string());
12343            let error = parse_session_u16(&data, "AUTH_SERIAL_NUM")
12344                .expect_err("a serial outside u16 must not be silently wrapped");
12345            assert!(
12346                matches!(error, Error::MissingSessionField("AUTH_SERIAL_NUM")),
12347                "out-of-range {out_of_range} must use the existing typed session-field error, got {error:?}"
12348            );
12349        }
12350    }
12351
12352    /// Positive acceptance: the whole u16 domain still parses exactly, so
12353    /// ordinary authentication fixtures are unaffected.
12354    #[test]
12355    fn auth_serial_num_parses_the_full_u16_domain_exactly() {
12356        for (text, expected) in [("0", 0_u16), ("1", 1), ("4464", 4464), ("65535", 65535)] {
12357            let mut data = BTreeMap::new();
12358            data.insert("AUTH_SERIAL_NUM".to_string(), text.to_string());
12359            assert_eq!(
12360                parse_session_u16(&data, "AUTH_SERIAL_NUM").expect("in-range serial parses"),
12361                expected
12362            );
12363        }
12364    }
12365
12366    #[test]
12367    fn db_unique_name_reads_real_key_not_confused_with_dbunique_name() {
12368        // etib.8: db_unique_name comes from AUTH_SC_REAL_DBUNIQUE_NAME ONLY. It
12369        // must NOT be confused with AUTH_SC_DBUNIQUE_NAME (which upstream maps to
12370        // db_name), and there is no fallback to it.
12371        let mut data = BTreeMap::new();
12372        data.insert(
12373            "AUTH_SC_REAL_DBUNIQUE_NAME".to_string(),
12374            "MYCDB_UNIQUE".to_string(),
12375        );
12376        data.insert(
12377            "AUTH_SC_DBUNIQUE_NAME".to_string(),
12378            "MYCDB_SHOULD_NOT_WIN".to_string(),
12379        );
12380        assert_eq!(parse_db_unique_name(&data).as_deref(), Some("MYCDB_UNIQUE"));
12381
12382        // Only the non-REAL key present -> None (no fallback).
12383        let mut only_non_real = BTreeMap::new();
12384        only_non_real.insert("AUTH_SC_DBUNIQUE_NAME".to_string(), "MYCDB".to_string());
12385        assert_eq!(parse_db_unique_name(&only_non_real), None);
12386
12387        // Missing entirely -> None.
12388        assert_eq!(parse_db_unique_name(&BTreeMap::new()), None);
12389    }
12390
12391    // Character column of the first `needle` in `line` (chars, not bytes — the
12392    // caret aligns by display column, so multibyte chars must be counted as 1).
12393    fn char_col(line: &str, needle: char) -> usize {
12394        line.chars()
12395            .position(|c| c == needle)
12396            .expect("char present")
12397    }
12398
12399    #[test]
12400    fn caret_points_at_the_flagged_char() {
12401        // offset 8 (1-based) is the `x`.
12402        let out = render_caret("select x from t", 8, "ORA-00904: invalid identifier");
12403        let lines: Vec<&str> = out.lines().collect();
12404        assert_eq!(lines[0], "ORA-00904: invalid identifier");
12405        assert!(lines[2].ends_with("select x from t"), "{:?}", lines[2]);
12406        assert_eq!(
12407            char_col(lines[3], '^'),
12408            char_col(lines[2], 'x'),
12409            "caret column must align under the flagged char"
12410        );
12411    }
12412
12413    #[test]
12414    fn caret_handles_multiline_sql() {
12415        // "select *\n" is 9 chars (\n at index 8); the `n` of `no_such` is char
12416        // index 14 -> 1-based offset 15, on line 2.
12417        let out = render_caret("select *\nfrom no_such", 15, "ORA-00942");
12418        let lines: Vec<&str> = out.lines().collect();
12419        assert!(lines[2].starts_with("2 | from no_such"), "{:?}", lines[2]);
12420        assert_eq!(char_col(lines[3], '^'), char_col(lines[2], 'n'));
12421    }
12422
12423    #[test]
12424    fn caret_counts_unicode_scalar_values() {
12425        // The multibyte `é` before the offset must not push the caret off — we
12426        // count chars, not bytes.
12427        let sql = "select 'café' x from t";
12428        let x_idx = sql.chars().position(|c| c == 'x').unwrap();
12429        let out = render_caret(sql, x_idx + 1, "h");
12430        let lines: Vec<&str> = out.lines().collect();
12431        assert_eq!(char_col(lines[3], '^'), char_col(lines[2], 'x'));
12432    }
12433
12434    #[test]
12435    fn caret_clamps_and_never_panics() {
12436        // out-of-range, zero, and empty inputs must render without panicking.
12437        let _ = render_caret("select 1", 999, "h");
12438        let _ = render_caret("select 1", 0, "h");
12439        let _ = render_caret("", 5, "h");
12440        assert!(render_caret("abc", 4, "h").ends_with('^'));
12441    }
12442
12443    fn identity() -> ClientIdentity {
12444        ClientIdentity::new("program", "machine", "osuser", "terminal", "driver")
12445            .expect("test identity should be valid")
12446    }
12447
12448    pub(crate) fn loopback_connection(
12449        read: transport::OracleReadHalf,
12450        write: transport::OracleWriteHalf,
12451    ) -> Connection {
12452        loopback_connection_from_core(ConnectionCore::<DriverTransport>::from_halves(
12453            read,
12454            write,
12455            "loopback_test_write",
12456        ))
12457    }
12458
12459    fn loopback_connection_from_core(core: DriverCore) -> Connection {
12460        Connection {
12461            descriptor: EasyConnect::parse("127.0.0.1:1521/FREEPDB1")
12462                .expect("test connect string should parse"),
12463            identity: identity(),
12464            core,
12465            session_id: 0,
12466            serial_num: 0,
12467            server_version: None,
12468            server_version_tuple: None,
12469            db_unique_name: None,
12470            capabilities: ClientCapabilities::default(),
12471            protocol_limits: ProtocolLimits::DEFAULT,
12472            ttc_seq_num: 0,
12473            sdu: 8192,
12474            protocol_version: 0,
12475            supports_fast_auth: false,
12476            supports_end_of_response: true,
12477            supports_oob: false,
12478            cursor_columns: BTreeMap::new(),
12479            fetch_metadata_by_sql: HashMap::new(),
12480            fetch_metadata_order: VecDeque::new(),
12481            shape_cache: Arc::new(StatementShapeCache::new()),
12482            dead: false,
12483            user: "test_user".into(),
12484            combo_key: Vec::new(),
12485            statement_cache: Vec::new(),
12486            statement_cache_size: STATEMENT_CACHE_SIZE,
12487            in_use_cursors: HashSet::new(),
12488            lob_prefetch_cursors: BTreeSet::new(),
12489            copied_cursors: HashSet::new(),
12490            cursors_to_close: Vec::new(),
12491            sessionless_data: None,
12492            notification_buffer: Vec::new(),
12493            notification_header_consumed: false,
12494            transaction_context: None,
12495            txn_in_progress: false,
12496            #[cfg(feature = "cassette")]
12497            capture_guard: None,
12498        }
12499    }
12500
12501    #[cfg(feature = "cassette")]
12502    fn synthetic_number_columns() -> Vec<ColumnMetadata> {
12503        vec![
12504            ColumnMetadata::new("INTCOL", oracledb_protocol::thin::ORA_TYPE_NUM_NUMBER)
12505                .with_csfrm(oracledb_protocol::thin::CS_FORM_IMPLICIT)
12506                .with_buffer_size(22)
12507                .with_max_size(22)
12508                .with_nulls_allowed(true),
12509            ColumnMetadata::new("NUMBERCOL", oracledb_protocol::thin::ORA_TYPE_NUM_NUMBER)
12510                .with_csfrm(oracledb_protocol::thin::CS_FORM_IMPLICIT)
12511                .with_buffer_size(22)
12512                .with_max_size(22)
12513                .with_nulls_allowed(true),
12514        ]
12515    }
12516
12517    #[cfg(feature = "cassette")]
12518    fn synthetic_connect_packet() -> Result<Vec<u8>> {
12519        let payload = build_connect_packet_payload(
12520            "(DESCRIPTION=(ADDRESS=(PROTOCOL=tcp)(HOST=fixture-host)(PORT=0))\
12521             (CONNECT_DATA=(SERVICE_NAME=SYNTHETIC)(CID=(PROGRAM=rust-oracledb)\
12522             (HOST=fixture-host)(USER=fixture-user))))",
12523            8192,
12524        )?;
12525        Ok(encode_packet(
12526            TNS_PACKET_TYPE_CONNECT,
12527            0,
12528            None,
12529            &payload,
12530            PacketLengthWidth::Legacy16,
12531        )?)
12532    }
12533
12534    #[cfg(feature = "cassette")]
12535    fn synthetic_accept_packet() -> Result<Vec<u8>> {
12536        Ok(encode_packet(
12537            TNS_PACKET_TYPE_ACCEPT,
12538            0,
12539            None,
12540            b"SYNTHETIC-ACCEPT",
12541            PacketLengthWidth::Legacy16,
12542        )?)
12543    }
12544
12545    #[cfg(feature = "cassette")]
12546    fn synthetic_execute_packet() -> Result<Vec<u8>> {
12547        let payload = build_execute_payload_with_bind_rows_and_options_with_seq(
12548            "select value from synthetic_fixture",
12549            2,
12550            1,
12551            true,
12552            &[],
12553            ExecuteOptions::default(),
12554            ClientCapabilities::default().ttc_field_version,
12555        )?;
12556        Ok(encode_packet(
12557            TNS_PACKET_TYPE_DATA,
12558            0,
12559            Some(0),
12560            &payload,
12561            PacketLengthWidth::Large32,
12562        )?)
12563    }
12564
12565    #[cfg(feature = "cassette")]
12566    fn synthetic_fetch_packet() -> Result<Vec<u8>> {
12567        let payload =
12568            build_fetch_payload_with_seq(42, 2, 2, ClientCapabilities::default().ttc_field_version);
12569        Ok(encode_packet(
12570            TNS_PACKET_TYPE_DATA,
12571            0,
12572            Some(0),
12573            &payload,
12574            PacketLengthWidth::Large32,
12575        )?)
12576    }
12577
12578    #[cfg(feature = "cassette")]
12579    fn synthetic_function_packet(function_code: u8, seq_num: u8) -> Result<Vec<u8>> {
12580        let payload = build_function_payload_with_seq(
12581            function_code,
12582            seq_num,
12583            ClientCapabilities::default().ttc_field_version,
12584        );
12585        Ok(encode_packet(
12586            TNS_PACKET_TYPE_DATA,
12587            0,
12588            Some(0),
12589            &payload,
12590            PacketLengthWidth::Large32,
12591        )?)
12592    }
12593
12594    #[cfg(feature = "cassette")]
12595    fn hex_value(byte: u8) -> Result<u8> {
12596        match byte {
12597            b'0'..=b'9' => Ok(byte - b'0'),
12598            b'a'..=b'f' => Ok(byte - b'a' + 10),
12599            b'A'..=b'F' => Ok(byte - b'A' + 10),
12600            _ => Err(Error::Runtime(format!(
12601                "invalid synthetic fixture hex byte {byte:#04x}"
12602            ))),
12603        }
12604    }
12605
12606    #[cfg(feature = "cassette")]
12607    fn decode_hex_fixture(hex: &str) -> Result<Vec<u8>> {
12608        let clean = hex
12609            .bytes()
12610            .filter(|byte| !byte.is_ascii_whitespace())
12611            .collect::<Vec<_>>();
12612        if clean.len() % 2 != 0 {
12613            return Err(Error::Runtime(
12614                "synthetic fixture hex must contain an even number of digits".into(),
12615            ));
12616        }
12617        let mut out = Vec::with_capacity(clean.len() / 2);
12618        for pair in clean.chunks_exact(2) {
12619            out.push((hex_value(pair[0])? << 4) | hex_value(pair[1])?);
12620        }
12621        Ok(out)
12622    }
12623
12624    #[cfg(feature = "cassette")]
12625    fn synthetic_execute_response_payload() -> Result<Vec<u8>> {
12626        decode_hex_fixture(concat!(
12627            "101710740fb986350b6010fbcb6e06a74ed0787e060a110328014001018201800000",
12628            "014000000000020369010140023ffe010501050556414c554500000000000000000000",
12629            "010707787e060a110b1000021fe8010a010a00062201010001020000000708414c33",
12630            "32555446380801060323a4d500010100000000000004010102013b010102057b0000",
12631            "01010003000000000000000000000000030001010000000002057b0101010300194f",
12632            "52412d30313430333a206e6f206461746120666f756e640a1d",
12633        ))
12634    }
12635
12636    #[cfg(feature = "cassette")]
12637    fn synthetic_fetch_response_payload() -> Result<Vec<u8>> {
12638        decode_hex_fixture("06020101000205dc0001010101000702c1041d")
12639    }
12640
12641    #[cfg(feature = "cassette")]
12642    fn synthetic_plain_function_response_payload() -> [u8; 1] {
12643        [TNS_MSG_TYPE_END_OF_RESPONSE]
12644    }
12645
12646    #[cfg(feature = "cassette")]
12647    #[test]
12648    fn synthetic_cassette_replays_connect_execute_fetch_close_offline() -> Result<()> {
12649        let cassette = include_bytes!("../tests/fixtures/cassettes/select_7_plus_5.tns-cassette");
12650        let (read, write, audit) =
12651            transport::replay_split_with_audit(cassette, transport::ReplayWriteMode::Check)
12652                .map_err(|err| Error::Runtime(format!("invalid replay cassette: {err}")))?;
12653        let mut core = ConnectionCore::<DriverTransport>::from_halves(
12654            read,
12655            write,
12656            "synthetic_fixture_replay_write",
12657        );
12658        let runtime = build_io_runtime()?;
12659
12660        runtime.block_on(async {
12661            let cx = test_cx()?;
12662            let connect_packet = synthetic_connect_packet()?;
12663            core.write_all(&cx, &connect_packet).await?;
12664            let accept = core.read_packet(PacketLengthWidth::Legacy16).await?;
12665            assert_eq!(accept.packet_type, TNS_PACKET_TYPE_ACCEPT);
12666            assert_eq!(accept.payload, b"SYNTHETIC-ACCEPT");
12667
12668            let mut conn = loopback_connection_from_core(core);
12669            let execute = conn
12670                .execute_raw(
12671                    &cx,
12672                    "select value from synthetic_fixture",
12673                    2,
12674                    &[],
12675                    ExecuteOptions::default(),
12676                    None,
12677                )
12678                .await?;
12679            assert_eq!(execute.columns.len(), 1);
12680            assert_eq!(execute.rows.len(), 1);
12681            assert_eq!(
12682                execute.cell(0, 0).and_then(QueryValue::as_text),
12683                Some("AL32UTF8")
12684            );
12685
12686            let previous_row = vec![
12687                Some(QueryValue::number_from_text("2", true)),
12688                Some(QueryValue::number_from_text("0.5", false)),
12689            ];
12690            let columns = synthetic_number_columns();
12691            let fetched = conn
12692                .fetch_rows_with_columns(&cx, 42, 2, &columns, Some(&previous_row))
12693                .await?;
12694            assert_eq!(fetched.rows.len(), 1);
12695            assert_eq!(
12696                fetched
12697                    .cell(0, 0)
12698                    .and_then(QueryValue::as_number_text)
12699                    .as_deref(),
12700                Some("3")
12701            );
12702            assert_eq!(
12703                fetched
12704                    .cell(0, 1)
12705                    .and_then(QueryValue::as_number_text)
12706                    .as_deref(),
12707                Some("0.5")
12708            );
12709
12710            conn.close(&cx).await?;
12711            Ok::<_, Error>(())
12712        })?;
12713
12714        audit
12715            .assert_finished()
12716            .map_err(|err| Error::Runtime(err.to_string()))?;
12717        let _ = (
12718            synthetic_accept_packet()?,
12719            synthetic_execute_packet()?,
12720            synthetic_fetch_packet()?,
12721            synthetic_function_packet(TNS_FUNC_ROLLBACK, 3)?,
12722            synthetic_function_packet(TNS_FUNC_LOGOFF, 4)?,
12723            synthetic_execute_response_payload()?,
12724            synthetic_fetch_response_payload()?,
12725            synthetic_plain_function_response_payload(),
12726        );
12727        Ok(())
12728    }
12729
12730    fn server_error(message: &str) -> Error {
12731        Error::Protocol(oracledb_protocol::ProtocolError::ServerError(
12732            message.to_string(),
12733        ))
12734    }
12735
12736    fn structured_error(code: u32, pos: i32) -> Error {
12737        Error::Protocol(oracledb_protocol::ProtocolError::ServerErrorInfo(Box::new(
12738            oracledb_protocol::ServerErrorDetails {
12739                message: format!("ORA-{code:05}: synthetic"),
12740                code,
12741                pos,
12742                ..Default::default()
12743            },
12744        )))
12745    }
12746
12747    fn column(name: &str, ora_type_num: u8, is_json: bool) -> ColumnMetadata {
12748        ColumnMetadata::new(name, ora_type_num).with_is_json(is_json)
12749    }
12750
12751    #[test]
12752    fn json_lob_probe_candidates_selects_named_non_json_lobs() {
12753        use oracledb_protocol::thin::{ORA_TYPE_NUM_BLOB, ORA_TYPE_NUM_CLOB, ORA_TYPE_NUM_VARCHAR};
12754        let columns = vec![
12755            column("doc", ORA_TYPE_NUM_CLOB, false),         // candidate
12756            column("blob_doc", ORA_TYPE_NUM_BLOB, false),    // candidate
12757            column("already_json", ORA_TYPE_NUM_CLOB, true), // skipped: is_json
12758            column("name", ORA_TYPE_NUM_VARCHAR, false),     // skipped: not a LOB
12759            column("", ORA_TYPE_NUM_CLOB, false),            // skipped: unnamed expression
12760        ];
12761        // Names come back upper-cased (the catalog stores them upper-cased) and
12762        // only the two named, non-JSON LOB columns are probed, by original index.
12763        assert_eq!(
12764            json_lob_probe_candidates(&columns),
12765            vec![(0, "DOC".to_string()), (1, "BLOB_DOC".to_string())]
12766        );
12767    }
12768
12769    #[test]
12770    fn json_lob_probe_candidates_empty_when_no_lobs() {
12771        use oracledb_protocol::thin::ORA_TYPE_NUM_VARCHAR;
12772        let columns = vec![column("name", ORA_TYPE_NUM_VARCHAR, false)];
12773        assert!(json_lob_probe_candidates(&columns).is_empty());
12774    }
12775
12776    #[test]
12777    fn ora_code_parses_from_message_and_struct() {
12778        // string path: parsed from the ORA- prefix
12779        assert_eq!(
12780            server_error("ORA-00060: deadlock detected").ora_code(),
12781            Some(60)
12782        );
12783        // structured path: read straight from .code
12784        assert_eq!(structured_error(942, 0).ora_code(), Some(942));
12785        // no ORA- code present
12786        assert_eq!(server_error("listener problem").ora_code(), None);
12787        // non-server errors have no code
12788        assert_eq!(Error::CallTimeout(500).ora_code(), None);
12789    }
12790
12791    #[test]
12792    fn stable_error_methods_classify_without_display_parsing() {
12793        let transient = server_error("ORA-00060: deadlock detected");
12794        assert_eq!(transient.kind(), ErrorKind::Database);
12795        assert_eq!(transient.oracle_code(), Some(60));
12796        assert_eq!(
12797            transient.connection_disposition(),
12798            ConnectionDisposition::Reusable
12799        );
12800        assert_eq!(
12801            transient.retry_hint(),
12802            RetryHint::RetrySameConnectionIfIdempotent
12803        );
12804
12805        let lost = server_error("ORA-03113: end-of-file on communication channel");
12806        assert_eq!(lost.kind(), ErrorKind::Database);
12807        assert_eq!(lost.connection_disposition(), ConnectionDisposition::Dead);
12808        assert_eq!(lost.retry_hint(), RetryHint::ReconnectThenRetryIfIdempotent);
12809
12810        let timeout = Error::CallTimeout(500);
12811        assert_eq!(timeout.kind(), ErrorKind::Timeout);
12812        assert_eq!(
12813            timeout.connection_disposition(),
12814            ConnectionDisposition::Reusable
12815        );
12816        assert_eq!(
12817            timeout.retry_hint(),
12818            RetryHint::RetrySameConnectionIfIdempotent
12819        );
12820
12821        let cancelled = Error::Cancelled;
12822        assert_eq!(cancelled.kind(), ErrorKind::Cancel);
12823        assert_eq!(cancelled.oracle_code(), Some(1013));
12824        assert_eq!(
12825            cancelled.retry_hint(),
12826            RetryHint::RetrySameConnectionIfIdempotent
12827        );
12828
12829        let bind = Error::Bind(BindError::PositionalCountMismatch {
12830            expected: 2,
12831            actual: 1,
12832        });
12833        assert_eq!(bind.kind(), ErrorKind::Conversion);
12834        assert_eq!(bind.retry_hint(), RetryHint::Never);
12835
12836        let resource = Error::Protocol(oracledb_protocol::ProtocolError::ResourceLimit {
12837            limit: "binds",
12838            observed: 4,
12839            maximum: 3,
12840        });
12841        assert_eq!(resource.kind(), ErrorKind::ResourceLimit);
12842        assert_eq!(
12843            resource.connection_disposition(),
12844            ConnectionDisposition::Reusable
12845        );
12846        assert_eq!(resource.retry_hint(), RetryHint::Never);
12847
12848        let session_dead = server_error("ORA-00600: internal error code, arguments: []");
12849        assert_eq!(
12850            session_dead.connection_disposition(),
12851            ConnectionDisposition::Dead
12852        );
12853        assert!(
12854            !session_dead.is_connection_lost(),
12855            "ORA-00600 kills the session but is not a connection-lost retry code"
12856        );
12857        assert_eq!(session_dead.retry_hint(), RetryHint::Never);
12858    }
12859
12860    // ---- W1-T6.2: internal Outcome/CancelKind discipline ----------------------
12861    //
12862    // Cancellation is not "just another error". Each asupersync `CancelKind`
12863    // drives a specific connection disposition BEFORE we flatten to the public
12864    // `Error` at the boundary, and the mapping is METHOD-based (it reads
12865    // `CancelReason::kind`), never a display-string parse.
12866
12867    #[test]
12868    fn cancel_kind_maps_to_disposition() {
12869        // The timeout family (deadline / quota exhaustion) drains and stays
12870        // reusable — it composes like a `call_timeout`.
12871        for kind in [
12872            CancelKind::Timeout,
12873            CancelKind::Deadline,
12874            CancelKind::PollQuota,
12875            CancelKind::CostBudget,
12876        ] {
12877            assert_eq!(
12878                CancelDisposition::from_kind(kind),
12879                CancelDisposition::Timeout,
12880                "{kind:?} must drain + stay reusable (timeout disposition)"
12881            );
12882        }
12883
12884        // Runtime shutdown / resource loss / linked-exit closes the connection.
12885        for kind in [
12886            CancelKind::Shutdown,
12887            CancelKind::ResourceUnavailable,
12888            CancelKind::LinkedExit,
12889        ] {
12890            assert_eq!(
12891                CancelDisposition::from_kind(kind),
12892                CancelDisposition::Close,
12893                "{kind:?} must close the connection"
12894            );
12895        }
12896
12897        // Explicit / topological cancels drain quietly and stay reusable.
12898        for kind in [
12899            CancelKind::User,
12900            CancelKind::RaceLost,
12901            CancelKind::FailFast,
12902            CancelKind::ParentCancelled,
12903        ] {
12904            assert_eq!(
12905                CancelDisposition::from_kind(kind),
12906                CancelDisposition::Cancel,
12907                "{kind:?} must drain quietly + stay reusable (cancel disposition)"
12908            );
12909        }
12910    }
12911
12912    #[test]
12913    fn cancel_disposition_flattens_to_distinct_error_variants() {
12914        // The boundary flatten: each disposition produces a DISTINCT public
12915        // error variant — a cancel is NEVER a generic `Runtime`/`Io` error — and
12916        // the resulting error classifies correctly via the W1-T6.1 methods.
12917
12918        // Timeout -> CallTimeout: reusable + retryable on the same connection.
12919        let timeout = CancelDisposition::Timeout.into_error(750);
12920        assert!(matches!(timeout, Error::CallTimeout(750)));
12921        assert_eq!(timeout.kind(), ErrorKind::Timeout);
12922        assert_eq!(
12923            timeout.connection_disposition(),
12924            ConnectionDisposition::Reusable
12925        );
12926        assert_eq!(
12927            timeout.retry_hint(),
12928            RetryHint::RetrySameConnectionIfIdempotent,
12929            "a drained timeout may be retried on the same connection"
12930        );
12931
12932        // Cancel -> Cancelled (ORA-01013): distinct cancel variant, reusable +
12933        // retryable — explicitly NOT Error::Runtime / Error::Io.
12934        let cancelled = CancelDisposition::Cancel.into_error(750);
12935        assert!(matches!(cancelled, Error::Cancelled));
12936        assert!(
12937            !matches!(cancelled, Error::Runtime(_) | Error::Io(_)),
12938            "a cancel must be a distinct variant, never a generic runtime/io error"
12939        );
12940        assert_eq!(cancelled.kind(), ErrorKind::Cancel);
12941        assert_eq!(cancelled.oracle_code(), Some(1013));
12942        assert_eq!(
12943            cancelled.connection_disposition(),
12944            ConnectionDisposition::Reusable
12945        );
12946        assert_eq!(
12947            cancelled.retry_hint(),
12948            RetryHint::RetrySameConnectionIfIdempotent
12949        );
12950
12951        // Close -> ConnectionClosed: connection is dead; reconnect before retry.
12952        let closed = CancelDisposition::Close.into_error(750);
12953        assert!(matches!(closed, Error::ConnectionClosed(_)));
12954        assert_eq!(closed.kind(), ErrorKind::Network);
12955        assert_eq!(closed.connection_disposition(), ConnectionDisposition::Dead);
12956        assert_eq!(
12957            closed.retry_hint(),
12958            RetryHint::ReconnectThenRetryIfIdempotent
12959        );
12960    }
12961
12962    /// The interrupt-vs-checkpoint race fix: Asupersync marks a cancelled
12963    /// in-flight read as `Interrupted` with the exact payload `"cancelled"`, and
12964    /// the driver keys off that DETERMINISTIC marker so a real cancellation is
12965    /// never mistyped as a raw `Io(Interrupted)` when a *later* `checkpoint()`
12966    /// on the reading thread has not yet observed the cancel flag. A genuine OS
12967    /// `EINTR` (any other message) is left untouched.
12968    #[test]
12969    fn asupersync_cancellation_marker_is_detected_deterministically() {
12970        use std::io::{Error as IoError, ErrorKind};
12971        assert!(
12972            is_asupersync_cancellation(&IoError::new(ErrorKind::Interrupted, "cancelled")),
12973            "the asupersync cancellation marker must be recognized regardless of checkpoint timing"
12974        );
12975        assert!(
12976            !is_asupersync_cancellation(&IoError::new(
12977                ErrorKind::Interrupted,
12978                "Interrupted system call"
12979            )),
12980            "a real OS EINTR (different message) must NOT be treated as a cancellation"
12981        );
12982        // A raw OS EINTR carries the platform's strerror text, never "cancelled".
12983        assert!(!is_asupersync_cancellation(&IoError::from_raw_os_error(4)));
12984        assert!(
12985            !is_asupersync_cancellation(&IoError::new(ErrorKind::WouldBlock, "cancelled")),
12986            "only the Interrupted kind is the cancellation signal"
12987        );
12988    }
12989
12990    #[test]
12991    fn missing_cancel_reason_is_a_plain_cancel_at_checkpoint() {
12992        // A cancel with no recorded `CancelReason` is still a cancel — never a
12993        // runtime error. (The in-operation timeout path defaults the OTHER way,
12994        // to Timeout, because it is entered by a deadline elapse; see
12995        // `recover_from_call_timeout`.)
12996        assert_eq!(cancel_disposition(None), CancelDisposition::Cancel);
12997    }
12998
12999    #[test]
13000    fn cancelled_cx_resolves_to_the_kind_mapped_distinct_error() {
13001        // End-to-end against a real asupersync context: inject a cancel of a
13002        // given kind, confirm `checkpoint()` actually fails and `cancel_reason()`
13003        // carries the kind, then assert the SAME mapping the between-round-trip
13004        // helper applies surfaces the kind-mapped DISTINCT error (never the old
13005        // Error::Runtime(display_string)). A `detached_cancel_context` carries
13006        // cancellation + budget state with no effect caps — exactly what a unit
13007        // test of the cancel mapping needs, with no live runtime required.
13008        fn err_for(kind: CancelKind) -> Error {
13009            let cx = Cx::detached_cancel_context();
13010            cx.cancel_with(kind, Some("test cancel"));
13011            // The cancel must actually be observable through the methods the
13012            // driver relies on — not a display string.
13013            assert!(
13014                cx.checkpoint().is_err(),
13015                "{kind:?}: a cancelled context must fail its checkpoint"
13016            );
13017            let reason = cx.cancel_reason().unwrap_or_else(|| {
13018                panic!("{kind:?}: cancel_reason must carry the structured kind")
13019            });
13020            assert_eq!(reason.kind, kind, "cancel_reason must round-trip the kind");
13021            cancel_disposition(Some(reason)).into_error(750)
13022        }
13023
13024        // Timeout family -> Error::CallTimeout (Timeout kind, reusable).
13025        for kind in [CancelKind::Timeout, CancelKind::Deadline] {
13026            let err = err_for(kind);
13027            assert!(
13028                matches!(err, Error::CallTimeout(_)),
13029                "{kind:?} should surface CallTimeout, got {err:?}"
13030            );
13031            assert_eq!(err.kind(), ErrorKind::Timeout);
13032        }
13033
13034        // Shutdown -> Error::ConnectionClosed (dead).
13035        let shutdown = err_for(CancelKind::Shutdown);
13036        assert!(
13037            matches!(shutdown, Error::ConnectionClosed(_)),
13038            "Shutdown should surface ConnectionClosed, got {shutdown:?}"
13039        );
13040        assert_eq!(
13041            shutdown.connection_disposition(),
13042            ConnectionDisposition::Dead
13043        );
13044
13045        // User / RaceLost -> Error::Cancelled (distinct, reusable) — and crucially
13046        // NOT the old Error::Runtime(display_string).
13047        for kind in [CancelKind::User, CancelKind::RaceLost] {
13048            let err = err_for(kind);
13049            assert!(
13050                matches!(err, Error::Cancelled),
13051                "{kind:?} should surface Cancelled, got {err:?}"
13052            );
13053            assert!(
13054                !matches!(err, Error::Runtime(_)),
13055                "{kind:?} must NOT be flattened to a generic Error::Runtime"
13056            );
13057            assert_eq!(err.kind(), ErrorKind::Cancel);
13058        }
13059    }
13060
13061    #[test]
13062    fn uncancelled_checkpoint_is_ok() {
13063        // The fast path: a healthy context passes the checkpoint untouched, so
13064        // the cancel-mapping branch is never taken.
13065        let cx = Cx::detached_cancel_context();
13066        assert!(cx.checkpoint().is_ok());
13067        assert!(cx.cancel_reason().is_none());
13068    }
13069
13070    #[test]
13071    fn offset_only_from_structured_nonzero() {
13072        assert_eq!(structured_error(942, 14).offset(), Some(14));
13073        assert_eq!(structured_error(942, 0).offset(), None);
13074        assert_eq!(
13075            server_error("ORA-00942: table or view does not exist").offset(),
13076            None
13077        );
13078    }
13079
13080    #[test]
13081    fn transient_classification() {
13082        for &code in TRANSIENT_ORA_CODES {
13083            let err = server_error(&format!("ORA-{code:05}: transient"));
13084            assert!(err.is_transient(), "ORA-{code:05} should be transient");
13085            assert!(err.is_retryable(), "transient implies retryable");
13086            assert!(
13087                !err.is_connection_lost(),
13088                "ORA-{code:05} is not connection-lost"
13089            );
13090        }
13091        // a permanent error: table or view does not exist
13092        let perm = server_error("ORA-00942: table or view does not exist");
13093        assert!(!perm.is_transient());
13094        assert!(!perm.is_connection_lost());
13095        assert!(!perm.is_retryable());
13096    }
13097
13098    #[test]
13099    fn connection_lost_classification() {
13100        for &code in CONNECTION_LOST_ORA_CODES {
13101            let err = server_error(&format!("ORA-{code:05}: lost"));
13102            assert!(
13103                err.is_connection_lost(),
13104                "ORA-{code:05} should be connection-lost"
13105            );
13106            assert!(err.is_retryable(), "connection-lost implies retryable");
13107            assert!(
13108                !err.is_transient(),
13109                "ORA-{code:05} is not a transient (retry-in-place) code"
13110            );
13111        }
13112        // raw I/O counts as the transport being gone
13113        let io = Error::Io(std::io::Error::new(
13114            std::io::ErrorKind::ConnectionReset,
13115            "reset",
13116        ));
13117        assert!(io.is_connection_lost());
13118        assert!(io.is_retryable());
13119
13120        // A plain call timeout is NOT connection-lost: on a timeout the driver
13121        // breaks + drains the wire and the connection stays reusable, mirroring
13122        // python-oracledb's DPY-4024 (ERR_CALL_TIMEOUT_EXCEEDED) which — unlike
13123        // DPY-4011 — does not set is_session_dead (errors.py:124-125). It is
13124        // transient (retry in place) and therefore retryable.
13125        let timeout = Error::CallTimeout(1000);
13126        assert!(
13127            !timeout.is_connection_lost(),
13128            "a call timeout leaves the connection usable after the drain"
13129        );
13130        assert!(
13131            timeout.is_transient(),
13132            "a call timeout is a retry-in-place (transient) condition"
13133        );
13134        assert!(
13135            timeout.is_retryable(),
13136            "transient implies retryable on the same connection"
13137        );
13138
13139        // ConnectionClosed (raised only when the post-timeout drain itself fails
13140        // — a SECOND timeout, the reference's disconnect path) IS connection-lost:
13141        // the wire could not be left clean, so the connection must be discarded.
13142        let recovery_failed =
13143            Error::ConnectionClosed("socket timed out while recovering".to_string());
13144        assert!(
13145            recovery_failed.is_connection_lost(),
13146            "a failed timeout-recovery drain marks the connection lost"
13147        );
13148        assert!(recovery_failed.is_retryable(), "reconnect, then retry");
13149        assert!(
13150            !recovery_failed.is_transient(),
13151            "ConnectionClosed needs a reconnect first, so it is not retry-in-place"
13152        );
13153    }
13154
13155    #[test]
13156    fn resource_limit_error_defines_pre_sync_and_post_sync_disposition() {
13157        let err = oracledb_protocol::ProtocolError::ResourceLimit {
13158            limit: "columns",
13159            observed: 3,
13160            maximum: 2,
13161        };
13162        assert_eq!(
13163            post_sync_protocol_error_disposition(&err),
13164            PostSyncProtocolDisposition::Dead,
13165            "a post-sync resource-limit decode failure leaves unread response bytes"
13166        );
13167
13168        let pre_sync = Error::Protocol(oracledb_protocol::ProtocolError::ResourceLimit {
13169            limit: "columns",
13170            observed: 3,
13171            maximum: 2,
13172        });
13173        assert_eq!(
13174            pre_sync.resource_limit(),
13175            Some(oracledb_protocol::ResourceLimit {
13176                limit: "columns",
13177                observed: 3,
13178                maximum: 2,
13179            })
13180        );
13181        assert!(
13182            !pre_sync.is_connection_lost(),
13183            "client-side/pre-sync resource-limit validation does not imply a lost connection"
13184        );
13185        assert!(
13186            !pre_sync.is_transient(),
13187            "raising the configured limit, not retrying in-place, is the remedy"
13188        );
13189        assert!(
13190            !pre_sync.is_retryable(),
13191            "resource limits are deterministic for the same input and policy"
13192        );
13193        assert!(matches!(
13194            pre_sync,
13195            Error::Protocol(oracledb_protocol::ProtocolError::ResourceLimit {
13196                limit: "columns",
13197                observed: 3,
13198                maximum: 2,
13199            })
13200        ));
13201    }
13202
13203    #[test]
13204    fn post_sync_resource_limit_marks_live_connection_dead() -> Result<()> {
13205        let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener");
13206        let addr = listener.local_addr().expect("listener address");
13207        let server = thread::spawn(move || {
13208            let (_socket, _) = listener.accept().expect("accept test client");
13209        });
13210
13211        let runtime = build_io_runtime().expect("asupersync runtime");
13212        let mut conn = runtime.block_on(async {
13213            let stream = TcpStream::connect(addr).await.expect("connect to listener");
13214            let (read, write) = transport::plain_split(stream);
13215            loopback_connection(read, write)
13216        });
13217
13218        let err = conn
13219            .note_parse::<()>(Err(oracledb_protocol::ProtocolError::ResourceLimit {
13220                limit: "response_bytes",
13221                observed: 33,
13222                maximum: 32,
13223            }))
13224            .expect_err("post-sync resource-limit violation must be surfaced");
13225
13226        assert!(matches!(
13227            err,
13228            Error::Protocol(oracledb_protocol::ProtocolError::ResourceLimit {
13229                limit: "response_bytes",
13230                observed: 33,
13231                maximum: 32,
13232            })
13233        ));
13234        assert!(
13235            conn.is_dead(),
13236            "post-sync resource-limit violation stops consuming a response and kills the session"
13237        );
13238
13239        drop(conn);
13240        server.join().expect("server thread joins");
13241        Ok(())
13242    }
13243
13244    #[test]
13245    fn protocol_version_and_fast_auth_accessors_report_negotiated_values() {
13246        // K2 ServerFeatures accessors: the two getters read the fields the
13247        // ACCEPT negotiation populates. Build a loopback connection, set the
13248        // two accept-derived fields to distinct non-default values, and assert
13249        // each getter returns its own field (distinct types u16/bool guarantee
13250        // they cannot be transposed).
13251        let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener");
13252        let addr = listener.local_addr().expect("listener address");
13253        let server = thread::spawn(move || {
13254            let (_socket, _) = listener.accept().expect("accept test client");
13255        });
13256
13257        let runtime = build_io_runtime().expect("asupersync runtime");
13258        let mut conn = runtime.block_on(async {
13259            let stream = TcpStream::connect(addr).await.expect("connect to listener");
13260            let (read, write) = transport::plain_split(stream);
13261            loopback_connection(read, write)
13262        });
13263
13264        // Defaults from the loopback constructor.
13265        assert_eq!(conn.protocol_version(), 0);
13266        assert!(!conn.supports_fast_auth());
13267
13268        // Simulate a negotiated ACCEPT: TNS version 319 over the fast-auth path.
13269        conn.protocol_version = 319;
13270        conn.supports_fast_auth = true;
13271        assert_eq!(conn.protocol_version(), 319);
13272        assert!(conn.supports_fast_auth());
13273
13274        drop(conn);
13275        server.join().expect("server thread joins");
13276    }
13277
13278    #[test]
13279    fn host_port_protocol_accessors_report_connected_descriptor() {
13280        // etib.7: host()/port()/protocol() expose the connected endpoint from the
13281        // resolved descriptor. Build a loopback connection, plant a known
13282        // descriptor, and assert each getter returns its own field (the distinct
13283        // types &str/u16/Protocol guarantee they cannot be transposed).
13284        let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener");
13285        let addr = listener.local_addr().expect("listener address");
13286        let server = thread::spawn(move || {
13287            let (_socket, _) = listener.accept().expect("accept test client");
13288        });
13289
13290        let runtime = build_io_runtime().expect("asupersync runtime");
13291        let mut conn = runtime.block_on(async {
13292            let stream = TcpStream::connect(addr).await.expect("connect to listener");
13293            let (read, write) = transport::plain_split(stream);
13294            loopback_connection(read, write)
13295        });
13296
13297        conn.descriptor = EasyConnect {
13298            host: "db.example.com".to_string(),
13299            port: 2484,
13300            service_name: "FREEPDB1".to_string(),
13301            protocol: NetProtocol::Tcps,
13302        };
13303
13304        assert_eq!(conn.host(), "db.example.com");
13305        assert_eq!(conn.port(), 2484);
13306        assert_eq!(conn.protocol(), NetProtocol::Tcps);
13307        // The getters agree with the descriptor they delegate to.
13308        assert_eq!(conn.host(), conn.descriptor().host);
13309        assert_eq!(conn.port(), conn.descriptor().port);
13310        assert_eq!(conn.protocol(), conn.descriptor().protocol);
13311
13312        drop(conn);
13313        server.join().expect("server thread joins");
13314    }
13315
13316    // ---- A8 (bead oraclemcp-release-073-iec3.1.11): native single-round-trip
13317    // pipelining, proven offline over a loopback socket -----------------------
13318    //
13319    // `run_pipeline`/`run_pipeline_decoded` write every operation *before*
13320    // reading any response (one write burst, then N+1 boundary reads), so an
13321    // N-statement batch is a single wire round trip. python-oracledb's thin
13322    // mode issues the same batch as N sequential execute round trips. The
13323    // loopback "server" below refuses to answer until it has read the whole
13324    // batch, which terminates only if the client truly pipelined — a
13325    // deterministic, offline proof of the collapse (no live server, cassette
13326    // decode reused for the per-op result-materialization layer).
13327
13328    /// The synthetic execute response payload reused from the committed
13329    /// `select_7_plus_5` cassette fixture — no live capture, pure TNS framing +
13330    /// TTC decoder exercise. Decodes with the *same* per-op decoder the
13331    /// sequential execute path uses.
13332    fn synthetic_pipeline_execute_response_payload() -> Vec<u8> {
13333        const HEX: &str = concat!(
13334            "101710740fb986350b6010fbcb6e06a74ed0787e060a110328014001018201800000",
13335            "014000000000020369010140023ffe010501050556414c554500000000000000000000",
13336            "010707787e060a110b1000021fe8010a010a00062201010001020000000708414c33",
13337            "32555446380801060323a4d500010100000000000004010102013b010102057b0000",
13338            "01010003000000000000000000000000030001010000000002057b0101010300194f",
13339            "52412d30313430333a206e6f206461746120666f756e640a1d",
13340        );
13341        let raw = HEX.as_bytes();
13342        let mut bytes = Vec::with_capacity(raw.len() / 2);
13343        let mut index = 0;
13344        while index < raw.len() {
13345            let hi = (raw[index] as char).to_digit(16).expect("hex digit");
13346            let lo = (raw[index + 1] as char).to_digit(16).expect("hex digit");
13347            bytes.push(((hi << 4) | lo) as u8);
13348            index += 2;
13349        }
13350        bytes
13351    }
13352
13353    fn synthetic_lob_read_response_payload(locator: &[u8], data: &[u8], amount: u64) -> Vec<u8> {
13354        let mut payload = oracledb_protocol::wire::TtcWriter::new();
13355        payload.write_u8(oracledb_protocol::thin::TNS_MSG_TYPE_LOB_DATA);
13356        payload
13357            .write_bytes_with_length(data)
13358            .expect("synthetic LOB payload is encodable");
13359        payload.write_u8(oracledb_protocol::thin::TNS_MSG_TYPE_PARAMETER);
13360        payload.write_raw(locator);
13361        payload.write_ub8(amount);
13362        payload.write_u8(TNS_MSG_TYPE_END_OF_RESPONSE);
13363        payload.into_bytes()
13364    }
13365
13366    fn synthetic_aq_enqueue_response_payload(msgid: &[u8; 16]) -> Vec<u8> {
13367        let mut payload = oracledb_protocol::wire::TtcWriter::new();
13368        payload.write_u8(oracledb_protocol::thin::TNS_MSG_TYPE_PARAMETER);
13369        payload.write_raw(msgid);
13370        payload.write_ub2(0);
13371        payload.write_u8(TNS_MSG_TYPE_END_OF_RESPONSE);
13372        payload.into_bytes()
13373    }
13374
13375    fn synthetic_direct_path_prepare_response_payload(cursor_id: u16) -> Vec<u8> {
13376        let mut payload = oracledb_protocol::wire::TtcWriter::new();
13377        payload.write_u8(oracledb_protocol::thin::TNS_MSG_TYPE_PARAMETER);
13378        payload.write_ub4(0); // column metadata count
13379        payload.write_ub2(0); // parameter count
13380        payload.write_ub2(4); // output value count; cursor id is index 3
13381        payload.write_ub4(0);
13382        payload.write_ub4(0);
13383        payload.write_ub4(0);
13384        payload.write_ub4(u32::from(cursor_id));
13385        payload.write_u8(TNS_MSG_TYPE_END_OF_RESPONSE);
13386        let payload = payload.into_bytes();
13387
13388        let decoded = oracledb_protocol::dpl::parse_direct_path_prepare_response(
13389            &payload,
13390            ClientCapabilities::default(),
13391        )
13392        .expect("synthetic direct path prepare response decodes");
13393        assert_eq!(decoded.cursor_id, cursor_id);
13394        assert!(decoded.column_metadata.is_empty());
13395        payload
13396    }
13397
13398    fn synthetic_direct_path_simple_response_payload() -> Vec<u8> {
13399        let mut payload = oracledb_protocol::wire::TtcWriter::new();
13400        payload.write_u8(oracledb_protocol::thin::TNS_MSG_TYPE_PARAMETER);
13401        payload.write_ub2(0); // output value count
13402        payload.write_u8(TNS_MSG_TYPE_END_OF_RESPONSE);
13403        let payload = payload.into_bytes();
13404        oracledb_protocol::dpl::parse_direct_path_simple_response(
13405            &payload,
13406            ClientCapabilities::default(),
13407        )
13408        .expect("synthetic direct path simple response decodes");
13409        payload
13410    }
13411
13412    fn synthetic_subscribe_register_response_payload() -> Vec<u8> {
13413        // Real thin CQN register response captured by the protocol golden at
13414        // `thin::subscr::tests::subscribe_response_decodes_registration_and_client_id`.
13415        let payload = vec![
13416            0x08, 0x01, 0x01, 0x00, 0x02, 0x01, 0x2E, 0x01, 0x01, 0x02, 0x01, 0x2E, 0x00, 0x00,
13417            0x01, 0x01, 0x01, 0x36, 0x36, 0x28, 0x41, 0x44, 0x44, 0x52, 0x45, 0x53, 0x53, 0x3D,
13418            0x28, 0x50, 0x52, 0x4F, 0x54, 0x4F, 0x43, 0x4F, 0x4C, 0x3D, 0x54, 0x43, 0x50, 0x29,
13419            0x28, 0x48, 0x4F, 0x53, 0x54, 0x3D, 0x32, 0x39, 0x30, 0x61, 0x63, 0x30, 0x33, 0x30,
13420            0x30, 0x33, 0x38, 0x37, 0x29, 0x28, 0x50, 0x4F, 0x52, 0x54, 0x3D, 0x31, 0x35, 0x32,
13421            0x31, 0x29, 0x29, 0x01, 0x0A, 0x0A, 0x4F, 0x43, 0x49, 0x3A, 0x45, 0x50, 0x3A, 0x33,
13422            0x30, 0x31, 0x09, 0x01, 0x01, 0x02, 0xDD, 0x48, 0x1D,
13423        ];
13424        let decoded = parse_subscribe_response_with_limits(
13425            &payload,
13426            ClientCapabilities::default(),
13427            ProtocolLimits::DEFAULT,
13428        )
13429        .expect("captured CQN register response decodes");
13430        assert_eq!(decoded.registration_id, 302);
13431        assert_eq!(decoded.client_id.as_deref(), Some(&b"OCI:EP:301"[..]));
13432        payload
13433    }
13434
13435    fn serve_dropped_response_recovery(
13436        listener: TcpListener,
13437        expected_request: Vec<u8>,
13438        stranded_response: Vec<u8>,
13439        request_seen: std::sync::mpsc::Sender<()>,
13440    ) -> std::io::Result<bool> {
13441        const TRAILING_CANCEL_ERROR: &[u8] = &[0x04, 0x01, 0x0d];
13442
13443        use std::io::Write as _;
13444        let (mut socket, _) = listener.accept()?;
13445        socket.set_read_timeout(Some(Duration::from_secs(5)))?;
13446
13447        assert_eq!(
13448            read_one_wire_data_payload(&mut socket),
13449            expected_request,
13450            "request must preserve its exact payload"
13451        );
13452        request_seen
13453            .send(())
13454            .expect("client waits for request proof");
13455
13456        let (next_packet_type, next_body) = read_one_wire_packet_bytes(&mut socket);
13457        if next_packet_type == TNS_PACKET_TYPE_MARKER {
13458            assert_eq!(
13459                next_body,
13460                vec![1, 0, TNS_MARKER_TYPE_BREAK],
13461                "reuse must BREAK the stranded response before its request"
13462            );
13463            socket.write_all(&data_packet(&stranded_response, true))?;
13464            socket.write_all(&marker_packet(TNS_MARKER_TYPE_BREAK))?;
13465            assert_eq!(
13466                read_marker_type(&mut socket),
13467                TNS_MARKER_TYPE_RESET,
13468                "response drain must complete the RESET handshake"
13469            );
13470            socket.write_all(&marker_packet(TNS_MARKER_TYPE_RESET))?;
13471            socket.write_all(&data_packet(TRAILING_CANCEL_ERROR, true))?;
13472
13473            assert_eq!(
13474                read_one_wire_packet(&mut socket),
13475                TNS_PACKET_TYPE_DATA,
13476                "fresh execute follows the completed response drain"
13477            );
13478            socket.write_all(&data_packet(
13479                &synthetic_pipeline_execute_response_payload(),
13480                true,
13481            ))?;
13482            socket.flush()?;
13483            Ok(true)
13484        } else {
13485            assert_eq!(
13486                next_packet_type, TNS_PACKET_TYPE_DATA,
13487                "without recovery the next operation is sent directly"
13488            );
13489            socket.write_all(&data_packet(&stranded_response, true))?;
13490            socket.write_all(&data_packet(
13491                &synthetic_pipeline_execute_response_payload(),
13492                true,
13493            ))?;
13494            socket.flush()?;
13495            Ok(false)
13496        }
13497    }
13498
13499    fn synthetic_aq_enqueue_request() -> (AqQueueDesc, AqMsgProps, AqEnqOptions) {
13500        let queue = AqQueueDesc::new(
13501            "AQ_QUEUE".to_owned(),
13502            oracledb_protocol::thin::aq::AqPayloadKind::Raw,
13503            None,
13504        );
13505        let props = AqMsgProps {
13506            payload: Some(oracledb_protocol::thin::aq::AqPayloadValue::Raw(
13507                b"payload".to_vec(),
13508            )),
13509            ..AqMsgProps::default()
13510        };
13511        (queue, props, AqEnqOptions::default())
13512    }
13513
13514    /// The committed single-row execute fixture ends with ORA-01403, which
13515    /// marks the cursor exhausted. Rewrite only that terminal error number and
13516    /// omit its message to model the same decoded row/metadata with an open
13517    /// cursor whose continuation must be fetched.
13518    fn synthetic_open_cursor_execute_response_payload() -> Vec<u8> {
13519        const TERMINAL_NO_DATA_PREFIX: &[u8] = &[
13520            0x02, 0x05, 0x7b, // error number: ub4(1403)
13521            0x01, 0x01, // row count: ub8(1)
13522            0x01, 0x03, // SQL type: ub4(3)
13523            0x00, // server checksum: ub4(0)
13524            0x19, // ORA-01403 message length
13525        ];
13526
13527        let mut response = synthetic_pipeline_execute_response_payload();
13528        let terminal = response
13529            .windows(TERMINAL_NO_DATA_PREFIX.len())
13530            .rposition(|window| window == TERMINAL_NO_DATA_PREFIX)
13531            .expect("synthetic response must contain its terminal ORA-01403");
13532        response[terminal + 1] = 0;
13533        response[terminal + 2] = 0;
13534        response.truncate(terminal + 8);
13535        response.push(TNS_MSG_TYPE_END_OF_RESPONSE);
13536
13537        let decoded = sequential_op_decode(&response);
13538        assert_ne!(decoded.cursor_id, 0, "fixture must retain its cursor id");
13539        assert!(decoded.more_rows, "fixture must leave the cursor open");
13540        assert_eq!(decoded.rows.len(), 1, "fixture must retain its first row");
13541        response
13542    }
13543
13544    /// One borrowed NUMBER continuation row with no terminal ORA-01403. The
13545    /// parser therefore reports `more_rows = true`, allowing tests to prove the
13546    /// speculative request-before-callback ordering without a live database.
13547    fn synthetic_open_borrowed_fetch_response(value: &str) -> Vec<u8> {
13548        use oracledb_protocol::thin::{
13549            encode_number_text, TNS_MSG_TYPE_ROW_DATA, TNS_MSG_TYPE_ROW_HEADER,
13550        };
13551
13552        let number = encode_number_text(value).expect("synthetic NUMBER encodes");
13553        let number_len = u8::try_from(number.len()).expect("NUMBER uses short TTC bytes");
13554        let mut response = vec![
13555            TNS_MSG_TYPE_ROW_HEADER,
13556            0, // row-header flags
13557            1,
13558            1, // ub2(num requests = 1)
13559            1,
13560            1, // ub4(iteration = 1)
13561            1,
13562            1, // ub4(num iterations = 1)
13563            0, // ub2(buffer length = 0)
13564            0, // ub4(bit-vector bytes = 0)
13565            0, // ub4(rxhrid length = 0)
13566            TNS_MSG_TYPE_ROW_DATA,
13567            number_len,
13568        ];
13569        response.extend_from_slice(&number);
13570        response.push(TNS_MSG_TYPE_END_OF_RESPONSE);
13571        response
13572    }
13573
13574    /// Reference decode of one op's response through the *same* public decoder
13575    /// the sequential execute path invokes (default caps/limits, matching the
13576    /// loopback connection) — the "sequential" side of the byte-identity check.
13577    fn sequential_op_decode(payload: &[u8]) -> QueryResult {
13578        parse_query_response_with_binds_options_columns_and_limits(
13579            payload,
13580            ClientCapabilities::default(),
13581            &[],
13582            ExecuteOptions::default(),
13583            &[],
13584            ProtocolLimits::DEFAULT,
13585        )
13586        .expect("synthetic execute response decodes")
13587    }
13588
13589    /// Read one whole Large32-framed TNS packet (header + body) from a blocking
13590    /// std socket, returning the packet-type byte. Post-connect every packet on
13591    /// the wire is a Large32 DATA packet.
13592    fn read_one_wire_packet_bytes(socket: &mut std::net::TcpStream) -> (u8, Vec<u8>) {
13593        let mut header = [0u8; 8];
13594        socket.read_exact(&mut header).expect("read packet header");
13595        let declared = u32::from_be_bytes([header[0], header[1], header[2], header[3]]) as usize;
13596        let mut body = vec![0u8; declared - header.len()];
13597        socket.read_exact(&mut body).expect("read packet body");
13598        (header[4], body)
13599    }
13600
13601    fn read_one_wire_packet(socket: &mut std::net::TcpStream) -> u8 {
13602        read_one_wire_packet_bytes(socket).0
13603    }
13604
13605    fn read_one_wire_data_payload(socket: &mut std::net::TcpStream) -> Vec<u8> {
13606        let (packet_type, body) = read_one_wire_packet_bytes(socket);
13607        assert_eq!(
13608            packet_type, TNS_PACKET_TYPE_DATA,
13609            "expected a DATA request after recovery"
13610        );
13611        assert!(body.len() >= 2, "DATA packet must contain its flags");
13612        body[2..].to_vec()
13613    }
13614
13615    /// Drive an `n`-statement pipeline over a loopback socket whose server side
13616    /// reads the ENTIRE batch (n ops + end-pipeline) before writing any byte
13617    /// back. Returns the decoded per-op results and the number of request
13618    /// packets the server read before it answered (== `n + 1` iff the client
13619    /// pipelined into a single round trip; a sequential client would deadlock
13620    /// this server after op-1).
13621    fn pipeline_batch_over_loopback(n: usize) -> (Vec<QueryResult>, usize) {
13622        let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener");
13623        let addr = listener.local_addr().expect("listener address");
13624        let server = thread::spawn(move || {
13625            use std::io::Write as _;
13626            let (mut socket, _) = listener.accept().expect("accept test client");
13627            let mut requests_before_first_response = 0usize;
13628            for _ in 0..=n {
13629                let _packet_type = read_one_wire_packet(&mut socket);
13630                requests_before_first_response += 1;
13631            }
13632            let execute_response =
13633                data_packet(&synthetic_pipeline_execute_response_payload(), true);
13634            for _ in 0..n {
13635                socket
13636                    .write_all(&execute_response)
13637                    .expect("write op response");
13638            }
13639            // The (n+1)-th (end-pipeline) response: a bare END_OF_RESPONSE frame,
13640            // consumed by the runner for framing only.
13641            socket
13642                .write_all(&data_packet(
13643                    &[oracledb_protocol::thin::TNS_MSG_TYPE_END_OF_RESPONSE],
13644                    true,
13645                ))
13646                .expect("write end-pipeline response");
13647            socket.flush().expect("flush responses");
13648            requests_before_first_response
13649        });
13650
13651        let runtime = build_io_runtime().expect("asupersync runtime");
13652        let results = runtime.block_on(async {
13653            let cx = Cx::current().expect("ambient Cx");
13654            let stream = TcpStream::connect(addr).await.expect("connect to listener");
13655            let (read, write) = transport::plain_split(stream);
13656            let mut conn = loopback_connection(read, write);
13657            let requests: Vec<PipelineRequest> = (0..n)
13658                .map(|_| {
13659                    PipelineRequest::execute("select value from synthetic_fixture", Vec::new(), 2)
13660                })
13661                .collect();
13662            conn.run_pipeline_decoded(&cx, &requests, false)
13663                .await
13664                .expect("pipelined batch runs as one round trip")
13665                .into_iter()
13666                .enumerate()
13667                .map(|(index, op)| op.unwrap_or_else(|err| panic!("op {index} decoded: {err:?}")))
13668                .collect::<Vec<_>>()
13669        });
13670
13671        let requests_before_first_response = server.join().expect("server thread joins");
13672        (results, requests_before_first_response)
13673    }
13674
13675    #[test]
13676    fn pipeline_batch_offline_collapses_to_one_round_trip() {
13677        const N: usize = 10;
13678        let (results, requests_before_first_response) = pipeline_batch_over_loopback(N);
13679
13680        assert_eq!(
13681            requests_before_first_response,
13682            N + 1,
13683            "all {N} ops + end-pipeline are written before any response is read == 1 round trip"
13684        );
13685        assert_eq!(results.len(), N, "one decoded result per pipelined op");
13686
13687        // Byte-identical to sequential: each pipelined op decodes through the
13688        // same per-op decoder the ordinary execute path uses, over the same wire
13689        // bytes, so the materialized QueryResult is identical.
13690        let reference = sequential_op_decode(&synthetic_pipeline_execute_response_payload());
13691        for (index, decoded) in results.into_iter().enumerate() {
13692            assert_eq!(
13693                decoded, reference,
13694                "pipelined op {index} decodes byte-identically to the sequential path"
13695            );
13696        }
13697    }
13698
13699    #[test]
13700    fn concurrent_pipeline_batches_do_not_serialize() {
13701        // Two independent connections each run a pipeline concurrently on their
13702        // own OS thread + runtime. The driver holds no cross-connection lock
13703        // (each Connection owns its transport), so — unlike python-oracledb's
13704        // GIL-bound thin mode — the batches proceed in parallel. Both must
13705        // complete with results identical to the sequential decode.
13706        const N: usize = 10;
13707        let reference = sequential_op_decode(&synthetic_pipeline_execute_response_payload());
13708
13709        let worker_a = thread::spawn(|| pipeline_batch_over_loopback(N));
13710        let worker_b = thread::spawn(|| pipeline_batch_over_loopback(N));
13711        let (results_a, count_a) = worker_a.join().expect("worker a joins");
13712        let (results_b, count_b) = worker_b.join().expect("worker b joins");
13713
13714        assert_eq!(count_a, N + 1, "batch A collapsed to one round trip");
13715        assert_eq!(count_b, N + 1, "batch B collapsed to one round trip");
13716        assert_eq!(results_a.len(), N);
13717        assert_eq!(results_b.len(), N);
13718        for (index, decoded) in results_a.iter().chain(results_b.iter()).enumerate() {
13719            assert_eq!(
13720                *decoded, reference,
13721                "concurrent pipelined op {index} matches the sequential decode"
13722            );
13723        }
13724    }
13725
13726    #[test]
13727    fn pipeline_inactivity_timeout_breaks_and_drains_before_reuse() -> Result<()> {
13728        // GH#14: an opt-in read-inactivity timeout must use the same
13729        // BREAK→drain recovery as every other CallTimeout. Pipelining writes
13730        // both the operation and end-pipeline packets before its first read, so
13731        // this loopback refuses to answer until the client BREAKs the stranded
13732        // response; only then will it accept a follow-up query on the same
13733        // connection.
13734        const TRAILING_CANCEL_ERROR: &[u8] = &[0x04, 0x01, 0x0d];
13735        const TIMEOUT: Duration = Duration::from_millis(100);
13736
13737        let listener = TcpListener::bind("127.0.0.1:0")?;
13738        let addr = listener.local_addr()?;
13739        let server = thread::spawn(move || -> std::io::Result<()> {
13740            use std::io::Write as _;
13741
13742            let (mut socket, _) = listener.accept()?;
13743            socket.set_read_timeout(Some(Duration::from_secs(5)))?;
13744
13745            for _ in 0..2 {
13746                assert_eq!(
13747                    read_one_wire_packet(&mut socket),
13748                    TNS_PACKET_TYPE_DATA,
13749                    "pipeline must send its operation and end-pipeline packets before reading"
13750                );
13751            }
13752            assert_eq!(
13753                read_marker_type(&mut socket),
13754                TNS_MARKER_TYPE_BREAK,
13755                "inactivity timeout must BREAK the stranded pipeline response"
13756            );
13757            socket.write_all(&data_packet(b"stranded pipeline response", true))?;
13758            socket.write_all(&marker_packet(TNS_MARKER_TYPE_BREAK))?;
13759            assert_eq!(
13760                read_marker_type(&mut socket),
13761                TNS_MARKER_TYPE_RESET,
13762                "pipeline drain must complete the RESET handshake"
13763            );
13764            socket.write_all(&marker_packet(TNS_MARKER_TYPE_RESET))?;
13765            socket.write_all(&data_packet(TRAILING_CANCEL_ERROR, true))?;
13766
13767            assert_eq!(
13768                read_one_wire_packet(&mut socket),
13769                TNS_PACKET_TYPE_DATA,
13770                "only a cleanly recovered connection may issue the follow-up query"
13771            );
13772            socket.write_all(&data_packet(
13773                &synthetic_pipeline_execute_response_payload(),
13774                true,
13775            ))?;
13776            socket.flush()?;
13777            Ok(())
13778        });
13779
13780        let runtime = build_io_runtime()?;
13781        runtime.block_on(async {
13782            let cx = test_cx()?;
13783            let stream = TcpStream::connect(addr).await?;
13784            let (read, write) = transport::plain_split(stream);
13785            let mut connection = loopback_connection(read, write);
13786            connection.core.set_inactivity_timeout(Some(TIMEOUT));
13787
13788            let err = connection
13789                .run_pipeline(&cx, &[PipelineRequest::Commit], false)
13790                .await
13791                .expect_err("silent pipeline response must hit the inactivity timeout");
13792            assert!(
13793                matches!(err, Error::CallTimeout(ms) if ms == 100),
13794                "expected the configured inactivity timeout, got {err:?}"
13795            );
13796            assert_eq!(
13797                connection.core.recovery.phase(),
13798                SessionRecoveryPhase::Ready,
13799                "CallTimeout must return only after BREAK-and-drain leaves the wire clean"
13800            );
13801
13802            let reused = connection
13803                .execute_raw(
13804                    &cx,
13805                    "select value from pipeline_timeout_reuse_fixture",
13806                    2,
13807                    &[],
13808                    ExecuteOptions::default(),
13809                    None,
13810                )
13811                .await?;
13812            assert_eq!(
13813                reused,
13814                sequential_op_decode(&synthetic_pipeline_execute_response_payload()),
13815                "the follow-up query must not consume a stranded pipeline response"
13816            );
13817            assert_eq!(
13818                connection.core.recovery.phase(),
13819                SessionRecoveryPhase::Ready
13820            );
13821            Ok::<_, Error>(())
13822        })?;
13823
13824        server.join().expect("pipeline timeout server joins")?;
13825        Ok(())
13826    }
13827
13828    #[test]
13829    fn in_flight_packet_resource_limit_marks_connection_dead() -> Result<()> {
13830        let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener");
13831        let addr = listener.local_addr().expect("listener address");
13832        let server = thread::spawn(move || {
13833            let (mut socket, _) = listener.accept().expect("accept test client");
13834            use std::io::Write as _;
13835            socket
13836                .write_all(&data_packet(&[0x01, 0x02, 0x03], true))
13837                .expect("write oversized packet");
13838        });
13839
13840        let runtime = build_io_runtime().expect("asupersync runtime");
13841        let conn = runtime.block_on(async {
13842            let cx = Cx::current()
13843                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
13844            let stream = TcpStream::connect(addr).await.expect("connect to listener");
13845            let (read, write) = transport::plain_split(stream);
13846            let mut conn = loopback_connection(read, write);
13847            let limits = ProtocolLimits {
13848                max_packet_bytes: 12,
13849                max_frame_bytes: 16,
13850                max_response_bytes: 32,
13851                ..ProtocolLimits::DEFAULT
13852            }
13853            .validate()?;
13854            conn.protocol_limits = limits;
13855            conn.core.set_protocol_limits(limits)?;
13856
13857            let err = conn
13858                .core
13859                .read_data_response(&cx)
13860                .await
13861                .expect_err("oversized in-flight packet must fail closed");
13862            assert!(matches!(
13863                err,
13864                Error::Protocol(oracledb_protocol::ProtocolError::ResourceLimit {
13865                    limit: "packet_bytes",
13866                    observed: 13,
13867                    maximum: 12,
13868                })
13869            ));
13870            Ok::<Connection, Error>(conn)
13871        })?;
13872
13873        assert!(
13874            conn.is_dead(),
13875            "in-flight resource-limit violations leave unread wire bytes"
13876        );
13877        assert_eq!(conn.core.recovery.phase(), SessionRecoveryPhase::Dead);
13878
13879        drop(conn);
13880        server.join().expect("server thread joins");
13881        Ok(())
13882    }
13883
13884    // ---- classic (pre-END_OF_RESPONSE) probed response reads ----------------
13885    //
13886    // Pre-23ai servers (ACCEPT protocol version < 319) never send the
13887    // END_OF_RESPONSE/EOF data flags, so `read_data_response_probed` in classic
13888    // mode must decide completion with the caller's probe over the accumulated
13889    // payload — the flag-driven reader would wait until the call timeout.
13890
13891    /// A classic two-packet response: the probe sees the accumulated payload
13892    /// after each DATA packet, reports "incomplete" after packet 1 and
13893    /// "complete" after packet 2, and the returned buffer is the flag-stripped
13894    /// concatenation of both packets.
13895    #[test]
13896    fn classic_probed_read_reassembles_until_probe_reports_complete() -> Result<()> {
13897        let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener");
13898        let addr = listener.local_addr().expect("listener address");
13899        let server = thread::spawn(move || {
13900            let (mut socket, _) = listener.accept().expect("accept test client");
13901            use std::io::Write as _;
13902            // Both packets are flagless: a classic server never sets
13903            // END_OF_RESPONSE/EOF, so only the probe can end the read.
13904            socket
13905                .write_all(&data_packet(&[0x01, 0x02], false))
13906                .expect("write first classic packet");
13907            socket
13908                .write_all(&data_packet(&[0x03, 0x04], false))
13909                .expect("write second classic packet");
13910        });
13911
13912        let runtime = build_io_runtime().expect("asupersync runtime");
13913        let probed = Arc::new(std::sync::Mutex::new(Vec::<Vec<u8>>::new()));
13914        let probed_in = Arc::clone(&probed);
13915        let response = runtime.block_on(async {
13916            let cx = Cx::current()
13917                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
13918            let stream = TcpStream::connect(addr).await.expect("connect to listener");
13919            let (read, write) = transport::plain_split(stream);
13920            let mut conn = loopback_connection(read, write);
13921            conn.core
13922                .read_data_response_probed(&cx, true, move |bytes| {
13923                    probed_in
13924                        .lock()
13925                        .expect("probe snapshot lock")
13926                        .push(bytes.to_vec());
13927                    // "Parser consumed the whole response": needs all 4 bytes.
13928                    bytes.len() >= 4
13929                })
13930                .await
13931        })?;
13932
13933        assert_eq!(
13934            response,
13935            [0x01, 0x02, 0x03, 0x04],
13936            "classic read must reassemble both flag-stripped payloads"
13937        );
13938        let probed = probed.lock().expect("probe snapshot lock");
13939        assert_eq!(
13940            probed.as_slice(),
13941            &[vec![0x01, 0x02], vec![0x01, 0x02, 0x03, 0x04]],
13942            "the probe must run over the accumulated payload after every packet"
13943        );
13944        server.join().expect("server thread joins");
13945        Ok(())
13946    }
13947
13948    /// MARKER-free happy path: a single flagless classic packet whose probe
13949    /// reports complete immediately returns that packet's payload.
13950    #[test]
13951    fn classic_probed_read_single_packet_happy_path() -> Result<()> {
13952        let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener");
13953        let addr = listener.local_addr().expect("listener address");
13954        let server = thread::spawn(move || {
13955            let (mut socket, _) = listener.accept().expect("accept test client");
13956            use std::io::Write as _;
13957            socket
13958                .write_all(&data_packet(
13959                    &[0x09, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06],
13960                    false,
13961                ))
13962                .expect("write classic packet");
13963        });
13964
13965        let runtime = build_io_runtime().expect("asupersync runtime");
13966        let response = runtime.block_on(async {
13967            let cx = Cx::current()
13968                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
13969            let stream = TcpStream::connect(addr).await.expect("connect to listener");
13970            let (read, write) = transport::plain_split(stream);
13971            let mut conn = loopback_connection(read, write);
13972            conn.core
13973                .read_data_response_probed(&cx, true, |bytes| !bytes.is_empty())
13974                .await
13975        })?;
13976
13977        assert_eq!(response, [0x09, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06]);
13978        server.join().expect("server thread joins");
13979        Ok(())
13980    }
13981
13982    /// With `classic == false` the probed read delegates to the flag-framed
13983    /// reader: an END_OF_RESPONSE-flagged packet completes the response even
13984    /// though the probe never says so — the 23ai path is byte-identical and
13985    /// the probe is never consulted.
13986    #[test]
13987    fn probed_read_ignores_probe_when_not_classic() -> Result<()> {
13988        let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener");
13989        let addr = listener.local_addr().expect("listener address");
13990        let server = thread::spawn(move || {
13991            let (mut socket, _) = listener.accept().expect("accept test client");
13992            use std::io::Write as _;
13993            socket
13994                .write_all(&data_packet(&[0x01, 0x02, 0x03], true))
13995                .expect("write flag-framed packet");
13996        });
13997
13998        let runtime = build_io_runtime().expect("asupersync runtime");
13999        let response = runtime.block_on(async {
14000            let cx = Cx::current()
14001                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
14002            let stream = TcpStream::connect(addr).await.expect("connect to listener");
14003            let (read, write) = transport::plain_split(stream);
14004            let mut conn = loopback_connection(read, write);
14005            conn.core
14006                .read_data_response_probed(&cx, false, |_| false)
14007                .await
14008        })?;
14009
14010        assert_eq!(response, [0x01, 0x02, 0x03]);
14011        server.join().expect("server thread joins");
14012        Ok(())
14013    }
14014
14015    /// A non-DATA/non-MARKER packet mid-response is a protocol violation: the
14016    /// classic probed read fails closed with `UnexpectedPacket` instead of
14017    /// accumulating garbage.
14018    #[test]
14019    fn classic_probed_read_rejects_non_data_packet() -> Result<()> {
14020        let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener");
14021        let addr = listener.local_addr().expect("listener address");
14022        let server = thread::spawn(move || {
14023            let (mut socket, _) = listener.accept().expect("accept test client");
14024            use std::io::Write as _;
14025            let packet = encode_packet(
14026                TNS_PACKET_TYPE_ACCEPT,
14027                0,
14028                None,
14029                &[0x00],
14030                PacketLengthWidth::Large32,
14031            )
14032            .expect("encode unexpected packet");
14033            socket.write_all(&packet).expect("write unexpected packet");
14034        });
14035
14036        let runtime = build_io_runtime().expect("asupersync runtime");
14037        let err = runtime.block_on(async {
14038            let cx = Cx::current().expect("ambient Cx");
14039            let stream = TcpStream::connect(addr).await.expect("connect to listener");
14040            let (read, write) = transport::plain_split(stream);
14041            let mut conn = loopback_connection(read, write);
14042            conn.core
14043                .read_data_response_probed(&cx, true, |_| true)
14044                .await
14045                .expect_err("non-DATA packet must fail closed")
14046        });
14047
14048        assert!(matches!(
14049            err,
14050            Error::UnexpectedPacket(TNS_PACKET_TYPE_ACCEPT)
14051        ));
14052        assert_eq!(err.kind(), ErrorKind::Protocol);
14053        server.join().expect("server thread joins");
14054        Ok(())
14055    }
14056
14057    // Regression (bead rust-oracledb-n2s): the multi-packet wide-row response
14058    // reassembler must NOT treat a non-final DATA packet that merely ends in the
14059    // byte 0x1d (TNS_MSG_TYPE_END_OF_RESPONSE, 29) as the end of the response.
14060    // Only a packet carrying the END_OF_RESPONSE/EOF data flag, or a minimal
14061    // packet whose entire post-flags payload is exactly the single byte 0x1d,
14062    // ends the response. Before the fix the `0x1d` check was unguarded and a
14063    // mid-stream wide-row packet boundary that happened to land on 0x1d
14064    // truncated the buffer ("encoded NUMBER too long" / "truncated TTC payload").
14065    #[test]
14066    fn data_packet_ends_response_requires_flag_or_lone_marker_byte() {
14067        const EOR: u8 = TNS_MSG_TYPE_END_OF_RESPONSE; // 29 / 0x1d
14068        const FOB: u8 = TNS_MSG_TYPE_FLUSH_OUT_BINDS; // 19 / 0x13
14069        let eor_flag = oracledb_protocol::thin::TNS_DATA_FLAGS_END_OF_RESPONSE;
14070        let eof_flag = oracledb_protocol::thin::TNS_DATA_FLAGS_EOF;
14071
14072        // The END_OF_RESPONSE data flag ends the response regardless of payload.
14073        assert!(data_packet_ends_response(eor_flag, &[0x01, 0x02, EOR]));
14074        assert!(data_packet_ends_response(eor_flag, &[]));
14075        // The EOF data flag (final packet of legacy framing) likewise ends it.
14076        assert!(data_packet_ends_response(eof_flag, &[0x01, 0x02, 0x03]));
14077
14078        // A lone marker byte arriving as its own minimal packet is a real
14079        // end-of-response (END_OF_RESPONSE or FLUSH_OUT_BINDS) -- the no-EOR
14080        // framing fallback.
14081        assert!(data_packet_ends_response(0x0000, &[EOR]));
14082        assert!(data_packet_ends_response(0x0000, &[FOB]));
14083
14084        // THE BUG: a flagless (mid-stream) wide-row packet whose payload merely
14085        // ENDS in a marker byte (0x1d END_OF_RESPONSE, or 0x13 FLUSH_OUT_BINDS) is
14086        // NOT the end of the response. These must all be false so reassembly keeps
14087        // reading the following packets.
14088        assert!(!data_packet_ends_response(0x0000, &[0xc1, 0x02, EOR]));
14089        assert!(!data_packet_ends_response(0x0000, &[0x00, EOR]));
14090        assert!(!data_packet_ends_response(0x0000, &[EOR, 0x05, 0x06, EOR]));
14091        assert!(!data_packet_ends_response(0x0000, &[0xc1, 0x02, FOB]));
14092        assert!(!data_packet_ends_response(0x0000, &[0x00, FOB]));
14093        // A flagless packet that does not end in a marker byte also keeps reading.
14094        assert!(!data_packet_ends_response(0x0000, &[0x01, 0x02, 0x03]));
14095        assert!(!data_packet_ends_response(0x0000, &[]));
14096    }
14097
14098    /// Pure replay of the `read_data_response_boundary` decision logic over a
14099    /// hand-built packet sequence, returning the reassembled bytes, the index it
14100    /// stopped at, and whether flush-out-binds was detected. Mirrors the async
14101    /// loop's break conditions exactly (minus I/O), INCLUDING the single-packet
14102    /// passthrough (bead rust-oracledb-0n0): when the response is one terminal
14103    /// packet, the owned buffer is moved instead of copied. The `payload` here is
14104    /// already flag-stripped, so the passthrough is a plain move of the same bytes
14105    /// — proving the optimization is byte-identical to the extend path.
14106    fn replay_boundary(packets: &[(u16, Vec<u8>)]) -> (Vec<u8>, Option<usize>, bool) {
14107        let mut reassembled = Vec::new();
14108        let mut stopped_at = None;
14109        for (index, (flags, payload)) in packets.iter().enumerate() {
14110            let ends = data_packet_ends_response(*flags, payload);
14111            if ends && reassembled.is_empty() {
14112                // Passthrough: move the (already flag-stripped) owned buffer.
14113                reassembled = payload.clone();
14114                stopped_at = Some(index);
14115                break;
14116            }
14117            reassembled.extend_from_slice(payload);
14118            if ends {
14119                stopped_at = Some(index);
14120                break;
14121            }
14122        }
14123        let flush_out_binds = matches!(reassembled.last(), Some(&TNS_MSG_TYPE_FLUSH_OUT_BINDS));
14124        (reassembled, stopped_at, flush_out_binds)
14125    }
14126
14127    // End-to-end of the boundary loop over a hand-built multi-packet sequence:
14128    // body packets that END in the marker bytes 0x1d (END_OF_RESPONSE) and 0x13
14129    // (FLUSH_OUT_BINDS) -- the old false-positive triggers -- followed by the
14130    // real END_OF_RESPONSE-flagged tail. The reassembled payload must concatenate
14131    // every body packet's bytes (after its 2-byte data flags) in order, proving
14132    // no early break and no byte loss.
14133    #[test]
14134    fn boundary_loop_reassembles_packets_ending_in_marker_byte() {
14135        const EOR: u8 = TNS_MSG_TYPE_END_OF_RESPONSE;
14136        const FOB: u8 = TNS_MSG_TYPE_FLUSH_OUT_BINDS;
14137        let packets: [(u16, Vec<u8>); 5] = [
14138            (0x0000, vec![0x10, 0x11, EOR]), // ends in 0x1d -> must NOT stop
14139            (0x0000, vec![0x20, 0x21, 0x22, FOB]), // ends in 0x13 -> must NOT stop
14140            (0x0000, vec![0x30, 0x31, 0x32, EOR]), // ends in 0x1d -> must NOT stop
14141            (0x0000, vec![0x33, 0x34, 0x35]), // ordinary body packet
14142            (
14143                oracledb_protocol::thin::TNS_DATA_FLAGS_END_OF_RESPONSE,
14144                vec![0x40, 0x41, EOR], // real end: carries the EOR flag
14145            ),
14146        ];
14147
14148        let (reassembled, stopped_at, flush_out_binds) = replay_boundary(&packets);
14149
14150        assert_eq!(
14151            stopped_at,
14152            Some(4),
14153            "reassembly must stop only on the flagged final packet, not on a body packet ending in a marker byte"
14154        );
14155        assert!(
14156            !flush_out_binds,
14157            "the response ended in END_OF_RESPONSE, not FLUSH_OUT_BINDS"
14158        );
14159        assert_eq!(
14160            reassembled,
14161            vec![
14162                0x10, 0x11, EOR, // packet 0
14163                0x20, 0x21, 0x22, FOB, // packet 1
14164                0x30, 0x31, 0x32, EOR, // packet 2
14165                0x33, 0x34, 0x35, // packet 3
14166                0x40, 0x41, EOR, // packet 4 (final)
14167            ],
14168            "every body packet's bytes must be concatenated in order with none dropped"
14169        );
14170    }
14171
14172    // A genuine flush-out-binds response (the EOR-flagged final packet ends in
14173    // the FLUSH_OUT_BINDS byte) must set the flag -- AND a mid-stream body packet
14174    // ending in that same byte must not have ended the response prematurely.
14175    #[test]
14176    fn boundary_loop_detects_flush_out_binds_only_at_true_boundary() {
14177        const FOB: u8 = TNS_MSG_TYPE_FLUSH_OUT_BINDS;
14178        let packets: [(u16, Vec<u8>); 2] = [
14179            (0x0000, vec![0x01, 0x02, FOB]), // body packet ending in 0x13 -> keep reading
14180            (
14181                oracledb_protocol::thin::TNS_DATA_FLAGS_END_OF_RESPONSE,
14182                vec![0x03, FOB], // EOR-flagged tail whose last message is FLUSH_OUT_BINDS
14183            ),
14184        ];
14185
14186        let (reassembled, stopped_at, flush_out_binds) = replay_boundary(&packets);
14187
14188        assert_eq!(stopped_at, Some(1), "stop on the EOR-flagged tail");
14189        assert!(
14190            flush_out_binds,
14191            "flush-out-binds must be detected from the terminal FLUSH_OUT_BINDS message byte"
14192        );
14193        assert_eq!(reassembled, vec![0x01, 0x02, FOB, 0x03, FOB]);
14194    }
14195
14196    // Single-packet passthrough (bead rust-oracledb-0n0): a response that is ONE
14197    // terminal DATA packet must produce byte-identical reassembled output whether
14198    // it takes the passthrough (move the owned buffer) or the legacy extend path,
14199    // and FLUSH_OUT_BINDS detection on its terminal byte must be unchanged.
14200    #[test]
14201    fn single_packet_passthrough_is_byte_identical_to_extend() {
14202        const EOR: u8 = TNS_MSG_TYPE_END_OF_RESPONSE;
14203        const FOB: u8 = TNS_MSG_TYPE_FLUSH_OUT_BINDS;
14204        let eor_flag = oracledb_protocol::thin::TNS_DATA_FLAGS_END_OF_RESPONSE;
14205
14206        // Helper: the legacy extend-only reassembly, for the equivalence oracle.
14207        fn replay_extend_only(packets: &[(u16, Vec<u8>)]) -> (Vec<u8>, Option<usize>, bool) {
14208            let mut reassembled = Vec::new();
14209            let mut stopped_at = None;
14210            for (index, (flags, payload)) in packets.iter().enumerate() {
14211                reassembled.extend_from_slice(payload);
14212                if data_packet_ends_response(*flags, payload) {
14213                    stopped_at = Some(index);
14214                    break;
14215                }
14216            }
14217            let flush = matches!(reassembled.last(), Some(&TNS_MSG_TYPE_FLUSH_OUT_BINDS));
14218            (reassembled, stopped_at, flush)
14219        }
14220
14221        // Several single-terminal-packet shapes, including one ending in the
14222        // FLUSH_OUT_BINDS byte (so the flag-detection path is exercised).
14223        let cases: &[(u16, Vec<u8>)] = &[
14224            (eor_flag, vec![0x40, 0x41, EOR]),
14225            (eor_flag, vec![0x03, FOB]), // terminal FLUSH_OUT_BINDS
14226            (eor_flag, vec![0xde, 0xad, 0xbe, 0xef]),
14227            (eor_flag, vec![0x00]),
14228        ];
14229        for (flags, payload) in cases {
14230            let one = [(*flags, payload.clone())];
14231            let passthrough = replay_boundary(&one);
14232            let extend = replay_extend_only(&one);
14233            assert_eq!(
14234                passthrough, extend,
14235                "passthrough must equal extend for single packet {payload:02x?}"
14236            );
14237            // And the bytes are exactly the (already flag-stripped) payload.
14238            assert_eq!(&passthrough.0, payload);
14239        }
14240    }
14241
14242    #[test]
14243    fn parse_user_and_proxy_bracket_forms() {
14244        // Plain user.
14245        assert_eq!(parse_user_and_proxy("scott"), ("scott".into(), None));
14246        // base[proxy] — reference form.
14247        assert_eq!(
14248            parse_user_and_proxy("scott[proxy]"),
14249            ("scott".into(), Some("proxy".into()))
14250        );
14251        // [proxy] only — upstream e861662 token-auth form (empty base).
14252        assert_eq!(
14253            parse_user_and_proxy("[proxy_only]"),
14254            ("".into(), Some("proxy_only".into()))
14255        );
14256        // No closing bracket → unchanged.
14257        assert_eq!(
14258            parse_user_and_proxy("scott[proxy"),
14259            ("scott[proxy".into(), None)
14260        );
14261        // No opening bracket → unchanged.
14262        assert_eq!(parse_user_and_proxy("scott]"), ("scott]".into(), None));
14263    }
14264
14265    #[test]
14266    fn connect_options_desugars_bracket_user_and_full_user_display() {
14267        let base_proxy = ConnectOptions::new("h:1521/s", "scott[proxy]", "pw", identity());
14268        assert_eq!(base_proxy.user(), "scott");
14269        assert_eq!(base_proxy.proxy_user(), Some("proxy"));
14270        assert_eq!(base_proxy.full_user(), "scott[proxy]");
14271        assert!(matches!(base_proxy.auth_mode(), AuthMode::Proxy));
14272
14273        let proxy_only = ConnectOptions::new("h:1521/s", "[proxy_only]", "", identity())
14274            .with_access_token("super-secret-db-token-xyz");
14275        assert_eq!(proxy_only.user(), "");
14276        assert_eq!(proxy_only.proxy_user(), Some("proxy_only"));
14277        assert_eq!(proxy_only.full_user(), "[proxy_only]");
14278        // Debug must show the full-user form and never leak the token/password.
14279        let dbg = format!("{proxy_only:?}");
14280        assert!(dbg.contains("[proxy_only]"), "debug was {dbg}");
14281        assert!(
14282            !dbg.contains("super-secret-db-token-xyz"),
14283            "token leaked in debug: {dbg}"
14284        );
14285        assert!(
14286            dbg.contains("***redacted***"),
14287            "password/token field missing redaction: {dbg}"
14288        );
14289
14290        // Explicit with_proxy_user overwrites bracket desugar.
14291        let overwritten = ConnectOptions::new("h:1521/s", "scott[from_bracket]", "pw", identity())
14292            .with_proxy_user(Some("from_explicit".into()));
14293        assert_eq!(overwritten.user(), "scott");
14294        assert_eq!(overwritten.proxy_user(), Some("from_explicit"));
14295        assert_eq!(overwritten.full_user(), "scott[from_explicit]");
14296    }
14297
14298    #[test]
14299    fn proxy_only_token_auth_encodes_phase_two_proxy_header() {
14300        // Wire path: empty base user + PROXY_CLIENT_NAME for proxy-only.
14301        use oracledb_protocol::crypto::EncryptedPassword;
14302        use oracledb_protocol::thin::build_auth_phase_two_payload_with_proxy_with_seq;
14303        let encrypted = EncryptedPassword {
14304            session_key: "00".repeat(32),
14305            speedy_key: None,
14306            password: "00".repeat(32),
14307            combo_key: vec![0u8; 32],
14308        };
14309        let payload = build_auth_phase_two_payload_with_proxy_with_seq(
14310            "",
14311            &encrypted,
14312            "rust-oracledb",
14313            0,
14314            "",
14315            1,
14316            &[],
14317            Some("proxy_only"),
14318            None,
14319            17, // TNS_CCAP_FIELD_VERSION_23_1
14320        )
14321        .expect("phase-two proxy payload");
14322        let text = String::from_utf8_lossy(&payload);
14323        assert!(
14324            text.contains("PROXY_CLIENT_NAME") && text.contains("proxy_only"),
14325            "payload missing PROXY_CLIENT_NAME/proxy_only: {text:?}"
14326        );
14327    }
14328
14329    #[test]
14330    fn descriptor_builder_uses_identity_in_listener_cid() {
14331        let options = ConnectOptions::new("localhost/FREEPDB1", "user", "password", identity());
14332        let descriptor =
14333            EasyConnect::parse(&options.connect_string).expect("test connect string should parse");
14334        let full_descriptor = EasyConnect::parse_descriptor(&options.connect_string)
14335            .expect("test connect string should parse as full descriptor");
14336        let description = full_descriptor.first_description();
14337        let built = listener_connect_descriptor_with_server(
14338            &descriptor,
14339            description,
14340            &options.identity,
14341            false,
14342            false,
14343            true,
14344            None,
14345        );
14346        assert!(built.contains("(PROGRAM=program)"));
14347        assert!(built.contains("(HOST=machine)"));
14348        assert!(built.contains("(USER=osuser)"));
14349        assert!(!built.contains("(SERVER=emon)"));
14350        // emon variant injects the SERVER directive ahead of the CID block
14351        let emon = listener_connect_descriptor_with_server(
14352            &descriptor,
14353            description,
14354            &options.identity,
14355            true,
14356            false,
14357            true,
14358            None,
14359        );
14360        assert!(emon.contains("(SERVICE_NAME=FREEPDB1)(SERVER=emon)(CID="));
14361    }
14362
14363    #[test]
14364    fn token_auth_descriptor_uses_tcps_security_and_passthrough() {
14365        let connect_string = concat!(
14366            "(DESCRIPTION=(ADDRESS=(PROTOCOL=tcps)(HOST=adb.example.test)(PORT=2484))",
14367            "(CONNECT_DATA=(SERVICE_NAME=adbsvc))",
14368            "(SECURITY=(SSL_SERVER_DN_MATCH=off)",
14369            "(SSL_SERVER_CERT_DN=CN=adb.example.test)",
14370            "(OCI_IAM_HOST=private-endpoint)))"
14371        );
14372        let descriptor = EasyConnect::parse(connect_string).expect("parse tcps descriptor");
14373        let full_descriptor =
14374            EasyConnect::parse_descriptor(connect_string).expect("parse full descriptor");
14375        let description = full_descriptor.first_description();
14376        let built = auth_connect_descriptor(
14377            &descriptor,
14378            description,
14379            true,
14380            false,
14381            description.security.ssl_server_cert_dn.as_deref(),
14382        );
14383
14384        assert!(built.contains("(PROTOCOL=tcps)"));
14385        assert!(built.contains("(SECURITY="));
14386        assert!(built.contains("(SSL_SERVER_DN_MATCH=OFF)"));
14387        assert!(built.contains("(SSL_SERVER_CERT_DN=CN=adb.example.test)"));
14388        assert!(built.contains("(OCI_IAM_HOST=private-endpoint)"));
14389        assert!(built.contains("(TOKEN_AUTH=OCI_TOKEN)"));
14390        assert!(!built.contains("WALLET"));
14391    }
14392
14393    // ---- H2: connect-descriptor golden wire-bytes per auth mode ----------
14394    //
14395    // The tests above prove individual clauses show up (`.contains(...)`).
14396    // These pin the ENTIRE built descriptor string byte-for-byte against a
14397    // committed fixture (`tests/golden/connect_descriptor/*.descriptor.txt`)
14398    // AND the entire framed CONNECT wire packet byte-for-byte against a
14399    // committed hex fixture (`tests/golden/connect_descriptor/*.packet.hex`),
14400    // one pair per auth mode (password/plain-TCP, wallet-TCPS with a DC2
14401    // pinned cert DN, IAM-token TCPS). A regression in `auth_connect_descriptor`
14402    // / `descriptor_security_clause` / `build_connect_packet_payload` — a
14403    // reordered field, a dropped clause, a header byte that drifts — fails
14404    // one of these with a diff naming exactly what changed, without a live
14405    // database. The parser side of the same round trip (DC2 cert-DN, DC4
14406    // `CONNECT_TIMEOUT` parsing, full per-auth-mode topology) is pinned
14407    // separately in `oracledb-protocol/tests/connect_descriptor_golden.rs`,
14408    // where the parser types are public.
14409    //
14410    // The wire-packet fixtures mirror the exact `build_connect_packet_payload`
14411    // + `encode_packet` call the real `Connection::connect` path makes (see
14412    // `TNS_PACKET_TYPE_CONNECT` above, around the CONNECT-resend loop), with a
14413    // fixed SDU of 8192 so the header fields are deterministic.
14414
14415    /// Decodes a lowercase/uppercase hex-digit fixture (whitespace ignored)
14416    /// into raw bytes. A small hand-rolled helper rather than a new
14417    /// dependency: the fixture files are plain, newline-wrapped hex dumps.
14418    /// (Named distinctly from the `#[cfg(feature = "cassette")]`-only
14419    /// `decode_hex_fixture` above: this one must be available unconditionally
14420    /// — the golden connect-descriptor tests run under both the default and
14421    /// `cassette` feature sets — and panics rather than returning `Result`,
14422    /// which is the right shape for a test-only fixture loader.)
14423    fn decode_golden_packet_hex(hex: &str) -> Vec<u8> {
14424        let digits: Vec<u8> = hex.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
14425        assert!(
14426            digits.len().is_multiple_of(2),
14427            "hex fixture must have an even number of hex digits"
14428        );
14429        digits
14430            .chunks_exact(2)
14431            .map(|pair| {
14432                let hi = (pair[0] as char).to_digit(16).expect("valid hex digit");
14433                let lo = (pair[1] as char).to_digit(16).expect("valid hex digit");
14434                ((hi << 4) | lo) as u8
14435            })
14436            .collect()
14437    }
14438
14439    /// Builds the full framed CONNECT wire packet for `connect_data`, exactly
14440    /// as `Connection::connect` does on the first (non-resend, non-redirect)
14441    /// attempt: `build_connect_packet_payload` then `encode_packet` with
14442    /// `PacketLengthWidth::Legacy16` and no packet flags / checksum.
14443    fn golden_connect_packet_bytes(connect_data: &str, sdu: u16) -> Vec<u8> {
14444        let payload =
14445            build_connect_packet_payload(connect_data, sdu).expect("connect packet payload");
14446        encode_packet(
14447            TNS_PACKET_TYPE_CONNECT,
14448            0,
14449            None,
14450            &payload,
14451            PacketLengthWidth::Legacy16,
14452        )
14453        .expect("encode CONNECT packet")
14454    }
14455
14456    #[test]
14457    fn golden_password_plain_tcp_descriptor_and_wire_bytes() {
14458        let connect_string = concat!(
14459            "(DESCRIPTION=(ADDRESS=(PROTOCOL=tcp)(HOST=db.example.test)(PORT=1521))",
14460            "(CONNECT_DATA=(SERVICE_NAME=FREEPDB1)))"
14461        );
14462        let descriptor = EasyConnect::parse(connect_string).expect("parse tcp descriptor");
14463        let full_descriptor =
14464            EasyConnect::parse_descriptor(connect_string).expect("parse full tcp descriptor");
14465        let description = full_descriptor.first_description();
14466        let built = auth_connect_descriptor(&descriptor, description, false, true, None);
14467
14468        let expected_descriptor =
14469            include_str!("../tests/golden/connect_descriptor/password_tcp.descriptor.txt").trim();
14470        assert_eq!(
14471            built, expected_descriptor,
14472            "password/plain-TCP built connect descriptor drifted from the golden fixture"
14473        );
14474
14475        let packet = golden_connect_packet_bytes(&built, 8192);
14476        let expected_packet = decode_golden_packet_hex(include_str!(
14477            "../tests/golden/connect_descriptor/password_tcp.packet.hex"
14478        ));
14479        assert_eq!(
14480            packet, expected_packet,
14481            "password/plain-TCP CONNECT wire packet drifted from the golden fixture"
14482        );
14483    }
14484
14485    #[test]
14486    fn golden_wallet_tcps_cert_dn_descriptor_and_wire_bytes() {
14487        let connect_string = concat!(
14488            "(DESCRIPTION=(ADDRESS=(PROTOCOL=tcps)(HOST=adb.example.test)(PORT=2484))",
14489            "(CONNECT_DATA=(SERVICE_NAME=adbsvc))",
14490            "(SECURITY=(SSL_SERVER_DN_MATCH=on)",
14491            "(SSL_SERVER_CERT_DN=CN=adb.example.test,O=ExampleCorp,C=US)",
14492            "(MY_WALLET_DIRECTORY=/opt/wallets/adb)))"
14493        );
14494        let descriptor = EasyConnect::parse(connect_string).expect("parse wallet tcps descriptor");
14495        let full_descriptor = EasyConnect::parse_descriptor(connect_string)
14496            .expect("parse full wallet tcps descriptor");
14497        let description = full_descriptor.first_description();
14498        // bead DC2: the pinned cert DN travels ONLY through the DSN-parsed
14499        // `description.security.*` fields here, never through the
14500        // `with_ssl_server_cert_dn` / `with_ssl_server_dn_match` builders —
14501        // the exact path F-DC2 found broken.
14502        let built = auth_connect_descriptor(
14503            &descriptor,
14504            description,
14505            false,
14506            description.security.ssl_server_dn_match,
14507            description.security.ssl_server_cert_dn.as_deref(),
14508        );
14509
14510        let expected_descriptor =
14511            include_str!("../tests/golden/connect_descriptor/wallet_tcps_cert_dn.descriptor.txt")
14512                .trim();
14513        assert_eq!(
14514            built, expected_descriptor,
14515            "wallet-TCPS (DC2 cert-DN) built connect descriptor drifted from the golden fixture"
14516        );
14517        // The wallet directory is a local-only TLS-setup directive: it must
14518        // never be echoed back into the descriptor sent to the listener.
14519        assert!(!built.contains("WALLET"));
14520
14521        let packet = golden_connect_packet_bytes(&built, 8192);
14522        let expected_packet = decode_golden_packet_hex(include_str!(
14523            "../tests/golden/connect_descriptor/wallet_tcps_cert_dn.packet.hex"
14524        ));
14525        assert_eq!(
14526            packet, expected_packet,
14527            "wallet-TCPS (DC2 cert-DN) CONNECT wire packet drifted from the golden fixture"
14528        );
14529    }
14530
14531    #[test]
14532    fn golden_iam_token_tcps_descriptor_and_wire_bytes() {
14533        let connect_string = concat!(
14534            "(DESCRIPTION=(ADDRESS=(PROTOCOL=tcps)(HOST=adb.example.test)(PORT=2484))",
14535            "(CONNECT_DATA=(SERVICE_NAME=adbsvc))",
14536            "(SECURITY=(SSL_SERVER_DN_MATCH=off)))"
14537        );
14538        let descriptor = EasyConnect::parse(connect_string).expect("parse iam token descriptor");
14539        let full_descriptor =
14540            EasyConnect::parse_descriptor(connect_string).expect("parse full iam token descriptor");
14541        let description = full_descriptor.first_description();
14542        let built = auth_connect_descriptor(
14543            &descriptor,
14544            description,
14545            true,
14546            description.security.ssl_server_dn_match,
14547            None,
14548        );
14549
14550        let expected_descriptor =
14551            include_str!("../tests/golden/connect_descriptor/iam_token_tcps.descriptor.txt").trim();
14552        assert_eq!(
14553            built, expected_descriptor,
14554            "IAM-token TCPS built connect descriptor drifted from the golden fixture"
14555        );
14556
14557        let packet = golden_connect_packet_bytes(&built, 8192);
14558        let expected_packet = decode_golden_packet_hex(include_str!(
14559            "../tests/golden/connect_descriptor/iam_token_tcps.packet.hex"
14560        ));
14561        assert_eq!(
14562            packet, expected_packet,
14563            "IAM-token TCPS CONNECT wire packet drifted from the golden fixture"
14564        );
14565    }
14566
14567    #[test]
14568    fn transport_connect_timeout_bounds_post_dial_accept_read() -> Result<()> {
14569        let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener");
14570        let addr = listener.local_addr().expect("listener address");
14571        let server = thread::spawn(move || -> std::io::Result<()> {
14572            let (mut socket, _) = listener.accept()?;
14573            socket.set_read_timeout(Some(Duration::from_secs(5)))?;
14574            let mut header = [0u8; 8];
14575            socket.read_exact(&mut header)?;
14576            let declared = usize::from(u16::from_be_bytes([header[0], header[1]]));
14577            let mut payload = vec![0u8; declared.saturating_sub(header.len())];
14578            socket.read_exact(&mut payload)?;
14579            thread::sleep(Duration::from_millis(300));
14580            Ok(())
14581        });
14582
14583        let options = ConnectOptions::new(
14584            format!(
14585                "127.0.0.1:{}/FREEPDB1?transport_connect_timeout=100ms",
14586                addr.port()
14587            ),
14588            "user",
14589            "password",
14590            identity(),
14591        );
14592        let runtime = build_io_runtime().expect("asupersync runtime");
14593        let started = Instant::now();
14594        let err = runtime
14595            .block_on(async {
14596                let cx = Cx::current().expect("ambient Cx");
14597                Connection::connect(&cx, options).await
14598            })
14599            .expect_err("stalling listener should hit transport connect timeout");
14600        assert!(
14601            matches!(err, Error::CallTimeout(ms) if ms == 100),
14602            "expected 100ms CallTimeout, got {err:?}"
14603        );
14604        assert!(
14605            started.elapsed() < Duration::from_secs(2),
14606            "post-dial ACCEPT read should be bounded"
14607        );
14608        server.join().expect("server thread joins")?;
14609        Ok(())
14610    }
14611
14612    /// bead F-DC4: a full `(DESCRIPTION=...)` Oracle Net descriptor's own
14613    /// `CONNECT_TIMEOUT` must actually bound a TCPS connect attempt end to
14614    /// end, including a stalled TLS handshake — not just the TCP dial, and
14615    /// not just the easy-connect `?transport_connect_timeout=` query-param
14616    /// form covered by `transport_connect_timeout_bounds_post_dial_accept_read`
14617    /// above. This is an end-to-end companion to
14618    /// `tls::tests::tls_handshake_honors_a_short_caller_supplied_timeout_against_a_stalled_peer`
14619    /// (`tests/tls_handshake.rs`), which isolates the handshake-timeout fix
14620    /// itself by calling `tls_handshake` directly; this test instead proves
14621    /// the full `Connection::connect` pipeline actually delivers that
14622    /// behavior for a full descriptor, whichever layer (the outer connect
14623    /// deadline or the handshake's own timeout) ends up enforcing it.
14624    #[test]
14625    fn full_descriptor_connect_timeout_bounds_a_stalled_tls_handshake() -> Result<()> {
14626        let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener");
14627        let addr = listener.local_addr().expect("listener address");
14628        // Hold the TCP connection open with zero bytes sent, well past the
14629        // assertion window, so only the configured connect timeout (not a
14630        // connection-close race) can explain a fast return.
14631        let server = thread::spawn(move || -> std::io::Result<()> {
14632            let (socket, _) = listener.accept()?;
14633            thread::sleep(Duration::from_secs(3));
14634            drop(socket);
14635            Ok(())
14636        });
14637
14638        let wallet_dir = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/tls");
14639        let connect_string = format!(
14640            "(DESCRIPTION=(CONNECT_TIMEOUT=0.3)\
14641             (ADDRESS=(PROTOCOL=tcps)(HOST=127.0.0.1)(PORT={}))\
14642             (CONNECT_DATA=(SERVICE_NAME=FREEPDB1))\
14643             (SECURITY=(SSL_SERVER_DN_MATCH=off)(MY_WALLET_DIRECTORY=\"{wallet_dir}\")))",
14644            addr.port(),
14645        );
14646        let options = ConnectOptions::new(connect_string, "user", "password", identity());
14647        let runtime = build_io_runtime().expect("asupersync runtime");
14648        let started = Instant::now();
14649        let err = runtime
14650            .block_on(async {
14651                let cx = Cx::current().expect("ambient Cx");
14652                Connection::connect(&cx, options).await
14653            })
14654            .expect_err("a peer that never speaks TLS must not let connect succeed");
14655        let elapsed = started.elapsed();
14656        assert!(
14657            elapsed < Duration::from_secs(2),
14658            "the descriptor's 0.3s CONNECT_TIMEOUT must bound the TLS handshake too, not just \
14659             the TCP dial (the old hard-coded ~20s handshake timeout, or the peer's own 3s \
14660             silent hold, would both blow this budget); actually took {elapsed:?}, err={err:?}"
14661        );
14662        server.join().expect("server thread joins")?;
14663        Ok(())
14664    }
14665
14666    /// DC4's short-budget coverage proves the timeout fires promptly. This
14667    /// companion pin proves an explicitly configured budget above the old
14668    /// 20-second ceiling reaches the production TLS handoff unchanged, while
14669    /// keeping the stalled-peer exercise short by letting the peer close first.
14670    #[test]
14671    fn full_descriptor_preserves_above_twenty_second_tls_handshake_budget() -> Result<()> {
14672        const CONFIGURED_TIMEOUT: Duration = Duration::from_secs(45);
14673        const SERVER_HOLD: Duration = Duration::from_millis(250);
14674
14675        let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener");
14676        let addr = listener.local_addr().expect("listener address");
14677        let server = thread::spawn(move || -> std::io::Result<()> {
14678            let (socket, _) = listener.accept()?;
14679            thread::sleep(SERVER_HOLD);
14680            drop(socket);
14681            Ok(())
14682        });
14683
14684        let wallet_dir = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/tls");
14685        let connect_string = format!(
14686            "(DESCRIPTION=(CONNECT_TIMEOUT=45)\
14687             (ADDRESS=(PROTOCOL=tcps)(HOST=127.0.0.1)(PORT={}))\
14688             (CONNECT_DATA=(SERVICE_NAME=FREEPDB1))\
14689             (SECURITY=(SSL_SERVER_DN_MATCH=off)(MY_WALLET_DIRECTORY=\"{wallet_dir}\")))",
14690            addr.port(),
14691        );
14692        let parsed = EasyConnect::parse_descriptor(&connect_string)
14693            .expect("full descriptor with a 45-second CONNECT_TIMEOUT parses");
14694        let resolved_connect_timeout =
14695            transport_connect_timeout_duration(parsed.first_description().tcp_connect_timeout);
14696        assert_eq!(resolved_connect_timeout, CONFIGURED_TIMEOUT);
14697        assert_eq!(
14698            tls::handshake_timeout_from_connect_timeout(resolved_connect_timeout),
14699            CONFIGURED_TIMEOUT,
14700            "the production TLS handoff must not re-clamp a configured 45-second budget"
14701        );
14702
14703        let options = ConnectOptions::new(connect_string, "user", "password", identity());
14704        let runtime = build_io_runtime().expect("asupersync runtime");
14705        let started = Instant::now();
14706        let err = runtime
14707            .block_on(async {
14708                let cx = Cx::current().expect("ambient Cx");
14709                Connection::connect(&cx, options).await
14710            })
14711            .expect_err("a peer that never completes TLS must not connect");
14712        let elapsed = started.elapsed();
14713        assert!(
14714            elapsed >= SERVER_HOLD / 2 && elapsed < Duration::from_secs(2),
14715            "the stalled peer must drive the observed failure without turning a {CONFIGURED_TIMEOUT:?} \
14716             configuration check into a long wait; took {elapsed:?}, err={err:?}"
14717        );
14718        server.join().expect("server thread joins")?;
14719        Ok(())
14720    }
14721
14722    // ---- bead F-DC2 / F-DC3: DSN-only SSL_SERVER_CERT_DN / SSL_SERVER_DN_MATCH ----
14723    //
14724    // `tests/tls_handshake.rs` proves the crate-internal `tls::` module's DN
14725    // check is correct when `TlsParams` is built directly. It does NOT prove
14726    // that the full `Connection::connect` pipeline actually threads a
14727    // `SSL_SERVER_CERT_DN` / `SSL_SERVER_DN_MATCH` supplied ONLY via the
14728    // connect string (never through the `with_ssl_server_cert_dn` /
14729    // `with_ssl_server_dn_match` builder methods) into that verifier — which
14730    // is exactly the gap F-DC2 found: `Connection::connect` was passing the
14731    // raw, unmerged `options.ssl_server_dn_match` /
14732    // `options.ssl_server_cert_dn` (both default/absent unless the builder
14733    // was called) into `tls::resolve_tls_params`, silently dropping whatever
14734    // the DSN itself said. The three tests below drive the real
14735    // `Connection::connect` against a real rustls server presenting the
14736    // CA-signed `leaf.crt` fixture (subject DN
14737    // "C=US, O=ExampleDB, CN=db.example.com", SAN db.example.com/localhost —
14738    // no DNS SAN for a bare IP), dialled by IP (`HOST=127.0.0.1`) so the
14739    // outcome never depends on real DNS resolution.
14740    //
14741    // Discrimination does not rely on the coarse "did it fail" question —
14742    // dialling a non-Oracle stub server always eventually fails one way or
14743    // another. It relies on *which* failure: `check_cert_dn`'s
14744    // `DnMatchError::CertDnMismatch` echoes the pinned DN verbatim in its
14745    // message, while the broken fallback path (`server_cert_dn` silently
14746    // dropped to `None`) instead runs `check_server_name` against
14747    // `HOST=127.0.0.1`, which can never match this leaf's SAN (127.0.0.1 is
14748    // only present as an IP-typed SAN entry, and `parse_cert_identity` only
14749    // collects DNS-typed SAN names) — so that fallback ALWAYS fails, but with
14750    // `DnMatchError::NameMismatch`, a message that never contains the pinned
14751    // DN string. That distinction is what a passing test after the fix, and a
14752    // failing test before it, actually turns on — verified by temporarily
14753    // reverting the `descriptor_ssl_server_dn_match` /
14754    // `descriptor_ssl_server_cert_dn` wiring in `Connection::connect` and
14755    // confirming each test below fails.
14756
14757    /// Spawn a one-shot real rustls TLS server presenting the CA-signed
14758    /// `leaf.crt`/`leaf.key` fixture (the same identity `tests/tls_handshake.rs`
14759    /// uses). It completes exactly one handshake, reads whatever the peer sends
14760    /// next (the driver's TNS CONNECT packet) without attempting to understand
14761    /// it, and then closes without answering — so a `Connection::connect` that
14762    /// gets PAST the TLS/DN layer always still fails overall, just with
14763    /// something other than `Error::Tls`.
14764    fn spawn_dc2_dc3_fixture_tls_server() -> (u16, thread::JoinHandle<()>) {
14765        let mut fixtures = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
14766        fixtures.push("tests");
14767        fixtures.push("fixtures");
14768        fixtures.push("tls");
14769        let leaf = std::fs::read(fixtures.join("leaf.crt")).expect("read leaf.crt fixture");
14770        let key = std::fs::read(fixtures.join("leaf.key")).expect("read leaf.key fixture");
14771        let ca = std::fs::read(fixtures.join("ca.crt")).expect("read ca.crt fixture");
14772
14773        let mut chain: Vec<rustls::pki_types::CertificateDer<'static>> =
14774            rustls_pemfile::certs(&mut std::io::BufReader::new(&leaf[..]))
14775                .filter_map(std::result::Result::ok)
14776                .collect();
14777        chain.extend(
14778            rustls_pemfile::certs(&mut std::io::BufReader::new(&ca[..]))
14779                .filter_map(std::result::Result::ok),
14780        );
14781        let key_der = rustls_pemfile::private_key(&mut std::io::BufReader::new(&key[..]))
14782            .expect("read leaf.key")
14783            .expect("leaf.key must contain a private key");
14784
14785        let config = rustls::ServerConfig::builder()
14786            .with_no_client_auth()
14787            .with_single_cert(chain, key_der)
14788            .expect("fixture server config");
14789        let config = std::sync::Arc::new(config);
14790
14791        let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener");
14792        let port = listener.local_addr().expect("listener address").port();
14793        let handle = thread::spawn(move || {
14794            if let Ok((mut sock, _)) = listener.accept() {
14795                if let Ok(mut conn) = rustls::ServerConnection::new(config) {
14796                    let mut tls = rustls::Stream::new(&mut conn, &mut sock);
14797                    let mut buf = [0u8; 4096];
14798                    // Best-effort: complete the handshake (if the client's
14799                    // verifier accepts it) and read whatever the client sends
14800                    // next. Never answer it — closing here is what turns
14801                    // "got past TLS" into a clean, fast non-Tls failure for
14802                    // the driver instead of a hang.
14803                    let _ = tls.read(&mut buf);
14804                }
14805            }
14806        });
14807        (port, handle)
14808    }
14809
14810    /// Copy the fixture CA-only trust wallet (`ca_wallet.pem` — the CA that
14811    /// signs `leaf.crt`, no client identity) into a fresh temp directory named
14812    /// `ewallet.pem`, the filename the directory-based wallet loader
14813    /// (`resolve_wallet_dir`/`load_wallet`) requires. Mirrors `tls.rs`'s own
14814    /// `temp_wallet_dir` test helper (a different module, so not reusable
14815    /// directly here).
14816    fn dc2_dc3_temp_ca_trust_wallet_dir(label: &str) -> std::path::PathBuf {
14817        let mut fixture = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
14818        fixture.push("tests");
14819        fixture.push("fixtures");
14820        fixture.push("tls");
14821        fixture.push("ca_wallet.pem");
14822
14823        let dir = std::env::temp_dir().join(format!(
14824            "oracledb-dc2-dc3-wallet-{label}-{}",
14825            std::process::id()
14826        ));
14827        std::fs::create_dir_all(&dir).expect("create temp wallet dir");
14828        std::fs::copy(&fixture, dir.join("ewallet.pem")).expect("copy ca_wallet.pem fixture");
14829        dir
14830    }
14831
14832    /// Copy an unrelated synthetic CA into a temp wallet. The local TLS server
14833    /// presents `leaf.crt`, signed by `ca.crt`; trusting this unrelated CA
14834    /// makes the client fail at chain validation (`UnknownIssuer`) before any
14835    /// Oracle protocol bytes are exchanged.
14836    fn dc5_untrusted_temp_wallet_dir(label: &str) -> std::path::PathBuf {
14837        let mut fixture = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
14838        fixture.push("tests");
14839        fixture.push("fixtures");
14840        fixture.push("tls");
14841        fixture.push("synthetic");
14842        fixture.push("ca.pem");
14843
14844        let dir = std::env::temp_dir().join(format!(
14845            "oracledb-dc5-untrusted-wallet-{label}-{}",
14846            std::process::id()
14847        ));
14848        std::fs::create_dir_all(&dir).expect("create temp untrusted wallet dir");
14849        std::fs::copy(&fixture, dir.join("ewallet.pem")).expect("copy unrelated ca.pem fixture");
14850        dir
14851    }
14852
14853    /// Build a full `(DESCRIPTION=...)` TCPS connect string pinning
14854    /// `SSL_SERVER_CERT_DN` (and, when `dn_match_off` is set,
14855    /// `SSL_SERVER_DN_MATCH=OFF`) entirely in the connect string — the
14856    /// DSN-only path F-DC2/F-DC3 found broken. `wallet_dir` must hold a
14857    /// directory-loadable wallet trusting the CA that signed the server's
14858    /// presented certificate.
14859    fn dc2_dc3_connect_string(
14860        port: u16,
14861        wallet_dir: &std::path::Path,
14862        cert_dn: &str,
14863        dn_match_off: bool,
14864    ) -> String {
14865        let dn_match_clause = if dn_match_off {
14866            "(SSL_SERVER_DN_MATCH=off)"
14867        } else {
14868            ""
14869        };
14870        let wallet = wallet_dir.display();
14871        format!(
14872            "(DESCRIPTION=(CONNECT_TIMEOUT=10)\
14873             (ADDRESS=(PROTOCOL=tcps)(HOST=127.0.0.1)(PORT={port}))\
14874             (CONNECT_DATA=(SERVICE_NAME=FREEPDB1))\
14875             (SECURITY={dn_match_clause}(SSL_SERVER_CERT_DN={cert_dn})\
14876             (MY_WALLET_DIRECTORY=\"{wallet}\")))"
14877        )
14878    }
14879
14880    /// The exact phrase `DnMatchError::CertDnMismatch` renders
14881    /// (`crates/oracledb-protocol/src/tls/dn.rs`), independent of the pinned
14882    /// DN value — used to detect that `check_cert_dn` genuinely ran.
14883    const DC_CERT_DN_MISMATCH_PHRASE: &str =
14884        "distinguished name (DN) on the server certificate does not match";
14885    /// The exact phrase `DnMatchError::NameMismatch` renders — used to detect
14886    /// the broken fallback path (hostname/SAN match) the DC2 bug silently
14887    /// took instead of the pinned-DN check.
14888    const DC_NAME_MISMATCH_PHRASE: &str = "does not match the names in the server certificate";
14889
14890    /// A single connect attempt against the loopback address is
14891    /// failover-eligible (`is_failover_eligible`), so a TLS/DN rejection
14892    /// surfaces wrapped in `Error::AllAddressesFailed` rather than as a bare
14893    /// `Error::Tls` — but its `Display`/`Debug` chain still embeds the
14894    /// original message verbatim (via the inner error's own `Display`), so
14895    /// asserting on the *rendered text* is the discriminator that survives
14896    /// that wrapping.
14897    fn dc_rendered_error(err: &Error) -> String {
14898        format!("{err} / debug={err:?}")
14899    }
14900
14901    /// B5 / oraclemcp-u9ldz: an untrusted TCPS server certificate is a terminal
14902    /// certificate-verification failure, not a transport timeout and not a
14903    /// generic all-addresses aggregate. Differential proof against
14904    /// python-oracledb 4.0.1 on the same synthetic shape:
14905    /// `CERTIFICATE_VERIFY_FAILED: unable to get local issuer certificate` in
14906    /// ~10 ms, with the server receiving `tlsv1 alert unknown ca`.
14907    #[test]
14908    fn b5_tcps_unknown_issuer_is_tls_error_not_timeout_or_failover() {
14909        let (port, server) = spawn_dc2_dc3_fixture_tls_server();
14910        let wallet_dir = dc5_untrusted_temp_wallet_dir("unknown-issuer");
14911        let options = ConnectOptions::new(
14912            format!("tcps://127.0.0.1:{port}/FREEPDB1?transport_connect_timeout=10"),
14913            "user",
14914            "password",
14915            identity(),
14916        )
14917        .with_wallet_location(wallet_dir.display().to_string());
14918
14919        let runtime = build_io_runtime().expect("asupersync runtime");
14920        let started = Instant::now();
14921        let err = runtime
14922            .block_on(async {
14923                let cx = Cx::current().expect("ambient Cx");
14924                Connection::connect(&cx, options).await
14925            })
14926            .expect_err("an untrusted synthetic TCPS certificate must be refused");
14927        let elapsed = started.elapsed();
14928        let rendered = dc_rendered_error(&err);
14929        assert!(
14930            elapsed < Duration::from_secs(5),
14931            "untrusted issuer must fail fast, not wait for connect timeout; took {elapsed:?}, \
14932             error={rendered}"
14933        );
14934        assert!(
14935            matches!(&err, Error::Tls(detail)
14936                if detail.contains("certificate") && detail.contains("not trusted")),
14937            "untrusted issuer must surface as a structured TLS certificate error, not timeout or \
14938             failover aggregation; got {rendered}"
14939        );
14940        assert!(
14941            !matches!(err, Error::CallTimeout(_) | Error::AllAddressesFailed(_)),
14942            "untrusted issuer must not be hidden as timeout/failover aggregation: {rendered}"
14943        );
14944        let _ = server.join();
14945    }
14946
14947    /// F-DC2 discriminating test: a `SSL_SERVER_CERT_DN` pinned ONLY in the
14948    /// connect string (never through `with_ssl_server_cert_dn`) that does NOT
14949    /// match the server's real subject DN must be refused, with the DN check
14950    /// actually having run (`check_cert_dn`'s rejection echoes the pinned DN
14951    /// verbatim) — not merely refused for some unrelated reason, and not the
14952    /// broken fallback (a hostname/SAN `NameMismatch` against HOST=127.0.0.1,
14953    /// which never mentions the pinned DN at all).
14954    #[test]
14955    fn f_dc2_dsn_only_pinned_cert_dn_mismatch_is_refused() {
14956        let (port, server) = spawn_dc2_dc3_fixture_tls_server();
14957        let wallet_dir = dc2_dc3_temp_ca_trust_wallet_dir("mismatch");
14958        let wrong_dn = "CN=not-the-real-server.invalid,O=Nope,C=ZZ";
14959        let connect_string = dc2_dc3_connect_string(port, &wallet_dir, wrong_dn, false);
14960        let options = ConnectOptions::new(connect_string, "user", "password", identity());
14961        let runtime = build_io_runtime().expect("asupersync runtime");
14962        let err = runtime
14963            .block_on(async {
14964                let cx = Cx::current().expect("ambient Cx");
14965                Connection::connect(&cx, options).await
14966            })
14967            .expect_err(
14968                "a server whose DN does not match a DSN-only pinned SSL_SERVER_CERT_DN must be \
14969                 refused",
14970            );
14971        let rendered = dc_rendered_error(&err);
14972        assert!(
14973            rendered.contains(wrong_dn) && rendered.contains(DC_CERT_DN_MISMATCH_PHRASE),
14974            "the DSN-only pinned SSL_SERVER_CERT_DN must actually reach the verifier — expected \
14975             the rejection to echo the pinned DN {wrong_dn:?} via a CertDnMismatch \
14976             ({DC_CERT_DN_MISMATCH_PHRASE:?}), proving `check_cert_dn` ran with the DSN-supplied \
14977             value; got: {rendered}"
14978        );
14979        let _ = server.join();
14980    }
14981
14982    /// F-DC2 positive counterpart: a `SSL_SERVER_CERT_DN` pinned ONLY in the
14983    /// connect string that DOES match the server's real subject DN must pass
14984    /// the TLS/DN layer cleanly. The stub server never answers a real Oracle
14985    /// listener response, so the overall connect still fails — but the
14986    /// rejection must carry NEITHER DN-match failure phrase, proving the
14987    /// pinned DN was accepted rather than never checked (or checked and
14988    /// rejected via the broken hostname/SAN fallback).
14989    #[test]
14990    fn f_dc2_dsn_only_pinned_cert_dn_match_passes_tls_layer() {
14991        let (port, server) = spawn_dc2_dc3_fixture_tls_server();
14992        let wallet_dir = dc2_dc3_temp_ca_trust_wallet_dir("match");
14993        let real_dn = "CN=db.example.com,O=ExampleDB,C=US";
14994        let connect_string = dc2_dc3_connect_string(port, &wallet_dir, real_dn, false);
14995        let options = ConnectOptions::new(connect_string, "user", "password", identity());
14996        let runtime = build_io_runtime().expect("asupersync runtime");
14997        let err = runtime
14998            .block_on(async {
14999                let cx = Cx::current().expect("ambient Cx");
15000                Connection::connect(&cx, options).await
15001            })
15002            .expect_err(
15003                "the stub server never answers a real Oracle listener response, so connect must \
15004                 still fail overall",
15005            );
15006        let rendered = dc_rendered_error(&err);
15007        assert!(
15008            !rendered.contains(DC_CERT_DN_MISMATCH_PHRASE)
15009                && !rendered.contains(DC_NAME_MISMATCH_PHRASE),
15010            "a matching DSN-only pinned SSL_SERVER_CERT_DN must pass the TLS/DN layer cleanly — \
15011             got a DN-match rejection instead, meaning the pin was not honored (it likely fell \
15012             back to a hostname/SAN check against HOST=127.0.0.1, which this certificate's SAN \
15013             never matches): {rendered}"
15014        );
15015        let _ = server.join();
15016    }
15017
15018    /// F-DC3 discriminating test: `SSL_SERVER_DN_MATCH=OFF` set ONLY in the
15019    /// connect string must deliberately disable the DN check — including
15020    /// against a mismatched pinned `SSL_SERVER_CERT_DN` — never leave it
15021    /// silently enforced. Paired with the default-enforces case above
15022    /// (`f_dc2_dsn_only_pinned_cert_dn_mismatch_is_refused`, which pins the
15023    /// SAME wrong DN without DN_MATCH=OFF and must still be refused).
15024    #[test]
15025    fn f_dc3_dn_match_off_deliberately_skips_the_pinned_cert_dn_check() {
15026        let (port, server) = spawn_dc2_dc3_fixture_tls_server();
15027        let wallet_dir = dc2_dc3_temp_ca_trust_wallet_dir("off");
15028        let wrong_dn = "CN=not-the-real-server.invalid,O=Nope,C=ZZ";
15029        let connect_string = dc2_dc3_connect_string(port, &wallet_dir, wrong_dn, true);
15030        let options = ConnectOptions::new(connect_string, "user", "password", identity());
15031        let runtime = build_io_runtime().expect("asupersync runtime");
15032        let err = runtime
15033            .block_on(async {
15034                let cx = Cx::current().expect("ambient Cx");
15035                Connection::connect(&cx, options).await
15036            })
15037            .expect_err("the stub server never answers a real Oracle listener response");
15038        let rendered = dc_rendered_error(&err);
15039        assert!(
15040            !rendered.contains(DC_CERT_DN_MISMATCH_PHRASE)
15041                && !rendered.contains(DC_NAME_MISMATCH_PHRASE),
15042            "SSL_SERVER_DN_MATCH=OFF set only in the connect string must deliberately disable \
15043             the DN check — even with a mismatched pinned SSL_SERVER_CERT_DN — got a DN-match \
15044             rejection instead, meaning OFF did not actually reach the verifier: {rendered}"
15045        );
15046        let _ = server.join();
15047    }
15048
15049    /// F-DC3 precedence pin: effective `dn_match` is the **AND** of the
15050    /// DSN-parsed `SECURITY.SSL_SERVER_DN_MATCH` and
15051    /// `ConnectOptions::ssl_server_dn_match` (both default `true`). Either side
15052    /// set to OFF disables identity matching; chain-of-trust validation is
15053    /// unaffected (see `OracleServerCertVerifier::run_dn_match` /
15054    /// `resolve_tls_params`).
15055    #[test]
15056    fn f_dc3_dsn_and_options_dn_match_merge_is_and_not_options_only() {
15057        let default_dsn = "tcps://h.example:2484/svc";
15058        let default_desc = EasyConnect::parse_descriptor(default_dsn).expect("parse default dsn");
15059        let default_options = ConnectOptions::new(default_dsn, "u", "p", identity());
15060        assert!(
15061            effective_ssl_server_dn_match(default_desc.first_description(), &default_options),
15062            "default DSN + default options must keep DN matching ON"
15063        );
15064
15065        // DSN-only OFF parses to false on the security block.
15066        let off_dsn = concat!(
15067            "(DESCRIPTION=(ADDRESS=(PROTOCOL=tcps)(HOST=h.example)(PORT=2484))",
15068            "(CONNECT_DATA=(SERVICE_NAME=svc))",
15069            "(SECURITY=(SSL_SERVER_DN_MATCH=off)))"
15070        );
15071        let off_desc = EasyConnect::parse_descriptor(off_dsn).expect("parse OFF dsn");
15072        assert!(
15073            !off_desc.first_description().security.ssl_server_dn_match,
15074            "DSN SSL_SERVER_DN_MATCH=off must parse to security.ssl_server_dn_match=false"
15075        );
15076        let off_options = ConnectOptions::new(off_dsn, "u", "p", identity());
15077        assert!(
15078            !effective_ssl_server_dn_match(off_desc.first_description(), &off_options),
15079            "DSN SSL_SERVER_DN_MATCH=OFF must win over default options=true"
15080        );
15081
15082        let on_dsn = concat!(
15083            "(DESCRIPTION=(ADDRESS=(PROTOCOL=tcps)(HOST=h.example)(PORT=2484))",
15084            "(CONNECT_DATA=(SERVICE_NAME=svc))",
15085            "(SECURITY=(SSL_SERVER_DN_MATCH=on)))"
15086        );
15087        let on_desc = EasyConnect::parse_descriptor(on_dsn).expect("parse ON dsn");
15088        assert!(on_desc.first_description().security.ssl_server_dn_match);
15089
15090        // Builder surface: options OFF combines with DSN ON → effective false.
15091        let options_off =
15092            ConnectOptions::new(on_dsn, "u", "p", identity()).with_ssl_server_dn_match(false);
15093        assert!(!options_off.ssl_server_dn_match());
15094        assert!(
15095            !effective_ssl_server_dn_match(on_desc.first_description(), &options_off),
15096            "explicit ConnectOptions::with_ssl_server_dn_match(false) must win over DSN=ON"
15097        );
15098
15099        let both_off =
15100            ConnectOptions::new(off_dsn, "u", "p", identity()).with_ssl_server_dn_match(false);
15101        assert!(
15102            !effective_ssl_server_dn_match(off_desc.first_description(), &both_off),
15103            "both OFF stays OFF"
15104        );
15105
15106        // resolve_tls_params must receive the effective false and set dn_match=false
15107        // (identity short-circuit only — chain still validated by the verifier).
15108        let easy = EasyConnect::parse(off_dsn).expect("easy parse OFF");
15109        let params = crate::tls::resolve_tls_params(
15110            &easy,
15111            None,
15112            None,
15113            effective_ssl_server_dn_match(off_desc.first_description(), &off_options),
15114            None,
15115            false,
15116        )
15117        .expect("resolve with dn_match=false");
15118        assert!(
15119            !params.dn_match,
15120            "resolve_tls_params must record dn_match=false when effective OFF"
15121        );
15122    }
15123
15124    #[test]
15125    fn async_cancel_handle_requests_break_and_reconciles_ready() -> Result<()> {
15126        let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener");
15127        let addr = listener.local_addr().expect("listener address");
15128        let server = thread::spawn(move || {
15129            let (mut socket, _) = listener.accept().expect("accept test client");
15130            socket
15131                .set_read_timeout(Some(Duration::from_secs(5)))
15132                .expect("set read timeout");
15133            let mut packet = [0u8; 11];
15134            socket.read_exact(&mut packet).expect("read marker packet");
15135            packet
15136        });
15137
15138        let runtime = build_io_runtime().expect("asupersync runtime");
15139        let recovery = Arc::new(SessionRecovery::new());
15140        let mut handle = runtime.block_on(async {
15141            let stream = TcpStream::connect(addr).await.expect("connect to listener");
15142            let (_read, write) = transport::plain_split(stream);
15143            CancelHandle {
15144                write: Arc::new(AsyncMutex::with_name("oracle_tcp_write_test", write)),
15145                recovery: Arc::clone(&recovery),
15146            }
15147        });
15148
15149        runtime.block_on(async {
15150            let cx = Cx::current()
15151                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
15152            handle.cancel(&cx).await
15153        })?;
15154
15155        let packet = server.join().expect("server thread joins");
15156        assert_eq!(
15157            packet,
15158            [
15159                0,
15160                0,
15161                0,
15162                11,
15163                TNS_PACKET_TYPE_MARKER,
15164                0,
15165                0,
15166                0,
15167                1,
15168                0,
15169                TNS_MARKER_TYPE_BREAK
15170            ]
15171        );
15172        assert_eq!(
15173            recovery.phase(),
15174            SessionRecoveryPhase::BreakSent,
15175            "CancelHandle::cancel(&Cx) requests cancellation without draining"
15176        );
15177        assert!(
15178            recovery.begin_pending_drain()?,
15179            "the connection owner must be able to adopt the pending cancel response"
15180        );
15181        recovery.finish_drain_ready();
15182        assert_eq!(
15183            recovery.phase(),
15184            SessionRecoveryPhase::Ready,
15185            "successful cancel-response drain reconciles the session to Ready"
15186        );
15187        Ok(())
15188    }
15189
15190    #[test]
15191    fn async_cancel_handle_owner_drain_reconciles_ready() -> Result<()> {
15192        const INFLIGHT_BODY: &[u8] = &[0xCA, 0xFE];
15193        const ERROR_BODY: &[u8] = &[0x04, 0x01, 0x0d];
15194        const FRESH_BODY: &[u8] = &[0x07, 0x05, 0x0c];
15195
15196        let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener");
15197        let addr = listener.local_addr().expect("listener address");
15198        let server = thread::spawn(move || {
15199            let (mut socket, _) = listener.accept().expect("accept test client");
15200            socket
15201                .set_read_timeout(Some(Duration::from_secs(5)))
15202                .expect("set read timeout");
15203            use std::io::Write as _;
15204
15205            assert_eq!(
15206                read_marker_type(&mut socket),
15207                TNS_MARKER_TYPE_BREAK,
15208                "CancelHandle must send exactly one BREAK request"
15209            );
15210            socket
15211                .write_all(&data_packet(INFLIGHT_BODY, true))
15212                .expect("write in-flight response");
15213            socket
15214                .write_all(&marker_packet(TNS_MARKER_TYPE_BREAK))
15215                .expect("write break-ack marker");
15216            assert_eq!(
15217                read_marker_type(&mut socket),
15218                TNS_MARKER_TYPE_RESET,
15219                "owner drain must answer the break marker with RESET"
15220            );
15221            socket
15222                .write_all(&marker_packet(TNS_MARKER_TYPE_RESET))
15223                .expect("write reset-confirm marker");
15224            socket
15225                .write_all(&data_packet(ERROR_BODY, true))
15226                .expect("write trailing cancel error packet");
15227            socket
15228                .write_all(&data_packet(FRESH_BODY, true))
15229                .expect("write fresh response");
15230        });
15231
15232        let runtime = build_io_runtime().expect("asupersync runtime");
15233        let next = runtime.block_on(async {
15234            let cx = Cx::current()
15235                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
15236            let stream = TcpStream::connect(addr).await.expect("connect to listener");
15237            let (read, write) = transport::plain_split(stream);
15238            let mut conn = loopback_connection(read, write);
15239            let mut handle = conn.cancel_handle()?;
15240
15241            handle.cancel(&cx).await?;
15242            assert_eq!(
15243                conn.core.recovery.phase(),
15244                SessionRecoveryPhase::BreakSent,
15245                "handle cancel only requests recovery"
15246            );
15247            conn.drain_cancel_response().await?;
15248            assert_eq!(
15249                conn.core.recovery.phase(),
15250                SessionRecoveryPhase::Ready,
15251                "owner drain reconciles a successful cancel response to Ready"
15252            );
15253            conn.core.read_data_response(&cx).await
15254        })?;
15255
15256        assert_eq!(
15257            next, FRESH_BODY,
15258            "after owner drain the next read must consume the fresh response"
15259        );
15260        server.join().expect("server thread joins");
15261        Ok(())
15262    }
15263
15264    #[test]
15265    fn async_cancel_handle_owner_drain_failure_marks_dead() -> Result<()> {
15266        let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener");
15267        let addr = listener.local_addr().expect("listener address");
15268        let server = thread::spawn(move || {
15269            let (mut socket, _) = listener.accept().expect("accept test client");
15270            socket
15271                .set_read_timeout(Some(Duration::from_secs(5)))
15272                .expect("set read timeout");
15273            assert_eq!(
15274                read_marker_type(&mut socket),
15275                TNS_MARKER_TYPE_BREAK,
15276                "CancelHandle must send the BREAK before the failed drain"
15277            );
15278            // Drop the socket without sending a complete cancel response.
15279        });
15280
15281        let runtime = build_io_runtime().expect("asupersync runtime");
15282        runtime.block_on(async {
15283            let cx = Cx::current()
15284                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
15285            let stream = TcpStream::connect(addr).await.expect("connect to listener");
15286            let (read, write) = transport::plain_split(stream);
15287            let mut conn = loopback_connection(read, write);
15288            let mut handle = conn.cancel_handle()?;
15289
15290            handle.cancel(&cx).await?;
15291            assert_eq!(conn.core.recovery.phase(), SessionRecoveryPhase::BreakSent);
15292            assert!(
15293                conn.drain_cancel_response().await.is_err(),
15294                "incomplete cancel response must fail owner drain"
15295            );
15296            assert!(
15297                conn.is_dead(),
15298                "failed owner drain marks the connection dead"
15299            );
15300            assert_eq!(conn.core.recovery.phase(), SessionRecoveryPhase::Dead);
15301            Ok::<(), Error>(())
15302        })?;
15303
15304        server.join().expect("server thread joins");
15305        Ok(())
15306    }
15307
15308    #[test]
15309    fn async_cancel_handle_does_not_send_duplicate_break_when_recovery_pending() -> Result<()> {
15310        let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener");
15311        let addr = listener.local_addr().expect("listener address");
15312        let server = thread::spawn(move || {
15313            let (mut socket, _) = listener.accept().expect("accept test client");
15314            socket
15315                .set_read_timeout(Some(Duration::from_secs(5)))
15316                .expect("set read timeout");
15317            let mut packet = [0u8; 11];
15318            socket.read_exact(&mut packet).expect("read first break");
15319            socket
15320                .set_read_timeout(Some(Duration::from_millis(200)))
15321                .expect("set short read timeout");
15322            let mut extra = [0u8; 1];
15323            let extra_read = socket.read_exact(&mut extra);
15324            assert!(
15325                matches!(
15326                    extra_read.as_ref().map_err(std::io::Error::kind),
15327                    Err(std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut)
15328                ),
15329                "duplicate BREAK check expected a read timeout, got {extra_read:?} with byte {:02x}",
15330                extra[0]
15331            );
15332            packet
15333        });
15334
15335        let runtime = build_io_runtime().expect("asupersync runtime");
15336        let recovery = Arc::new(SessionRecovery::new());
15337        let mut handle = runtime.block_on(async {
15338            let stream = TcpStream::connect(addr).await.expect("connect to listener");
15339            let (_read, write) = transport::plain_split(stream);
15340            CancelHandle {
15341                write: Arc::new(AsyncMutex::with_name("oracle_tcp_write_test", write)),
15342                recovery: Arc::clone(&recovery),
15343            }
15344        });
15345
15346        runtime.block_on(async {
15347            let cx = Cx::current()
15348                .ok_or_else(|| Error::Runtime("asupersync did not install an ambient Cx".into()))?;
15349            handle.cancel(&cx).await?;
15350            assert_eq!(recovery.phase(), SessionRecoveryPhase::BreakSent);
15351            handle.cancel(&cx).await?;
15352            assert!(
15353                recovery.begin_pending_drain()?,
15354                "test moves the pending cancel into Draining"
15355            );
15356            handle.cancel(&cx).await
15357        })?;
15358
15359        let packet = server.join().expect("server thread joins");
15360        assert_eq!(
15361            packet,
15362            [
15363                0,
15364                0,
15365                0,
15366                11,
15367                TNS_PACKET_TYPE_MARKER,
15368                0,
15369                0,
15370                0,
15371                1,
15372                0,
15373                TNS_MARKER_TYPE_BREAK
15374            ]
15375        );
15376        assert_eq!(recovery.phase(), SessionRecoveryPhase::Draining);
15377        Ok(())
15378    }
15379
15380    #[test]
15381    fn blocking_cancel_handle_sends_tns_break_marker() {
15382        let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener");
15383        let addr = listener.local_addr().expect("listener address");
15384        let server = thread::spawn(move || {
15385            let (mut socket, _) = listener.accept().expect("accept test client");
15386            socket
15387                .set_read_timeout(Some(Duration::from_secs(5)))
15388                .expect("set read timeout");
15389            let mut packet = [0u8; 11];
15390            socket.read_exact(&mut packet).expect("read marker packet");
15391            packet
15392        });
15393
15394        let runtime = build_io_runtime().expect("asupersync runtime");
15395        let recovery = Arc::new(SessionRecovery::new());
15396        let mut handle = runtime.block_on(async {
15397            let stream = TcpStream::connect(addr).await.expect("connect to listener");
15398            let (_read, write) = transport::plain_split(stream);
15399            CancelHandle {
15400                write: Arc::new(AsyncMutex::with_name("oracle_tcp_write_test", write)),
15401                recovery: Arc::clone(&recovery),
15402            }
15403        });
15404
15405        handle.cancel_blocking().expect("cancel marker write");
15406
15407        let packet = server.join().expect("server thread joins");
15408        assert_eq!(
15409            packet,
15410            [
15411                0,
15412                0,
15413                0,
15414                11,
15415                TNS_PACKET_TYPE_MARKER,
15416                0,
15417                0,
15418                0,
15419                1,
15420                0,
15421                TNS_MARKER_TYPE_BREAK
15422            ]
15423        );
15424        assert_eq!(recovery.phase(), SessionRecoveryPhase::BreakSent);
15425    }
15426
15427    // ---- break_and_drain regression (bead rust-oracledb-2vx) -------------------
15428    //
15429    // On a call timeout the driver must send a BREAK and then DRAIN the server's
15430    // in-flight response + RESET handshake + trailing error packet, leaving the
15431    // wire at a clean boundary so the NEXT operation on the reused connection
15432    // reads its own response — not the stale bytes left behind by the timed-out
15433    // call. The reference does this via `_break_external()` + `_receive_packet()`
15434    // (-> `_reset()` on the MARKER), protocol.pyx:449-451, 507-557.
15435
15436    const EOR_FLAG: u16 = oracledb_protocol::thin::TNS_DATA_FLAGS_END_OF_RESPONSE;
15437
15438    /// A DATA packet carrying `message` after its 2-byte data flags. When
15439    /// `end_of_response` is set it carries the END_OF_RESPONSE data flag, so the
15440    /// reassembler treats it as the final packet of a response.
15441    fn data_packet(message: &[u8], end_of_response: bool) -> Vec<u8> {
15442        let flags = if end_of_response { EOR_FLAG } else { 0 };
15443        encode_packet(
15444            TNS_PACKET_TYPE_DATA,
15445            0,
15446            Some(flags),
15447            message,
15448            PacketLengthWidth::Large32,
15449        )
15450        .expect("encode data packet")
15451    }
15452
15453    /// A MARKER packet of the given marker type (`[1, 0, marker_type]` payload,
15454    /// matching `send_marker`).
15455    fn marker_packet(marker_type: u8) -> Vec<u8> {
15456        encode_packet(
15457            TNS_PACKET_TYPE_MARKER,
15458            0,
15459            None,
15460            &[1, 0, marker_type],
15461            PacketLengthWidth::Large32,
15462        )
15463        .expect("encode marker packet")
15464    }
15465
15466    #[derive(Debug)]
15467    struct ScriptedTransport;
15468
15469    impl WireTransport for ScriptedTransport {
15470        type Read = ScriptedRead;
15471        type Write = ScriptedWrite;
15472    }
15473
15474    #[derive(Debug)]
15475    struct ScriptedRead {
15476        state: Arc<std::sync::Mutex<ScriptedIoState>>,
15477    }
15478
15479    impl ScriptedRead {
15480        fn from_state(state: Arc<std::sync::Mutex<ScriptedIoState>>) -> Self {
15481            Self { state }
15482        }
15483    }
15484
15485    impl asupersync::io::AsyncRead for ScriptedRead {
15486        fn poll_read(
15487            self: std::pin::Pin<&mut Self>,
15488            cx: &mut std::task::Context<'_>,
15489            buf: &mut asupersync::io::ReadBuf<'_>,
15490        ) -> std::task::Poll<std::io::Result<()>> {
15491            let Ok(mut state) = self.state.lock() else {
15492                return std::task::Poll::Ready(Err(std::io::Error::other(
15493                    "scripted transport: poisoned read state",
15494                )));
15495            };
15496
15497            loop {
15498                match state.read.front_mut() {
15499                    Some(ReadAction::Bytes {
15500                        bytes,
15501                        offset,
15502                        max_chunk,
15503                        cancel_current_on_completion,
15504                    }) => {
15505                        if *offset >= bytes.len() {
15506                            state.read.pop_front();
15507                            continue;
15508                        }
15509                        let cap = max_chunk.unwrap_or(usize::MAX);
15510                        let available = bytes.len() - *offset;
15511                        let take = available.min(buf.remaining()).min(cap);
15512                        if take == 0 {
15513                            return std::task::Poll::Ready(Ok(()));
15514                        }
15515                        buf.put_slice(&bytes[*offset..*offset + take]);
15516                        *offset += take;
15517                        if *offset >= bytes.len() {
15518                            let cancel_current_on_completion = *cancel_current_on_completion;
15519                            state.read.pop_front();
15520                            if cancel_current_on_completion {
15521                                if let Some(cx) = Cx::current() {
15522                                    cx.cancel_fast(asupersync::CancelKind::User);
15523                                }
15524                            }
15525                        }
15526                        return std::task::Poll::Ready(Ok(()));
15527                    }
15528                    Some(ReadAction::Pending) => {
15529                        state.read.pop_front();
15530                        cx.waker().wake_by_ref();
15531                        return std::task::Poll::Pending;
15532                    }
15533                    Some(ReadAction::PendingUntil(gate)) => {
15534                        if gate.is_open() {
15535                            state.read.pop_front();
15536                            continue;
15537                        }
15538                        gate.register(cx.waker());
15539                        return std::task::Poll::Pending;
15540                    }
15541                    Some(ReadAction::Eof) | None => return std::task::Poll::Ready(Ok(())),
15542                    Some(ReadAction::Error(message)) => {
15543                        let message = *message;
15544                        state.read.pop_front();
15545                        return std::task::Poll::Ready(Err(std::io::Error::other(message)));
15546                    }
15547                    Some(ReadAction::ErrorKind(kind, message)) => {
15548                        let kind = *kind;
15549                        let message = *message;
15550                        state.read.pop_front();
15551                        return std::task::Poll::Ready(Err(std::io::Error::new(kind, message)));
15552                    }
15553                    Some(ReadAction::AdvanceTime(duration)) => {
15554                        let duration = *duration;
15555                        state.read.pop_front();
15556                        state.clock.advance(duration);
15557                    }
15558                }
15559            }
15560        }
15561    }
15562
15563    #[derive(Debug)]
15564    struct ScriptedWrite {
15565        state: Arc<std::sync::Mutex<ScriptedIoState>>,
15566    }
15567
15568    impl ScriptedWrite {
15569        fn from_state(state: Arc<std::sync::Mutex<ScriptedIoState>>) -> Self {
15570            Self { state }
15571        }
15572    }
15573
15574    impl asupersync::io::AsyncWrite for ScriptedWrite {
15575        fn poll_write(
15576            self: std::pin::Pin<&mut Self>,
15577            cx: &mut std::task::Context<'_>,
15578            buf: &[u8],
15579        ) -> std::task::Poll<std::io::Result<usize>> {
15580            let Ok(mut state) = self.state.lock() else {
15581                return std::task::Poll::Ready(Err(std::io::Error::other(
15582                    "scripted transport: poisoned write state",
15583                )));
15584            };
15585
15586            loop {
15587                match state.write.front_mut() {
15588                    Some(WriteAction::Expect { .. }) => {
15589                        let mut completed = None;
15590                        let take = {
15591                            let Some(WriteAction::Expect {
15592                                bytes,
15593                                offset,
15594                                max_chunk,
15595                            }) = state.write.front_mut()
15596                            else {
15597                                unreachable!("front action already matched Expect");
15598                            };
15599                            if *offset >= bytes.len() {
15600                                state.write.pop_front();
15601                                continue;
15602                            }
15603                            let cap = max_chunk.unwrap_or(usize::MAX);
15604                            let available = bytes.len() - *offset;
15605                            let take = available.min(buf.len()).min(cap);
15606                            if take == 0 {
15607                                return std::task::Poll::Ready(Ok(0));
15608                            }
15609                            if bytes[*offset..*offset + take] != buf[..take] {
15610                                return std::task::Poll::Ready(Err(std::io::Error::other(
15611                                    "scripted transport: write mismatch",
15612                                )));
15613                            }
15614                            *offset += take;
15615                            if *offset >= bytes.len() {
15616                                completed = Some(bytes.clone());
15617                            }
15618                            take
15619                        };
15620                        if let Some(bytes) = completed {
15621                            state.note_write(&bytes);
15622                            state.write.pop_front();
15623                        }
15624                        return std::task::Poll::Ready(Ok(take));
15625                    }
15626                    Some(WriteAction::Pending) => {
15627                        state.write.pop_front();
15628                        cx.waker().wake_by_ref();
15629                        return std::task::Poll::Pending;
15630                    }
15631                    Some(WriteAction::Eof) => {
15632                        state.write.pop_front();
15633                        return std::task::Poll::Ready(Ok(0));
15634                    }
15635                    Some(WriteAction::Error(message)) => {
15636                        let message = *message;
15637                        state.write.pop_front();
15638                        return std::task::Poll::Ready(Err(std::io::Error::other(message)));
15639                    }
15640                    Some(WriteAction::AdvanceTime(duration)) => {
15641                        let duration = *duration;
15642                        state.write.pop_front();
15643                        state.clock.advance(duration);
15644                    }
15645                    None => {
15646                        return std::task::Poll::Ready(Err(std::io::Error::other(
15647                            "scripted transport: unexpected write",
15648                        )));
15649                    }
15650                }
15651            }
15652        }
15653
15654        fn poll_flush(
15655            self: std::pin::Pin<&mut Self>,
15656            _cx: &mut std::task::Context<'_>,
15657        ) -> std::task::Poll<std::io::Result<()>> {
15658            std::task::Poll::Ready(Ok(()))
15659        }
15660
15661        fn poll_shutdown(
15662            self: std::pin::Pin<&mut Self>,
15663            _cx: &mut std::task::Context<'_>,
15664        ) -> std::task::Poll<std::io::Result<()>> {
15665            std::task::Poll::Ready(Ok(()))
15666        }
15667    }
15668
15669    #[derive(Clone, Debug, Default)]
15670    struct ScriptedClock {
15671        nanos: Arc<std::sync::atomic::AtomicU64>,
15672    }
15673
15674    impl ScriptedClock {
15675        fn advance(&self, duration: Duration) {
15676            let nanos = u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX);
15677            self.nanos.fetch_add(nanos, Ordering::Relaxed);
15678        }
15679
15680        fn elapsed(&self) -> Duration {
15681            Duration::from_nanos(self.nanos.load(Ordering::Relaxed))
15682        }
15683    }
15684
15685    #[derive(Clone, Default)]
15686    struct ScriptedGate {
15687        ready: Arc<AtomicBool>,
15688        waker: Arc<std::sync::Mutex<Option<Waker>>>,
15689    }
15690
15691    impl std::fmt::Debug for ScriptedGate {
15692        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
15693            f.debug_struct("ScriptedGate")
15694                .field("ready", &self.ready.load(Ordering::Relaxed))
15695                .finish_non_exhaustive()
15696        }
15697    }
15698
15699    impl ScriptedGate {
15700        fn open(&self) {
15701            self.ready.store(true, Ordering::Release);
15702            if let Ok(mut waker) = self.waker.lock() {
15703                if let Some(waker) = waker.take() {
15704                    waker.wake();
15705                }
15706            }
15707        }
15708
15709        fn is_open(&self) -> bool {
15710            self.ready.load(Ordering::Acquire)
15711        }
15712
15713        fn register(&self, waker: &Waker) {
15714            if let Ok(mut current) = self.waker.lock() {
15715                let replace = current
15716                    .as_ref()
15717                    .is_none_or(|registered| !registered.will_wake(waker));
15718                if replace {
15719                    *current = Some(waker.clone());
15720                }
15721            }
15722        }
15723    }
15724
15725    #[derive(Debug)]
15726    struct ScriptedIoState {
15727        read: VecDeque<ReadAction>,
15728        write: VecDeque<WriteAction>,
15729        clock: ScriptedClock,
15730        break_writes: usize,
15731        reset_writes: usize,
15732    }
15733
15734    impl ScriptedIoState {
15735        fn new(read: Vec<ReadAction>, write: Vec<WriteAction>, clock: ScriptedClock) -> Self {
15736            Self {
15737                read: read.into(),
15738                write: write.into(),
15739                clock,
15740                break_writes: 0,
15741                reset_writes: 0,
15742            }
15743        }
15744
15745        fn is_consumed(&self) -> bool {
15746            self.read.is_empty() && self.write.is_empty()
15747        }
15748
15749        fn note_write(&mut self, bytes: &[u8]) {
15750            let break_marker = marker_packet(TNS_MARKER_TYPE_BREAK);
15751            let reset_marker = marker_packet(TNS_MARKER_TYPE_RESET);
15752            if bytes == break_marker.as_slice() {
15753                self.break_writes += 1;
15754            } else if bytes == reset_marker.as_slice() {
15755                self.reset_writes += 1;
15756            }
15757        }
15758    }
15759
15760    #[derive(Debug)]
15761    enum ReadAction {
15762        Bytes {
15763            bytes: Vec<u8>,
15764            offset: usize,
15765            max_chunk: Option<usize>,
15766            cancel_current_on_completion: bool,
15767        },
15768        Pending,
15769        PendingUntil(ScriptedGate),
15770        Eof,
15771        Error(&'static str),
15772        ErrorKind(std::io::ErrorKind, &'static str),
15773        AdvanceTime(Duration),
15774    }
15775
15776    impl ReadAction {
15777        fn bytes(bytes: Vec<u8>, max_chunk: Option<usize>) -> Self {
15778            Self::Bytes {
15779                bytes,
15780                offset: 0,
15781                max_chunk,
15782                cancel_current_on_completion: false,
15783            }
15784        }
15785
15786        fn bytes_then_cancel_current(bytes: Vec<u8>, max_chunk: Option<usize>) -> Self {
15787            Self::Bytes {
15788                bytes,
15789                offset: 0,
15790                max_chunk,
15791                cancel_current_on_completion: true,
15792            }
15793        }
15794    }
15795
15796    #[derive(Debug)]
15797    enum WriteAction {
15798        Expect {
15799            bytes: Vec<u8>,
15800            offset: usize,
15801            max_chunk: Option<usize>,
15802        },
15803        Pending,
15804        Eof,
15805        Error(&'static str),
15806        AdvanceTime(Duration),
15807    }
15808
15809    impl WriteAction {
15810        fn expect_bytes(bytes: Vec<u8>, max_chunk: Option<usize>) -> Self {
15811            Self::Expect {
15812                bytes,
15813                offset: 0,
15814                max_chunk,
15815            }
15816        }
15817    }
15818
15819    fn test_cx() -> Result<Cx> {
15820        Cx::current().ok_or_else(|| Error::Runtime("missing ambient Cx in test runtime".into()))
15821    }
15822
15823    #[test]
15824    fn split_connect_descriptor_uses_legacy16_data_framing_before_accept() -> Result<()> {
15825        // OCI Autonomous DB descriptors exceed the listener's 230-byte inline
15826        // CONNECT-data allowance. The descriptor follows CONNECT as a DATA
15827        // packet, but the listener is still using the pre-ACCEPT Legacy16
15828        // header at that point.
15829        let connect_data = format!(
15830            "(DESCRIPTION=(ADDRESS=(PROTOCOL=tcps)(HOST=adb.example.oraclecloud.com)(PORT=1522))\
15831             (CONNECT_DATA=(SERVICE_NAME={})))",
15832            "x".repeat(oracledb_protocol::thin::TNS_MAX_CONNECT_DATA)
15833        );
15834        assert!(
15835            !connect_data_fits_inline(&connect_data),
15836            "the descriptor must take the split-CONNECT path"
15837        );
15838
15839        let expected = encode_packet(
15840            TNS_PACKET_TYPE_DATA,
15841            0,
15842            Some(0),
15843            connect_data.as_bytes(),
15844            PacketLengthWidth::Legacy16,
15845        )?;
15846        assert_eq!(
15847            &expected[..2],
15848            &u16::try_from(expected.len())
15849                .expect("test packet fits a Legacy16 header")
15850                .to_be_bytes(),
15851            "the first two bytes carry the Legacy16 packet length"
15852        );
15853        assert_eq!(
15854            &expected[2..4],
15855            &[0, 0],
15856            "Legacy16 reserves header bytes 2..4; Large32 would put its length there"
15857        );
15858        assert_eq!(expected[4], TNS_PACKET_TYPE_DATA);
15859        assert_eq!(
15860            &expected[6..8],
15861            &[0, 0],
15862            "split CONNECT keeps DATA flags at zero"
15863        );
15864        let large32 = encode_packet(
15865            TNS_PACKET_TYPE_DATA,
15866            0,
15867            Some(0),
15868            connect_data.as_bytes(),
15869            PacketLengthWidth::Large32,
15870        )?;
15871        assert_ne!(
15872            &expected[..4],
15873            &large32[..4],
15874            "the split-CONNECT header must not be Large32"
15875        );
15876
15877        let state = Arc::new(std::sync::Mutex::new(ScriptedIoState::new(
15878            Vec::new(),
15879            vec![WriteAction::expect_bytes(expected, None)],
15880            ScriptedClock::default(),
15881        )));
15882        let core = ConnectionCore::<ScriptedTransport>::from_halves(
15883            ScriptedRead::from_state(Arc::clone(&state)),
15884            ScriptedWrite::from_state(Arc::clone(&state)),
15885            "split_connect_legacy16_write",
15886        );
15887        let runtime = build_io_runtime()?;
15888        runtime.block_on(async {
15889            let cx = test_cx()?;
15890            core.send_connect_data_packet(&cx, connect_data.as_bytes(), 8192)
15891                .await
15892        })?;
15893        assert!(
15894            state
15895                .lock()
15896                .map_err(|_| Error::Runtime("scripted transport state lock poisoned".into()))?
15897                .is_consumed(),
15898            "the pre-ACCEPT split descriptor must be written as the exact Legacy16 DATA packet"
15899        );
15900        Ok(())
15901    }
15902
15903    #[test]
15904    fn connection_core_routes_connect_execute_fetch_over_scripted_transport() -> Result<()> {
15905        const EXECUTE_BODY: &[u8] = b"scripted execute payload";
15906        const FETCH_BODY: &[u8] = b"scripted fetch payload";
15907        const EXECUTE_RESPONSE: &[u8] = b"scripted execute response";
15908        const FETCH_RESPONSE: &[u8] = b"scripted fetch response";
15909
15910        let connect_packet = encode_packet(
15911            TNS_PACKET_TYPE_CONNECT,
15912            0,
15913            None,
15914            b"SCRIPTED-CONNECT",
15915            PacketLengthWidth::Legacy16,
15916        )?;
15917        let accept_packet = encode_packet(
15918            TNS_PACKET_TYPE_ACCEPT,
15919            0,
15920            None,
15921            b"SCRIPTED-ACCEPT",
15922            PacketLengthWidth::Legacy16,
15923        )?;
15924        let execute_packet = encode_packet(
15925            TNS_PACKET_TYPE_DATA,
15926            0,
15927            Some(0),
15928            EXECUTE_BODY,
15929            PacketLengthWidth::Large32,
15930        )?;
15931        let fetch_packet = encode_packet(
15932            TNS_PACKET_TYPE_DATA,
15933            0,
15934            Some(0),
15935            FETCH_BODY,
15936            PacketLengthWidth::Large32,
15937        )?;
15938
15939        let script = Arc::new(std::sync::Mutex::new(ScriptedIoState::new(
15940            vec![
15941                ReadAction::bytes(accept_packet, None),
15942                ReadAction::bytes(data_packet(EXECUTE_RESPONSE, true), None),
15943                ReadAction::bytes(data_packet(FETCH_RESPONSE, true), None),
15944            ],
15945            vec![
15946                WriteAction::expect_bytes(connect_packet.clone(), None),
15947                WriteAction::expect_bytes(execute_packet, None),
15948                WriteAction::expect_bytes(fetch_packet, None),
15949            ],
15950            ScriptedClock::default(),
15951        )));
15952        let mut core = ConnectionCore::<ScriptedTransport>::from_halves(
15953            ScriptedRead::from_state(Arc::clone(&script)),
15954            ScriptedWrite::from_state(Arc::clone(&script)),
15955            "scripted_core_write",
15956        );
15957
15958        let runtime = build_io_runtime()?;
15959        runtime.block_on(async {
15960            let cx = test_cx()?;
15961            core.write_all(&cx, &connect_packet).await?;
15962            let accept = core.read_packet(PacketLengthWidth::Legacy16).await?;
15963            assert_eq!(accept.packet_type, TNS_PACKET_TYPE_ACCEPT);
15964            assert_eq!(accept.payload, b"SCRIPTED-ACCEPT");
15965
15966            core.send_data_packet(&cx, EXECUTE_BODY, 8192).await?;
15967            let execute_response = core.read_data_response(&cx).await?;
15968            assert_eq!(execute_response, EXECUTE_RESPONSE);
15969
15970            core.send_data_packet(&cx, FETCH_BODY, 8192).await?;
15971            let fetch_response = core.read_data_response(&cx).await?;
15972            assert_eq!(fetch_response, FETCH_RESPONSE);
15973            Ok::<_, Error>(())
15974        })?;
15975
15976        let state = script
15977            .lock()
15978            .map_err(|_| Error::Runtime("scripted I/O state lock poisoned".into()))?;
15979        assert!(
15980            state.is_consumed(),
15981            "scripted core must perform exactly the expected connect/execute/fetch I/O"
15982        );
15983        Ok(())
15984    }
15985
15986    #[test]
15987    fn classic_token_auth_negotiates_then_sends_phase_two_over_scripted_transport() -> Result<()> {
15988        const USER: &str = "iam_user";
15989        const TOKEN: &str = "not-a-real-iam-token";
15990        const DRIVER_NAME: &str = "rust-oracledb";
15991        const CONNECT_STRING: &str = "adb.example.oraclecloud.com/service";
15992
15993        let protocol_payload = build_protocol_negotiation_payload()?;
15994        let data_types_payload = build_data_types_payload()?;
15995        let token_payload = build_auth_phase_two_token_payload_with_pop_and_proxy(
15996            USER,
15997            TOKEN,
15998            DRIVER_NAME,
15999            PYTHON_ORACLEDB_COMPAT_VERSION_NUM,
16000            CONNECT_STRING,
16001            None,
16002            None,
16003            None,
16004        )?;
16005        let expected_writes = [protocol_payload, data_types_payload, token_payload]
16006            .into_iter()
16007            .map(|payload| {
16008                encode_packet(
16009                    TNS_PACKET_TYPE_DATA,
16010                    0,
16011                    Some(0),
16012                    &payload,
16013                    PacketLengthWidth::Large32,
16014                )
16015                .map_err(Error::from)
16016            })
16017            .collect::<Result<Vec<_>>>()?;
16018
16019        // A classic server completes each connect-phase response with STATUS,
16020        // not the 23ai END_OF_RESPONSE data flag. This drives the exact
16021        // classic reader selected for an ACCEPT without fast-auth.
16022        let classic_status =
16023            data_packet(&[oracledb_protocol::thin::TNS_MSG_TYPE_STATUS, 0, 0], false);
16024        let script = Arc::new(std::sync::Mutex::new(ScriptedIoState::new(
16025            vec![
16026                ReadAction::bytes(classic_status.clone(), None),
16027                ReadAction::bytes(classic_status.clone(), None),
16028                ReadAction::bytes(classic_status, None),
16029            ],
16030            expected_writes
16031                .into_iter()
16032                .map(|packet| WriteAction::expect_bytes(packet, None))
16033                .collect(),
16034            ScriptedClock::default(),
16035        )));
16036        let mut core = ConnectionCore::<ScriptedTransport>::from_halves(
16037            ScriptedRead::from_state(Arc::clone(&script)),
16038            ScriptedWrite::from_state(Arc::clone(&script)),
16039            "classic_token_auth_write",
16040        );
16041
16042        let runtime = build_io_runtime()?;
16043        runtime.block_on(async {
16044            let cx = test_cx()?;
16045            let (auth, capabilities) = core
16046                .authenticate_classic_token(
16047                    &cx,
16048                    TokenAuthentication {
16049                        user: USER,
16050                        token: TOKEN,
16051                        driver_name: DRIVER_NAME,
16052                        version_num: PYTHON_ORACLEDB_COMPAT_VERSION_NUM,
16053                        connect_string: CONNECT_STRING,
16054                        edition: None,
16055                        pop: None,
16056                        proxy_user: None,
16057                    },
16058                    8192,
16059                )
16060                .await?;
16061            assert!(auth.session_data.is_empty());
16062            assert_eq!(capabilities, ClientCapabilities::default());
16063            Ok::<_, Error>(())
16064        })?;
16065
16066        assert!(
16067            script
16068                .lock()
16069                .map_err(|_| Error::Runtime("scripted I/O state lock poisoned".into()))?
16070                .is_consumed(),
16071            "classic token auth must send protocol negotiation, data types, then AUTH_TOKEN phase two"
16072        );
16073        Ok(())
16074    }
16075
16076    #[test]
16077    fn scripted_transport_replays_short_pending_and_virtual_time() -> Result<()> {
16078        const EXECUTE_BODY: &[u8] = b"fault-matrix execute payload";
16079        const EXECUTE_RESPONSE: &[u8] = b"fault-matrix execute response";
16080
16081        let connect_packet = encode_packet(
16082            TNS_PACKET_TYPE_CONNECT,
16083            0,
16084            None,
16085            b"FAULT-MATRIX-CONNECT",
16086            PacketLengthWidth::Legacy16,
16087        )?;
16088        let accept_packet = encode_packet(
16089            TNS_PACKET_TYPE_ACCEPT,
16090            0,
16091            None,
16092            b"FAULT-MATRIX-ACCEPT",
16093            PacketLengthWidth::Legacy16,
16094        )?;
16095        let execute_packet = encode_packet(
16096            TNS_PACKET_TYPE_DATA,
16097            0,
16098            Some(0),
16099            EXECUTE_BODY,
16100            PacketLengthWidth::Large32,
16101        )?;
16102        let execute_response_packet = data_packet(EXECUTE_RESPONSE, true);
16103
16104        let clock = ScriptedClock::default();
16105        let script = Arc::new(std::sync::Mutex::new(ScriptedIoState::new(
16106            vec![
16107                ReadAction::Pending,
16108                ReadAction::AdvanceTime(Duration::from_millis(7)),
16109                ReadAction::bytes(accept_packet, Some(3)),
16110                ReadAction::Pending,
16111                ReadAction::AdvanceTime(Duration::from_millis(11)),
16112                ReadAction::bytes(execute_response_packet, Some(2)),
16113            ],
16114            vec![
16115                WriteAction::Pending,
16116                WriteAction::AdvanceTime(Duration::from_millis(5)),
16117                WriteAction::expect_bytes(connect_packet.clone(), Some(2)),
16118                WriteAction::Pending,
16119                WriteAction::AdvanceTime(Duration::from_millis(13)),
16120                WriteAction::expect_bytes(execute_packet, Some(3)),
16121            ],
16122            clock.clone(),
16123        )));
16124        let mut core = ConnectionCore::<ScriptedTransport>::from_halves(
16125            ScriptedRead::from_state(Arc::clone(&script)),
16126            ScriptedWrite::from_state(Arc::clone(&script)),
16127            "scripted_fault_matrix_write",
16128        );
16129
16130        let runtime = build_io_runtime()?;
16131        runtime.block_on(async {
16132            let cx = test_cx()?;
16133            core.write_all(&cx, &connect_packet).await?;
16134            let accept = core.read_packet(PacketLengthWidth::Legacy16).await?;
16135            assert_eq!(accept.packet_type, TNS_PACKET_TYPE_ACCEPT);
16136            assert_eq!(accept.payload, b"FAULT-MATRIX-ACCEPT");
16137
16138            core.send_data_packet(&cx, EXECUTE_BODY, 8192).await?;
16139            let execute_response = core.read_data_response(&cx).await?;
16140            assert_eq!(execute_response, EXECUTE_RESPONSE);
16141            Ok::<_, Error>(())
16142        })?;
16143
16144        assert_eq!(
16145            clock.elapsed(),
16146            Duration::from_millis(36),
16147            "virtual time advances are deterministic and do not require wall-clock sleeps"
16148        );
16149        let state = script
16150            .lock()
16151            .map_err(|_| Error::Runtime("scripted I/O state lock poisoned".into()))?;
16152        assert!(
16153            state.is_consumed(),
16154            "scripted fault matrix must consume every read/write step"
16155        );
16156        Ok(())
16157    }
16158
16159    const DPOR_SATURATION_WINDOW: usize = 1;
16160    const DPOR_WIRE_SEED: u64 = 0xE3_E4_D0_00;
16161    const DPOR_WIRE_MAX_ITERS: usize = 96;
16162    const DPOR_WIRE_TIMEOUT_MS: u32 = 1;
16163    fn dpor_wire_recovery_timeout() -> Duration {
16164        Duration::from_secs(1)
16165    }
16166
16167    #[derive(Clone, Copy, Debug)]
16168    enum DporWireMode {
16169        UserCancel,
16170        Timeout,
16171    }
16172
16173    fn dpor_wire_seed(mode: DporWireMode) -> u64 {
16174        DPOR_WIRE_SEED
16175            + match mode {
16176                DporWireMode::UserCancel => 0,
16177                DporWireMode::Timeout => 1,
16178            }
16179    }
16180
16181    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
16182    enum DporWireResultKind {
16183        Cancelled,
16184        CallTimeout,
16185    }
16186
16187    #[derive(Debug)]
16188    struct DporWireObservation {
16189        result: DporWireResultKind,
16190        phase: SessionRecoveryPhase,
16191        break_writes: usize,
16192        reset_writes: usize,
16193        script_consumed: bool,
16194    }
16195
16196    async fn dpor_read_until_cancel_gate(
16197        core: &mut ConnectionCore<ScriptedTransport>,
16198        cx: &Cx,
16199        cancel_gate: &ScriptedGate,
16200    ) -> Result<Vec<u8>> {
16201        let recovery = Arc::clone(&core.recovery);
16202        let read = async {
16203            let mut guard = CancelDrainGuard::arm(recovery)?;
16204            let response = core.read_data_response(cx).await;
16205            if response.is_ok() {
16206                guard.disarm();
16207            }
16208            response
16209        };
16210        let cancel = poll_fn(|task_cx| {
16211            if cancel_gate.is_open() {
16212                Poll::Ready(())
16213            } else {
16214                cancel_gate.register(task_cx.waker());
16215                Poll::Pending
16216            }
16217        });
16218        let mut read = pin!(read);
16219        let mut cancel = pin!(cancel);
16220
16221        poll_fn(|task_cx| {
16222            if cancel_gate.is_open() {
16223                return Poll::Ready(Err(Error::Cancelled));
16224            }
16225            if let Poll::Ready(result) = read.as_mut().poll(task_cx) {
16226                return Poll::Ready(result);
16227            }
16228            if let Poll::Ready(()) = cancel.as_mut().poll(task_cx) {
16229                return Poll::Ready(Err(Error::Cancelled));
16230            }
16231            Poll::Pending
16232        })
16233        .await
16234    }
16235
16236    async fn dpor_read_until_timeout_gate(
16237        core: &mut ConnectionCore<ScriptedTransport>,
16238        cx: &Cx,
16239        timeout_gate: &ScriptedGate,
16240    ) -> Result<Vec<u8>> {
16241        let recovery = Arc::clone(&core.recovery);
16242        let read = async {
16243            let mut guard = CancelDrainGuard::arm(recovery)?;
16244            let response = core.read_data_response(cx).await;
16245            if response.is_ok() {
16246                guard.disarm();
16247            }
16248            response
16249        };
16250        let timeout = poll_fn(|task_cx| {
16251            if timeout_gate.is_open() {
16252                Poll::Ready(())
16253            } else {
16254                timeout_gate.register(task_cx.waker());
16255                Poll::Pending
16256            }
16257        });
16258        let mut read = pin!(read);
16259        let mut timeout = pin!(timeout);
16260
16261        poll_fn(|task_cx| {
16262            if timeout_gate.is_open() {
16263                return Poll::Ready(Err(Error::CallTimeout(DPOR_WIRE_TIMEOUT_MS)));
16264            }
16265            if let Poll::Ready(result) = read.as_mut().poll(task_cx) {
16266                return Poll::Ready(result);
16267            }
16268            if let Poll::Ready(()) = timeout.as_mut().poll(task_cx) {
16269                return Poll::Ready(Err(Error::CallTimeout(DPOR_WIRE_TIMEOUT_MS)));
16270            }
16271            Poll::Pending
16272        })
16273        .await
16274    }
16275
16276    fn dpor_wire_script(
16277        gate: ScriptedGate,
16278        execute_packet: Vec<u8>,
16279    ) -> Arc<std::sync::Mutex<ScriptedIoState>> {
16280        const INFLIGHT_BODY: &[u8] = b"dpor in-flight response";
16281        const ERROR_BODY: &[u8] = &[0x04, 0x01, 0x0d];
16282
16283        Arc::new(std::sync::Mutex::new(ScriptedIoState::new(
16284            vec![
16285                ReadAction::PendingUntil(gate),
16286                ReadAction::bytes(data_packet(INFLIGHT_BODY, true), Some(3)),
16287                ReadAction::Pending,
16288                ReadAction::bytes(marker_packet(TNS_MARKER_TYPE_BREAK), Some(2)),
16289                ReadAction::Pending,
16290                ReadAction::bytes(marker_packet(TNS_MARKER_TYPE_RESET), Some(2)),
16291                ReadAction::Pending,
16292                ReadAction::bytes(data_packet(ERROR_BODY, true), Some(2)),
16293            ],
16294            vec![
16295                WriteAction::Pending,
16296                WriteAction::expect_bytes(execute_packet, Some(3)),
16297                WriteAction::Pending,
16298                WriteAction::expect_bytes(marker_packet(TNS_MARKER_TYPE_BREAK), Some(2)),
16299                WriteAction::Pending,
16300                WriteAction::expect_bytes(marker_packet(TNS_MARKER_TYPE_RESET), Some(2)),
16301            ],
16302            ScriptedClock::default(),
16303        )))
16304    }
16305
16306    async fn run_dpor_wire_operation(
16307        mode: DporWireMode,
16308        gate: ScriptedGate,
16309        script: Arc<std::sync::Mutex<ScriptedIoState>>,
16310        execute_body: &'static [u8],
16311    ) -> Result<DporWireObservation> {
16312        let cx = Cx::current().expect("LabRuntime task should install an ambient Cx");
16313        let mut core = ConnectionCore::<ScriptedTransport>::from_halves(
16314            ScriptedRead::from_state(Arc::clone(&script)),
16315            ScriptedWrite::from_state(Arc::clone(&script)),
16316            "dpor_wire_core_write",
16317        );
16318        core.send_data_packet(&cx, execute_body, 8192).await?;
16319
16320        let result = match mode {
16321            DporWireMode::UserCancel => dpor_read_until_cancel_gate(&mut core, &cx, &gate).await,
16322            DporWireMode::Timeout => dpor_read_until_timeout_gate(&mut core, &cx, &gate).await,
16323        };
16324        let result = match result {
16325            Ok(payload) => {
16326                return Err(Error::Runtime(format!(
16327                    "DPOR wire race unexpectedly completed normally with payload {payload:?}"
16328                )));
16329            }
16330            Err(Error::Cancelled) => {
16331                core.recovery.begin_drain_after_break()?;
16332                core.cancel_and_drain_wire(dpor_wire_recovery_timeout())?;
16333                core.recovery.finish_drain_ready();
16334                DporWireResultKind::Cancelled
16335            }
16336            Err(Error::CallTimeout(_)) => {
16337                core.recovery.begin_drain_after_break()?;
16338                core.break_and_drain_wire(dpor_wire_recovery_timeout())?;
16339                core.recovery.finish_drain_ready();
16340                DporWireResultKind::CallTimeout
16341            }
16342            Err(err) => return Err(err),
16343        };
16344
16345        let state = script
16346            .lock()
16347            .map_err(|_| Error::Runtime("scripted DPOR wire state lock poisoned".into()))?;
16348        Ok(DporWireObservation {
16349            result,
16350            phase: core.recovery.phase(),
16351            break_writes: state.break_writes,
16352            reset_writes: state.reset_writes,
16353            script_consumed: state.is_consumed(),
16354        })
16355    }
16356
16357    fn explore_dpor_wire_mode(mode: DporWireMode) -> asupersync::lab::ExplorationReport {
16358        const EXECUTE_BODY: &[u8] = b"dpor execute payload";
16359        let execute_packet = encode_packet(
16360            TNS_PACKET_TYPE_DATA,
16361            0,
16362            Some(0),
16363            EXECUTE_BODY,
16364            PacketLengthWidth::Large32,
16365        )
16366        .expect("encode DPOR execute packet");
16367
16368        let mut explorer = DporExplorer::new(
16369            ExplorerConfig::new(dpor_wire_seed(mode), DPOR_WIRE_MAX_ITERS).max_steps(100_000),
16370        );
16371        explorer.explore(|runtime: &mut LabRuntime| {
16372            // Full ConnectionCore execution inside LabRuntime does not quiesce
16373            // because the recovery path intentionally leaves the lab executor
16374            // and drains on a blocking recovery thread. Keep DPOR on the finite
16375            // operation-vs-interrupt ordering, then run the actual scripted
16376            // ConnectionCore recovery path below for every explored schedule.
16377            let order = Arc::new(std::sync::Mutex::new(Vec::new()));
16378            let root = runtime.state.create_root_region(Budget::INFINITE);
16379
16380            let operation_order = Arc::clone(&order);
16381            let (operation, _operation_handle) = runtime
16382                .state
16383                .create_task(root, Budget::INFINITE, async move {
16384                    operation_order
16385                        .lock()
16386                        .expect("record DPOR wire operation ordering")
16387                        .push("operation");
16388                })
16389                .expect("create DPOR wire operation task");
16390            runtime.scheduler.lock().schedule(operation, 0);
16391
16392            let interrupt_order = Arc::clone(&order);
16393            let (interrupt, _interrupt_handle) = runtime
16394                .state
16395                .create_task(root, Budget::INFINITE, async move {
16396                    interrupt_order
16397                        .lock()
16398                        .expect("record DPOR wire interrupt ordering")
16399                        .push("interrupt");
16400                })
16401                .expect("create DPOR wire interrupt task");
16402            runtime.scheduler.lock().schedule(interrupt, 0);
16403            runtime.run_until_quiescent();
16404            assert!(
16405                runtime.is_quiescent(),
16406                "DPOR wire ordering model did not quiesce"
16407            );
16408
16409            let observed_order = order.lock().expect("read DPOR wire ordering").clone();
16410            assert_eq!(
16411                observed_order.len(),
16412                2,
16413                "DPOR wire ordering should include operation and interrupt"
16414            );
16415
16416            let replay_gate = ScriptedGate::default();
16417            replay_gate.open();
16418            let script = dpor_wire_script(replay_gate.clone(), execute_packet.clone());
16419            let io_runtime = build_io_runtime().expect("asupersync runtime for DPOR wire replay");
16420            let observed = io_runtime
16421                .block_on(run_dpor_wire_operation(
16422                    mode,
16423                    replay_gate,
16424                    script,
16425                    EXECUTE_BODY,
16426                ))
16427                .expect("DPOR wire operation should not fail");
16428            let expected = match mode {
16429                DporWireMode::UserCancel => DporWireResultKind::Cancelled,
16430                DporWireMode::Timeout => DporWireResultKind::CallTimeout,
16431            };
16432            assert_eq!(
16433                observed.result, expected,
16434                "delivered cancel/timeout mapped to the wrong public error"
16435            );
16436            assert_eq!(
16437                observed.phase,
16438                SessionRecoveryPhase::Ready,
16439                "wire recovery must finish at a clean Ready boundary"
16440            );
16441            assert_eq!(observed.break_writes, 1, "exactly one BREAK is required");
16442            assert_eq!(observed.reset_writes, 1, "exactly one RESET is required");
16443            assert!(
16444                observed.script_consumed,
16445                "wire recovery must consume the whole scripted break response"
16446            );
16447        })
16448    }
16449
16450    #[test]
16451    fn dpor_wire_cancel_and_timeout_recovery_saturates() {
16452        for mode in [DporWireMode::UserCancel, DporWireMode::Timeout] {
16453            let report = explore_dpor_wire_mode(mode);
16454            eprintln!(
16455                "[dpor-wire] mode={mode:?} seed={} max_iters={} runs={} classes={} saturated={}",
16456                dpor_wire_seed(mode),
16457                DPOR_WIRE_MAX_ITERS,
16458                report.total_runs,
16459                report.unique_classes,
16460                report.coverage.is_saturated(DPOR_SATURATION_WINDOW)
16461            );
16462            assert!(
16463                !report.has_violations(),
16464                "DPOR wire {mode:?} found violations at seeds {:?}",
16465                report.violation_seeds()
16466            );
16467            assert!(
16468                report.total_runs == DPOR_WIRE_MAX_ITERS,
16469                "DPOR wire fallback seed space did not complete for {mode:?}: runs={}, classes={}, new={}",
16470                report.total_runs,
16471                report.unique_classes,
16472                report.coverage.new_class_discoveries
16473            );
16474        }
16475    }
16476
16477    #[test]
16478    fn flush_out_binds_observes_cancel_before_follow_up_round_trip() -> Result<()> {
16479        let first_response = data_packet(&[TNS_MSG_TYPE_FLUSH_OUT_BINDS], true);
16480        let unread_response = data_packet(b"must-not-read-after-cancel", true);
16481        let script = Arc::new(std::sync::Mutex::new(ScriptedIoState::new(
16482            vec![
16483                ReadAction::bytes_then_cancel_current(first_response, None),
16484                ReadAction::bytes(unread_response, None),
16485            ],
16486            Vec::new(),
16487            ScriptedClock::default(),
16488        )));
16489
16490        let runtime = build_io_runtime()?;
16491        let err = runtime.block_on(async {
16492            let cx = test_cx()?;
16493            let mut read = ScriptedRead::from_state(Arc::clone(&script));
16494            let write = Arc::new(AsyncMutex::with_name(
16495                "flush_cancel_checkpoint_test_write",
16496                ScriptedWrite::from_state(Arc::clone(&script)),
16497            ));
16498            let err = read_data_response_flushing_out_binds(&mut read, &cx, &write, 8192)
16499                .await
16500                .expect_err("cancel checkpoint must stop before FLUSH_OUT_BINDS follow-up");
16501            Ok::<_, Error>(err)
16502        })?;
16503
16504        // W1-T6.2: a cancel checkpoint surfaces the DISTINCT Error::Cancelled
16505        // (ORA-01013), never a generic Error::Runtime(display_string).
16506        assert!(
16507            matches!(&err, Error::Cancelled),
16508            "flush continuation should stop on the cancellation checkpoint with a distinct cancel error, got {err:?}"
16509        );
16510        assert_eq!(err.kind(), ErrorKind::Cancel);
16511        let state = script
16512            .lock()
16513            .map_err(|_| Error::Runtime("scripted I/O state lock poisoned".into()))?;
16514        assert_eq!(
16515            state.read.len(),
16516            1,
16517            "the next response packet must not be read after cancellation"
16518        );
16519        assert!(
16520            state.write.is_empty(),
16521            "the FLUSH_OUT_BINDS follow-up must not be sent after cancellation"
16522        );
16523        Ok(())
16524    }
16525
16526    #[test]
16527    fn pending_cx_cancel_is_observed_before_fetch_continuation_write() -> Result<()> {
16528        let listener = TcpListener::bind("127.0.0.1:0")?;
16529        let addr = listener.local_addr()?;
16530        let server = thread::spawn(move || -> std::io::Result<Option<u8>> {
16531            let (mut socket, _) = listener.accept()?;
16532            socket.set_read_timeout(Some(Duration::from_millis(300)))?;
16533            let mut first_byte = [0u8; 1];
16534            match socket.read(&mut first_byte) {
16535                Ok(0) => Ok(None),
16536                Ok(_) => Ok(Some(first_byte[0])),
16537                Err(err)
16538                    if matches!(
16539                        err.kind(),
16540                        std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
16541                    ) =>
16542                {
16543                    Ok(None)
16544                }
16545                Err(err) => Err(err),
16546            }
16547        });
16548
16549        let runtime = build_io_runtime()?;
16550        runtime.block_on(async {
16551            let cx = test_cx()?;
16552            let stream = TcpStream::connect(addr).await?;
16553            let (read, write) = transport::plain_split(stream);
16554            let mut connection = loopback_connection(read, write);
16555            let before_seq = connection.ttc_seq_num;
16556
16557            cx.cancel_fast(asupersync::CancelKind::User);
16558            let err = connection
16559                .fetch_rows_request(&cx, 42, 10)
16560                .await
16561                .expect_err("fetch continuation must checkpoint before writing");
16562            // W1-T6.2: an explicit user cancel surfaces the distinct
16563            // Error::Cancelled, not a generic Error::Runtime(display_string).
16564            assert!(
16565                matches!(&err, Error::Cancelled),
16566                "fetch continuation should stop on the cancellation checkpoint with a distinct cancel error, got {err:?}"
16567            );
16568            assert_eq!(err.kind(), ErrorKind::Cancel);
16569            assert_eq!(
16570                connection.ttc_seq_num, before_seq,
16571                "checkpoint must run before allocating the next TTC sequence number"
16572            );
16573            assert_eq!(
16574                connection.core.recovery.phase(),
16575                SessionRecoveryPhase::Ready,
16576                "checkpoint failure must not arm the recovery state machine"
16577            );
16578            Ok::<_, Error>(())
16579        })?;
16580
16581        let received = server.join().expect("server thread joins")?;
16582        assert_eq!(
16583            received, None,
16584            "cancelled fetch continuation must not write a FETCH packet"
16585        );
16586        Ok(())
16587    }
16588
16589    #[test]
16590    fn fetch_rows_ref_drop_mid_read_arms_break_recovery() -> Result<()> {
16591        let listener = TcpListener::bind("127.0.0.1:0")?;
16592        let addr = listener.local_addr()?;
16593        let (packet_tx, packet_rx) = std::sync::mpsc::channel();
16594        let (release_tx, release_rx) = std::sync::mpsc::channel();
16595        let server = thread::spawn(move || -> std::io::Result<()> {
16596            let (mut socket, _) = listener.accept()?;
16597            socket.set_read_timeout(Some(Duration::from_secs(2)))?;
16598            let mut buf = [0u8; 1024];
16599            let read = socket.read(&mut buf)?;
16600            packet_tx
16601                .send(read)
16602                .expect("test packet notification receiver is alive");
16603            let _ = release_rx.recv_timeout(Duration::from_secs(2));
16604            Ok(())
16605        });
16606
16607        let runtime = build_io_runtime()?;
16608        runtime.block_on(async {
16609            let cx = test_cx()?;
16610            let stream = TcpStream::connect(addr).await?;
16611            let (read, write) = transport::plain_split(stream);
16612            let mut connection = loopback_connection(read, write);
16613
16614            {
16615                let mut fetch = pin!(connection.fetch_rows_ref(&cx, 42, 10, None));
16616                let first_poll = poll_fn(|task_cx| Poll::Ready(fetch.as_mut().poll(task_cx))).await;
16617                assert!(
16618                    matches!(first_poll, Poll::Pending),
16619                    "fetch_rows_ref must wait for the server response"
16620                );
16621                let packet_len = packet_rx
16622                    .recv_timeout(Duration::from_secs(2))
16623                    .expect("fetch request should be sent before the response read waits");
16624                assert!(packet_len > 0, "fetch request packet must not be empty");
16625
16626                let second_poll =
16627                    poll_fn(|task_cx| Poll::Ready(fetch.as_mut().poll(task_cx))).await;
16628                assert!(
16629                    matches!(second_poll, Poll::Pending),
16630                    "fetch_rows_ref response read should still be pending"
16631                );
16632            }
16633
16634            assert_eq!(
16635                connection.core.recovery.phase(),
16636                SessionRecoveryPhase::BreakSent,
16637                "dropping fetch_rows_ref mid-read must arm break/drain recovery"
16638            );
16639            release_tx
16640                .send(())
16641                .expect("test server release receiver is alive");
16642            Ok::<_, Error>(())
16643        })?;
16644
16645        server
16646            .join()
16647            .expect("server thread joins")
16648            .map_err(Error::Io)?;
16649        Ok(())
16650    }
16651
16652    #[test]
16653    fn in_flight_context_cancel_does_not_leak_interrupted_io() -> Result<()> {
16654        let listener = TcpListener::bind("127.0.0.1:0")?;
16655        let addr = listener.local_addr()?;
16656        let (request_seen_tx, request_seen_rx) = std::sync::mpsc::channel();
16657        let (release_tx, release_rx) = std::sync::mpsc::channel();
16658        let (cx_tx, cx_rx) = std::sync::mpsc::channel::<Cx>();
16659        let server = thread::spawn(move || -> std::io::Result<()> {
16660            let (mut socket, _) = listener.accept()?;
16661            socket.set_read_timeout(Some(Duration::from_secs(2)))?;
16662            assert_eq!(
16663                read_one_wire_packet(&mut socket),
16664                TNS_PACKET_TYPE_DATA,
16665                "COMMIT request must reach the server before cancellation"
16666            );
16667            request_seen_tx
16668                .send(())
16669                .expect("client cancellation task is alive");
16670            let _ = release_rx.recv_timeout(Duration::from_secs(2));
16671            Ok(())
16672        });
16673
16674        let runtime = build_io_runtime()?;
16675        let canceller = thread::spawn(move || -> Result<()> {
16676            let cancel_cx = cx_rx
16677                .recv_timeout(Duration::from_secs(2))
16678                .map_err(|_| Error::Runtime("COMMIT task never exposed its Cx".into()))?;
16679            request_seen_rx
16680                .recv_timeout(Duration::from_secs(2))
16681                .map_err(|_| Error::Runtime("server never observed COMMIT request".into()))?;
16682            cancel_cx.cancel_fast(CancelKind::User);
16683            Ok(())
16684        });
16685        // A spawned task is essential: the Asupersync scheduler installs its
16686        // cancellation waker for task Cx values, matching normal async-driver
16687        // use rather than `Runtime::block_on`'s top-level harness context.
16688        let task = runtime.handle().spawn(async move {
16689            let cx = test_cx()?;
16690            cx_tx
16691                .send(cx.clone())
16692                .map_err(|_| Error::Runtime("cancellation test coordinator exited".into()))?;
16693            let stream = TcpStream::connect(addr).await?;
16694            let (read, write) = transport::plain_split(stream);
16695            let mut connection = loopback_connection(read, write);
16696
16697            let err = connection
16698                .commit(&cx)
16699                .await
16700                .expect_err("the cancelled in-flight COMMIT must not complete");
16701            Ok::<_, Error>((err, connection.core.recovery.phase()))
16702        });
16703        let (err, phase) = runtime.block_on(task)?;
16704        canceller.join().expect("cancellation thread joins")?;
16705
16706        let _ = release_tx.send(());
16707        server
16708            .join()
16709            .expect("server thread joins")
16710            .map_err(Error::Io)?;
16711
16712        assert!(
16713            matches!(&err, Error::Cancelled),
16714            "a mid-read user cancellation must surface the typed Error::Cancelled, not {err:?}"
16715        );
16716        assert_eq!(err.kind(), ErrorKind::Cancel);
16717        assert_eq!(
16718            phase,
16719            SessionRecoveryPhase::BreakSent,
16720            "the cancelled in-flight response must be reclaimed before a later request"
16721        );
16722        Ok(())
16723    }
16724
16725    #[test]
16726    fn scroll_cursor_context_cancel_arms_recovery_and_is_typed() -> Result<()> {
16727        let listener = TcpListener::bind("127.0.0.1:0")?;
16728        let addr = listener.local_addr()?;
16729        let (request_seen_tx, request_seen_rx) = std::sync::mpsc::channel();
16730        let (release_tx, release_rx) = std::sync::mpsc::channel();
16731        let (cx_tx, cx_rx) = std::sync::mpsc::channel::<Cx>();
16732        let server = thread::spawn(move || -> std::io::Result<()> {
16733            let (mut socket, _) = listener.accept()?;
16734            socket.set_read_timeout(Some(Duration::from_secs(2)))?;
16735            assert_eq!(
16736                read_one_wire_packet(&mut socket),
16737                TNS_PACKET_TYPE_DATA,
16738                "SCROLL request must reach the server before cancellation"
16739            );
16740            request_seen_tx
16741                .send(())
16742                .expect("client cancellation task is alive");
16743            let _ = release_rx.recv_timeout(Duration::from_secs(2));
16744            Ok(())
16745        });
16746
16747        let runtime = build_io_runtime()?;
16748        let canceller = thread::spawn(move || -> Result<()> {
16749            let cancel_cx = cx_rx
16750                .recv_timeout(Duration::from_secs(2))
16751                .map_err(|_| Error::Runtime("SCROLL task never exposed its Cx".into()))?;
16752            request_seen_rx
16753                .recv_timeout(Duration::from_secs(2))
16754                .map_err(|_| Error::Runtime("server never observed SCROLL request".into()))?;
16755            cancel_cx.cancel_fast(CancelKind::User);
16756            Ok(())
16757        });
16758        let task = runtime.handle().spawn(async move {
16759            let cx = test_cx()?;
16760            cx_tx
16761                .send(cx.clone())
16762                .map_err(|_| Error::Runtime("cancellation test coordinator exited".into()))?;
16763            let stream = TcpStream::connect(addr).await?;
16764            let (read, write) = transport::plain_split(stream);
16765            let mut connection = loopback_connection(read, write);
16766
16767            let err = connection
16768                .scroll_cursor(&cx, "select value from scroll_cancel_fixture", 42, 1, 0, 0)
16769                .await
16770                .expect_err("the cancelled in-flight SCROLL must not complete");
16771            Ok::<_, Error>((err, connection.core.recovery.phase()))
16772        });
16773        let (err, phase) = runtime.block_on(task)?;
16774        canceller.join().expect("cancellation thread joins")?;
16775
16776        let _ = release_tx.send(());
16777        server
16778            .join()
16779            .expect("server thread joins")
16780            .map_err(Error::Io)?;
16781
16782        assert!(
16783            matches!(&err, Error::Cancelled),
16784            "a mid-read SCROLL cancellation must surface Error::Cancelled, not {err:?}"
16785        );
16786        assert_eq!(
16787            phase,
16788            SessionRecoveryPhase::BreakSent,
16789            "a cancelled SCROLL response must be reclaimed before a later request"
16790        );
16791        Ok(())
16792    }
16793
16794    #[test]
16795    fn dropped_cancellable_read_is_drained_before_commit_request() -> Result<()> {
16796        const STRANDED_BODY: &[u8] = b"stranded response";
16797        const TRAILING_CANCEL_ERROR: &[u8] = &[0x04, 0x01, 0x0d];
16798
16799        let commit_packet = encode_packet(
16800            TNS_PACKET_TYPE_DATA,
16801            0,
16802            Some(0),
16803            &build_function_payload_with_seq(
16804                TNS_FUNC_COMMIT,
16805                1,
16806                ClientCapabilities::default().ttc_field_version,
16807            ),
16808            PacketLengthWidth::Large32,
16809        )?;
16810        let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener");
16811        let addr = listener.local_addr().expect("listener address");
16812        let server = thread::spawn(move || {
16813            let (mut socket, _) = listener.accept().expect("accept test client");
16814            socket
16815                .set_read_timeout(Some(Duration::from_secs(5)))
16816                .expect("set read timeout");
16817            use std::io::Write as _;
16818
16819            assert_eq!(
16820                read_marker_type(&mut socket),
16821                TNS_MARKER_TYPE_BREAK,
16822                "commit must send BREAK before its own request when recovery is pending"
16823            );
16824            socket
16825                .write_all(&data_packet(STRANDED_BODY, true))
16826                .expect("write stranded response");
16827            socket
16828                .write_all(&marker_packet(TNS_MARKER_TYPE_BREAK))
16829                .expect("write break-ack marker");
16830            assert_eq!(
16831                read_marker_type(&mut socket),
16832                TNS_MARKER_TYPE_RESET,
16833                "commit recovery drain must answer the server break marker with RESET"
16834            );
16835            socket
16836                .write_all(&marker_packet(TNS_MARKER_TYPE_RESET))
16837                .expect("write reset-confirm marker");
16838            socket
16839                .write_all(&data_packet(TRAILING_CANCEL_ERROR, true))
16840                .expect("write trailing cancel error packet");
16841
16842            let mut header = [0u8; 8];
16843            socket
16844                .read_exact(&mut header)
16845                .expect("read fresh commit request header");
16846            let len = u32::from_be_bytes([header[0], header[1], header[2], header[3]]) as usize;
16847            let mut commit_request = header.to_vec();
16848            let mut body = vec![0u8; len - header.len()];
16849            socket
16850                .read_exact(&mut body)
16851                .expect("read fresh commit request body");
16852            commit_request.extend_from_slice(&body);
16853            assert_eq!(
16854                commit_request, commit_packet,
16855                "fresh COMMIT request must be written after BREAK/RESET drain"
16856            );
16857            socket
16858                .write_all(&data_packet(&[TNS_MSG_TYPE_END_OF_RESPONSE], true))
16859                .expect("write commit response");
16860        });
16861
16862        let runtime = build_io_runtime()?;
16863        runtime.block_on(async {
16864            let cx = test_cx()?;
16865            let stream = TcpStream::connect(addr).await?;
16866            let (read, write) = transport::plain_split(stream);
16867            let mut connection = loopback_connection(read, write);
16868            {
16869                let _guard = CancelDrainGuard::arm(Arc::clone(&connection.core.recovery))?;
16870            }
16871            assert_eq!(
16872                connection.core.recovery.phase(),
16873                SessionRecoveryPhase::BreakSent
16874            );
16875            connection.commit(&cx).await?;
16876            assert_eq!(
16877                connection.core.recovery.phase(),
16878                SessionRecoveryPhase::Ready
16879            );
16880            Ok::<_, Error>(())
16881        })?;
16882
16883        server.join().expect("server thread joins");
16884        Ok(())
16885    }
16886
16887    fn assert_borrowed_stream_cursor_retired(
16888        connection: &Connection,
16889        cursor_id: u32,
16890        expected_phase: SessionRecoveryPhase,
16891    ) {
16892        assert_eq!(connection.core.recovery.phase(), expected_phase);
16893        assert!(!connection.in_use_cursors.contains(&cursor_id));
16894        assert!(!connection.copied_cursors.contains(&cursor_id));
16895        assert!(
16896            connection
16897                .statement_cache
16898                .iter()
16899                .all(|entry| entry.cursor_id != cursor_id),
16900            "failed borrowed stream must evict its cached cursor"
16901        );
16902        assert!(!connection.cursor_columns.contains_key(&cursor_id));
16903        assert!(!connection.lob_prefetch_cursors.contains(&cursor_id));
16904        assert_eq!(
16905            connection
16906                .cursors_to_close
16907                .iter()
16908                .filter(|queued| **queued == cursor_id)
16909                .count(),
16910            1,
16911            "failed borrowed stream must queue exactly one cursor close"
16912        );
16913    }
16914
16915    fn expected_next_close_piggyback(connection: &Connection, cursor_id: u32) -> Vec<u8> {
16916        let mut seq_num = connection.ttc_seq_num;
16917        let close_seq = next_ttc_sequence(&mut seq_num);
16918        oracledb_protocol::thin::build_close_cursors_piggyback(
16919            &[cursor_id],
16920            close_seq,
16921            connection.capabilities.ttc_field_version,
16922        )
16923    }
16924
16925    fn serve_borrowed_stream_break_drain(
16926        socket: &mut std::net::TcpStream,
16927        stranded_response: &[u8],
16928    ) {
16929        use std::io::Write as _;
16930        const TRAILING_CANCEL_ERROR: &[u8] = &[0x04, 0x01, 0x0d];
16931
16932        assert_eq!(
16933            read_marker_type(socket),
16934            TNS_MARKER_TYPE_BREAK,
16935            "reuse must BREAK before sending a request or cursor-close piggyback"
16936        );
16937        socket
16938            .write_all(&data_packet(stranded_response, true))
16939            .expect("write stranded borrowed response");
16940        socket
16941            .write_all(&marker_packet(TNS_MARKER_TYPE_BREAK))
16942            .expect("write break acknowledgement");
16943        assert_eq!(
16944            read_marker_type(socket),
16945            TNS_MARKER_TYPE_RESET,
16946            "drain must RESET before the reuse request"
16947        );
16948        socket
16949            .write_all(&marker_packet(TNS_MARKER_TYPE_RESET))
16950            .expect("write reset confirmation");
16951        socket
16952            .write_all(&data_packet(TRAILING_CANCEL_ERROR, true))
16953            .expect("write trailing cancel response");
16954    }
16955
16956    #[test]
16957    fn borrowed_stream_first_page_callback_error_retires_cursor() -> Result<()> {
16958        const SQL: &str = "select value from borrowed_callback_failure";
16959        let response = synthetic_pipeline_execute_response_payload();
16960        let expected = sequential_op_decode(&response);
16961        let cursor_id = expected.cursor_id;
16962        assert_ne!(cursor_id, 0, "fixture must open a query cursor");
16963        assert!(
16964            !expected.rows.is_empty(),
16965            "fixture must invoke the first-page callback"
16966        );
16967
16968        let listener = TcpListener::bind("127.0.0.1:0")?;
16969        let addr = listener.local_addr()?;
16970        let server = thread::spawn(move || -> std::io::Result<()> {
16971            use std::io::Write as _;
16972            let (mut socket, _) = listener.accept()?;
16973            socket.set_read_timeout(Some(Duration::from_secs(2)))?;
16974            let _execute = read_one_wire_packet(&mut socket);
16975            let response_packet = data_packet(&response, true);
16976            socket.write_all(&response_packet)?;
16977            socket.flush()?;
16978
16979            // The retry must parse a fresh statement after the failed stream
16980            // evicted its cached cursor. Its request also carries the deferred
16981            // close piggyback for the abandoned cursor.
16982            let _retry = read_one_wire_packet(&mut socket);
16983            socket.write_all(&response_packet)?;
16984            socket.flush()?;
16985            Ok(())
16986        });
16987
16988        let runtime = build_io_runtime()?;
16989        runtime.block_on(async {
16990            let cx = test_cx()?;
16991            let stream = TcpStream::connect(addr).await?;
16992            let (read, write) = transport::plain_split(stream);
16993            let mut connection = loopback_connection(read, write);
16994
16995            let err = connection
16996                .for_each_row_ref(&cx, SQL, 2, |_row| {
16997                    Err(Error::Runtime("stop borrowed callback".to_string()))
16998                })
16999                .await
17000                .expect_err("callback failure must be surfaced");
17001            assert!(matches!(err, Error::Runtime(message) if message == "stop borrowed callback"));
17002            assert!(!connection.in_use_cursors.contains(&cursor_id));
17003            assert!(
17004                connection
17005                    .statement_cache
17006                    .iter()
17007                    .all(|entry| entry.cursor_id != cursor_id),
17008                "failed borrowed stream must not leave its cursor reusable"
17009            );
17010            assert!(!connection.cursor_columns.contains_key(&cursor_id));
17011            assert_eq!(
17012                connection
17013                    .cursors_to_close
17014                    .iter()
17015                    .filter(|queued| **queued == cursor_id)
17016                    .count(),
17017                1,
17018                "failed borrowed stream must queue one cursor close"
17019            );
17020            assert_eq!(
17021                connection.core.recovery.phase(),
17022                SessionRecoveryPhase::Ready,
17023                "a first-page callback error has no stranded response"
17024            );
17025
17026            let mut retried_rows = 0usize;
17027            connection
17028                .for_each_row_ref(&cx, SQL, 2, |_row| {
17029                    retried_rows += 1;
17030                    Ok(())
17031                })
17032                .await?;
17033            assert_eq!(retried_rows, expected.rows.len());
17034            assert!(
17035                connection
17036                    .statement_cache
17037                    .iter()
17038                    .any(|entry| entry.cursor_id == cursor_id),
17039                "a successful retry must return its fresh cursor to the cache"
17040            );
17041            assert!(!connection.in_use_cursors.contains(&cursor_id));
17042            assert!(!connection.copied_cursors.contains(&cursor_id));
17043            assert!(
17044                !connection.cursors_to_close.contains(&cursor_id),
17045                "the retry request must consume the abandoned cursor's close piggyback"
17046            );
17047            Ok::<_, Error>(())
17048        })?;
17049
17050        server.join().expect("server thread joins")?;
17051        Ok(())
17052    }
17053
17054    #[test]
17055    fn borrowed_stream_paged_callback_error_drains_before_close_and_reuse() -> Result<()> {
17056        const SQL: &str = "select value from paged_borrowed_callback_failure";
17057        const FRESH_SQL: &str = "select 7 + 5 as value from dual";
17058
17059        let execute_response = synthetic_open_cursor_execute_response_payload();
17060        let first = sequential_op_decode(&execute_response);
17061        let cursor_id = first.cursor_id;
17062        let continuation = synthetic_open_borrowed_fetch_response("13");
17063        let parsed_continuation = parse_query_response_borrowed_with_limits(
17064            &continuation,
17065            ClientCapabilities::default(),
17066            &first.columns,
17067            first.rows.last().map(Vec::as_slice),
17068            ProtocolLimits::DEFAULT,
17069        )
17070        .expect("synthetic continuation must decode through the borrowed parser");
17071        assert_eq!(parsed_continuation.batch.row_count(), 1);
17072        assert!(parsed_continuation.more_rows);
17073        drop(parsed_continuation);
17074
17075        let (close_tx, close_rx) = std::sync::mpsc::channel::<Vec<u8>>();
17076        let listener = TcpListener::bind("127.0.0.1:0")?;
17077        let addr = listener.local_addr()?;
17078        let stranded = continuation.clone();
17079        let server = thread::spawn(move || {
17080            use std::io::Write as _;
17081            let (mut socket, _) = listener.accept().expect("accept test client");
17082            socket
17083                .set_read_timeout(Some(Duration::from_secs(5)))
17084                .expect("set read timeout");
17085
17086            let _execute = read_one_wire_data_payload(&mut socket);
17087            socket
17088                .write_all(&data_packet(&execute_response, true))
17089                .expect("write open-cursor execute response");
17090            let _first_fetch = read_one_wire_data_payload(&mut socket);
17091            socket
17092                .write_all(&data_packet(&continuation, true))
17093                .expect("write first continuation page");
17094
17095            // `for_each_row_ref` must send this speculative request before it
17096            // invokes the page callback that fails.
17097            let _speculative_fetch = read_one_wire_data_payload(&mut socket);
17098            serve_borrowed_stream_break_drain(&mut socket, &stranded);
17099
17100            let reuse = read_one_wire_data_payload(&mut socket);
17101            let expected_close = close_rx
17102                .recv_timeout(Duration::from_secs(2))
17103                .expect("client provides expected close piggyback");
17104            assert!(
17105                reuse.starts_with(&expected_close),
17106                "cursor close must be deduplicated and prepended only after BREAK/RESET drain"
17107            );
17108            socket
17109                .write_all(&data_packet(
17110                    &synthetic_pipeline_execute_response_payload(),
17111                    true,
17112                ))
17113                .expect("write fresh reuse response");
17114        });
17115
17116        let runtime = build_io_runtime()?;
17117        runtime.block_on(async {
17118            let cx = test_cx()?;
17119            let stream = TcpStream::connect(addr).await?;
17120            let (read, write) = transport::plain_split(stream);
17121            let mut connection = loopback_connection(read, write);
17122            let mut rows_seen = 0usize;
17123
17124            let err = connection
17125                .for_each_row_ref(&cx, SQL, 2, |_row| {
17126                    rows_seen += 1;
17127                    if rows_seen == 2 {
17128                        Err(Error::Runtime("stop paged callback".into()))
17129                    } else {
17130                        Ok(())
17131                    }
17132                })
17133                .await
17134                .expect_err("paged callback failure must be surfaced");
17135            assert!(matches!(err, Error::Runtime(message) if message == "stop paged callback"));
17136            assert_eq!(
17137                rows_seen, 2,
17138                "failure must occur in the fetched-page callback, after speculative send"
17139            );
17140            assert_borrowed_stream_cursor_retired(
17141                &connection,
17142                cursor_id,
17143                SessionRecoveryPhase::InFlight,
17144            );
17145            connection.close_cursor(cursor_id);
17146            assert_borrowed_stream_cursor_retired(
17147                &connection,
17148                cursor_id,
17149                SessionRecoveryPhase::InFlight,
17150            );
17151
17152            close_tx
17153                .send(expected_next_close_piggyback(&connection, cursor_id))
17154                .expect("server is waiting for close proof");
17155            let fresh = connection
17156                .execute_raw(&cx, FRESH_SQL, 2, &[], ExecuteOptions::default(), None)
17157                .await?;
17158            assert_eq!(
17159                fresh,
17160                sequential_op_decode(&synthetic_pipeline_execute_response_payload()),
17161                "same connection must decode its own response after drain"
17162            );
17163            assert_eq!(
17164                connection.core.recovery.phase(),
17165                SessionRecoveryPhase::Ready
17166            );
17167            assert!(!connection.cursors_to_close.contains(&cursor_id));
17168            connection.release_cursor(fresh.cursor_id);
17169            Ok::<_, Error>(())
17170        })?;
17171
17172        server.join().expect("server thread joins");
17173        Ok(())
17174    }
17175
17176    fn exercise_borrowed_stream_fetch_decode_failure(malformed: Vec<u8>) -> Result<()> {
17177        const SQL: &str = "select value from malformed_borrowed_fetch";
17178        const FRESH_SQL: &str = "select 7 + 5 as value from dual";
17179
17180        let execute_response = synthetic_open_cursor_execute_response_payload();
17181        let cursor_id = sequential_op_decode(&execute_response).cursor_id;
17182        let (close_tx, close_rx) = std::sync::mpsc::channel::<Vec<u8>>();
17183        let listener = TcpListener::bind("127.0.0.1:0")?;
17184        let addr = listener.local_addr()?;
17185        let server = thread::spawn(move || {
17186            use std::io::Write as _;
17187            let (mut socket, _) = listener.accept().expect("accept test client");
17188            socket
17189                .set_read_timeout(Some(Duration::from_secs(5)))
17190                .expect("set read timeout");
17191
17192            let _execute = read_one_wire_data_payload(&mut socket);
17193            socket
17194                .write_all(&data_packet(&execute_response, true))
17195                .expect("write open-cursor execute response");
17196            let _fetch = read_one_wire_data_payload(&mut socket);
17197            socket
17198                .write_all(&data_packet(&malformed, true))
17199                .expect("write malformed fetch response");
17200
17201            // The malformed response was fully framed and consumed, so there
17202            // must be no BREAK. The very next packet is the close+reuse DATA.
17203            let reuse = read_one_wire_data_payload(&mut socket);
17204            let expected_close = close_rx
17205                .recv_timeout(Duration::from_secs(2))
17206                .expect("client provides expected close piggyback");
17207            assert!(reuse.starts_with(&expected_close));
17208            socket
17209                .write_all(&data_packet(
17210                    &synthetic_pipeline_execute_response_payload(),
17211                    true,
17212                ))
17213                .expect("write fresh reuse response");
17214        });
17215
17216        let runtime = build_io_runtime()?;
17217        runtime.block_on(async {
17218            let cx = test_cx()?;
17219            let stream = TcpStream::connect(addr).await?;
17220            let (read, write) = transport::plain_split(stream);
17221            let mut connection = loopback_connection(read, write);
17222            let mut rows_seen = 0usize;
17223
17224            let err = connection
17225                .for_each_row_ref(&cx, SQL, 2, |_row| {
17226                    rows_seen += 1;
17227                    Ok(())
17228                })
17229                .await
17230                .expect_err("malformed continuation must fail decoding");
17231            assert!(matches!(err, Error::Protocol(_)));
17232            assert_eq!(rows_seen, 1, "only the execute page may reach the callback");
17233            assert_borrowed_stream_cursor_retired(
17234                &connection,
17235                cursor_id,
17236                SessionRecoveryPhase::Ready,
17237            );
17238            connection.close_cursor(cursor_id);
17239            assert_borrowed_stream_cursor_retired(
17240                &connection,
17241                cursor_id,
17242                SessionRecoveryPhase::Ready,
17243            );
17244
17245            close_tx
17246                .send(expected_next_close_piggyback(&connection, cursor_id))
17247                .expect("server is waiting for close proof");
17248            let fresh = connection
17249                .execute_raw(&cx, FRESH_SQL, 2, &[], ExecuteOptions::default(), None)
17250                .await?;
17251            assert_eq!(
17252                fresh,
17253                sequential_op_decode(&synthetic_pipeline_execute_response_payload())
17254            );
17255            assert_eq!(
17256                connection.core.recovery.phase(),
17257                SessionRecoveryPhase::Ready
17258            );
17259            assert!(!connection.cursors_to_close.contains(&cursor_id));
17260            connection.release_cursor(fresh.cursor_id);
17261            Ok::<_, Error>(())
17262        })?;
17263
17264        server.join().expect("server thread joins");
17265        Ok(())
17266    }
17267
17268    #[test]
17269    fn borrowed_stream_malformed_and_truncated_fetches_retire_before_reuse() -> Result<()> {
17270        exercise_borrowed_stream_fetch_decode_failure(vec![0xff])?;
17271        exercise_borrowed_stream_fetch_decode_failure(vec![
17272            oracledb_protocol::thin::TNS_MSG_TYPE_ROW_HEADER,
17273        ])
17274    }
17275
17276    #[test]
17277    fn borrowed_stream_fetch_eof_retires_cursor() -> Result<()> {
17278        const SQL: &str = "select value from eof_borrowed_fetch";
17279        let execute_response = synthetic_open_cursor_execute_response_payload();
17280        let cursor_id = sequential_op_decode(&execute_response).cursor_id;
17281        let listener = TcpListener::bind("127.0.0.1:0")?;
17282        let addr = listener.local_addr()?;
17283        let server = thread::spawn(move || {
17284            use std::io::Write as _;
17285            let (mut socket, _) = listener.accept().expect("accept test client");
17286            socket
17287                .set_read_timeout(Some(Duration::from_secs(5)))
17288                .expect("set read timeout");
17289            let _execute = read_one_wire_data_payload(&mut socket);
17290            socket
17291                .write_all(&data_packet(&execute_response, true))
17292                .expect("write open-cursor execute response");
17293            let _fetch = read_one_wire_data_payload(&mut socket);
17294            socket
17295                .shutdown(std::net::Shutdown::Both)
17296                .expect("close during fetch response");
17297        });
17298
17299        let runtime = build_io_runtime()?;
17300        runtime.block_on(async {
17301            let cx = test_cx()?;
17302            let stream = TcpStream::connect(addr).await?;
17303            let (read, write) = transport::plain_split(stream);
17304            let mut connection = loopback_connection(read, write);
17305            let err = connection
17306                .for_each_row_ref(&cx, SQL, 2, |_row| Ok(()))
17307                .await
17308                .expect_err("EOF during continuation fetch must fail");
17309            assert!(matches!(err, Error::Io(_) | Error::ConnectionClosed(_)));
17310            assert_borrowed_stream_cursor_retired(
17311                &connection,
17312                cursor_id,
17313                SessionRecoveryPhase::BreakSent,
17314            );
17315            connection.close_cursor(cursor_id);
17316            assert_borrowed_stream_cursor_retired(
17317                &connection,
17318                cursor_id,
17319                SessionRecoveryPhase::BreakSent,
17320            );
17321            Ok::<_, Error>(())
17322        })?;
17323
17324        server.join().expect("server thread joins");
17325        Ok(())
17326    }
17327
17328    #[test]
17329    fn borrowed_stream_cancel_before_fetch_request_retires_without_wire_write() -> Result<()> {
17330        const SQL: &str = "select value from pre_request_borrowed_cancel";
17331        let execute_response = synthetic_open_cursor_execute_response_payload();
17332        let cursor_id = sequential_op_decode(&execute_response).cursor_id;
17333        let listener = TcpListener::bind("127.0.0.1:0")?;
17334        let addr = listener.local_addr()?;
17335        let server = thread::spawn(move || -> std::io::Result<Option<u8>> {
17336            use std::io::Write as _;
17337            let (mut socket, _) = listener.accept()?;
17338            socket.set_read_timeout(Some(Duration::from_millis(500)))?;
17339            let _execute = read_one_wire_data_payload(&mut socket);
17340            socket.write_all(&data_packet(&execute_response, true))?;
17341            socket.flush()?;
17342
17343            let mut byte = [0u8; 1];
17344            match socket.read(&mut byte) {
17345                Ok(0) => Ok(None),
17346                Ok(_) => Ok(Some(byte[0])),
17347                Err(err)
17348                    if matches!(
17349                        err.kind(),
17350                        std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
17351                    ) =>
17352                {
17353                    Ok(None)
17354                }
17355                Err(err) => Err(err),
17356            }
17357        });
17358
17359        let runtime = build_io_runtime()?;
17360        runtime.block_on(async {
17361            let cx = test_cx()?;
17362            let stream = TcpStream::connect(addr).await?;
17363            let (read, write) = transport::plain_split(stream);
17364            let mut connection = loopback_connection(read, write);
17365            let err = connection
17366                .for_each_row_ref(&cx, SQL, 2, |_row| {
17367                    cx.cancel_fast(asupersync::CancelKind::User);
17368                    Ok(())
17369                })
17370                .await
17371                .expect_err("cancel checkpoint must stop before FETCH write");
17372            assert!(matches!(err, Error::Cancelled));
17373            assert_eq!(
17374                connection.ttc_seq_num, 1,
17375                "cancel-before-request must not allocate a FETCH sequence"
17376            );
17377            assert_borrowed_stream_cursor_retired(
17378                &connection,
17379                cursor_id,
17380                SessionRecoveryPhase::Ready,
17381            );
17382            connection.close_cursor(cursor_id);
17383            assert_borrowed_stream_cursor_retired(
17384                &connection,
17385                cursor_id,
17386                SessionRecoveryPhase::Ready,
17387            );
17388            Ok::<_, Error>(())
17389        })?;
17390
17391        let received = server.join().expect("server thread joins")?;
17392        assert_eq!(received, None, "cancel-before-request must write no FETCH");
17393        Ok(())
17394    }
17395
17396    #[test]
17397    fn borrowed_stream_drop_after_fetch_request_drains_before_close_and_reuse() -> Result<()> {
17398        const SQL: &str = "select value from post_request_borrowed_cancel";
17399        const FRESH_SQL: &str = "select 7 + 5 as value from dual";
17400        let execute_response = synthetic_open_cursor_execute_response_payload();
17401        let cursor_id = sequential_op_decode(&execute_response).cursor_id;
17402        let stranded = synthetic_open_borrowed_fetch_response("14");
17403        let (execute_ready_tx, execute_ready_rx) = std::sync::mpsc::channel();
17404        let (fetch_seen_tx, fetch_seen_rx) = std::sync::mpsc::channel();
17405        let (close_tx, close_rx) = std::sync::mpsc::channel::<Vec<u8>>();
17406        let listener = TcpListener::bind("127.0.0.1:0")?;
17407        let addr = listener.local_addr()?;
17408        let server = thread::spawn(move || {
17409            use std::io::Write as _;
17410            let (mut socket, _) = listener.accept().expect("accept test client");
17411            socket
17412                .set_read_timeout(Some(Duration::from_secs(5)))
17413                .expect("set read timeout");
17414            let _execute = read_one_wire_data_payload(&mut socket);
17415            socket
17416                .write_all(&data_packet(&execute_response, true))
17417                .expect("write open-cursor execute response");
17418            socket.flush().expect("flush execute response");
17419            execute_ready_tx
17420                .send(())
17421                .expect("client waits for execute response");
17422            let _fetch = read_one_wire_data_payload(&mut socket);
17423            fetch_seen_tx
17424                .send(())
17425                .expect("client waits for FETCH write");
17426
17427            serve_borrowed_stream_break_drain(&mut socket, &stranded);
17428            let reuse = read_one_wire_data_payload(&mut socket);
17429            let expected_close = close_rx
17430                .recv_timeout(Duration::from_secs(2))
17431                .expect("client provides expected close piggyback");
17432            assert!(reuse.starts_with(&expected_close));
17433            socket
17434                .write_all(&data_packet(
17435                    &synthetic_pipeline_execute_response_payload(),
17436                    true,
17437                ))
17438                .expect("write fresh reuse response");
17439        });
17440
17441        let runtime = build_io_runtime()?;
17442        runtime.block_on(async {
17443            let cx = test_cx()?;
17444            let stream = TcpStream::connect(addr).await?;
17445            let (read, write) = transport::plain_split(stream);
17446            let mut connection = loopback_connection(read, write);
17447            let mut rows_seen = 0usize;
17448
17449            {
17450                let mut fetch = pin!(connection.for_each_row_ref(&cx, SQL, 2, |_row| {
17451                    rows_seen += 1;
17452                    Ok(())
17453                }));
17454                let first_poll = poll_fn(|task_cx| Poll::Ready(fetch.as_mut().poll(task_cx))).await;
17455                assert!(matches!(first_poll, Poll::Pending));
17456                execute_ready_rx
17457                    .recv_timeout(Duration::from_secs(2))
17458                    .expect("server wrote execute response");
17459
17460                let deadline = Instant::now() + Duration::from_secs(2);
17461                loop {
17462                    match fetch_seen_rx.try_recv() {
17463                        Ok(()) => break,
17464                        Err(std::sync::mpsc::TryRecvError::Empty) => {}
17465                        Err(std::sync::mpsc::TryRecvError::Disconnected) => {
17466                            panic!("server exited before seeing FETCH")
17467                        }
17468                    }
17469                    assert!(Instant::now() < deadline, "FETCH request was never sent");
17470                    let polled = poll_fn(|task_cx| Poll::Ready(fetch.as_mut().poll(task_cx))).await;
17471                    assert!(matches!(polled, Poll::Pending));
17472                    thread::yield_now();
17473                }
17474            }
17475
17476            assert_eq!(
17477                rows_seen, 1,
17478                "execute page callback must run before FETCH wait"
17479            );
17480            assert_borrowed_stream_cursor_retired(
17481                &connection,
17482                cursor_id,
17483                SessionRecoveryPhase::BreakSent,
17484            );
17485            connection.close_cursor(cursor_id);
17486            assert_borrowed_stream_cursor_retired(
17487                &connection,
17488                cursor_id,
17489                SessionRecoveryPhase::BreakSent,
17490            );
17491
17492            close_tx
17493                .send(expected_next_close_piggyback(&connection, cursor_id))
17494                .expect("server is waiting for close proof");
17495            let fresh = connection
17496                .execute_raw(&cx, FRESH_SQL, 2, &[], ExecuteOptions::default(), None)
17497                .await?;
17498            assert_eq!(
17499                fresh,
17500                sequential_op_decode(&synthetic_pipeline_execute_response_payload())
17501            );
17502            assert_eq!(
17503                connection.core.recovery.phase(),
17504                SessionRecoveryPhase::Ready
17505            );
17506            assert!(!connection.cursors_to_close.contains(&cursor_id));
17507            connection.release_cursor(fresh.cursor_id);
17508            Ok::<_, Error>(())
17509        })?;
17510
17511        server.join().expect("server thread joins");
17512        Ok(())
17513    }
17514
17515    #[test]
17516    fn streaming_cancel_mid_stream_leaves_connection_reusable() -> Result<()> {
17517        // a4-x3s (rust-oracledb iec3.1.12) offline negative control: cancelling
17518        // the async row stream mid-flight must leave the connection at a CLEAN
17519        // wire boundary and fully reusable — no protocol desync, no leaked BREAK,
17520        // no stranded response bytes bleeding into the next query.
17521        //
17522        // The stream is `fetch_rows_ref` (the constant-memory borrowed-batch
17523        // lending iterator). Dropping its in-flight response read arms the
17524        // `CancelDrainGuard` (phase -> BreakSent). The NEXT operation runs the
17525        // shared `ensure_clean_before_request` drain: BREAK, then drain the
17526        // server's stranded response + break-ack MARKER + RESET handshake +
17527        // trailing ORA-01013 cancel error, then issue its own request against a
17528        // clean wire (mirrors python-oracledb `_break_external`/`_reset`,
17529        // protocol.pyx). Reuse is proven by running a REAL query afterwards and
17530        // decoding it byte-identically to the reference — not merely a phase.
17531        const STRANDED_BODY: &[u8] = b"stranded stream response";
17532        const TRAILING_CANCEL_ERROR: &[u8] = &[0x04, 0x01, 0x0d]; // ORA-01013 user cancel
17533
17534        let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener");
17535        let addr = listener.local_addr().expect("listener address");
17536        let server = thread::spawn(move || {
17537            use std::io::Write as _;
17538            let (mut socket, _) = listener.accept().expect("accept test client");
17539            socket
17540                .set_read_timeout(Some(Duration::from_secs(5)))
17541                .expect("set read timeout");
17542
17543            // 1) The stream's fetch request goes out first.
17544            let _fetch_request = read_one_wire_packet(&mut socket);
17545
17546            // 2) The reuse op's cancel drain breaks and drains the stranded call.
17547            assert_eq!(
17548                read_marker_type(&mut socket),
17549                TNS_MARKER_TYPE_BREAK,
17550                "the reuse op must BREAK the stranded stream before its own request"
17551            );
17552            socket
17553                .write_all(&data_packet(STRANDED_BODY, true))
17554                .expect("write stranded stream response");
17555            socket
17556                .write_all(&marker_packet(TNS_MARKER_TYPE_BREAK))
17557                .expect("write break-ack marker");
17558            assert_eq!(
17559                read_marker_type(&mut socket),
17560                TNS_MARKER_TYPE_RESET,
17561                "the drain must answer the server break marker with RESET"
17562            );
17563            socket
17564                .write_all(&marker_packet(TNS_MARKER_TYPE_RESET))
17565                .expect("write reset-confirm marker");
17566            socket
17567                .write_all(&data_packet(TRAILING_CANCEL_ERROR, true))
17568                .expect("write trailing cancel error packet");
17569
17570            // 3) The reuse query's fresh request lands on a clean wire; answer it.
17571            let _reuse_request = read_one_wire_packet(&mut socket);
17572            socket
17573                .write_all(&data_packet(
17574                    &synthetic_pipeline_execute_response_payload(),
17575                    true,
17576                ))
17577                .expect("write reuse query response");
17578            socket.flush().expect("flush responses");
17579        });
17580
17581        let runtime = build_io_runtime()?;
17582        runtime.block_on(async {
17583            let cx = test_cx()?;
17584            let stream = TcpStream::connect(addr).await?;
17585            let (read, write) = transport::plain_split(stream);
17586            let mut connection = loopback_connection(read, write);
17587
17588            // Start streaming, drive the request out, then cancel mid-stream by
17589            // dropping the borrowed-batch fetch future while its response read is
17590            // still pending.
17591            {
17592                let mut fetch = pin!(connection.fetch_rows_ref(&cx, 42, 10, None));
17593                let first = poll_fn(|task_cx| Poll::Ready(fetch.as_mut().poll(task_cx))).await;
17594                assert!(
17595                    matches!(first, Poll::Pending),
17596                    "the stream must be waiting on the server response when cancelled"
17597                );
17598            }
17599            assert_eq!(
17600                connection.core.recovery.phase(),
17601                SessionRecoveryPhase::BreakSent,
17602                "cancelling the stream mid-read arms break/drain recovery"
17603            );
17604
17605            // Reuse the SAME connection: a real query must decode correctly,
17606            // proving the wire was drained to a clean boundary.
17607            let reused = connection
17608                .execute_raw(
17609                    &cx,
17610                    "select value from synthetic_fixture",
17611                    2,
17612                    &[],
17613                    ExecuteOptions::default(),
17614                    None,
17615                )
17616                .await?;
17617            assert_eq!(
17618                connection.core.recovery.phase(),
17619                SessionRecoveryPhase::Ready,
17620                "after the drain the connection is back to Ready"
17621            );
17622            assert_eq!(
17623                reused,
17624                sequential_op_decode(&synthetic_pipeline_execute_response_payload()),
17625                "the reused connection decodes the follow-up query byte-identically"
17626            );
17627            Ok::<_, Error>(())
17628        })?;
17629
17630        server.join().expect("server thread joins");
17631        Ok(())
17632    }
17633
17634    #[test]
17635    fn dropped_define_fetch_mid_read_drains_before_connection_reuse() -> Result<()> {
17636        const CURSOR_ID: u32 = 42;
17637        const STRANDED_BODY: &[u8] = b"stranded define-fetch response";
17638        const TRAILING_CANCEL_ERROR: &[u8] = &[0x04, 0x01, 0x0d];
17639        const FRESH_SQL: &str = "select value from define_fetch_reuse_fixture";
17640
17641        let listener = TcpListener::bind("127.0.0.1:0")?;
17642        let addr = listener.local_addr()?;
17643        let (define_seen_tx, define_seen_rx) = std::sync::mpsc::channel();
17644        let server = thread::spawn(move || -> std::io::Result<bool> {
17645            use std::io::Write as _;
17646            let (mut socket, _) = listener.accept()?;
17647            socket.set_read_timeout(Some(Duration::from_secs(5)))?;
17648
17649            assert_eq!(
17650                read_one_wire_packet(&mut socket),
17651                TNS_PACKET_TYPE_DATA,
17652                "define-fetch must send a DATA request"
17653            );
17654            define_seen_tx
17655                .send(())
17656                .expect("client waits for define-fetch request proof");
17657
17658            let (next_packet_type, next_body) = read_one_wire_packet_bytes(&mut socket);
17659            if next_packet_type == TNS_PACKET_TYPE_MARKER {
17660                assert_eq!(
17661                    next_body,
17662                    vec![1, 0, TNS_MARKER_TYPE_BREAK],
17663                    "reuse must BREAK the stranded define-fetch before its request"
17664                );
17665                socket.write_all(&data_packet(STRANDED_BODY, true))?;
17666                socket.write_all(&marker_packet(TNS_MARKER_TYPE_BREAK))?;
17667                assert_eq!(
17668                    read_marker_type(&mut socket),
17669                    TNS_MARKER_TYPE_RESET,
17670                    "define-fetch drain must complete the RESET handshake"
17671                );
17672                socket.write_all(&marker_packet(TNS_MARKER_TYPE_RESET))?;
17673                socket.write_all(&data_packet(TRAILING_CANCEL_ERROR, true))?;
17674
17675                assert_eq!(
17676                    read_one_wire_packet(&mut socket),
17677                    TNS_PACKET_TYPE_DATA,
17678                    "fresh execute follows the completed drain"
17679                );
17680                socket.write_all(&data_packet(
17681                    &synthetic_pipeline_execute_response_payload(),
17682                    true,
17683                ))?;
17684                socket.flush()?;
17685                Ok(true)
17686            } else {
17687                assert_eq!(
17688                    next_packet_type, TNS_PACKET_TYPE_DATA,
17689                    "without recovery the next operation is sent directly"
17690                );
17691                // Reproduce stale-wire poisoning: the abandoned define-fetch
17692                // response arrives before the fresh execute response.
17693                socket.write_all(&data_packet(STRANDED_BODY, true))?;
17694                socket.write_all(&data_packet(
17695                    &synthetic_pipeline_execute_response_payload(),
17696                    true,
17697                ))?;
17698                socket.flush()?;
17699                Ok(false)
17700            }
17701        });
17702
17703        let runtime = build_io_runtime()?;
17704        let outcome = runtime.block_on(async {
17705            let cx = test_cx()?;
17706            let stream = TcpStream::connect(addr).await?;
17707            let (read, write) = transport::plain_split(stream);
17708            let mut connection = loopback_connection(read, write);
17709            let define_columns = vec![ColumnMetadata::new(
17710                "DOC",
17711                oracledb_protocol::thin::ORA_TYPE_NUM_JSON,
17712            )];
17713
17714            {
17715                let mut define = pin!(connection.define_and_fetch_rows_with_columns(
17716                    &cx,
17717                    CURSOR_ID,
17718                    10,
17719                    &define_columns,
17720                    None,
17721                ));
17722                let first = poll_fn(|task_cx| Poll::Ready(define.as_mut().poll(task_cx))).await;
17723                assert!(
17724                    matches!(first, Poll::Pending),
17725                    "define-fetch must be waiting for its response before drop"
17726                );
17727                define_seen_rx
17728                    .recv_timeout(Duration::from_secs(2))
17729                    .expect("server observed define-fetch request");
17730            }
17731
17732            let reused = connection
17733                .execute_raw(&cx, FRESH_SQL, 2, &[], ExecuteOptions::default(), None)
17734                .await;
17735            Ok::<_, Error>((reused, connection.core.recovery.phase()))
17736        });
17737
17738        let recovered = server.join().expect("define-fetch server joins")?;
17739        let (reused, phase) = outcome?;
17740        let reused = reused.expect("fresh execute must not decode the stranded define-fetch");
17741        assert_eq!(
17742            reused,
17743            sequential_op_decode(&synthetic_pipeline_execute_response_payload()),
17744            "reuse must decode its own response byte-identically"
17745        );
17746        assert!(recovered, "reuse must take the BREAK/drain branch");
17747        assert_eq!(phase, SessionRecoveryPhase::Ready);
17748        Ok(())
17749    }
17750
17751    #[test]
17752    fn dropped_commit_mid_read_drains_before_connection_reuse() -> Result<()> {
17753        const STRANDED_BODY: &[u8] = &[TNS_MSG_TYPE_END_OF_RESPONSE];
17754        const TRAILING_CANCEL_ERROR: &[u8] = &[0x04, 0x01, 0x0d];
17755        const FRESH_SQL: &str = "select value from commit_reuse_fixture";
17756
17757        let listener = TcpListener::bind("127.0.0.1:0")?;
17758        let addr = listener.local_addr()?;
17759        let (commit_seen_tx, commit_seen_rx) = std::sync::mpsc::channel();
17760        let server = thread::spawn(move || -> std::io::Result<bool> {
17761            use std::io::Write as _;
17762            let (mut socket, _) = listener.accept()?;
17763            socket.set_read_timeout(Some(Duration::from_secs(5)))?;
17764
17765            assert_eq!(
17766                read_one_wire_packet(&mut socket),
17767                TNS_PACKET_TYPE_DATA,
17768                "commit must send a DATA request"
17769            );
17770            commit_seen_tx
17771                .send(())
17772                .expect("client waits for commit request proof");
17773
17774            let (next_packet_type, next_body) = read_one_wire_packet_bytes(&mut socket);
17775            if next_packet_type == TNS_PACKET_TYPE_MARKER {
17776                assert_eq!(
17777                    next_body,
17778                    vec![1, 0, TNS_MARKER_TYPE_BREAK],
17779                    "reuse must BREAK the stranded commit before its request"
17780                );
17781                socket.write_all(&data_packet(STRANDED_BODY, true))?;
17782                socket.write_all(&marker_packet(TNS_MARKER_TYPE_BREAK))?;
17783                assert_eq!(
17784                    read_marker_type(&mut socket),
17785                    TNS_MARKER_TYPE_RESET,
17786                    "commit drain must complete the RESET handshake"
17787                );
17788                socket.write_all(&marker_packet(TNS_MARKER_TYPE_RESET))?;
17789                socket.write_all(&data_packet(TRAILING_CANCEL_ERROR, true))?;
17790
17791                assert_eq!(
17792                    read_one_wire_packet(&mut socket),
17793                    TNS_PACKET_TYPE_DATA,
17794                    "fresh execute follows the completed drain"
17795                );
17796                socket.write_all(&data_packet(
17797                    &synthetic_pipeline_execute_response_payload(),
17798                    true,
17799                ))?;
17800                socket.flush()?;
17801                Ok(true)
17802            } else {
17803                assert_eq!(
17804                    next_packet_type, TNS_PACKET_TYPE_DATA,
17805                    "without recovery the next operation is sent directly"
17806                );
17807                // Negative control for the unfixed path: the abandoned commit
17808                // response arrives before the fresh execute response.
17809                socket.write_all(&data_packet(STRANDED_BODY, true))?;
17810                socket.write_all(&data_packet(
17811                    &synthetic_pipeline_execute_response_payload(),
17812                    true,
17813                ))?;
17814                socket.flush()?;
17815                Ok(false)
17816            }
17817        });
17818
17819        let runtime = build_io_runtime()?;
17820        let outcome = runtime.block_on(async {
17821            let cx = test_cx()?;
17822            let stream = TcpStream::connect(addr).await?;
17823            let (read, write) = transport::plain_split(stream);
17824            let mut connection = loopback_connection(read, write);
17825
17826            {
17827                let mut commit = pin!(connection.commit(&cx));
17828                let first = poll_fn(|task_cx| Poll::Ready(commit.as_mut().poll(task_cx))).await;
17829                assert!(
17830                    matches!(first, Poll::Pending),
17831                    "commit must be waiting for its response before drop"
17832                );
17833                commit_seen_rx
17834                    .recv_timeout(Duration::from_secs(2))
17835                    .expect("server observed commit request");
17836            }
17837
17838            let phase_after_drop = connection.core.recovery.phase();
17839            let reused = connection
17840                .execute_raw(&cx, FRESH_SQL, 2, &[], ExecuteOptions::default(), None)
17841                .await;
17842            Ok::<_, Error>((phase_after_drop, reused, connection.core.recovery.phase()))
17843        });
17844
17845        let recovered = server.join().expect("commit server joins")?;
17846        let (phase_after_drop, reused, final_phase) = outcome?;
17847        let reused = reused.expect("fresh execute must not decode the stranded commit");
17848        assert_eq!(
17849            reused,
17850            sequential_op_decode(&synthetic_pipeline_execute_response_payload()),
17851            "reuse must decode its own response byte-identically"
17852        );
17853        assert!(recovered, "reuse must take the BREAK/drain branch");
17854        assert_eq!(phase_after_drop, SessionRecoveryPhase::BreakSent);
17855        assert_eq!(final_phase, SessionRecoveryPhase::Ready);
17856        Ok(())
17857    }
17858
17859    #[test]
17860    fn successful_commit_disarms_recovery_before_reuse() -> Result<()> {
17861        const FRESH_SQL: &str = "select value from commit_success_fixture";
17862        let execute_response = synthetic_pipeline_execute_response_payload();
17863        let server_execute_response = execute_response.clone();
17864        let listener = TcpListener::bind("127.0.0.1:0")?;
17865        let addr = listener.local_addr()?;
17866        let server = thread::spawn(move || -> std::io::Result<()> {
17867            use std::io::Write as _;
17868            let (mut socket, _) = listener.accept()?;
17869            socket.set_read_timeout(Some(Duration::from_secs(2)))?;
17870
17871            assert_eq!(
17872                read_one_wire_packet(&mut socket),
17873                TNS_PACKET_TYPE_DATA,
17874                "commit must send a DATA request"
17875            );
17876            socket.write_all(&data_packet(&[TNS_MSG_TYPE_END_OF_RESPONSE], true))?;
17877            socket.flush()?;
17878
17879            assert_eq!(
17880                read_one_wire_packet(&mut socket),
17881                TNS_PACKET_TYPE_DATA,
17882                "a successful commit must not cause a spurious BREAK on reuse"
17883            );
17884            socket.write_all(&data_packet(&server_execute_response, true))?;
17885            socket.flush()
17886        });
17887
17888        let runtime = build_io_runtime()?;
17889        runtime.block_on(async {
17890            let cx = test_cx()?;
17891            let stream = TcpStream::connect(addr).await?;
17892            let (read, write) = transport::plain_split(stream);
17893            let mut connection = loopback_connection(read, write);
17894
17895            connection.commit(&cx).await?;
17896            assert_eq!(
17897                connection.core.recovery.phase(),
17898                SessionRecoveryPhase::Ready,
17899                "a completed response must disarm the plain-function guard"
17900            );
17901
17902            let reused = connection
17903                .execute_raw(&cx, FRESH_SQL, 2, &[], ExecuteOptions::default(), None)
17904                .await?;
17905            assert_eq!(reused, sequential_op_decode(&execute_response));
17906            assert_eq!(
17907                connection.core.recovery.phase(),
17908                SessionRecoveryPhase::Ready
17909            );
17910            Ok::<_, Error>(())
17911        })?;
17912
17913        server.join().expect("successful commit server joins")?;
17914        Ok(())
17915    }
17916
17917    #[test]
17918    fn precancelled_commit_writes_nothing_and_keeps_wire_ready() -> Result<()> {
17919        let listener = TcpListener::bind("127.0.0.1:0")?;
17920        let addr = listener.local_addr()?;
17921        let server = thread::spawn(move || -> std::io::Result<bool> {
17922            let (mut socket, _) = listener.accept()?;
17923            socket.set_read_timeout(Some(Duration::from_millis(300)))?;
17924            let mut byte = [0u8; 1];
17925            match socket.read(&mut byte) {
17926                Ok(0) => Ok(false),
17927                Ok(_) => Ok(true),
17928                Err(err)
17929                    if matches!(
17930                        err.kind(),
17931                        std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
17932                    ) =>
17933                {
17934                    Ok(false)
17935                }
17936                Err(err) => Err(err),
17937            }
17938        });
17939
17940        let runtime = build_io_runtime()?;
17941        let (phase, sequence_before, sequence_after) = runtime.block_on(async {
17942            let cx = test_cx()?;
17943            let stream = TcpStream::connect(addr).await?;
17944            let (read, write) = transport::plain_split(stream);
17945            let mut connection = loopback_connection(read, write);
17946            let sequence_before = connection.ttc_seq_num;
17947            cx.cancel_fast(CancelKind::User);
17948
17949            let err = connection
17950                .commit(&cx)
17951                .await
17952                .expect_err("pending cancellation stops before COMMIT");
17953            assert!(matches!(err, Error::Cancelled), "{err:?}");
17954            Ok::<_, Error>((
17955                connection.core.recovery.phase(),
17956                sequence_before,
17957                connection.ttc_seq_num,
17958            ))
17959        })?;
17960
17961        assert!(
17962            !server.join().expect("pre-cancel commit server joins")?,
17963            "pre-cancelled COMMIT must not write any wire bytes"
17964        );
17965        assert_eq!(phase, SessionRecoveryPhase::Ready);
17966        assert_eq!(sequence_after, sequence_before);
17967        Ok(())
17968    }
17969
17970    #[test]
17971    fn dropped_change_password_mid_response_drains_before_connection_reuse() -> Result<()> {
17972        const STRANDED_BODY: &[u8] = &[TNS_MSG_TYPE_END_OF_RESPONSE];
17973        const TRAILING_CANCEL_ERROR: &[u8] = &[0x04, 0x01, 0x0d];
17974        const FRESH_SQL: &str = "select value from change_password_reuse_fixture";
17975        const OLD_PASSWORD: &str = "old-change-password-proof";
17976        const NEW_PASSWORD: &str = "new-change-password-proof";
17977
17978        let listener = TcpListener::bind("127.0.0.1:0")?;
17979        let addr = listener.local_addr()?;
17980        let (change_seen_tx, change_seen_rx) = std::sync::mpsc::channel();
17981        let server = thread::spawn(move || -> std::io::Result<bool> {
17982            use std::io::Write as _;
17983            let (mut socket, _) = listener.accept()?;
17984            socket.set_read_timeout(Some(Duration::from_secs(5)))?;
17985
17986            let (packet_type, body) = read_one_wire_packet_bytes(&mut socket);
17987            assert_eq!(
17988                packet_type, TNS_PACKET_TYPE_DATA,
17989                "password change must send a DATA request"
17990            );
17991            assert!(
17992                !body
17993                    .windows(OLD_PASSWORD.len())
17994                    .any(|window| window == OLD_PASSWORD.as_bytes()),
17995                "password-change request must not expose the old password"
17996            );
17997            assert!(
17998                !body
17999                    .windows(NEW_PASSWORD.len())
18000                    .any(|window| window == NEW_PASSWORD.as_bytes()),
18001                "password-change request must not expose the new password"
18002            );
18003            change_seen_tx
18004                .send(())
18005                .expect("client waits for password-change request proof");
18006
18007            let (next_packet_type, next_body) = read_one_wire_packet_bytes(&mut socket);
18008            if next_packet_type == TNS_PACKET_TYPE_MARKER {
18009                assert_eq!(
18010                    next_body,
18011                    vec![1, 0, TNS_MARKER_TYPE_BREAK],
18012                    "reuse must BREAK the stranded password change before its request"
18013                );
18014                socket.write_all(&data_packet(STRANDED_BODY, true))?;
18015                socket.write_all(&marker_packet(TNS_MARKER_TYPE_BREAK))?;
18016                assert_eq!(
18017                    read_marker_type(&mut socket),
18018                    TNS_MARKER_TYPE_RESET,
18019                    "password-change drain must complete the RESET handshake"
18020                );
18021                socket.write_all(&marker_packet(TNS_MARKER_TYPE_RESET))?;
18022                socket.write_all(&data_packet(TRAILING_CANCEL_ERROR, true))?;
18023
18024                assert_eq!(
18025                    read_one_wire_packet(&mut socket),
18026                    TNS_PACKET_TYPE_DATA,
18027                    "fresh execute follows the completed drain"
18028                );
18029                socket.write_all(&data_packet(
18030                    &synthetic_pipeline_execute_response_payload(),
18031                    true,
18032                ))?;
18033                socket.flush()?;
18034                Ok(true)
18035            } else {
18036                assert_eq!(
18037                    next_packet_type, TNS_PACKET_TYPE_DATA,
18038                    "without recovery the next operation is sent directly"
18039                );
18040                socket.write_all(&data_packet(STRANDED_BODY, true))?;
18041                socket.write_all(&data_packet(
18042                    &synthetic_pipeline_execute_response_payload(),
18043                    true,
18044                ))?;
18045                socket.flush()?;
18046                Ok(false)
18047            }
18048        });
18049
18050        let runtime = build_io_runtime()?;
18051        let outcome = runtime.block_on(async {
18052            let cx = test_cx()?;
18053            let stream = TcpStream::connect(addr).await?;
18054            let (read, write) = transport::plain_split(stream);
18055            let mut connection = loopback_connection(read, write);
18056            connection.combo_key = vec![0x11; 32];
18057
18058            {
18059                let mut change_password =
18060                    pin!(connection.change_password(&cx, OLD_PASSWORD, NEW_PASSWORD,));
18061                let first =
18062                    poll_fn(|task_cx| Poll::Ready(change_password.as_mut().poll(task_cx))).await;
18063                assert!(
18064                    matches!(first, Poll::Pending),
18065                    "password change must be waiting for its response before drop"
18066                );
18067                change_seen_rx
18068                    .recv_timeout(Duration::from_secs(2))
18069                    .expect("server observed password-change request");
18070            }
18071
18072            let phase_after_drop = connection.core.recovery.phase();
18073            let reused = connection
18074                .execute_raw(&cx, FRESH_SQL, 2, &[], ExecuteOptions::default(), None)
18075                .await;
18076            Ok::<_, Error>((phase_after_drop, reused, connection.core.recovery.phase()))
18077        });
18078
18079        let recovered = server.join().expect("password-change server joins")?;
18080        let (phase_after_drop, reused, final_phase) = outcome?;
18081        let reused = reused.expect("fresh execute must not decode the stranded password change");
18082        assert_eq!(
18083            reused,
18084            sequential_op_decode(&synthetic_pipeline_execute_response_payload()),
18085            "reuse must decode its own response byte-identically"
18086        );
18087        assert!(recovered, "reuse must take the BREAK/drain branch");
18088        assert_eq!(phase_after_drop, SessionRecoveryPhase::BreakSent);
18089        assert_eq!(final_phase, SessionRecoveryPhase::Ready);
18090        Ok(())
18091    }
18092
18093    #[test]
18094    fn successful_change_password_disarms_recovery_before_reuse() -> Result<()> {
18095        const FRESH_SQL: &str = "select value from change_password_success_fixture";
18096        let execute_response = synthetic_pipeline_execute_response_payload();
18097        let server_execute_response = execute_response.clone();
18098        let listener = TcpListener::bind("127.0.0.1:0")?;
18099        let addr = listener.local_addr()?;
18100        let server = thread::spawn(move || -> std::io::Result<()> {
18101            use std::io::Write as _;
18102            let (mut socket, _) = listener.accept()?;
18103            socket.set_read_timeout(Some(Duration::from_secs(2)))?;
18104
18105            assert_eq!(
18106                read_one_wire_packet(&mut socket),
18107                TNS_PACKET_TYPE_DATA,
18108                "password change must send a DATA request"
18109            );
18110            socket.write_all(&data_packet(&[TNS_MSG_TYPE_END_OF_RESPONSE], true))?;
18111            socket.flush()?;
18112
18113            assert_eq!(
18114                read_one_wire_packet(&mut socket),
18115                TNS_PACKET_TYPE_DATA,
18116                "a successful password change must not cause a spurious BREAK on reuse"
18117            );
18118            socket.write_all(&data_packet(&server_execute_response, true))?;
18119            socket.flush()
18120        });
18121
18122        let runtime = build_io_runtime()?;
18123        runtime.block_on(async {
18124            let cx = test_cx()?;
18125            let stream = TcpStream::connect(addr).await?;
18126            let (read, write) = transport::plain_split(stream);
18127            let mut connection = loopback_connection(read, write);
18128            connection.combo_key = vec![0x11; 32];
18129
18130            connection
18131                .change_password(
18132                    &cx,
18133                    "old-change-password-proof",
18134                    "new-change-password-proof",
18135                )
18136                .await?;
18137            assert_eq!(
18138                connection.core.recovery.phase(),
18139                SessionRecoveryPhase::Ready,
18140                "a completed password-change response must disarm its guard"
18141            );
18142
18143            let reused = connection
18144                .execute_raw(&cx, FRESH_SQL, 2, &[], ExecuteOptions::default(), None)
18145                .await?;
18146            assert_eq!(reused, sequential_op_decode(&execute_response));
18147            assert_eq!(
18148                connection.core.recovery.phase(),
18149                SessionRecoveryPhase::Ready
18150            );
18151            Ok::<_, Error>(())
18152        })?;
18153
18154        server
18155            .join()
18156            .expect("successful password-change server joins")?;
18157        Ok(())
18158    }
18159
18160    #[test]
18161    fn precancelled_change_password_writes_nothing_and_keeps_wire_ready() -> Result<()> {
18162        let listener = TcpListener::bind("127.0.0.1:0")?;
18163        let addr = listener.local_addr()?;
18164        let server = thread::spawn(move || -> std::io::Result<bool> {
18165            let (mut socket, _) = listener.accept()?;
18166            socket.set_read_timeout(Some(Duration::from_millis(300)))?;
18167            let mut byte = [0u8; 1];
18168            match socket.read(&mut byte) {
18169                Ok(0) => Ok(false),
18170                Ok(_) => Ok(true),
18171                Err(err)
18172                    if matches!(
18173                        err.kind(),
18174                        std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
18175                    ) =>
18176                {
18177                    Ok(false)
18178                }
18179                Err(err) => Err(err),
18180            }
18181        });
18182
18183        let runtime = build_io_runtime()?;
18184        let (phase, sequence_before, sequence_after) = runtime.block_on(async {
18185            let cx = test_cx()?;
18186            let stream = TcpStream::connect(addr).await?;
18187            let (read, write) = transport::plain_split(stream);
18188            let mut connection = loopback_connection(read, write);
18189            let sequence_before = connection.ttc_seq_num;
18190            cx.cancel_fast(CancelKind::User);
18191
18192            let err = connection
18193                .change_password(
18194                    &cx,
18195                    "old-change-password-proof",
18196                    "new-change-password-proof",
18197                )
18198                .await
18199                .expect_err("pending cancellation stops before PASSWORD CHANGE");
18200            assert!(matches!(err, Error::Cancelled), "{err:?}");
18201            Ok::<_, Error>((
18202                connection.core.recovery.phase(),
18203                sequence_before,
18204                connection.ttc_seq_num,
18205            ))
18206        })?;
18207
18208        assert!(
18209            !server
18210                .join()
18211                .expect("pre-cancel password-change server joins")?,
18212            "pre-cancelled PASSWORD CHANGE must not write any wire bytes"
18213        );
18214        assert_eq!(phase, SessionRecoveryPhase::Ready);
18215        assert_eq!(sequence_after, sequence_before);
18216        Ok(())
18217    }
18218
18219    #[test]
18220    fn dropped_subscribe_register_mid_response_drains_before_connection_reuse() -> Result<()> {
18221        const FRESH_SQL: &str = "select value from subscribe_register_reuse_fixture";
18222
18223        let expected_request = build_subscribe_payload_with_seq(
18224            1,
18225            TNS_SUBSCR_OP_REGISTER,
18226            Some("test_user"),
18227            None,
18228            oracledb_protocol::thin::TNS_SUBSCR_NAMESPACE_DBCHANGE,
18229            None,
18230            oracledb_protocol::thin::SUBSCR_QOS_ROWIDS,
18231            0,
18232            10,
18233            0,
18234            0,
18235            0,
18236            0,
18237            ClientCapabilities::default().ttc_field_version,
18238        )?;
18239        let stranded_response = synthetic_subscribe_register_response_payload();
18240        let listener = TcpListener::bind("127.0.0.1:0")?;
18241        let addr = listener.local_addr()?;
18242        let (request_seen_tx, request_seen_rx) = std::sync::mpsc::channel();
18243        let server = thread::spawn(move || {
18244            serve_dropped_response_recovery(
18245                listener,
18246                expected_request,
18247                stranded_response,
18248                request_seen_tx,
18249            )
18250        });
18251
18252        let runtime = build_io_runtime()?;
18253        let outcome = runtime.block_on(async {
18254            let cx = test_cx()?;
18255            let stream = TcpStream::connect(addr).await?;
18256            let (read, write) = transport::plain_split(stream);
18257            let mut connection = loopback_connection(read, write);
18258
18259            {
18260                let mut subscribe = pin!(connection.subscribe_register(
18261                    &cx,
18262                    oracledb_protocol::thin::TNS_SUBSCR_NAMESPACE_DBCHANGE,
18263                    None,
18264                    oracledb_protocol::thin::SUBSCR_QOS_ROWIDS,
18265                    0,
18266                    10,
18267                    0,
18268                    0,
18269                    0,
18270                ));
18271                let first = poll_fn(|task_cx| Poll::Ready(subscribe.as_mut().poll(task_cx))).await;
18272                assert!(
18273                    matches!(first, Poll::Pending),
18274                    "CQN register must be waiting for its response before drop"
18275                );
18276                request_seen_rx
18277                    .recv_timeout(Duration::from_secs(2))
18278                    .expect("server observed CQN register request");
18279            }
18280
18281            let phase_after_drop = connection.core.recovery.phase();
18282            let reused = connection
18283                .execute_raw(&cx, FRESH_SQL, 2, &[], ExecuteOptions::default(), None)
18284                .await;
18285            Ok::<_, Error>((phase_after_drop, reused, connection.core.recovery.phase()))
18286        });
18287
18288        let recovered = server.join().expect("CQN register server joins")?;
18289        let (phase_after_drop, reused, final_phase) = outcome?;
18290        let reused = reused.expect("fresh execute must not decode the stranded CQN register");
18291        assert_eq!(
18292            reused,
18293            sequential_op_decode(&synthetic_pipeline_execute_response_payload()),
18294            "reuse must decode its own response byte-identically"
18295        );
18296        assert!(recovered, "reuse must take the BREAK/drain branch");
18297        assert_eq!(phase_after_drop, SessionRecoveryPhase::BreakSent);
18298        assert_eq!(final_phase, SessionRecoveryPhase::Ready);
18299        Ok(())
18300    }
18301
18302    #[test]
18303    fn dropped_subscribe_unregister_mid_response_drains_before_connection_reuse() -> Result<()> {
18304        const FRESH_SQL: &str = "select value from subscribe_unregister_reuse_fixture";
18305        const CLIENT_ID: &[u8] = b"OCI:EP:301";
18306
18307        let expected_request = build_subscribe_payload_with_seq(
18308            1,
18309            TNS_SUBSCR_OP_UNREGISTER,
18310            Some("test_user"),
18311            Some(CLIENT_ID),
18312            oracledb_protocol::thin::TNS_SUBSCR_NAMESPACE_DBCHANGE,
18313            None,
18314            oracledb_protocol::thin::SUBSCR_QOS_ROWIDS,
18315            0,
18316            10,
18317            0,
18318            0,
18319            0,
18320            302,
18321            ClientCapabilities::default().ttc_field_version,
18322        )?;
18323        let listener = TcpListener::bind("127.0.0.1:0")?;
18324        let addr = listener.local_addr()?;
18325        let (request_seen_tx, request_seen_rx) = std::sync::mpsc::channel();
18326        let server = thread::spawn(move || {
18327            serve_dropped_response_recovery(
18328                listener,
18329                expected_request,
18330                vec![TNS_MSG_TYPE_END_OF_RESPONSE],
18331                request_seen_tx,
18332            )
18333        });
18334
18335        let runtime = build_io_runtime()?;
18336        let outcome = runtime.block_on(async {
18337            let cx = test_cx()?;
18338            let stream = TcpStream::connect(addr).await?;
18339            let (read, write) = transport::plain_split(stream);
18340            let mut connection = loopback_connection(read, write);
18341
18342            {
18343                let mut unsubscribe = pin!(connection.subscribe_unregister(
18344                    &cx,
18345                    302,
18346                    CLIENT_ID,
18347                    oracledb_protocol::thin::TNS_SUBSCR_NAMESPACE_DBCHANGE,
18348                    None,
18349                    oracledb_protocol::thin::SUBSCR_QOS_ROWIDS,
18350                    0,
18351                    10,
18352                    0,
18353                    0,
18354                    0,
18355                ));
18356                let first =
18357                    poll_fn(|task_cx| Poll::Ready(unsubscribe.as_mut().poll(task_cx))).await;
18358                assert!(
18359                    matches!(first, Poll::Pending),
18360                    "CQN unregister must be waiting for its response before drop"
18361                );
18362                request_seen_rx
18363                    .recv_timeout(Duration::from_secs(2))
18364                    .expect("server observed CQN unregister request");
18365            }
18366
18367            let phase_after_drop = connection.core.recovery.phase();
18368            let reused = connection
18369                .execute_raw(&cx, FRESH_SQL, 2, &[], ExecuteOptions::default(), None)
18370                .await;
18371            Ok::<_, Error>((phase_after_drop, reused, connection.core.recovery.phase()))
18372        });
18373
18374        let recovered = server.join().expect("CQN unregister server joins")?;
18375        let (phase_after_drop, reused, final_phase) = outcome?;
18376        let reused = reused.expect("fresh execute must not decode the stranded CQN unregister");
18377        assert_eq!(
18378            reused,
18379            sequential_op_decode(&synthetic_pipeline_execute_response_payload()),
18380            "reuse must decode its own response byte-identically"
18381        );
18382        assert!(recovered, "reuse must take the BREAK/drain branch");
18383        assert_eq!(phase_after_drop, SessionRecoveryPhase::BreakSent);
18384        assert_eq!(final_phase, SessionRecoveryPhase::Ready);
18385        Ok(())
18386    }
18387
18388    #[test]
18389    fn successful_subscribe_round_trips_disarm_recovery_before_reuse() -> Result<()> {
18390        const CLIENT_ID: &[u8] = b"OCI:EP:301";
18391        const FRESH_SQL: &str = "select value from subscribe_success_fixture";
18392
18393        let register_request = build_subscribe_payload_with_seq(
18394            1,
18395            TNS_SUBSCR_OP_REGISTER,
18396            Some("test_user"),
18397            None,
18398            oracledb_protocol::thin::TNS_SUBSCR_NAMESPACE_DBCHANGE,
18399            None,
18400            oracledb_protocol::thin::SUBSCR_QOS_ROWIDS,
18401            0,
18402            10,
18403            0,
18404            0,
18405            0,
18406            0,
18407            ClientCapabilities::default().ttc_field_version,
18408        )?;
18409        let unregister_request = build_subscribe_payload_with_seq(
18410            2,
18411            TNS_SUBSCR_OP_UNREGISTER,
18412            Some("test_user"),
18413            Some(CLIENT_ID),
18414            oracledb_protocol::thin::TNS_SUBSCR_NAMESPACE_DBCHANGE,
18415            None,
18416            oracledb_protocol::thin::SUBSCR_QOS_ROWIDS,
18417            0,
18418            10,
18419            0,
18420            0,
18421            0,
18422            302,
18423            ClientCapabilities::default().ttc_field_version,
18424        )?;
18425        let register_response = synthetic_subscribe_register_response_payload();
18426        let execute_response = synthetic_pipeline_execute_response_payload();
18427        let server_execute_response = execute_response.clone();
18428        let listener = TcpListener::bind("127.0.0.1:0")?;
18429        let addr = listener.local_addr()?;
18430        let server = thread::spawn(move || -> std::io::Result<()> {
18431            use std::io::Write as _;
18432            let (mut socket, _) = listener.accept()?;
18433            socket.set_read_timeout(Some(Duration::from_secs(2)))?;
18434
18435            assert_eq!(
18436                read_one_wire_data_payload(&mut socket),
18437                register_request,
18438                "successful CQN register request remains byte-identical"
18439            );
18440            socket.write_all(&data_packet(&register_response, true))?;
18441            socket.flush()?;
18442
18443            assert_eq!(
18444                read_one_wire_data_payload(&mut socket),
18445                unregister_request,
18446                "successful CQN unregister must follow directly without a spurious BREAK"
18447            );
18448            socket.write_all(&data_packet(&[TNS_MSG_TYPE_END_OF_RESPONSE], true))?;
18449            socket.flush()?;
18450
18451            assert_eq!(
18452                read_one_wire_packet(&mut socket),
18453                TNS_PACKET_TYPE_DATA,
18454                "fresh execute must follow both completed CQN responses directly"
18455            );
18456            socket.write_all(&data_packet(&server_execute_response, true))?;
18457            socket.flush()
18458        });
18459
18460        let runtime = build_io_runtime()?;
18461        runtime.block_on(async {
18462            let cx = test_cx()?;
18463            let stream = TcpStream::connect(addr).await?;
18464            let (read, write) = transport::plain_split(stream);
18465            let mut connection = loopback_connection(read, write);
18466            connection.supports_end_of_response = false;
18467
18468            let registered = connection
18469                .subscribe_register(
18470                    &cx,
18471                    oracledb_protocol::thin::TNS_SUBSCR_NAMESPACE_DBCHANGE,
18472                    None,
18473                    oracledb_protocol::thin::SUBSCR_QOS_ROWIDS,
18474                    0,
18475                    10,
18476                    0,
18477                    0,
18478                    0,
18479                )
18480                .await?;
18481            assert_eq!(registered.registration_id, 302);
18482            assert_eq!(registered.client_id.as_deref(), Some(CLIENT_ID));
18483            assert_eq!(
18484                connection.core.recovery.phase(),
18485                SessionRecoveryPhase::Ready,
18486                "completed classic CQN register response must disarm its guard"
18487            );
18488
18489            connection
18490                .subscribe_unregister(
18491                    &cx,
18492                    registered.registration_id,
18493                    CLIENT_ID,
18494                    oracledb_protocol::thin::TNS_SUBSCR_NAMESPACE_DBCHANGE,
18495                    None,
18496                    oracledb_protocol::thin::SUBSCR_QOS_ROWIDS,
18497                    0,
18498                    10,
18499                    0,
18500                    0,
18501                    0,
18502                )
18503                .await?;
18504            assert_eq!(
18505                connection.core.recovery.phase(),
18506                SessionRecoveryPhase::Ready,
18507                "completed classic CQN unregister response must disarm its guard"
18508            );
18509
18510            let reused = connection
18511                .execute_raw(&cx, FRESH_SQL, 2, &[], ExecuteOptions::default(), None)
18512                .await?;
18513            assert_eq!(reused, sequential_op_decode(&execute_response));
18514            assert_eq!(
18515                connection.core.recovery.phase(),
18516                SessionRecoveryPhase::Ready
18517            );
18518            Ok::<_, Error>(())
18519        })?;
18520
18521        server.join().expect("successful CQN server joins")?;
18522        Ok(())
18523    }
18524
18525    #[test]
18526    fn dropped_sessionless_begin_mid_response_drains_before_connection_reuse() -> Result<()> {
18527        const FRESH_SQL: &str = "select value from sessionless_begin_reuse_fixture";
18528        const TRANSACTION_ID: &[u8] = b"qa-sessionless-begin";
18529
18530        let expected_request = build_tpc_txn_switch_payload_with_seq(
18531            1,
18532            0,
18533            TNS_TPC_TXN_START,
18534            TPC_TXN_FLAGS_NEW | TPC_TXN_FLAGS_SESSIONLESS,
18535            30,
18536            Some(TRANSACTION_ID),
18537        );
18538        let listener = TcpListener::bind("127.0.0.1:0")?;
18539        let addr = listener.local_addr()?;
18540        let (request_seen_tx, request_seen_rx) = std::sync::mpsc::channel();
18541        let server = thread::spawn(move || {
18542            serve_dropped_response_recovery(
18543                listener,
18544                expected_request,
18545                vec![TNS_MSG_TYPE_END_OF_RESPONSE],
18546                request_seen_tx,
18547            )
18548        });
18549
18550        let runtime = build_io_runtime()?;
18551        let outcome = runtime.block_on(async {
18552            let cx = test_cx()?;
18553            let stream = TcpStream::connect(addr).await?;
18554            let (read, write) = transport::plain_split(stream);
18555            let mut connection = loopback_connection(read, write);
18556
18557            {
18558                let mut begin =
18559                    pin!(connection.begin_sessionless_transaction(&cx, TRANSACTION_ID, 30, false,));
18560                let first = poll_fn(|task_cx| Poll::Ready(begin.as_mut().poll(task_cx))).await;
18561                assert!(
18562                    matches!(first, Poll::Pending),
18563                    "sessionless begin must be waiting for its response before drop"
18564                );
18565                request_seen_rx
18566                    .recv_timeout(Duration::from_secs(2))
18567                    .expect("server observed sessionless begin request");
18568            }
18569
18570            let phase_after_drop = connection.core.recovery.phase();
18571            let reused = connection
18572                .execute_raw(&cx, FRESH_SQL, 2, &[], ExecuteOptions::default(), None)
18573                .await;
18574            Ok::<_, Error>((phase_after_drop, reused, connection.core.recovery.phase()))
18575        });
18576
18577        let recovered = server.join().expect("sessionless begin server joins")?;
18578        let (phase_after_drop, reused, final_phase) = outcome?;
18579        let reused =
18580            reused.expect("fresh execute must not decode the stranded sessionless response");
18581        assert_eq!(
18582            reused,
18583            // ubs:ignore — decodes an Oracle TTC test fixture, not a JWT.
18584            sequential_op_decode(&synthetic_pipeline_execute_response_payload()),
18585            "reuse must decode its own response byte-identically"
18586        );
18587        assert!(recovered, "reuse must take the BREAK/drain branch");
18588        assert_eq!(phase_after_drop, SessionRecoveryPhase::BreakSent);
18589        assert_eq!(final_phase, SessionRecoveryPhase::Ready);
18590        Ok(())
18591    }
18592
18593    #[test]
18594    fn dropped_sessionless_suspend_mid_response_drains_before_connection_reuse() -> Result<()> {
18595        const FRESH_SQL: &str = "select value from sessionless_suspend_reuse_fixture";
18596        const TRANSACTION_ID: &[u8] = b"qa-sessionless-suspend";
18597
18598        let expected_request = build_tpc_txn_switch_payload_with_seq(
18599            1,
18600            0,
18601            TNS_TPC_TXN_DETACH,
18602            TPC_TXN_FLAGS_SESSIONLESS,
18603            0,
18604            None,
18605        );
18606        let listener = TcpListener::bind("127.0.0.1:0")?;
18607        let addr = listener.local_addr()?;
18608        let (request_seen_tx, request_seen_rx) = std::sync::mpsc::channel();
18609        let server = thread::spawn(move || {
18610            serve_dropped_response_recovery(
18611                listener,
18612                expected_request,
18613                vec![TNS_MSG_TYPE_END_OF_RESPONSE],
18614                request_seen_tx,
18615            )
18616        });
18617
18618        let runtime = build_io_runtime()?;
18619        let outcome = runtime.block_on(async {
18620            let cx = test_cx()?;
18621            let stream = TcpStream::connect(addr).await?;
18622            let (read, write) = transport::plain_split(stream);
18623            let mut connection = loopback_connection(read, write);
18624            connection.sessionless_data = Some(SessionlessData {
18625                transaction_id: TRANSACTION_ID.to_vec(),
18626                timeout: 30,
18627                operation: TNS_TPC_TXN_START,
18628                flags: TPC_TXN_FLAGS_NEW,
18629                piggyback_pending: false,
18630                started_on_server: false,
18631            });
18632
18633            {
18634                let mut suspend = pin!(connection.suspend_sessionless_transaction(&cx));
18635                let first = poll_fn(|task_cx| Poll::Ready(suspend.as_mut().poll(task_cx))).await;
18636                assert!(
18637                    matches!(first, Poll::Pending),
18638                    "sessionless suspend must be waiting for its response before drop"
18639                );
18640                request_seen_rx
18641                    .recv_timeout(Duration::from_secs(2))
18642                    .expect("server observed sessionless suspend request");
18643            }
18644
18645            let phase_after_drop = connection.core.recovery.phase();
18646            let reused = connection
18647                .execute_raw(&cx, FRESH_SQL, 2, &[], ExecuteOptions::default(), None)
18648                .await;
18649            Ok::<_, Error>((phase_after_drop, reused, connection.core.recovery.phase()))
18650        });
18651
18652        let recovered = server.join().expect("sessionless suspend server joins")?;
18653        let (phase_after_drop, reused, final_phase) = outcome?;
18654        let reused =
18655            reused.expect("fresh execute must not decode the stranded sessionless response");
18656        assert_eq!(
18657            reused,
18658            // ubs:ignore — decodes an Oracle TTC test fixture, not a JWT.
18659            sequential_op_decode(&synthetic_pipeline_execute_response_payload()),
18660            "reuse must decode its own response byte-identically"
18661        );
18662        assert!(recovered, "reuse must take the BREAK/drain branch");
18663        assert_eq!(phase_after_drop, SessionRecoveryPhase::BreakSent);
18664        assert_eq!(final_phase, SessionRecoveryPhase::Ready);
18665        Ok(())
18666    }
18667
18668    #[test]
18669    fn dropped_tpc_begin_mid_response_drains_before_connection_reuse() -> Result<()> {
18670        const FRESH_SQL: &str = "select value from tpc_begin_reuse_fixture";
18671        const GTID: &[u8] = b"qa-tpc-begin";
18672        const BQUAL: &[u8] = b"branch";
18673
18674        let xid = TpcXid {
18675            format_id: 4400,
18676            global_transaction_id: GTID,
18677            branch_qualifier: BQUAL,
18678        };
18679        let expected_request = build_tpc_switch_payload_with_seq_and_version(
18680            1,
18681            TNS_TPC_TXN_START,
18682            TPC_TXN_FLAGS_NEW,
18683            0,
18684            Some(&xid),
18685            None,
18686            ClientCapabilities::default().ttc_field_version,
18687        );
18688        let listener = TcpListener::bind("127.0.0.1:0")?;
18689        let addr = listener.local_addr()?;
18690        let (request_seen_tx, request_seen_rx) = std::sync::mpsc::channel();
18691        let server = thread::spawn(move || {
18692            serve_dropped_response_recovery(
18693                listener,
18694                expected_request,
18695                vec![TNS_MSG_TYPE_END_OF_RESPONSE],
18696                request_seen_tx,
18697            )
18698        });
18699
18700        let runtime = build_io_runtime()?;
18701        let outcome = runtime.block_on(async {
18702            let cx = test_cx()?;
18703            let stream = TcpStream::connect(addr).await?;
18704            let (read, write) = transport::plain_split(stream);
18705            let mut connection = loopback_connection(read, write);
18706
18707            {
18708                let mut begin =
18709                    pin!(connection.tpc_begin(&cx, 4400, GTID, BQUAL, TPC_TXN_FLAGS_NEW, 0,));
18710                let first = poll_fn(|task_cx| Poll::Ready(begin.as_mut().poll(task_cx))).await;
18711                assert!(
18712                    matches!(first, Poll::Pending),
18713                    "TPC begin must be waiting for its response before drop"
18714                );
18715                request_seen_rx
18716                    .recv_timeout(Duration::from_secs(2))
18717                    .expect("server observed TPC begin request");
18718            }
18719
18720            let phase_after_drop = connection.core.recovery.phase();
18721            let reused = connection
18722                .execute_raw(&cx, FRESH_SQL, 2, &[], ExecuteOptions::default(), None)
18723                .await;
18724            Ok::<_, Error>((phase_after_drop, reused, connection.core.recovery.phase()))
18725        });
18726
18727        let recovered = server.join().expect("TPC begin server joins")?;
18728        let (phase_after_drop, reused, final_phase) = outcome?;
18729        let reused =
18730            reused.expect("fresh execute must not decode the stranded TPC switch response");
18731        assert_eq!(
18732            reused,
18733            // ubs:ignore — decodes an Oracle TTC test fixture, not a JWT.
18734            sequential_op_decode(&synthetic_pipeline_execute_response_payload()),
18735            "reuse must decode its own response byte-identically"
18736        );
18737        assert!(recovered, "reuse must take the BREAK/drain branch");
18738        assert_eq!(phase_after_drop, SessionRecoveryPhase::BreakSent);
18739        assert_eq!(final_phase, SessionRecoveryPhase::Ready);
18740        Ok(())
18741    }
18742
18743    #[test]
18744    fn dropped_tpc_commit_mid_response_drains_before_connection_reuse() -> Result<()> {
18745        const FRESH_SQL: &str = "select value from tpc_commit_reuse_fixture";
18746        const GTID: &[u8] = b"qa-tpc-commit";
18747        const BQUAL: &[u8] = b"branch";
18748
18749        let xid = TpcXid {
18750            format_id: 4400,
18751            global_transaction_id: GTID,
18752            branch_qualifier: BQUAL,
18753        };
18754        let expected_request = build_tpc_change_state_payload_with_seq_and_version(
18755            1,
18756            TNS_TPC_TXN_COMMIT,
18757            TNS_TPC_TXN_STATE_READ_ONLY,
18758            0,
18759            Some(&xid),
18760            None,
18761            ClientCapabilities::default().ttc_field_version,
18762        );
18763        let listener = TcpListener::bind("127.0.0.1:0")?;
18764        let addr = listener.local_addr()?;
18765        let (request_seen_tx, request_seen_rx) = std::sync::mpsc::channel();
18766        let server = thread::spawn(move || {
18767            serve_dropped_response_recovery(
18768                listener,
18769                expected_request,
18770                vec![TNS_MSG_TYPE_END_OF_RESPONSE],
18771                request_seen_tx,
18772            )
18773        });
18774
18775        let runtime = build_io_runtime()?;
18776        let outcome = runtime.block_on(async {
18777            let cx = test_cx()?;
18778            let stream = TcpStream::connect(addr).await?;
18779            let (read, write) = transport::plain_split(stream);
18780            let mut connection = loopback_connection(read, write);
18781
18782            {
18783                let mut commit = pin!(connection.tpc_commit(&cx, Some((4400, GTID, BQUAL)), true,));
18784                let first = poll_fn(|task_cx| Poll::Ready(commit.as_mut().poll(task_cx))).await;
18785                assert!(
18786                    matches!(first, Poll::Pending),
18787                    "TPC commit must be waiting for its response before drop"
18788                );
18789                request_seen_rx
18790                    .recv_timeout(Duration::from_secs(2))
18791                    .expect("server observed TPC commit request");
18792            }
18793
18794            let phase_after_drop = connection.core.recovery.phase();
18795            let reused = connection
18796                .execute_raw(&cx, FRESH_SQL, 2, &[], ExecuteOptions::default(), None)
18797                .await;
18798            Ok::<_, Error>((phase_after_drop, reused, connection.core.recovery.phase()))
18799        });
18800
18801        let recovered = server.join().expect("TPC commit server joins")?;
18802        let (phase_after_drop, reused, final_phase) = outcome?;
18803        let reused = reused.expect("fresh execute must not decode the stranded TPC response");
18804        assert_eq!(
18805            reused,
18806            // ubs:ignore — decodes an Oracle TTC test fixture, not a JWT.
18807            sequential_op_decode(&synthetic_pipeline_execute_response_payload()),
18808            "reuse must decode its own response byte-identically"
18809        );
18810        assert!(recovered, "reuse must take the BREAK/drain branch");
18811        assert_eq!(phase_after_drop, SessionRecoveryPhase::BreakSent);
18812        assert_eq!(final_phase, SessionRecoveryPhase::Ready);
18813        Ok(())
18814    }
18815
18816    #[test]
18817    fn successful_tpc_round_trips_disarm_recovery() -> Result<()> {
18818        const GTID: &[u8] = b"qa-tpc-success";
18819        const BQUAL: &[u8] = b"branch";
18820
18821        let xid = TpcXid {
18822            format_id: 4400,
18823            global_transaction_id: GTID,
18824            branch_qualifier: BQUAL,
18825        };
18826        let expected_begin = build_tpc_switch_payload_with_seq_and_version(
18827            1,
18828            TNS_TPC_TXN_START,
18829            TPC_TXN_FLAGS_NEW,
18830            0,
18831            Some(&xid),
18832            None,
18833            ClientCapabilities::default().ttc_field_version,
18834        );
18835        let expected_commit = build_tpc_change_state_payload_with_seq_and_version(
18836            2,
18837            TNS_TPC_TXN_COMMIT,
18838            TNS_TPC_TXN_STATE_READ_ONLY,
18839            0,
18840            Some(&xid),
18841            Some(&[]),
18842            ClientCapabilities::default().ttc_field_version,
18843        );
18844        let mut commit_response = oracledb_protocol::wire::TtcWriter::new();
18845        commit_response.write_u8(oracledb_protocol::thin::TNS_MSG_TYPE_PARAMETER);
18846        commit_response.write_ub4(TNS_TPC_TXN_STATE_READ_ONLY);
18847        commit_response.write_u8(TNS_MSG_TYPE_END_OF_RESPONSE);
18848        let commit_response = commit_response.into_bytes();
18849
18850        let listener = TcpListener::bind("127.0.0.1:0")?;
18851        let addr = listener.local_addr()?;
18852        let server = thread::spawn(move || -> std::io::Result<()> {
18853            use std::io::Write as _;
18854            let (mut socket, _) = listener.accept()?;
18855            socket.set_read_timeout(Some(Duration::from_secs(2)))?;
18856
18857            assert_eq!(read_one_wire_data_payload(&mut socket), expected_begin);
18858            socket.write_all(&data_packet(&[TNS_MSG_TYPE_END_OF_RESPONSE], true))?;
18859            socket.flush()?;
18860
18861            assert_eq!(read_one_wire_data_payload(&mut socket), expected_commit);
18862            socket.write_all(&data_packet(&commit_response, true))?;
18863            socket.flush()
18864        });
18865
18866        let runtime = build_io_runtime()?;
18867        runtime.block_on(async {
18868            let cx = test_cx()?;
18869            let stream = TcpStream::connect(addr).await?;
18870            let (read, write) = transport::plain_split(stream);
18871            let mut connection = loopback_connection(read, write);
18872
18873            connection
18874                .tpc_begin(&cx, 4400, GTID, BQUAL, TPC_TXN_FLAGS_NEW, 0)
18875                .await?;
18876            assert_eq!(
18877                connection.core.recovery.phase(),
18878                SessionRecoveryPhase::Ready
18879            );
18880            connection
18881                .tpc_commit(&cx, Some((4400, GTID, BQUAL)), true)
18882                .await?;
18883            assert_eq!(
18884                connection.core.recovery.phase(),
18885                SessionRecoveryPhase::Ready
18886            );
18887            Ok::<_, Error>(())
18888        })?;
18889
18890        server.join().expect("successful TPC server joins")?;
18891        Ok(())
18892    }
18893
18894    #[test]
18895    fn dropped_direct_path_prepare_mid_response_drains_before_connection_reuse() -> Result<()> {
18896        const FRESH_SQL: &str = "select value from dpl_prepare_reuse_fixture";
18897        const CURSOR_ID: u16 = 73;
18898
18899        let column_names = Vec::<String>::new();
18900        let expected_request =
18901            oracledb_protocol::dpl::build_direct_path_prepare_payload_with_version(
18902                "QA",
18903                "DPL_DROP",
18904                &column_names,
18905                1,
18906                ClientCapabilities::default().ttc_field_version,
18907            )?;
18908        let listener = TcpListener::bind("127.0.0.1:0")?;
18909        let addr = listener.local_addr()?;
18910        let (request_seen_tx, request_seen_rx) = std::sync::mpsc::channel();
18911        let server = thread::spawn(move || {
18912            serve_dropped_response_recovery(
18913                listener,
18914                expected_request,
18915                synthetic_direct_path_prepare_response_payload(CURSOR_ID),
18916                request_seen_tx,
18917            )
18918        });
18919
18920        let runtime = build_io_runtime()?;
18921        let outcome = runtime.block_on(async {
18922            let cx = test_cx()?;
18923            let stream = TcpStream::connect(addr).await?;
18924            let (read, write) = transport::plain_split(stream);
18925            let mut connection = loopback_connection(read, write);
18926
18927            {
18928                let mut prepare =
18929                    pin!(connection.direct_path_prepare(&cx, "QA", "DPL_DROP", &column_names,));
18930                let first = poll_fn(|task_cx| Poll::Ready(prepare.as_mut().poll(task_cx))).await;
18931                assert!(
18932                    matches!(first, Poll::Pending),
18933                    "direct path prepare must await its response before drop"
18934                );
18935                request_seen_rx
18936                    .recv_timeout(Duration::from_secs(2))
18937                    .expect("server observed direct path prepare request");
18938            }
18939
18940            let phase_after_drop = connection.core.recovery.phase();
18941            let reused = connection
18942                .execute_raw(&cx, FRESH_SQL, 2, &[], ExecuteOptions::default(), None)
18943                .await;
18944            Ok::<_, Error>((phase_after_drop, reused, connection.core.recovery.phase()))
18945        });
18946
18947        let recovered = server.join().expect("direct path prepare server joins")?;
18948        let (phase_after_drop, reused, final_phase) = outcome?;
18949        let reused = reused.expect("fresh execute must not decode the stranded DPL response");
18950        assert_eq!(
18951            reused,
18952            // ubs:ignore — decodes an Oracle TTC test fixture, not a JWT.
18953            sequential_op_decode(&synthetic_pipeline_execute_response_payload()),
18954            "reuse must decode its own response byte-identically"
18955        );
18956        assert!(recovered, "reuse must take the BREAK/drain branch");
18957        assert_eq!(phase_after_drop, SessionRecoveryPhase::BreakSent);
18958        assert_eq!(final_phase, SessionRecoveryPhase::Ready);
18959        Ok(())
18960    }
18961
18962    #[test]
18963    fn dropped_direct_path_load_stream_mid_response_drains_before_connection_reuse() -> Result<()> {
18964        const FRESH_SQL: &str = "select value from dpl_stream_reuse_fixture";
18965        const CURSOR_ID: u16 = 73;
18966
18967        let stream = oracledb_protocol::dpl::encode_direct_path_rows(&[], &[], 1)?;
18968        let expected_request =
18969            oracledb_protocol::dpl::build_direct_path_load_stream_payload_with_version(
18970                CURSOR_ID,
18971                &stream,
18972                1,
18973                ClientCapabilities::default().ttc_field_version,
18974            )?;
18975        let listener = TcpListener::bind("127.0.0.1:0")?;
18976        let addr = listener.local_addr()?;
18977        let (request_seen_tx, request_seen_rx) = std::sync::mpsc::channel();
18978        let server = thread::spawn(move || {
18979            serve_dropped_response_recovery(
18980                listener,
18981                expected_request,
18982                synthetic_direct_path_simple_response_payload(),
18983                request_seen_tx,
18984            )
18985        });
18986
18987        let runtime = build_io_runtime()?;
18988        let outcome = runtime.block_on(async {
18989            let cx = test_cx()?;
18990            let stream_socket = TcpStream::connect(addr).await?;
18991            let (read, write) = transport::plain_split(stream_socket);
18992            let mut connection = loopback_connection(read, write);
18993
18994            {
18995                let mut load = pin!(connection.direct_path_load_stream(&cx, CURSOR_ID, &stream));
18996                let first = poll_fn(|task_cx| Poll::Ready(load.as_mut().poll(task_cx))).await;
18997                assert!(
18998                    matches!(first, Poll::Pending),
18999                    "direct path load stream must await its response before drop"
19000                );
19001                request_seen_rx
19002                    .recv_timeout(Duration::from_secs(2))
19003                    .expect("server observed direct path load stream request");
19004            }
19005
19006            let phase_after_drop = connection.core.recovery.phase();
19007            let reused = connection
19008                .execute_raw(&cx, FRESH_SQL, 2, &[], ExecuteOptions::default(), None)
19009                .await;
19010            Ok::<_, Error>((phase_after_drop, reused, connection.core.recovery.phase()))
19011        });
19012
19013        let recovered = server.join().expect("direct path load server joins")?;
19014        let (phase_after_drop, reused, final_phase) = outcome?;
19015        let reused = reused.expect("fresh execute must not decode the stranded DPL response");
19016        assert_eq!(
19017            reused,
19018            // ubs:ignore — decodes an Oracle TTC test fixture, not a JWT.
19019            sequential_op_decode(&synthetic_pipeline_execute_response_payload()),
19020            "reuse must decode its own response byte-identically"
19021        );
19022        assert!(recovered, "reuse must take the BREAK/drain branch");
19023        assert_eq!(phase_after_drop, SessionRecoveryPhase::BreakSent);
19024        assert_eq!(final_phase, SessionRecoveryPhase::Ready);
19025        Ok(())
19026    }
19027
19028    #[test]
19029    fn dropped_direct_path_op_mid_response_drains_before_connection_reuse() -> Result<()> {
19030        const FRESH_SQL: &str = "select value from dpl_op_reuse_fixture";
19031        const CURSOR_ID: u16 = 73;
19032
19033        let expected_request = oracledb_protocol::dpl::build_direct_path_op_payload_with_version(
19034            CURSOR_ID,
19035            oracledb_protocol::dpl::TNS_DP_OP_FINISH,
19036            1,
19037            ClientCapabilities::default().ttc_field_version,
19038        );
19039        let listener = TcpListener::bind("127.0.0.1:0")?;
19040        let addr = listener.local_addr()?;
19041        let (request_seen_tx, request_seen_rx) = std::sync::mpsc::channel();
19042        let server = thread::spawn(move || {
19043            serve_dropped_response_recovery(
19044                listener,
19045                expected_request,
19046                synthetic_direct_path_simple_response_payload(),
19047                request_seen_tx,
19048            )
19049        });
19050
19051        let runtime = build_io_runtime()?;
19052        let outcome = runtime.block_on(async {
19053            let cx = test_cx()?;
19054            let stream = TcpStream::connect(addr).await?;
19055            let (read, write) = transport::plain_split(stream);
19056            let mut connection = loopback_connection(read, write);
19057
19058            {
19059                let mut op = pin!(connection.direct_path_op(
19060                    &cx,
19061                    CURSOR_ID,
19062                    oracledb_protocol::dpl::TNS_DP_OP_FINISH,
19063                ));
19064                let first = poll_fn(|task_cx| Poll::Ready(op.as_mut().poll(task_cx))).await;
19065                assert!(
19066                    matches!(first, Poll::Pending),
19067                    "direct path op must await its response before drop"
19068                );
19069                request_seen_rx
19070                    .recv_timeout(Duration::from_secs(2))
19071                    .expect("server observed direct path op request");
19072            }
19073
19074            let phase_after_drop = connection.core.recovery.phase();
19075            let reused = connection
19076                .execute_raw(&cx, FRESH_SQL, 2, &[], ExecuteOptions::default(), None)
19077                .await;
19078            Ok::<_, Error>((phase_after_drop, reused, connection.core.recovery.phase()))
19079        });
19080
19081        let recovered = server.join().expect("direct path op server joins")?;
19082        let (phase_after_drop, reused, final_phase) = outcome?;
19083        let reused = reused.expect("fresh execute must not decode the stranded DPL response");
19084        assert_eq!(
19085            reused,
19086            // ubs:ignore — decodes an Oracle TTC test fixture, not a JWT.
19087            sequential_op_decode(&synthetic_pipeline_execute_response_payload()),
19088            "reuse must decode its own response byte-identically"
19089        );
19090        assert!(recovered, "reuse must take the BREAK/drain branch");
19091        assert_eq!(phase_after_drop, SessionRecoveryPhase::BreakSent);
19092        assert_eq!(final_phase, SessionRecoveryPhase::Ready);
19093        Ok(())
19094    }
19095
19096    #[test]
19097    fn successful_direct_path_round_trips_disarm_recovery_before_reuse() -> Result<()> {
19098        const FRESH_SQL: &str = "select value from dpl_success_fixture";
19099        const CURSOR_ID: u16 = 73;
19100
19101        let column_names = Vec::<String>::new();
19102        let stream = oracledb_protocol::dpl::encode_direct_path_rows(&[], &[], 1)?;
19103        let field_version = ClientCapabilities::default().ttc_field_version;
19104        let expected_prepare =
19105            oracledb_protocol::dpl::build_direct_path_prepare_payload_with_version(
19106                "QA",
19107                "DPL_SUCCESS",
19108                &column_names,
19109                1,
19110                field_version,
19111            )?;
19112        let expected_load =
19113            oracledb_protocol::dpl::build_direct_path_load_stream_payload_with_version(
19114                CURSOR_ID,
19115                &stream,
19116                2,
19117                field_version,
19118            )?;
19119        let expected_op = oracledb_protocol::dpl::build_direct_path_op_payload_with_version(
19120            CURSOR_ID,
19121            oracledb_protocol::dpl::TNS_DP_OP_FINISH,
19122            3,
19123            field_version,
19124        );
19125        let execute_response = synthetic_pipeline_execute_response_payload();
19126        let server_execute_response = execute_response.clone();
19127        let listener = TcpListener::bind("127.0.0.1:0")?;
19128        let addr = listener.local_addr()?;
19129        let server = thread::spawn(move || -> std::io::Result<()> {
19130            use std::io::Write as _;
19131            let (mut socket, _) = listener.accept()?;
19132            socket.set_read_timeout(Some(Duration::from_secs(2)))?;
19133
19134            assert_eq!(read_one_wire_data_payload(&mut socket), expected_prepare);
19135            socket.write_all(&data_packet(
19136                &synthetic_direct_path_prepare_response_payload(CURSOR_ID),
19137                true,
19138            ))?;
19139            socket.flush()?;
19140
19141            assert_eq!(read_one_wire_data_payload(&mut socket), expected_load);
19142            socket.write_all(&data_packet(
19143                &synthetic_direct_path_simple_response_payload(),
19144                true,
19145            ))?;
19146            socket.flush()?;
19147
19148            assert_eq!(read_one_wire_data_payload(&mut socket), expected_op);
19149            socket.write_all(&data_packet(
19150                &synthetic_direct_path_simple_response_payload(),
19151                true,
19152            ))?;
19153            socket.flush()?;
19154
19155            assert_eq!(
19156                read_one_wire_packet(&mut socket),
19157                TNS_PACKET_TYPE_DATA,
19158                "reuse after DPL op must not send a spurious BREAK"
19159            );
19160            socket.write_all(&data_packet(&server_execute_response, true))?;
19161            socket.flush()
19162        });
19163
19164        let runtime = build_io_runtime()?;
19165        runtime.block_on(async {
19166            let cx = test_cx()?;
19167            let socket = TcpStream::connect(addr).await?;
19168            let (read, write) = transport::plain_split(socket);
19169            let mut connection = loopback_connection(read, write);
19170
19171            let prepared = connection
19172                .direct_path_prepare(&cx, "QA", "DPL_SUCCESS", &column_names)
19173                .await?;
19174            assert_eq!(prepared.cursor_id, CURSOR_ID);
19175            assert_eq!(
19176                connection.core.recovery.phase(),
19177                SessionRecoveryPhase::Ready
19178            );
19179
19180            connection
19181                .direct_path_load_stream(&cx, CURSOR_ID, &stream)
19182                .await?;
19183            assert_eq!(
19184                connection.core.recovery.phase(),
19185                SessionRecoveryPhase::Ready
19186            );
19187
19188            connection
19189                .direct_path_op(&cx, CURSOR_ID, oracledb_protocol::dpl::TNS_DP_OP_FINISH)
19190                .await?;
19191            assert_eq!(
19192                connection.core.recovery.phase(),
19193                SessionRecoveryPhase::Ready
19194            );
19195
19196            let reused = connection
19197                .execute_raw(&cx, FRESH_SQL, 2, &[], ExecuteOptions::default(), None)
19198                .await?;
19199            assert_eq!(reused, sequential_op_decode(&execute_response));
19200            assert_eq!(
19201                connection.core.recovery.phase(),
19202                SessionRecoveryPhase::Ready
19203            );
19204            Ok::<_, Error>(())
19205        })?;
19206
19207        server
19208            .join()
19209            .expect("successful direct path server joins")?;
19210        Ok(())
19211    }
19212
19213    #[test]
19214    fn precancelled_direct_path_prepare_writes_nothing_and_keeps_wire_ready() -> Result<()> {
19215        let listener = TcpListener::bind("127.0.0.1:0")?;
19216        let addr = listener.local_addr()?;
19217        let server = thread::spawn(move || -> std::io::Result<bool> {
19218            let (mut socket, _) = listener.accept()?;
19219            socket.set_read_timeout(Some(Duration::from_millis(300)))?;
19220            let mut byte = [0u8; 1];
19221            match socket.read(&mut byte) {
19222                Ok(0) => Ok(false),
19223                Ok(_) => Ok(true),
19224                Err(err)
19225                    if matches!(
19226                        err.kind(),
19227                        std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
19228                    ) =>
19229                {
19230                    Ok(false)
19231                }
19232                Err(err) => Err(err),
19233            }
19234        });
19235
19236        let runtime = build_io_runtime()?;
19237        let (phase, sequence_before, sequence_after) = runtime.block_on(async {
19238            let cx = test_cx()?;
19239            let stream = TcpStream::connect(addr).await?;
19240            let (read, write) = transport::plain_split(stream);
19241            let mut connection = loopback_connection(read, write);
19242            let sequence_before = connection.ttc_seq_num;
19243            cx.cancel_fast(CancelKind::User);
19244
19245            let err = connection
19246                .direct_path_prepare(&cx, "QA", "DPL_PRE_CANCEL", &[])
19247                .await
19248                .expect_err("pending cancellation stops before DPL PREPARE");
19249            assert!(matches!(err, Error::Cancelled), "{err:?}");
19250            Ok::<_, Error>((
19251                connection.core.recovery.phase(),
19252                sequence_before,
19253                connection.ttc_seq_num,
19254            ))
19255        })?;
19256
19257        assert!(
19258            !server
19259                .join()
19260                .expect("pre-cancel direct path server joins")?,
19261            "pre-cancelled DPL PREPARE must not write any wire bytes"
19262        );
19263        assert_eq!(phase, SessionRecoveryPhase::Ready);
19264        assert_eq!(sequence_after, sequence_before);
19265        Ok(())
19266    }
19267
19268    #[test]
19269    fn precancelled_subscribe_register_writes_nothing_and_keeps_wire_ready() -> Result<()> {
19270        let listener = TcpListener::bind("127.0.0.1:0")?;
19271        let addr = listener.local_addr()?;
19272        let server = thread::spawn(move || -> std::io::Result<bool> {
19273            let (mut socket, _) = listener.accept()?;
19274            socket.set_read_timeout(Some(Duration::from_millis(300)))?;
19275            let mut byte = [0u8; 1];
19276            match socket.read(&mut byte) {
19277                Ok(0) => Ok(false),
19278                Ok(_) => Ok(true),
19279                Err(err)
19280                    if matches!(
19281                        err.kind(),
19282                        std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
19283                    ) =>
19284                {
19285                    Ok(false)
19286                }
19287                Err(err) => Err(err),
19288            }
19289        });
19290
19291        let runtime = build_io_runtime()?;
19292        let (phase, sequence_before, sequence_after) = runtime.block_on(async {
19293            let cx = test_cx()?;
19294            let stream = TcpStream::connect(addr).await?;
19295            let (read, write) = transport::plain_split(stream);
19296            let mut connection = loopback_connection(read, write);
19297            let sequence_before = connection.ttc_seq_num;
19298            cx.cancel_fast(CancelKind::User);
19299
19300            let err = connection
19301                .subscribe_register(
19302                    &cx,
19303                    oracledb_protocol::thin::TNS_SUBSCR_NAMESPACE_DBCHANGE,
19304                    None,
19305                    oracledb_protocol::thin::SUBSCR_QOS_ROWIDS,
19306                    0,
19307                    10,
19308                    0,
19309                    0,
19310                    0,
19311                )
19312                .await
19313                .expect_err("pending cancellation stops before CQN REGISTER");
19314            assert!(matches!(err, Error::Cancelled), "{err:?}");
19315            Ok::<_, Error>((
19316                connection.core.recovery.phase(),
19317                sequence_before,
19318                connection.ttc_seq_num,
19319            ))
19320        })?;
19321
19322        assert!(
19323            !server
19324                .join()
19325                .expect("pre-cancel CQN register server joins")?,
19326            "pre-cancelled CQN REGISTER must not write any wire bytes"
19327        );
19328        assert_eq!(phase, SessionRecoveryPhase::Ready);
19329        assert_eq!(sequence_after, sequence_before);
19330        Ok(())
19331    }
19332
19333    #[test]
19334    fn dropped_lob_read_mid_response_drains_before_connection_reuse() -> Result<()> {
19335        const TRAILING_CANCEL_ERROR: &[u8] = &[0x04, 0x01, 0x0d];
19336        const FRESH_SQL: &str = "select value from lob_read_reuse_fixture";
19337
19338        let locator = vec![0x11; 4];
19339        let stranded_response = synthetic_lob_read_response_payload(&locator, b"x", 1);
19340        let server_stranded_response = stranded_response.clone();
19341        let listener = TcpListener::bind("127.0.0.1:0")?;
19342        let addr = listener.local_addr()?;
19343        let (lob_seen_tx, lob_seen_rx) = std::sync::mpsc::channel();
19344        let server = thread::spawn(move || -> std::io::Result<bool> {
19345            use std::io::Write as _;
19346            let (mut socket, _) = listener.accept()?;
19347            socket.set_read_timeout(Some(Duration::from_secs(5)))?;
19348
19349            assert_eq!(
19350                read_one_wire_packet(&mut socket),
19351                TNS_PACKET_TYPE_DATA,
19352                "LOB read must send a DATA request"
19353            );
19354            lob_seen_tx
19355                .send(())
19356                .expect("client waits for LOB read request proof");
19357
19358            let (next_packet_type, next_body) = read_one_wire_packet_bytes(&mut socket);
19359            if next_packet_type == TNS_PACKET_TYPE_MARKER {
19360                assert_eq!(
19361                    next_body,
19362                    vec![1, 0, TNS_MARKER_TYPE_BREAK],
19363                    "reuse must BREAK the stranded LOB read before its request"
19364                );
19365                socket.write_all(&data_packet(&server_stranded_response, true))?;
19366                socket.write_all(&marker_packet(TNS_MARKER_TYPE_BREAK))?;
19367                assert_eq!(
19368                    read_marker_type(&mut socket),
19369                    TNS_MARKER_TYPE_RESET,
19370                    "LOB read drain must complete the RESET handshake"
19371                );
19372                socket.write_all(&marker_packet(TNS_MARKER_TYPE_RESET))?;
19373                socket.write_all(&data_packet(TRAILING_CANCEL_ERROR, true))?;
19374
19375                assert_eq!(
19376                    read_one_wire_packet(&mut socket),
19377                    TNS_PACKET_TYPE_DATA,
19378                    "fresh execute follows the completed drain"
19379                );
19380                socket.write_all(&data_packet(
19381                    &synthetic_pipeline_execute_response_payload(),
19382                    true,
19383                ))?;
19384                socket.flush()?;
19385                Ok(true)
19386            } else {
19387                assert_eq!(
19388                    next_packet_type, TNS_PACKET_TYPE_DATA,
19389                    "without recovery the next operation is sent directly"
19390                );
19391                socket.write_all(&data_packet(&server_stranded_response, true))?;
19392                socket.write_all(&data_packet(
19393                    &synthetic_pipeline_execute_response_payload(),
19394                    true,
19395                ))?;
19396                socket.flush()?;
19397                Ok(false)
19398            }
19399        });
19400
19401        let runtime = build_io_runtime()?;
19402        let outcome = runtime.block_on(async {
19403            let cx = test_cx()?;
19404            let stream = TcpStream::connect(addr).await?;
19405            let (read, write) = transport::plain_split(stream);
19406            let mut connection = loopback_connection(read, write);
19407
19408            {
19409                let mut read_lob = pin!(connection.read_lob(&cx, &locator, 1, 1));
19410                let first = poll_fn(|task_cx| Poll::Ready(read_lob.as_mut().poll(task_cx))).await;
19411                assert!(
19412                    matches!(first, Poll::Pending),
19413                    "LOB read must be waiting for its response before drop"
19414                );
19415                lob_seen_rx
19416                    .recv_timeout(Duration::from_secs(2))
19417                    .expect("server observed LOB read request");
19418            }
19419
19420            let phase_after_drop = connection.core.recovery.phase();
19421            let reused = connection
19422                .execute_raw(&cx, FRESH_SQL, 2, &[], ExecuteOptions::default(), None)
19423                .await;
19424            Ok::<_, Error>((phase_after_drop, reused, connection.core.recovery.phase()))
19425        });
19426
19427        let recovered = server.join().expect("LOB read server joins")?;
19428        let (phase_after_drop, reused, final_phase) = outcome?;
19429        let reused = reused.expect("fresh execute must not decode the stranded LOB response");
19430        assert_eq!(
19431            reused,
19432            sequential_op_decode(&synthetic_pipeline_execute_response_payload()),
19433            "reuse must decode its own response byte-identically"
19434        );
19435        assert!(recovered, "reuse must take the BREAK/drain branch");
19436        assert_eq!(phase_after_drop, SessionRecoveryPhase::BreakSent);
19437        assert_eq!(final_phase, SessionRecoveryPhase::Ready);
19438        Ok(())
19439    }
19440
19441    #[test]
19442    fn dropped_aq_enqueue_mid_response_drains_before_connection_reuse() -> Result<()> {
19443        const TRAILING_CANCEL_ERROR: &[u8] = &[0x04, 0x01, 0x0d];
19444        const FRESH_SQL: &str = "select value from aq_enqueue_reuse_fixture";
19445
19446        let msgid = [0x2a; 16];
19447        let stranded_response = synthetic_aq_enqueue_response_payload(&msgid);
19448        let server_stranded_response = stranded_response.clone();
19449        let listener = TcpListener::bind("127.0.0.1:0")?;
19450        let addr = listener.local_addr()?;
19451        let (aq_seen_tx, aq_seen_rx) = std::sync::mpsc::channel();
19452        let server = thread::spawn(move || -> std::io::Result<bool> {
19453            use std::io::Write as _;
19454            let (mut socket, _) = listener.accept()?;
19455            socket.set_read_timeout(Some(Duration::from_secs(5)))?;
19456
19457            assert_eq!(
19458                read_one_wire_packet(&mut socket),
19459                TNS_PACKET_TYPE_DATA,
19460                "AQ enqueue must send a DATA request"
19461            );
19462            aq_seen_tx
19463                .send(())
19464                .expect("client waits for AQ enqueue request proof");
19465
19466            let (next_packet_type, next_body) = read_one_wire_packet_bytes(&mut socket);
19467            if next_packet_type == TNS_PACKET_TYPE_MARKER {
19468                assert_eq!(
19469                    next_body,
19470                    vec![1, 0, TNS_MARKER_TYPE_BREAK],
19471                    "reuse must BREAK the stranded AQ enqueue before its request"
19472                );
19473                socket.write_all(&data_packet(&server_stranded_response, true))?;
19474                socket.write_all(&marker_packet(TNS_MARKER_TYPE_BREAK))?;
19475                assert_eq!(
19476                    read_marker_type(&mut socket),
19477                    TNS_MARKER_TYPE_RESET,
19478                    "AQ enqueue drain must complete the RESET handshake"
19479                );
19480                socket.write_all(&marker_packet(TNS_MARKER_TYPE_RESET))?;
19481                socket.write_all(&data_packet(TRAILING_CANCEL_ERROR, true))?;
19482
19483                assert_eq!(
19484                    read_one_wire_packet(&mut socket),
19485                    TNS_PACKET_TYPE_DATA,
19486                    "fresh execute follows the completed drain"
19487                );
19488                socket.write_all(&data_packet(
19489                    &synthetic_pipeline_execute_response_payload(),
19490                    true,
19491                ))?;
19492                socket.flush()?;
19493                Ok(true)
19494            } else {
19495                assert_eq!(
19496                    next_packet_type, TNS_PACKET_TYPE_DATA,
19497                    "without recovery the next operation is sent directly"
19498                );
19499                socket.write_all(&data_packet(&server_stranded_response, true))?;
19500                socket.write_all(&data_packet(
19501                    &synthetic_pipeline_execute_response_payload(),
19502                    true,
19503                ))?;
19504                socket.flush()?;
19505                Ok(false)
19506            }
19507        });
19508
19509        let runtime = build_io_runtime()?;
19510        let outcome = runtime.block_on(async {
19511            let cx = test_cx()?;
19512            let stream = TcpStream::connect(addr).await?;
19513            let (read, write) = transport::plain_split(stream);
19514            let mut connection = loopback_connection(read, write);
19515            let (queue, props, options) = synthetic_aq_enqueue_request();
19516
19517            {
19518                let mut enqueue = pin!(connection.aq_enq_one(&cx, &queue, &props, &options));
19519                let first = poll_fn(|task_cx| Poll::Ready(enqueue.as_mut().poll(task_cx))).await;
19520                assert!(
19521                    matches!(first, Poll::Pending),
19522                    "AQ enqueue must be waiting for its response before drop"
19523                );
19524                aq_seen_rx
19525                    .recv_timeout(Duration::from_secs(2))
19526                    .expect("server observed AQ enqueue request");
19527            }
19528
19529            let phase_after_drop = connection.core.recovery.phase();
19530            let reused = connection
19531                .execute_raw(&cx, FRESH_SQL, 2, &[], ExecuteOptions::default(), None)
19532                .await;
19533            Ok::<_, Error>((phase_after_drop, reused, connection.core.recovery.phase()))
19534        });
19535
19536        let recovered = server.join().expect("AQ enqueue server joins")?;
19537        let (phase_after_drop, reused, final_phase) = outcome?;
19538        let reused = reused.expect("fresh execute must not decode the stranded AQ response");
19539        assert_eq!(
19540            reused,
19541            sequential_op_decode(&synthetic_pipeline_execute_response_payload()),
19542            "reuse must decode its own response byte-identically"
19543        );
19544        assert!(recovered, "reuse must take the BREAK/drain branch");
19545        assert_eq!(phase_after_drop, SessionRecoveryPhase::BreakSent);
19546        assert_eq!(final_phase, SessionRecoveryPhase::Ready);
19547        Ok(())
19548    }
19549
19550    #[test]
19551    fn successful_aq_enqueue_disarms_recovery_before_reuse() -> Result<()> {
19552        const FRESH_SQL: &str = "select value from aq_enqueue_success_fixture";
19553        let msgid = [0x2a; 16];
19554        let aq_response = synthetic_aq_enqueue_response_payload(&msgid);
19555        let execute_response = synthetic_pipeline_execute_response_payload();
19556        let server_execute_response = execute_response.clone();
19557        let listener = TcpListener::bind("127.0.0.1:0")?;
19558        let addr = listener.local_addr()?;
19559        let server = thread::spawn(move || -> std::io::Result<()> {
19560            use std::io::Write as _;
19561            let (mut socket, _) = listener.accept()?;
19562            socket.set_read_timeout(Some(Duration::from_secs(2)))?;
19563
19564            assert_eq!(
19565                read_one_wire_packet(&mut socket),
19566                TNS_PACKET_TYPE_DATA,
19567                "AQ enqueue must send a DATA request"
19568            );
19569            socket.write_all(&data_packet(&aq_response, true))?;
19570            socket.flush()?;
19571
19572            assert_eq!(
19573                read_one_wire_packet(&mut socket),
19574                TNS_PACKET_TYPE_DATA,
19575                "a successful AQ enqueue must not cause a spurious BREAK on reuse"
19576            );
19577            socket.write_all(&data_packet(&server_execute_response, true))?;
19578            socket.flush()
19579        });
19580
19581        let runtime = build_io_runtime()?;
19582        runtime.block_on(async {
19583            let cx = test_cx()?;
19584            let stream = TcpStream::connect(addr).await?;
19585            let (read, write) = transport::plain_split(stream);
19586            let mut connection = loopback_connection(read, write);
19587            let (queue, props, options) = synthetic_aq_enqueue_request();
19588
19589            let assigned = connection.aq_enq_one(&cx, &queue, &props, &options).await?;
19590            assert_eq!(assigned.as_deref(), Some(&msgid[..]));
19591            assert_eq!(
19592                connection.core.recovery.phase(),
19593                SessionRecoveryPhase::Ready,
19594                "a completed AQ response must disarm its guard"
19595            );
19596
19597            let reused = connection
19598                .execute_raw(&cx, FRESH_SQL, 2, &[], ExecuteOptions::default(), None)
19599                .await?;
19600            assert_eq!(reused, sequential_op_decode(&execute_response));
19601            assert_eq!(
19602                connection.core.recovery.phase(),
19603                SessionRecoveryPhase::Ready
19604            );
19605            Ok::<_, Error>(())
19606        })?;
19607
19608        server.join().expect("successful AQ enqueue server joins")?;
19609        Ok(())
19610    }
19611
19612    #[test]
19613    fn precancelled_aq_enqueue_writes_nothing_and_keeps_wire_ready() -> Result<()> {
19614        let listener = TcpListener::bind("127.0.0.1:0")?;
19615        let addr = listener.local_addr()?;
19616        let server = thread::spawn(move || -> std::io::Result<bool> {
19617            let (mut socket, _) = listener.accept()?;
19618            socket.set_read_timeout(Some(Duration::from_millis(300)))?;
19619            let mut byte = [0u8; 1];
19620            match socket.read(&mut byte) {
19621                Ok(0) => Ok(false),
19622                Ok(_) => Ok(true),
19623                Err(err)
19624                    if matches!(
19625                        err.kind(),
19626                        std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
19627                    ) =>
19628                {
19629                    Ok(false)
19630                }
19631                Err(err) => Err(err),
19632            }
19633        });
19634
19635        let runtime = build_io_runtime()?;
19636        let (phase, sequence_before, sequence_after) = runtime.block_on(async {
19637            let cx = test_cx()?;
19638            let stream = TcpStream::connect(addr).await?;
19639            let (read, write) = transport::plain_split(stream);
19640            let mut connection = loopback_connection(read, write);
19641            let sequence_before = connection.ttc_seq_num;
19642            let (queue, props, options) = synthetic_aq_enqueue_request();
19643            cx.cancel_fast(CancelKind::User);
19644
19645            let err = connection
19646                .aq_enq_one(&cx, &queue, &props, &options)
19647                .await
19648                .expect_err("pending cancellation stops before AQ ENQUEUE");
19649            assert!(matches!(err, Error::Cancelled), "{err:?}");
19650            Ok::<_, Error>((
19651                connection.core.recovery.phase(),
19652                sequence_before,
19653                connection.ttc_seq_num,
19654            ))
19655        })?;
19656
19657        assert!(
19658            !server.join().expect("pre-cancel AQ enqueue server joins")?,
19659            "pre-cancelled AQ ENQUEUE must not write any wire bytes"
19660        );
19661        assert_eq!(phase, SessionRecoveryPhase::Ready);
19662        assert_eq!(sequence_after, sequence_before);
19663        Ok(())
19664    }
19665
19666    #[test]
19667    fn successful_lob_read_disarms_recovery_before_reuse() -> Result<()> {
19668        const FRESH_SQL: &str = "select value from lob_read_success_fixture";
19669        let locator = vec![0x11; 4];
19670        let replacement_locator = vec![0x22; 4];
19671        let lob_response = synthetic_lob_read_response_payload(&replacement_locator, b"abc", 3);
19672        let execute_response = synthetic_pipeline_execute_response_payload();
19673        let server_execute_response = execute_response.clone();
19674        let listener = TcpListener::bind("127.0.0.1:0")?;
19675        let addr = listener.local_addr()?;
19676        let server = thread::spawn(move || -> std::io::Result<()> {
19677            use std::io::Write as _;
19678            let (mut socket, _) = listener.accept()?;
19679            socket.set_read_timeout(Some(Duration::from_secs(2)))?;
19680
19681            assert_eq!(
19682                read_one_wire_packet(&mut socket),
19683                TNS_PACKET_TYPE_DATA,
19684                "LOB read must send a DATA request"
19685            );
19686            socket.write_all(&data_packet(&lob_response, true))?;
19687            socket.flush()?;
19688
19689            assert_eq!(
19690                read_one_wire_packet(&mut socket),
19691                TNS_PACKET_TYPE_DATA,
19692                "a successful LOB read must not cause a spurious BREAK on reuse"
19693            );
19694            socket.write_all(&data_packet(&server_execute_response, true))?;
19695            socket.flush()
19696        });
19697
19698        let runtime = build_io_runtime()?;
19699        runtime.block_on(async {
19700            let cx = test_cx()?;
19701            let stream = TcpStream::connect(addr).await?;
19702            let (read, write) = transport::plain_split(stream);
19703            let mut connection = loopback_connection(read, write);
19704
19705            let result = connection.read_lob(&cx, &locator, 1, 3).await?;
19706            assert_eq!(result.data.as_deref(), Some(&b"abc"[..]));
19707            assert_eq!(result.locator, replacement_locator);
19708            assert_eq!(result.amount, 3);
19709            assert_eq!(
19710                connection.core.recovery.phase(),
19711                SessionRecoveryPhase::Ready,
19712                "a completed LOB response must disarm its guard"
19713            );
19714
19715            let reused = connection
19716                .execute_raw(&cx, FRESH_SQL, 2, &[], ExecuteOptions::default(), None)
19717                .await?;
19718            assert_eq!(reused, sequential_op_decode(&execute_response));
19719            assert_eq!(
19720                connection.core.recovery.phase(),
19721                SessionRecoveryPhase::Ready
19722            );
19723            Ok::<_, Error>(())
19724        })?;
19725
19726        server.join().expect("successful LOB read server joins")?;
19727        Ok(())
19728    }
19729
19730    #[test]
19731    fn precancelled_lob_read_writes_nothing_and_keeps_wire_ready() -> Result<()> {
19732        let listener = TcpListener::bind("127.0.0.1:0")?;
19733        let addr = listener.local_addr()?;
19734        let server = thread::spawn(move || -> std::io::Result<bool> {
19735            let (mut socket, _) = listener.accept()?;
19736            socket.set_read_timeout(Some(Duration::from_millis(300)))?;
19737            let mut byte = [0u8; 1];
19738            match socket.read(&mut byte) {
19739                Ok(0) => Ok(false),
19740                Ok(_) => Ok(true),
19741                Err(err)
19742                    if matches!(
19743                        err.kind(),
19744                        std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
19745                    ) =>
19746                {
19747                    Ok(false)
19748                }
19749                Err(err) => Err(err),
19750            }
19751        });
19752
19753        let runtime = build_io_runtime()?;
19754        let (phase, sequence_before, sequence_after) = runtime.block_on(async {
19755            let cx = test_cx()?;
19756            let stream = TcpStream::connect(addr).await?;
19757            let (read, write) = transport::plain_split(stream);
19758            let mut connection = loopback_connection(read, write);
19759            let sequence_before = connection.ttc_seq_num;
19760            cx.cancel_fast(CancelKind::User);
19761
19762            let err = connection
19763                .read_lob(&cx, &[0x11; 4], 1, 1)
19764                .await
19765                .expect_err("pending cancellation stops before LOB READ");
19766            assert!(matches!(err, Error::Cancelled), "{err:?}");
19767            Ok::<_, Error>((
19768                connection.core.recovery.phase(),
19769                sequence_before,
19770                connection.ttc_seq_num,
19771            ))
19772        })?;
19773
19774        assert!(
19775            !server.join().expect("pre-cancel LOB read server joins")?,
19776            "pre-cancelled LOB READ must not write any wire bytes"
19777        );
19778        assert_eq!(phase, SessionRecoveryPhase::Ready);
19779        assert_eq!(sequence_after, sequence_before);
19780        Ok(())
19781    }
19782
19783    #[test]
19784    fn precancelled_define_fetch_writes_nothing_and_keeps_wire_ready() -> Result<()> {
19785        const CURSOR_ID: u32 = 42;
19786        let listener = TcpListener::bind("127.0.0.1:0")?;
19787        let addr = listener.local_addr()?;
19788        let server = thread::spawn(move || -> std::io::Result<bool> {
19789            let (mut socket, _) = listener.accept()?;
19790            socket.set_read_timeout(Some(Duration::from_millis(300)))?;
19791            let mut byte = [0u8; 1];
19792            match socket.read(&mut byte) {
19793                Ok(0) => Ok(false),
19794                Ok(_) => Ok(true),
19795                Err(err)
19796                    if matches!(
19797                        err.kind(),
19798                        std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
19799                    ) =>
19800                {
19801                    Ok(false)
19802                }
19803                Err(err) => Err(err),
19804            }
19805        });
19806
19807        let runtime = build_io_runtime()?;
19808        let (phase, sequence_before, sequence_after) = runtime.block_on(async {
19809            let cx = test_cx()?;
19810            let stream = TcpStream::connect(addr).await?;
19811            let (read, write) = transport::plain_split(stream);
19812            let mut connection = loopback_connection(read, write);
19813            let sequence_before = connection.ttc_seq_num;
19814            cx.cancel_fast(CancelKind::User);
19815
19816            let err = connection
19817                .define_and_fetch_rows_with_columns(
19818                    &cx,
19819                    CURSOR_ID,
19820                    10,
19821                    &[ColumnMetadata::new(
19822                        "DOC",
19823                        oracledb_protocol::thin::ORA_TYPE_NUM_JSON,
19824                    )],
19825                    None,
19826                )
19827                .await
19828                .expect_err("pending cancellation stops before DEFINE-FETCH");
19829            assert!(matches!(err, Error::Cancelled), "{err:?}");
19830            Ok::<_, Error>((
19831                connection.core.recovery.phase(),
19832                sequence_before,
19833                connection.ttc_seq_num,
19834            ))
19835        })?;
19836
19837        assert!(
19838            !server.join().expect("pre-cancel server joins")?,
19839            "pre-cancelled DEFINE-FETCH must not write any wire bytes"
19840        );
19841        assert_eq!(phase, SessionRecoveryPhase::Ready);
19842        assert_eq!(sequence_after, sequence_before);
19843        Ok(())
19844    }
19845
19846    #[test]
19847    fn successful_define_fetch_disarms_recovery_before_reuse() -> Result<()> {
19848        const CURSOR_ID: u32 = 42;
19849        const FRESH_SQL: &str = "select value from define_fetch_success_fixture";
19850        let response = synthetic_pipeline_execute_response_payload();
19851        let server_response = response.clone();
19852        let listener = TcpListener::bind("127.0.0.1:0")?;
19853        let addr = listener.local_addr()?;
19854        let server = thread::spawn(move || -> std::io::Result<()> {
19855            use std::io::Write as _;
19856            let (mut socket, _) = listener.accept()?;
19857            socket.set_read_timeout(Some(Duration::from_secs(2)))?;
19858            assert_eq!(read_one_wire_packet(&mut socket), TNS_PACKET_TYPE_DATA);
19859            socket.write_all(&data_packet(&server_response, true))?;
19860            socket.flush()?;
19861
19862            assert_eq!(
19863                read_one_wire_packet(&mut socket),
19864                TNS_PACKET_TYPE_DATA,
19865                "a successful DEFINE-FETCH must not cause a spurious BREAK on reuse"
19866            );
19867            socket.write_all(&data_packet(&server_response, true))?;
19868            socket.flush()
19869        });
19870
19871        let runtime = build_io_runtime()?;
19872        runtime.block_on(async {
19873            let cx = test_cx()?;
19874            let stream = TcpStream::connect(addr).await?;
19875            let (read, write) = transport::plain_split(stream);
19876            let mut connection = loopback_connection(read, write);
19877            let columns = vec![ColumnMetadata::new(
19878                "VALUE",
19879                oracledb_protocol::thin::ORA_TYPE_NUM_NUMBER,
19880            )];
19881
19882            let defined = connection
19883                .define_and_fetch_rows_with_columns(&cx, CURSOR_ID, 10, &columns, None)
19884                .await?;
19885            assert_eq!(defined.rows.len(), 1);
19886            assert_eq!(
19887                connection.core.recovery.phase(),
19888                SessionRecoveryPhase::Ready
19889            );
19890
19891            let reused = connection
19892                .execute_raw(&cx, FRESH_SQL, 2, &[], ExecuteOptions::default(), None)
19893                .await?;
19894            assert_eq!(reused, sequential_op_decode(&response));
19895            assert_eq!(
19896                connection.core.recovery.phase(),
19897                SessionRecoveryPhase::Ready
19898            );
19899            Ok::<_, Error>(())
19900        })?;
19901
19902        server
19903            .join()
19904            .expect("successful define-fetch server joins")?;
19905        Ok(())
19906    }
19907
19908    #[test]
19909    fn oob_cancel_on_one_lane_leaves_other_lane_undisturbed() -> Result<()> {
19910        // a4-cn4 (rust-oracledb iec3.1.16) cross-lane isolation: an out-of-band
19911        // cancel — a bare BREAK fired from a `CancelHandle` (python-oracledb
19912        // `_break_external`) — on lane A must break + recover ONLY lane A. Lane B,
19913        // a fully independent `Connection` with its own socket, write mutex, and
19914        // recovery state, must complete its query untouched. The driver holds NO
19915        // cross-connection lock, so the isolation is structural; this pins it and
19916        // doubles as the no-cancel negative control (lane B never cancels).
19917        const A_INFLIGHT: &[u8] = b"lane-A in-flight response";
19918        const A_CANCEL_ERROR: &[u8] = &[0x04, 0x01, 0x0d]; // ORA-01013 user cancel
19919
19920        // --- lane A: scripts the out-of-band cancel choreography ---
19921        let listener_a = TcpListener::bind("127.0.0.1:0").expect("bind lane A");
19922        let addr_a = listener_a.local_addr().expect("lane A address");
19923        let server_a = thread::spawn(move || {
19924            use std::io::Write as _;
19925            let (mut socket, _) = listener_a.accept().expect("accept lane A");
19926            socket
19927                .set_read_timeout(Some(Duration::from_secs(5)))
19928                .expect("set read timeout");
19929            assert_eq!(
19930                read_marker_type(&mut socket),
19931                TNS_MARKER_TYPE_BREAK,
19932                "lane A must receive the out-of-band BREAK"
19933            );
19934            socket
19935                .write_all(&data_packet(A_INFLIGHT, true))
19936                .expect("write lane A in-flight response");
19937            socket
19938                .write_all(&marker_packet(TNS_MARKER_TYPE_BREAK))
19939                .expect("write lane A break-ack marker");
19940            assert_eq!(
19941                read_marker_type(&mut socket),
19942                TNS_MARKER_TYPE_RESET,
19943                "lane A owner drain answers the break marker with RESET"
19944            );
19945            socket
19946                .write_all(&marker_packet(TNS_MARKER_TYPE_RESET))
19947                .expect("write lane A reset-confirm marker");
19948            socket
19949                .write_all(&data_packet(A_CANCEL_ERROR, true))
19950                .expect("write lane A trailing cancel error");
19951        });
19952
19953        // --- lane B: answers an ordinary query, never cancelled ---
19954        let listener_b = TcpListener::bind("127.0.0.1:0").expect("bind lane B");
19955        let addr_b = listener_b.local_addr().expect("lane B address");
19956        let server_b = thread::spawn(move || {
19957            use std::io::Write as _;
19958            let (mut socket, _) = listener_b.accept().expect("accept lane B");
19959            socket
19960                .set_read_timeout(Some(Duration::from_secs(5)))
19961                .expect("set read timeout");
19962            let _query_request = read_one_wire_packet(&mut socket);
19963            socket
19964                .write_all(&data_packet(
19965                    &synthetic_pipeline_execute_response_payload(),
19966                    true,
19967                ))
19968                .expect("write lane B query response");
19969            socket.flush().expect("flush lane B");
19970        });
19971
19972        let runtime = build_io_runtime()?;
19973        runtime.block_on(async {
19974            let cx = test_cx()?;
19975            let (read_a, write_a) = transport::plain_split(TcpStream::connect(addr_a).await?);
19976            let mut conn_a = loopback_connection(read_a, write_a);
19977            let (read_b, write_b) = transport::plain_split(TcpStream::connect(addr_b).await?);
19978            let mut conn_b = loopback_connection(read_b, write_b);
19979
19980            // Out-of-band cancel lane A: fire the bare BREAK from the handle, then
19981            // the owner drains the multi-stage cancel response back to Ready.
19982            let mut handle = conn_a.cancel_handle()?;
19983            handle.cancel(&cx).await?;
19984            assert_eq!(
19985                conn_a.core.recovery.phase(),
19986                SessionRecoveryPhase::BreakSent,
19987                "the out-of-band handle only requests the break"
19988            );
19989            conn_a.drain_cancel_response().await?;
19990            assert_eq!(
19991                conn_a.core.recovery.phase(),
19992                SessionRecoveryPhase::Ready,
19993                "lane A recovers cleanly from the out-of-band cancel"
19994            );
19995            assert!(
19996                !conn_a.is_dead(),
19997                "an out-of-band cancel keeps lane A's session alive"
19998            );
19999
20000            // Lane B — untouched by lane A's cancel — completes its query
20001            // correctly (the no-cancel negative control).
20002            let result_b = conn_b
20003                .execute_raw(
20004                    &cx,
20005                    "select value from synthetic_fixture",
20006                    2,
20007                    &[],
20008                    ExecuteOptions::default(),
20009                    None,
20010                )
20011                .await?;
20012            assert_eq!(
20013                result_b,
20014                sequential_op_decode(&synthetic_pipeline_execute_response_payload()),
20015                "lane B's query is undisturbed by lane A's out-of-band cancel"
20016            );
20017            assert_eq!(
20018                conn_b.core.recovery.phase(),
20019                SessionRecoveryPhase::Ready,
20020                "lane B never entered recovery"
20021            );
20022            Ok::<_, Error>(())
20023        })?;
20024
20025        server_a.join().expect("lane A server joins");
20026        server_b.join().expect("lane B server joins");
20027        Ok(())
20028    }
20029
20030    #[test]
20031    fn missing_tls_close_notify_is_tolerated_only_at_terminal_session_boundary() -> Result<()> {
20032        fn scripted_read(actions: Vec<ReadAction>) -> ScriptedRead {
20033            ScriptedRead::from_state(Arc::new(std::sync::Mutex::new(ScriptedIoState::new(
20034                actions,
20035                Vec::new(),
20036                ScriptedClock::default(),
20037            ))))
20038        }
20039
20040        fn scripted_write() -> Arc<AsyncMutex<ScriptedWrite>> {
20041            Arc::new(AsyncMutex::with_name(
20042                "session_close_notify_test_write",
20043                ScriptedWrite::from_state(Arc::new(std::sync::Mutex::new(ScriptedIoState::new(
20044                    Vec::new(),
20045                    Vec::new(),
20046                    ScriptedClock::default(),
20047                )))),
20048            ))
20049        }
20050
20051        fn missing_notify() -> ReadAction {
20052            ReadAction::ErrorKind(
20053                std::io::ErrorKind::UnexpectedEof,
20054                ASUPERSYNC_TLS_MISSING_CLOSE_NOTIFY,
20055            )
20056        }
20057
20058        let runtime = build_io_runtime()?;
20059
20060        // The pre-ACCEPT and ordinary packet readers remain strict. Turning
20061        // this into EOF globally would mask malformed CONNECT exchanges.
20062        let mut strict_read = scripted_read(vec![missing_notify()]);
20063        let strict_error = runtime
20064            .block_on(read_packet_with_limits(
20065                &mut strict_read,
20066                PacketLengthWidth::Legacy16,
20067                ProtocolLimits::DEFAULT,
20068            ))
20069            .expect_err("pre-ACCEPT missing close_notify must remain an error");
20070        assert!(
20071            matches!(&strict_error, Error::Io(error)
20072                if error.kind() == std::io::ErrorKind::UnexpectedEof
20073                    && error.to_string() == ASUPERSYNC_TLS_MISSING_CLOSE_NOTIFY),
20074            "strict packet read must preserve the typed TLS EOF, got {strict_error:?}"
20075        );
20076
20077        // Oracle is allowed to answer terminal LOGOFF by closing the already
20078        // usable session without a TLS alert and without another TNS packet.
20079        let mut clean_close = scripted_read(vec![missing_notify()]);
20080        let clean_disposition = runtime.block_on(async {
20081            let cx = test_cx()?;
20082            let probe = |_bytes: &[u8]| false;
20083            read_session_close_response_with_limits(
20084                &mut clean_close,
20085                &cx,
20086                &scripted_write(),
20087                false,
20088                &probe,
20089                ProtocolLimits::DEFAULT,
20090            )
20091            .await
20092        })?;
20093        assert_eq!(
20094            clean_disposition,
20095            SessionCloseDisposition::PeerHardClosed,
20096            "a hard close at the empty terminal-response boundary is distinct from a response"
20097        );
20098
20099        // This is the exact tail used by Connection::close after LOGOFF has
20100        // been written. Both modern and classic framing must stop here: the
20101        // shared TLS stream is terminal, so any final TNS EOF write would
20102        // surface Asupersync's `BrokenPipe` instead of a successful close.
20103        for classic in [false, true] {
20104            let state = Arc::new(std::sync::Mutex::new(ScriptedIoState::new(
20105                vec![missing_notify()],
20106                Vec::new(),
20107                ScriptedClock::default(),
20108            )));
20109            let mut core = ConnectionCore::<ScriptedTransport>::from_halves(
20110                ScriptedRead::from_state(Arc::clone(&state)),
20111                ScriptedWrite::from_state(Arc::clone(&state)),
20112                "session_close_hard_close_write",
20113            );
20114            runtime.block_on(async {
20115                let cx = test_cx()?;
20116                core.finish_session_close(&cx, classic, |_bytes| false)
20117                    .await
20118            })?;
20119            assert!(
20120                state
20121                    .lock()
20122                    .expect("session-close scripted state")
20123                    .is_consumed(),
20124                "peer hard-close must consume the read without attempting a write (classic={classic})"
20125            );
20126        }
20127
20128        // A fully framed response still wins normally; the following hard TLS
20129        // close is never consulted once the application boundary is proven.
20130        let complete = data_packet(&[TNS_MSG_TYPE_END_OF_RESPONSE], true);
20131        let mut complete_close =
20132            scripted_read(vec![ReadAction::bytes(complete, Some(3)), missing_notify()]);
20133        let complete_disposition = runtime.block_on(async {
20134            let cx = test_cx()?;
20135            let probe = |_bytes: &[u8]| true;
20136            read_session_close_response_with_limits(
20137                &mut complete_close,
20138                &cx,
20139                &scripted_write(),
20140                false,
20141                &probe,
20142                ProtocolLimits::DEFAULT,
20143            )
20144            .await
20145        })?;
20146        assert_eq!(
20147            complete_disposition,
20148            SessionCloseDisposition::ResponseComplete,
20149            "a framed terminal response must not be classified as a peer hard-close"
20150        );
20151
20152        // The ordinary complete-response path retains the final TNS EOF write.
20153        let complete = data_packet(&[TNS_MSG_TYPE_END_OF_RESPONSE], true);
20154        let eof = encode_packet(
20155            TNS_PACKET_TYPE_DATA,
20156            0,
20157            Some(oracledb_protocol::thin::TNS_DATA_FLAGS_EOF),
20158            &[],
20159            PacketLengthWidth::Large32,
20160        )?;
20161        let state = Arc::new(std::sync::Mutex::new(ScriptedIoState::new(
20162            vec![ReadAction::bytes(complete, Some(3))],
20163            vec![WriteAction::expect_bytes(eof, Some(2))],
20164            ScriptedClock::default(),
20165        )));
20166        let mut core = ConnectionCore::<ScriptedTransport>::from_halves(
20167            ScriptedRead::from_state(Arc::clone(&state)),
20168            ScriptedWrite::from_state(Arc::clone(&state)),
20169            "session_close_complete_response_write",
20170        );
20171        runtime.block_on(async {
20172            let cx = test_cx()?;
20173            core.finish_session_close(&cx, false, |_bytes| true).await
20174        })?;
20175        assert!(
20176            state
20177                .lock()
20178                .expect("session-close scripted state")
20179                .is_consumed(),
20180            "a complete terminal response must retain the final TNS EOF write"
20181        );
20182
20183        // Once any TNS byte arrived, the same signal proves truncation and
20184        // remains a typed UnexpectedEof. Here the final payload byte is absent.
20185        let mut truncated = data_packet(b"truncated", true);
20186        truncated.pop();
20187        let mut truncated_close = scripted_read(vec![
20188            ReadAction::bytes(truncated, Some(3)),
20189            missing_notify(),
20190        ]);
20191        let truncated_error = runtime
20192            .block_on(async {
20193                let cx = test_cx()?;
20194                let probe = |_bytes: &[u8]| false;
20195                read_session_close_response_with_limits(
20196                    &mut truncated_close,
20197                    &cx,
20198                    &scripted_write(),
20199                    false,
20200                    &probe,
20201                    ProtocolLimits::DEFAULT,
20202                )
20203                .await
20204            })
20205            .expect_err("partial application response must not be accepted as clean close");
20206        assert!(
20207            matches!(&truncated_error, Error::Io(error)
20208                if error.kind() == std::io::ErrorKind::UnexpectedEof
20209                    && error.to_string() == ASUPERSYNC_TLS_MISSING_CLOSE_NOTIFY),
20210            "truncated response must preserve the typed TLS EOF, got {truncated_error:?}"
20211        );
20212
20213        Ok(())
20214    }
20215
20216    #[test]
20217    fn scripted_transport_injects_errors_and_eof() -> Result<()> {
20218        let payload = encode_packet(
20219            TNS_PACKET_TYPE_CONNECT,
20220            0,
20221            None,
20222            b"FAULT",
20223            PacketLengthWidth::Legacy16,
20224        )?;
20225
20226        let runtime = build_io_runtime()?;
20227
20228        let read_error = Arc::new(std::sync::Mutex::new(ScriptedIoState::new(
20229            vec![ReadAction::Error("scripted read fault")],
20230            Vec::new(),
20231            ScriptedClock::default(),
20232        )));
20233        let mut core = ConnectionCore::<ScriptedTransport>::from_halves(
20234            ScriptedRead::from_state(read_error),
20235            ScriptedWrite::from_state(Arc::new(std::sync::Mutex::new(ScriptedIoState::new(
20236                Vec::new(),
20237                Vec::new(),
20238                ScriptedClock::default(),
20239            )))),
20240            "scripted_read_error_write",
20241        );
20242        let read_err = runtime.block_on(async {
20243            match core.read_packet(PacketLengthWidth::Legacy16).await {
20244                Ok(_) => Err(Error::Runtime(
20245                    "scripted read fault unexpectedly succeeded".into(),
20246                )),
20247                Err(err) => Ok(err),
20248            }
20249        })?;
20250        assert!(
20251            read_err.to_string().contains("scripted read fault"),
20252            "scripted read error should keep its diagnostic"
20253        );
20254
20255        let read_eof = Arc::new(std::sync::Mutex::new(ScriptedIoState::new(
20256            vec![ReadAction::Eof],
20257            Vec::new(),
20258            ScriptedClock::default(),
20259        )));
20260        let mut core = ConnectionCore::<ScriptedTransport>::from_halves(
20261            ScriptedRead::from_state(read_eof),
20262            ScriptedWrite::from_state(Arc::new(std::sync::Mutex::new(ScriptedIoState::new(
20263                Vec::new(),
20264                Vec::new(),
20265                ScriptedClock::default(),
20266            )))),
20267            "scripted_read_eof_write",
20268        );
20269        let eof_err = runtime.block_on(async {
20270            match core.read_packet(PacketLengthWidth::Legacy16).await {
20271                Ok(_) => Err(Error::Runtime(
20272                    "scripted read EOF unexpectedly succeeded".into(),
20273                )),
20274                Err(err) => Ok(err),
20275            }
20276        })?;
20277        let eof_message = eof_err.to_string().to_ascii_lowercase();
20278        assert!(
20279            matches!(&eof_err, Error::Io(_))
20280                && (eof_message.contains("failed to fill whole buffer")
20281                    || eof_message.contains("early eof")
20282                    || eof_message.contains("unexpected eof")
20283                    || eof_message.contains("end of file")),
20284            "scripted EOF should surface as an incomplete read, got {eof_err:?}"
20285        );
20286
20287        let write_error = Arc::new(std::sync::Mutex::new(ScriptedIoState::new(
20288            Vec::new(),
20289            vec![WriteAction::Error("scripted write fault")],
20290            ScriptedClock::default(),
20291        )));
20292        let core = ConnectionCore::<ScriptedTransport>::from_halves(
20293            ScriptedRead::from_state(Arc::new(std::sync::Mutex::new(ScriptedIoState::new(
20294                Vec::new(),
20295                Vec::new(),
20296                ScriptedClock::default(),
20297            )))),
20298            ScriptedWrite::from_state(write_error),
20299            "scripted_write_error_write",
20300        );
20301        let write_err = runtime.block_on(async {
20302            let cx = test_cx()?;
20303            match core.write_all(&cx, &payload).await {
20304                Ok(()) => Err(Error::Runtime(
20305                    "scripted write error unexpectedly succeeded".into(),
20306                )),
20307                Err(err) => Ok(err),
20308            }
20309        })?;
20310        assert!(
20311            write_err.to_string().contains("scripted write fault"),
20312            "scripted write error should keep its diagnostic"
20313        );
20314
20315        let write_eof = Arc::new(std::sync::Mutex::new(ScriptedIoState::new(
20316            Vec::new(),
20317            vec![WriteAction::Eof],
20318            ScriptedClock::default(),
20319        )));
20320        let core = ConnectionCore::<ScriptedTransport>::from_halves(
20321            ScriptedRead::from_state(Arc::new(std::sync::Mutex::new(ScriptedIoState::new(
20322                Vec::new(),
20323                Vec::new(),
20324                ScriptedClock::default(),
20325            )))),
20326            ScriptedWrite::from_state(write_eof),
20327            "scripted_write_eof_write",
20328        );
20329        let write_eof_err = runtime.block_on(async {
20330            let cx = test_cx()?;
20331            match core.write_all(&cx, &payload).await {
20332                Ok(()) => Err(Error::Runtime(
20333                    "scripted write EOF unexpectedly succeeded".into(),
20334                )),
20335                Err(err) => Ok(err),
20336            }
20337        })?;
20338        assert!(
20339            write_eof_err
20340                .to_string()
20341                .contains("failed to write whole buffer")
20342                || write_eof_err.to_string().contains("write zero"),
20343            "scripted write EOF should surface as an incomplete write"
20344        );
20345
20346        Ok(())
20347    }
20348
20349    #[test]
20350    fn scripted_transport_rejects_mismatched_and_extra_writes() -> Result<()> {
20351        let expected = encode_packet(
20352            TNS_PACKET_TYPE_CONNECT,
20353            0,
20354            None,
20355            b"EXPECTED",
20356            PacketLengthWidth::Legacy16,
20357        )?;
20358        let actual = encode_packet(
20359            TNS_PACKET_TYPE_CONNECT,
20360            0,
20361            None,
20362            b"ACTUAL",
20363            PacketLengthWidth::Legacy16,
20364        )?;
20365
20366        let runtime = build_io_runtime()?;
20367        let mismatch = Arc::new(std::sync::Mutex::new(ScriptedIoState::new(
20368            Vec::new(),
20369            vec![WriteAction::expect_bytes(expected, None)],
20370            ScriptedClock::default(),
20371        )));
20372        let core = ConnectionCore::<ScriptedTransport>::from_halves(
20373            ScriptedRead::from_state(Arc::new(std::sync::Mutex::new(ScriptedIoState::new(
20374                Vec::new(),
20375                Vec::new(),
20376                ScriptedClock::default(),
20377            )))),
20378            ScriptedWrite::from_state(mismatch),
20379            "scripted_mismatch_write",
20380        );
20381        let mismatch_err = runtime.block_on(async {
20382            let cx = test_cx()?;
20383            match core.write_all(&cx, &actual).await {
20384                Ok(()) => Err(Error::Runtime(
20385                    "scripted mismatched write unexpectedly succeeded".into(),
20386                )),
20387                Err(err) => Ok(err),
20388            }
20389        })?;
20390        assert!(
20391            mismatch_err.to_string().contains("write mismatch"),
20392            "scripted write mismatch should be explicit"
20393        );
20394
20395        let extra = Arc::new(std::sync::Mutex::new(ScriptedIoState::new(
20396            Vec::new(),
20397            Vec::new(),
20398            ScriptedClock::default(),
20399        )));
20400        let core = ConnectionCore::<ScriptedTransport>::from_halves(
20401            ScriptedRead::from_state(Arc::new(std::sync::Mutex::new(ScriptedIoState::new(
20402                Vec::new(),
20403                Vec::new(),
20404                ScriptedClock::default(),
20405            )))),
20406            ScriptedWrite::from_state(extra),
20407            "scripted_extra_write",
20408        );
20409        let extra_err = runtime.block_on(async {
20410            let cx = test_cx()?;
20411            match core.write_all(&cx, &actual).await {
20412                Ok(()) => Err(Error::Runtime(
20413                    "scripted extra write unexpectedly succeeded".into(),
20414                )),
20415                Err(err) => Ok(err),
20416            }
20417        })?;
20418        assert!(
20419            extra_err.to_string().contains("unexpected write"),
20420            "scripted extra write should be rejected"
20421        );
20422
20423        Ok(())
20424    }
20425
20426    #[cfg(feature = "cassette")]
20427    #[test]
20428    fn connection_core_routes_connect_execute_fetch_over_replay_transport() -> Result<()> {
20429        use oracledb_protocol::net::cassette::{self, Direction};
20430
20431        const EXECUTE_BODY: &[u8] = b"replay execute payload";
20432        const FETCH_BODY: &[u8] = b"replay fetch payload";
20433        const EXECUTE_RESPONSE: &[u8] = b"replay execute response";
20434        const FETCH_RESPONSE: &[u8] = b"replay fetch response";
20435
20436        let connect_packet = encode_packet(
20437            TNS_PACKET_TYPE_CONNECT,
20438            0,
20439            None,
20440            b"REPLAY-CONNECT",
20441            PacketLengthWidth::Legacy16,
20442        )?;
20443        let accept_packet = encode_packet(
20444            TNS_PACKET_TYPE_ACCEPT,
20445            0,
20446            None,
20447            b"REPLAY-ACCEPT",
20448            PacketLengthWidth::Legacy16,
20449        )?;
20450        let execute_packet = encode_packet(
20451            TNS_PACKET_TYPE_DATA,
20452            0,
20453            Some(0),
20454            EXECUTE_BODY,
20455            PacketLengthWidth::Large32,
20456        )?;
20457        let fetch_packet = encode_packet(
20458            TNS_PACKET_TYPE_DATA,
20459            0,
20460            Some(0),
20461            FETCH_BODY,
20462            PacketLengthWidth::Large32,
20463        )?;
20464
20465        let mut cassette_bytes = Vec::new();
20466        cassette::write_header(&mut cassette_bytes);
20467        cassette::write_frame(
20468            &mut cassette_bytes,
20469            Direction::ClientToServer,
20470            0,
20471            &connect_packet,
20472        );
20473        cassette::write_frame(
20474            &mut cassette_bytes,
20475            Direction::ServerToClient,
20476            1,
20477            &accept_packet,
20478        );
20479        cassette::write_frame(
20480            &mut cassette_bytes,
20481            Direction::ClientToServer,
20482            2,
20483            &execute_packet,
20484        );
20485        cassette::write_frame(
20486            &mut cassette_bytes,
20487            Direction::ServerToClient,
20488            3,
20489            &data_packet(EXECUTE_RESPONSE, true),
20490        );
20491        cassette::write_frame(
20492            &mut cassette_bytes,
20493            Direction::ClientToServer,
20494            4,
20495            &fetch_packet,
20496        );
20497        cassette::write_frame(
20498            &mut cassette_bytes,
20499            Direction::ServerToClient,
20500            5,
20501            &data_packet(FETCH_RESPONSE, true),
20502        );
20503
20504        let (read, write) =
20505            transport::replay_split(&cassette_bytes, transport::ReplayWriteMode::Check)
20506                .map_err(|err| Error::Runtime(format!("invalid replay cassette: {err}")))?;
20507        let mut core =
20508            ConnectionCore::<DriverTransport>::from_halves(read, write, "replay_core_write");
20509
20510        let runtime = build_io_runtime()?;
20511        runtime.block_on(async {
20512            let cx = test_cx()?;
20513            core.write_all(&cx, &connect_packet).await?;
20514            let accept = core.read_packet(PacketLengthWidth::Legacy16).await?;
20515            assert_eq!(accept.packet_type, TNS_PACKET_TYPE_ACCEPT);
20516            assert_eq!(accept.payload, b"REPLAY-ACCEPT");
20517
20518            core.send_data_packet(&cx, EXECUTE_BODY, 8192).await?;
20519            let execute_response = core.read_data_response(&cx).await?;
20520            assert_eq!(execute_response, EXECUTE_RESPONSE);
20521
20522            core.send_data_packet(&cx, FETCH_BODY, 8192).await?;
20523            let fetch_response = core.read_data_response(&cx).await?;
20524            assert_eq!(fetch_response, FETCH_RESPONSE);
20525            Ok::<_, Error>(())
20526        })?;
20527        Ok(())
20528    }
20529
20530    /// K6 DoD: a captured query-FAILURE session is scrubbed of auth secrets,
20531    /// the artifact passes the secret-scan (C4), and offline replay of the
20532    /// artifact reproduces the failure with no database.
20533    #[cfg(feature = "cassette")]
20534    #[test]
20535    fn support_capture_scrubbed_cassette_replays_query_failure() -> Result<()> {
20536        use oracledb_protocol::net::cassette::{self, Direction};
20537
20538        const QUERY_BODY: &[u8] = b"SELECT * FROM missing_table";
20539        // The failure that offline replay must reproduce (secret-free ORA text).
20540        const ERROR_RESPONSE: &[u8] = b"ORA-00942: table or view does not exist";
20541
20542        let connect_packet = encode_packet(
20543            TNS_PACKET_TYPE_CONNECT,
20544            0,
20545            None,
20546            b"K6-CONNECT",
20547            PacketLengthWidth::Legacy16,
20548        )?;
20549        let accept_packet = encode_packet(
20550            TNS_PACKET_TYPE_ACCEPT,
20551            0,
20552            None,
20553            b"K6-ACCEPT",
20554            PacketLengthWidth::Legacy16,
20555        )?;
20556        // Auth-phase frames carrying secret material (password verifier +
20557        // session key) — exactly what a naive capture would leak to disk.
20558        let mut auth_body = b"AUTH_PASSWORD=hunter2 AUTH_SESSKEY=".to_vec();
20559        auth_body.extend_from_slice(&[0x5A_u8; 48]);
20560        let auth_request = encode_packet(
20561            TNS_PACKET_TYPE_DATA,
20562            0,
20563            Some(0),
20564            &auth_body,
20565            PacketLengthWidth::Large32,
20566        )?;
20567        let auth_response_body = b"AUTH_SVR SESSION_KEY=deadbeefcafef00ddeadbeefcafef00d".to_vec();
20568        let query_packet = encode_packet(
20569            TNS_PACKET_TYPE_DATA,
20570            0,
20571            Some(0),
20572            QUERY_BODY,
20573            PacketLengthWidth::Large32,
20574        )?;
20575
20576        // The raw captured session, as the recording transport would produce it.
20577        let mut raw = Vec::new();
20578        cassette::write_header(&mut raw);
20579        cassette::write_frame(&mut raw, Direction::ClientToServer, 0, &connect_packet);
20580        cassette::write_frame(&mut raw, Direction::ServerToClient, 1, &accept_packet);
20581        cassette::write_frame(&mut raw, Direction::ClientToServer, 2, &auth_request);
20582        cassette::write_frame(
20583            &mut raw,
20584            Direction::ServerToClient,
20585            3,
20586            &data_packet(&auth_response_body, true),
20587        );
20588        cassette::write_frame(&mut raw, Direction::ClientToServer, 4, &query_packet);
20589        cassette::write_frame(
20590            &mut raw,
20591            Direction::ServerToClient,
20592            5,
20593            &data_packet(ERROR_RESPONSE, true),
20594        );
20595
20596        // The RAW capture carries secrets — this is why the scrub gate exists.
20597        assert!(
20598            !transport::scan_for_secret_fields(&raw).is_empty(),
20599            "raw capture is expected to contain auth secrets"
20600        );
20601
20602        // Scrub the auth phase and run the fail-closed refuse gate.
20603        let (scrubbed, report) = transport::scrub_and_gate(&raw)
20604            .map_err(|e| Error::Runtime(format!("scrub gate refused unexpectedly: {e}")))?;
20605        assert!(report.redacted_frames >= 1, "auth frames must be redacted");
20606        // C4 secret-scan passes on the persisted artifact.
20607        assert!(
20608            transport::scan_for_secret_fields(&scrubbed).is_empty(),
20609            "scrubbed artifact must pass the secret scan"
20610        );
20611
20612        // Persist as a shareable file, reload it, and confirm it still scans clean.
20613        let path = std::env::temp_dir().join(format!(
20614            "oracledb-k6-replay-{}.tns-cassette",
20615            std::process::id()
20616        ));
20617        std::fs::write(&path, &scrubbed).map_err(|e| Error::Runtime(e.to_string()))?;
20618        let from_disk = std::fs::read(&path).map_err(|e| Error::Runtime(e.to_string()))?;
20619        assert!(transport::scan_for_secret_fields(&from_disk).is_empty());
20620
20621        // Offline replay reproduces the query FAILURE with no database.
20622        let (read, write) = transport::replay_split(&from_disk, transport::ReplayWriteMode::Ignore)
20623            .map_err(|err| Error::Runtime(format!("invalid replay cassette: {err}")))?;
20624        let mut core =
20625            ConnectionCore::<DriverTransport>::from_halves(read, write, "k6_replay_write");
20626        let runtime = build_io_runtime()?;
20627        let replay = runtime.block_on(async {
20628            let cx = test_cx()?;
20629            core.write_all(&cx, &connect_packet).await?;
20630            let accept = core.read_packet(PacketLengthWidth::Legacy16).await?;
20631            assert_eq!(accept.packet_type, TNS_PACKET_TYPE_ACCEPT);
20632
20633            // Auth round-trip: the recorded server frame is redacted but its TNS
20634            // framing is intact, so the decoder walks past it structurally.
20635            core.send_data_packet(&cx, &auth_body, 8192).await?;
20636            let _auth_response = core.read_data_response(&cx).await?;
20637
20638            core.send_data_packet(&cx, QUERY_BODY, 8192).await?;
20639            core.read_data_response(&cx).await
20640        });
20641        let _ = std::fs::remove_file(&path);
20642        let failure = replay?;
20643        assert_eq!(
20644            failure, ERROR_RESPONSE,
20645            "offline replay must reproduce the recorded query failure"
20646        );
20647        Ok(())
20648    }
20649
20650    #[cfg(feature = "cassette")]
20651    #[test]
20652    fn connection_core_routes_connect_execute_fetch_over_recording_transport() -> Result<()> {
20653        use oracledb_protocol::net::cassette::{self, Direction};
20654        use std::io::Write as _;
20655
20656        const EXECUTE_BODY: &[u8] = b"recording execute payload";
20657        const FETCH_BODY: &[u8] = b"recording fetch payload";
20658        const EXECUTE_RESPONSE: &[u8] = b"recording execute response";
20659        const FETCH_RESPONSE: &[u8] = b"recording fetch response";
20660
20661        let connect_packet = encode_packet(
20662            TNS_PACKET_TYPE_CONNECT,
20663            0,
20664            None,
20665            b"RECORDING-CONNECT",
20666            PacketLengthWidth::Legacy16,
20667        )?;
20668        let accept_packet = encode_packet(
20669            TNS_PACKET_TYPE_ACCEPT,
20670            0,
20671            None,
20672            b"RECORDING-ACCEPT",
20673            PacketLengthWidth::Legacy16,
20674        )?;
20675        let execute_packet = encode_packet(
20676            TNS_PACKET_TYPE_DATA,
20677            0,
20678            Some(0),
20679            EXECUTE_BODY,
20680            PacketLengthWidth::Large32,
20681        )?;
20682        let fetch_packet = encode_packet(
20683            TNS_PACKET_TYPE_DATA,
20684            0,
20685            Some(0),
20686            FETCH_BODY,
20687            PacketLengthWidth::Large32,
20688        )?;
20689        let execute_response_packet = data_packet(EXECUTE_RESPONSE, true);
20690        let fetch_response_packet = data_packet(FETCH_RESPONSE, true);
20691
20692        let listener = TcpListener::bind("127.0.0.1:0")?;
20693        let addr = listener.local_addr()?;
20694        let server_connect = connect_packet.clone();
20695        let server_accept = accept_packet.clone();
20696        let server_execute = execute_packet.clone();
20697        let server_fetch = fetch_packet.clone();
20698        let server_execute_response = execute_response_packet.clone();
20699        let server_fetch_response = fetch_response_packet.clone();
20700        let server = thread::spawn(move || -> std::io::Result<()> {
20701            let (mut socket, _) = listener.accept()?;
20702            socket.set_read_timeout(Some(Duration::from_secs(5)))?;
20703
20704            let mut got = vec![0u8; server_connect.len()];
20705            socket.read_exact(&mut got)?;
20706            assert_eq!(got, server_connect);
20707            socket.write_all(&server_accept)?;
20708
20709            let mut got = vec![0u8; server_execute.len()];
20710            socket.read_exact(&mut got)?;
20711            assert_eq!(got, server_execute);
20712            socket.write_all(&server_execute_response)?;
20713
20714            let mut got = vec![0u8; server_fetch.len()];
20715            socket.read_exact(&mut got)?;
20716            assert_eq!(got, server_fetch);
20717            socket.write_all(&server_fetch_response)?;
20718            Ok(())
20719        });
20720
20721        let runtime = build_io_runtime()?;
20722        let cassette_bytes = runtime.block_on(async {
20723            let cx = test_cx()?;
20724            let scope = transport::capture_scope();
20725            let stream = TcpStream::connect(addr).await?;
20726            let (read, write) = transport::plain_split(stream);
20727            let mut core =
20728                ConnectionCore::<DriverTransport>::from_halves(read, write, "recording_core_write");
20729
20730            core.write_all(&cx, &connect_packet).await?;
20731            let accept = core.read_packet(PacketLengthWidth::Legacy16).await?;
20732            assert_eq!(accept.packet_type, TNS_PACKET_TYPE_ACCEPT);
20733            assert_eq!(accept.payload, b"RECORDING-ACCEPT");
20734
20735            core.send_data_packet(&cx, EXECUTE_BODY, 8192).await?;
20736            let execute_response = core.read_data_response(&cx).await?;
20737            assert_eq!(execute_response, EXECUTE_RESPONSE);
20738
20739            core.send_data_packet(&cx, FETCH_BODY, 8192).await?;
20740            let fetch_response = core.read_data_response(&cx).await?;
20741            assert_eq!(fetch_response, FETCH_RESPONSE);
20742
20743            Ok::<_, Error>(scope.to_cassette_bytes())
20744        })?;
20745
20746        server
20747            .join()
20748            .map_err(|_| Error::Runtime("recording test server thread panicked".into()))??;
20749        let frames = cassette::decode_all(&cassette_bytes)
20750            .map_err(|err| Error::Runtime(format!("invalid recorded cassette: {err}")))?;
20751        let (accept_header, accept_payload) = accept_packet.split_at(8);
20752        let (execute_response_header, execute_response_payload) =
20753            execute_response_packet.split_at(8);
20754        let (fetch_response_header, fetch_response_payload) = fetch_response_packet.split_at(8);
20755
20756        assert_eq!(frames.len(), 9);
20757        assert_eq!(frames[0].direction, Direction::ClientToServer);
20758        assert_eq!(frames[0].bytes, connect_packet);
20759        assert_eq!(frames[1].direction, Direction::ServerToClient);
20760        assert_eq!(frames[1].bytes, accept_header);
20761        assert_eq!(frames[2].direction, Direction::ServerToClient);
20762        assert_eq!(frames[2].bytes, accept_payload);
20763        assert_eq!(frames[3].direction, Direction::ClientToServer);
20764        assert_eq!(frames[3].bytes, execute_packet);
20765        assert_eq!(frames[4].direction, Direction::ServerToClient);
20766        assert_eq!(frames[4].bytes, execute_response_header);
20767        assert_eq!(frames[5].direction, Direction::ServerToClient);
20768        assert_eq!(frames[5].bytes, execute_response_payload);
20769        assert_eq!(frames[6].direction, Direction::ClientToServer);
20770        assert_eq!(frames[6].bytes, fetch_packet);
20771        assert_eq!(frames[7].direction, Direction::ServerToClient);
20772        assert_eq!(frames[7].bytes, fetch_response_header);
20773        assert_eq!(frames[8].direction, Direction::ServerToClient);
20774        assert_eq!(frames[8].bytes, fetch_response_payload);
20775        Ok(())
20776    }
20777
20778    /// Reads exactly one 11-byte TNS marker packet from `socket` and returns its
20779    /// marker type byte (payload byte 2). Used by the server side of the seam to
20780    /// observe the BREAK and RESET markers the client emits.
20781    fn read_marker_type(socket: &mut std::net::TcpStream) -> u8 {
20782        let mut packet = [0u8; 11];
20783        socket.read_exact(&mut packet).expect("read marker packet");
20784        assert_eq!(
20785            packet[4], TNS_PACKET_TYPE_MARKER,
20786            "expected a MARKER packet"
20787        );
20788        packet[10]
20789    }
20790
20791    // THE FIX: break_and_drain_wire sends BREAK, then consumes the ENTIRE
20792    // post-timeout sequence so the stream is left at a clean boundary. The
20793    // sequence here matches the live wire trace against Oracle 23/26ai: the
20794    // server flushes the cancelled call's IN-FLIGHT RESPONSE *first* (a complete
20795    // DATA response carrying its own end-of-response flag) and only THEN sends
20796    // the break-ack MARKER, the RESET handshake, and the trailing ORA-01013
20797    // error packet. A drain that stopped at the in-flight response's
20798    // end-of-response would leave the MARKER + error in the socket; the fix
20799    // discards the in-flight response(s), runs the RESET dance, and consumes the
20800    // trailing error too. A FOLLOWING read_data_response then decodes the NEXT
20801    // response correctly rather than the stale leftovers.
20802    #[test]
20803    fn break_and_drain_consumes_inflight_response_and_reset_then_next_read_is_fresh() {
20804        // The cancelled call's in-flight response (carries end-of-response): the
20805        // stale bytes that must be discarded, NOT mistaken for the next result.
20806        const INFLIGHT_BODY: &[u8] = &[0xDE, 0xAD, 0xBE, 0xEF];
20807        // The trailing error packet (ORA-01013-shaped; arbitrary payload here).
20808        const ERROR_BODY: &[u8] = &[0x04, 0x01, 0x02];
20809        // The genuine response to the NEXT operation on the reused connection.
20810        const FRESH_BODY: &[u8] = &[0x11, 0x22, 0x33, 0x44, 0x55];
20811
20812        let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener");
20813        let addr = listener.local_addr().expect("listener address");
20814        let server = thread::spawn(move || {
20815            let (mut socket, _) = listener.accept().expect("accept test client");
20816            socket
20817                .set_read_timeout(Some(Duration::from_secs(5)))
20818                .expect("set read timeout");
20819            use std::io::Write as _;
20820
20821            // 1) Client sends BREAK.
20822            assert_eq!(
20823                read_marker_type(&mut socket),
20824                TNS_MARKER_TYPE_BREAK,
20825                "client must send a BREAK marker first"
20826            );
20827            // 2) Server flushes the cancelled call's in-flight response FIRST,
20828            //    with its OWN end-of-response flag (the exact race that made a
20829            //    stop-at-first-boundary drain leak the MARKER + error).
20830            socket
20831                .write_all(&data_packet(INFLIGHT_BODY, true))
20832                .expect("write in-flight response");
20833            // 3) Server's break-ack MARKER -> drives the client's RESET dance.
20834            socket
20835                .write_all(&marker_packet(TNS_MARKER_TYPE_BREAK))
20836                .expect("write break-ack marker");
20837            // 4) Client replies with RESET; server confirms with a RESET marker.
20838            assert_eq!(
20839                read_marker_type(&mut socket),
20840                TNS_MARKER_TYPE_RESET,
20841                "client must answer the marker with a RESET"
20842            );
20843            socket
20844                .write_all(&marker_packet(TNS_MARKER_TYPE_RESET))
20845                .expect("write reset-confirm marker");
20846            // 5) Trailing error packet (ORA-01013) that ends the break response.
20847            socket
20848                .write_all(&data_packet(ERROR_BODY, true))
20849                .expect("write trailing error packet");
20850            // 6) The FRESH response to the next operation on the reused conn.
20851            socket
20852                .write_all(&data_packet(FRESH_BODY, true))
20853                .expect("write fresh response");
20854        });
20855
20856        let runtime = build_io_runtime().expect("asupersync runtime");
20857        let next = runtime.block_on(async {
20858            let cx = Cx::current().expect("ambient Cx");
20859            let stream = TcpStream::connect(addr).await.expect("connect to listener");
20860            let (mut read, write) = transport::plain_split(stream);
20861            let write: SharedWriteHalf = Arc::new(AsyncMutex::with_name("drain_test_write", write));
20862
20863            // The fix: break + drain leaves the stream clean.
20864            break_and_drain_wire(&mut read, &write, Duration::from_secs(5))
20865                .await
20866                .expect("drain must succeed and leave the stream clean");
20867
20868            // The next operation reads its OWN response, not the stale leftovers.
20869            read_data_response(&mut read, &cx, &write)
20870                .await
20871                .expect("next read after drain must decode cleanly")
20872        });
20873
20874        assert_eq!(
20875            next, FRESH_BODY,
20876            "after break_and_drain the reused connection must read the FRESH response, \
20877             not the stale in-flight response ({INFLIGHT_BODY:?}) or error body ({ERROR_BODY:?})"
20878        );
20879        server.join().expect("server thread joins");
20880    }
20881
20882    // bead rust-oracledb-99xu: on a pre-23ai server (no END_OF_RESPONSE framing)
20883    // the trailing error response after a BREAK/RESET carries NEITHER the
20884    // END_OF_RESPONSE data flag NOR a terminal marker byte -- it ends at its
20885    // terminal TTC message (here a STATUS). The flag-only recovery reader could
20886    // not detect that boundary and blocked until its secondary timeout, so the
20887    // call-timeout / cancel surfaced as ConnectionClosed instead of the real
20888    // CallTimeout / Cancelled (observed live against Oracle 18c/21c). The
20889    // classic-framing drain must complete on the terminal message and leave the
20890    // stream clean for the reused connection.
20891    #[test]
20892    fn classic_pre23ai_break_drain_completes_on_terminal_message_not_flag() {
20893        // A minimal, valid classic terminal: a STATUS message (msg type 9) whose
20894        // ub4 call-status and ub2 sequence are both zero-length. It carries NO
20895        // end-of-response flag and does NOT end in a marker byte.
20896        // TNS_MSG_TYPE_STATUS == 9 (oracledb_protocol::thin::constants).
20897        const CLASSIC_STATUS_TERMINAL: &[u8] = &[9, 0, 0];
20898        const FRESH_BODY: &[u8] = &[0x11, 0x22, 0x33, 0x44, 0x55];
20899
20900        // Guard: the flag-only detectors must NOT recognise this classic
20901        // terminal, which is precisely why the `classic` completion rule is
20902        // load-bearing (if either fired, the bug could never have occurred).
20903        assert!(
20904            !data_packet_ends_response(0, CLASSIC_STATUS_TERMINAL),
20905            "flag-framed detector must not terminate a flagless classic response"
20906        );
20907        assert!(
20908            !post_reset_packet_ends_response(CLASSIC_STATUS_TERMINAL),
20909            "post-reset marker-byte detector must not terminate a STATUS terminal"
20910        );
20911        assert!(
20912            classic_connect_response_is_complete(CLASSIC_STATUS_TERMINAL, ProtocolLimits::DEFAULT)
20913                .unwrap(),
20914            "the classic reader must recognise the STATUS terminal as complete"
20915        );
20916
20917        let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener");
20918        let addr = listener.local_addr().expect("listener address");
20919        let server = thread::spawn(move || {
20920            let (mut socket, _) = listener.accept().expect("accept test client");
20921            socket
20922                .set_read_timeout(Some(Duration::from_secs(5)))
20923                .expect("set read timeout");
20924            use std::io::Write as _;
20925
20926            // The two-thread cancel path: the BREAK was already sent by the
20927            // handle thread, so the drain begins at the server's break-ack
20928            // MARKER. Reference cancel wire sequence, pre-23ai framing.
20929            socket
20930                .write_all(&marker_packet(TNS_MARKER_TYPE_BREAK))
20931                .expect("write break-ack marker");
20932            assert_eq!(
20933                read_marker_type(&mut socket),
20934                TNS_MARKER_TYPE_RESET,
20935                "client must answer the marker with a RESET"
20936            );
20937            socket
20938                .write_all(&marker_packet(TNS_MARKER_TYPE_RESET))
20939                .expect("write reset-confirm marker");
20940            // Trailing error/STATUS response in CLASSIC framing: no EOR flag.
20941            socket
20942                .write_all(&data_packet(CLASSIC_STATUS_TERMINAL, false))
20943                .expect("write flagless classic terminal");
20944            // The FRESH response the reused connection must read next.
20945            socket
20946                .write_all(&data_packet(FRESH_BODY, true))
20947                .expect("write fresh response");
20948        });
20949
20950        let runtime = build_io_runtime().expect("asupersync runtime");
20951        let next = runtime.block_on(async {
20952            let cx = Cx::current().expect("ambient Cx");
20953            let stream = TcpStream::connect(addr).await.expect("connect to listener");
20954            let (mut read, write) = transport::plain_split(stream);
20955            let write: SharedWriteHalf =
20956                Arc::new(AsyncMutex::with_name("classic_drain_test_write", write));
20957
20958            // classic = true: the drain completes on the terminal message.
20959            drain_break_response_recovery_with_limits(
20960                &mut read,
20961                &write,
20962                ProtocolLimits::DEFAULT,
20963                true,
20964            )
20965            .await
20966            .expect("classic drain must complete on the terminal message");
20967
20968            read_data_response(&mut read, &cx, &write)
20969                .await
20970                .expect("next read after classic drain must decode cleanly")
20971        });
20972
20973        assert_eq!(
20974            next, FRESH_BODY,
20975            "after the classic drain the reused connection must read the FRESH response"
20976        );
20977        server.join().expect("server thread joins");
20978    }
20979
20980    // W1-T2.2: core recovery must not inherit the expired operation context.
20981    // The core moves the read half to a short-lived no-ambient recovery thread;
20982    // this test timeout-cancels the caller context before recovery starts and
20983    // proves the bounded drain still completes.
20984    #[test]
20985    fn core_break_and_drain_runs_after_caller_context_timeout() {
20986        const INFLIGHT_BODY: &[u8] = &[0xD1, 0xA1, 0xB1];
20987        const ERROR_BODY: &[u8] = &[0x04, 0x01, 0x0d];
20988
20989        let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener");
20990        let addr = listener.local_addr().expect("listener address");
20991        let server = thread::spawn(move || {
20992            let (mut socket, _) = listener.accept().expect("accept test client");
20993            socket
20994                .set_read_timeout(Some(Duration::from_secs(5)))
20995                .expect("set read timeout");
20996            use std::io::Write as _;
20997
20998            assert_eq!(
20999                read_marker_type(&mut socket),
21000                TNS_MARKER_TYPE_BREAK,
21001                "recovery must send BREAK even after caller context timeout"
21002            );
21003            socket
21004                .write_all(&data_packet(INFLIGHT_BODY, true))
21005                .expect("write in-flight response");
21006            socket
21007                .write_all(&marker_packet(TNS_MARKER_TYPE_BREAK))
21008                .expect("write break-ack marker");
21009            assert_eq!(
21010                read_marker_type(&mut socket),
21011                TNS_MARKER_TYPE_RESET,
21012                "recovery must answer break marker with RESET"
21013            );
21014            socket
21015                .write_all(&marker_packet(TNS_MARKER_TYPE_RESET))
21016                .expect("write reset-confirm marker");
21017            socket
21018                .write_all(&data_packet(ERROR_BODY, true))
21019                .expect("write trailing error packet");
21020        });
21021
21022        let runtime = build_io_runtime().expect("asupersync runtime");
21023        runtime.block_on(async {
21024            let cx = Cx::current().expect("ambient Cx");
21025            let stream = TcpStream::connect(addr).await.expect("connect to listener");
21026            let (read, write) = transport::plain_split(stream);
21027            let mut core = ConnectionCore::<DriverTransport>::from_halves(
21028                read,
21029                write,
21030                "timeout_drain_test_write",
21031            );
21032
21033            cx.cancel_fast(asupersync::CancelKind::Timeout);
21034            assert!(
21035                cx.checkpoint().is_err(),
21036                "test must start from an expired caller context"
21037            );
21038
21039            core.break_and_drain_wire(Duration::from_secs(5))
21040                .expect("recovery drain must ignore the expired caller context");
21041        });
21042
21043        server.join().expect("server thread joins");
21044    }
21045
21046    #[test]
21047    fn preexpired_query_deadline_does_not_break_idle_connection() -> Result<()> {
21048        const INFLIGHT_BODY: &[u8] = &[0xd1, 0xa1, 0xb1];
21049        const CANCEL_ERROR: &[u8] = &[0x04, 0x01, 0x0d];
21050
21051        let commit_packet = encode_packet(
21052            TNS_PACKET_TYPE_DATA,
21053            0,
21054            Some(0),
21055            &build_function_payload_with_seq(
21056                TNS_FUNC_COMMIT,
21057                1,
21058                ClientCapabilities::default().ttc_field_version,
21059            ),
21060            PacketLengthWidth::Large32,
21061        )?;
21062
21063        let listener = TcpListener::bind("127.0.0.1:0")?;
21064        let addr = listener.local_addr()?;
21065        let server = thread::spawn(move || -> std::io::Result<usize> {
21066            let (mut socket, _) = listener.accept()?;
21067            socket.set_read_timeout(Some(Duration::from_secs(5)))?;
21068            use std::io::Write as _;
21069
21070            let mut break_count = 0usize;
21071            loop {
21072                let mut header = [0u8; 8];
21073                socket.read_exact(&mut header)?;
21074                let declared =
21075                    u32::from_be_bytes([header[0], header[1], header[2], header[3]]) as usize;
21076                let mut packet = header.to_vec();
21077                let mut body = vec![0u8; declared - header.len()];
21078                socket.read_exact(&mut body)?;
21079                packet.extend_from_slice(&body);
21080                if packet[4] == TNS_PACKET_TYPE_MARKER {
21081                    assert_eq!(packet[10], TNS_MARKER_TYPE_BREAK);
21082                    break_count += 1;
21083                    socket.write_all(&data_packet(INFLIGHT_BODY, true))?;
21084                    socket.write_all(&marker_packet(TNS_MARKER_TYPE_BREAK))?;
21085                    assert_eq!(read_marker_type(&mut socket), TNS_MARKER_TYPE_RESET);
21086                    socket.write_all(&marker_packet(TNS_MARKER_TYPE_RESET))?;
21087                    socket.write_all(&data_packet(CANCEL_ERROR, true))?;
21088                    socket.flush()?;
21089                    continue;
21090                }
21091
21092                assert_eq!(
21093                    packet, commit_packet,
21094                    "the first ordinary request after both local expiries must be COMMIT"
21095                );
21096                socket.write_all(&data_packet(&[TNS_MSG_TYPE_END_OF_RESPONSE], true))?;
21097                socket.flush()?;
21098                return Ok(break_count);
21099            }
21100        });
21101
21102        let runtime = build_io_runtime()?;
21103        runtime.block_on(async {
21104            let cx = Cx::current().expect("runtime installs an ambient Cx");
21105            let socket = TcpStream::connect(addr).await?;
21106            let (read, write) = transport::plain_split(socket);
21107            let mut connection = loopback_connection(read, write);
21108
21109            fn assert_send<T: Send>(_: &T) {}
21110            let zero_timeout = connection.execute_with(
21111                &cx,
21112                Execute::new("begin null; end;").timeout(Duration::ZERO),
21113            );
21114            assert_send(&zero_timeout);
21115            let err = zero_timeout
21116                .await
21117                .expect_err("an already-expired query deadline must fail");
21118            assert!(
21119                matches!(err, Error::CallTimeout(0)),
21120                "pre-expired deadline must surface CallTimeout(0), got {err:?}"
21121            );
21122
21123            let ambient_deadline = QueryDeadline::from_budget(
21124                asupersync::time::wall_now(),
21125                Budget::new().with_deadline(Time::ZERO),
21126                None,
21127            );
21128            let ambient_err = connection
21129                .execute_with_deadline(&cx, Execute::new("begin null; end;"), ambient_deadline)
21130                .await
21131                .expect_err("an expired ambient deadline must fail before polling");
21132            assert!(
21133                matches!(ambient_err, Error::CallTimeout(0)),
21134                "expired ambient deadline must surface CallTimeout(0), got {ambient_err:?}"
21135            );
21136            assert_eq!(
21137                connection.core.recovery.phase(),
21138                SessionRecoveryPhase::Ready,
21139                "a future that was never polled must not arm wire recovery"
21140            );
21141            assert!(
21142                !connection.dead,
21143                "local before-start expiry must not mark a healthy connection dead"
21144            );
21145
21146            connection.commit(&cx).await?;
21147            Ok::<_, Error>(())
21148        })?;
21149
21150        let break_count = server.join().expect("server thread joins")?;
21151        assert_eq!(
21152            break_count, 0,
21153            "neither a zero request timeout nor an expired ambient deadline may send BREAK"
21154        );
21155        Ok(())
21156    }
21157
21158    #[test]
21159    fn prestart_expiry_preserves_structured_cancel_without_wire_io() -> Result<()> {
21160        let listener = TcpListener::bind("127.0.0.1:0")?;
21161        let addr = listener.local_addr()?;
21162        let server = thread::spawn(move || -> std::io::Result<Vec<usize>> {
21163            let mut received = Vec::with_capacity(2);
21164            for _ in 0..2 {
21165                let (mut socket, _) = listener.accept()?;
21166                socket.set_read_timeout(Some(Duration::from_secs(5)))?;
21167                let mut bytes = Vec::new();
21168                socket.read_to_end(&mut bytes)?;
21169                received.push(bytes.len());
21170            }
21171            Ok(received)
21172        });
21173
21174        for kind in [CancelKind::User, CancelKind::Shutdown] {
21175            let runtime = build_io_runtime()?;
21176            runtime.block_on(async {
21177                let socket = TcpStream::connect(addr).await?;
21178                let (read, write) = transport::plain_split(socket);
21179                let mut connection = loopback_connection(read, write);
21180                let cx = Cx::current().expect("runtime installs an ambient Cx");
21181                cx.cancel_with(kind, Some("pre-start cancellation test"));
21182
21183                let err = connection
21184                    .execute_with(
21185                        &cx,
21186                        Execute::new("begin null; end;").timeout(Duration::ZERO),
21187                    )
21188                    .await
21189                    .expect_err("an already-expired operation must not start");
21190
21191                match kind {
21192                    CancelKind::User => {
21193                        assert!(
21194                            matches!(err, Error::Cancelled),
21195                            "user cancellation must remain distinct, got {err:?}"
21196                        );
21197                        assert!(
21198                            !connection.is_dead(),
21199                            "user cancellation before start leaves the connection reusable"
21200                        );
21201                        assert_eq!(
21202                            connection.core.recovery.phase(),
21203                            SessionRecoveryPhase::Ready
21204                        );
21205                    }
21206                    CancelKind::Shutdown => {
21207                        assert!(
21208                            matches!(err, Error::ConnectionClosed(_)),
21209                            "shutdown cancellation must close the connection, got {err:?}"
21210                        );
21211                        assert!(
21212                            connection.is_dead(),
21213                            "shutdown cancellation before start must mark the connection dead"
21214                        );
21215                        assert_eq!(connection.core.recovery.phase(), SessionRecoveryPhase::Dead);
21216                    }
21217                    _ => unreachable!("the test covers exactly User and Shutdown"),
21218                }
21219
21220                drop(connection);
21221                Ok::<_, Error>(())
21222            })?;
21223        }
21224
21225        let received = server.join().expect("server thread joins")?;
21226        assert_eq!(
21227            received,
21228            vec![0, 0],
21229            "before-start cancellation must emit neither a request nor BREAK"
21230        );
21231        Ok(())
21232    }
21233
21234    // bead rust-oracledb-yhz: a compliant-but-non-minimal server may send
21235    // MULTIPLE RESET markers after the client's RESET (reference _reset second
21236    // loop, protocol.pyx:554-556). The drain must consume ALL of them and send
21237    // exactly ONE RESET. The pre-fix reset_after_marker returned on the first
21238    // RESET marker, so the caller read the second one, mistook it for a fresh
21239    // break, and sent a DUPLICATE RESET — poisoning the reused connection.
21240    #[test]
21241    fn reset_after_marker_drains_multiple_trailing_markers_no_duplicate_reset() {
21242        const INFLIGHT_BODY: &[u8] = &[0xDE, 0xAD];
21243        const ERROR_BODY: &[u8] = &[0x04, 0x01, 0x02];
21244        const FRESH_BODY: &[u8] = &[0x11, 0x22, 0x33];
21245
21246        let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener");
21247        let addr = listener.local_addr().expect("listener address");
21248        let server = thread::spawn(move || {
21249            let (mut socket, _) = listener.accept().expect("accept test client");
21250            socket
21251                .set_read_timeout(Some(Duration::from_secs(5)))
21252                .expect("set read timeout");
21253            use std::io::Write as _;
21254
21255            assert_eq!(
21256                read_marker_type(&mut socket),
21257                TNS_MARKER_TYPE_BREAK,
21258                "client must send a BREAK marker first"
21259            );
21260            socket
21261                .write_all(&data_packet(INFLIGHT_BODY, true))
21262                .expect("write in-flight response");
21263            socket
21264                .write_all(&marker_packet(TNS_MARKER_TYPE_BREAK))
21265                .expect("write break-ack marker");
21266            // The client answers the marker with exactly ONE RESET.
21267            assert_eq!(
21268                read_marker_type(&mut socket),
21269                TNS_MARKER_TYPE_RESET,
21270                "client must answer with a RESET"
21271            );
21272            // Server now sends TWO RESET markers (the yhz trigger) before the
21273            // trailing error + the fresh response.
21274            socket
21275                .write_all(&marker_packet(TNS_MARKER_TYPE_RESET))
21276                .expect("write reset marker #1");
21277            socket
21278                .write_all(&marker_packet(TNS_MARKER_TYPE_RESET))
21279                .expect("write reset marker #2");
21280            socket
21281                .write_all(&data_packet(ERROR_BODY, true))
21282                .expect("write trailing error packet");
21283            socket
21284                .write_all(&data_packet(FRESH_BODY, true))
21285                .expect("write fresh response");
21286            // No DUPLICATE RESET may arrive. With the bug the client answers the
21287            // second RESET marker with a second RESET, which (sent during the
21288            // drain) is already in our buffer by now.
21289            socket
21290                .set_read_timeout(Some(Duration::from_millis(750)))
21291                .expect("set short read timeout");
21292            let mut extra = [0u8; 11];
21293            if socket.read_exact(&mut extra).is_ok() {
21294                panic!(
21295                    "client sent a DUPLICATE marker (type {}): the drain did not \
21296                     consume all trailing RESET markers (bead rust-oracledb-yhz)",
21297                    extra[10]
21298                );
21299            }
21300        });
21301
21302        let runtime = build_io_runtime().expect("asupersync runtime");
21303        let next = runtime.block_on(async {
21304            let cx = Cx::current().expect("ambient Cx");
21305            let stream = TcpStream::connect(addr).await.expect("connect to listener");
21306            let (mut read, write) = transport::plain_split(stream);
21307            let write: SharedWriteHalf = Arc::new(AsyncMutex::with_name("yhz_test_write", write));
21308            break_and_drain_wire(&mut read, &write, Duration::from_secs(5))
21309                .await
21310                .expect("drain must succeed even with multiple RESET markers");
21311            read_data_response(&mut read, &cx, &write)
21312                .await
21313                .expect("next read after drain must decode cleanly")
21314        });
21315
21316        assert_eq!(
21317            next, FRESH_BODY,
21318            "after draining multiple RESET markers the reused connection must read \
21319             the FRESH response"
21320        );
21321        server.join().expect("server thread joins");
21322    }
21323
21324    // THE BUG (pre-fix contrast): if the timeout path sends ONLY a BREAK and
21325    // does NOT drain, the in-flight response tail is still sitting in the socket.
21326    // The next read_data_response then reassembles those STALE bytes as if they
21327    // were the next operation's response — the wire is desynced. This test pins
21328    // that broken behavior to prove the regression test above is meaningful: it
21329    // asserts that without the drain the next read returns the stale tail.
21330    #[test]
21331    fn break_without_drain_leaves_stale_bytes_for_next_read() {
21332        const STALE_BODY: &[u8] = &[0x53, 0x54, 0x41, 0x4c, 0x45]; // "STALE"
21333        const FRESH_BODY: &[u8] = &[0x11, 0x22, 0x33];
21334
21335        let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener");
21336        let addr = listener.local_addr().expect("listener address");
21337        let server = thread::spawn(move || {
21338            let (mut socket, _) = listener.accept().expect("accept test client");
21339            socket
21340                .set_read_timeout(Some(Duration::from_secs(5)))
21341                .expect("set read timeout");
21342            use std::io::Write as _;
21343            // Client sends BREAK (the only thing the OLD code did).
21344            assert_eq!(read_marker_type(&mut socket), TNS_MARKER_TYPE_BREAK);
21345            // The server's in-flight response (end-of-response) was already on
21346            // its way when the break fired: it lands in the socket unconsumed.
21347            socket
21348                .write_all(&data_packet(STALE_BODY, true))
21349                .expect("write stale in-flight response");
21350            // ... and then the fresh response the caller actually wanted.
21351            socket
21352                .write_all(&data_packet(FRESH_BODY, true))
21353                .expect("write fresh response");
21354        });
21355
21356        let runtime = build_io_runtime().expect("asupersync runtime");
21357        let first_read = runtime.block_on(async {
21358            let cx = Cx::current().expect("ambient Cx");
21359            let stream = TcpStream::connect(addr).await.expect("connect to listener");
21360            let (mut read, write) = transport::plain_split(stream);
21361            let write: SharedWriteHalf =
21362                Arc::new(AsyncMutex::with_name("nodrain_test_write", write));
21363
21364            // Reproduce the OLD timeout path: send BREAK, do NOT drain.
21365            send_marker_shared(&cx, &write, TNS_MARKER_TYPE_BREAK)
21366                .await
21367                .expect("send break");
21368
21369            // The very next read picks up the STALE in-flight response.
21370            read_data_response(&mut read, &cx, &write)
21371                .await
21372                .expect("read after bare break")
21373        });
21374
21375        assert_eq!(
21376            first_read, STALE_BODY,
21377            "without the drain, the next read misframes onto the stale in-flight bytes — \
21378             this is the bug break_and_drain fixes"
21379        );
21380        server.join().expect("server thread joins");
21381    }
21382
21383    // bead rust-oracledb-zhm: the DML-RETURNING error path (test_1600 test_1612,
21384    // ORA-12899) deadlocked. Confirmed by live wire trace against Oracle 23ai:
21385    // a RETURNING statement that errors does NOT come back as a plain DATA
21386    // response. The server signals it out-of-band, exactly as on a call-timeout
21387    // BREAK: it sends a BREAK marker, the client runs the RESET dance, and the
21388    // server then sends a FLUSH_OUT_BINDS *request* — a DATA packet whose data
21389    // flags are 0x0000 (NO end-of-response flag, because the break-recovery path
21390    // does not use request-boundary framing) and whose payload ends in the
21391    // FLUSH_OUT_BINDS message byte (0x13). The reference recognises this as
21392    // end-of-response while *processing* the message (messages/base.pyx:1267-1269
21393    // sets end_of_response on TNS_MSG_TYPE_FLUSH_OUT_BINDS) and replies with a
21394    // FLUSH_OUT_BINDS message; the server then sends another BREAK/RESET pair and
21395    // finally the real ORA-12899 error packet.
21396    //
21397    // THE BUG: our `read_data_response_boundary` fed the post-RESET trailing
21398    // packet back through `data_packet_ends_response`, which (correctly, for the
21399    // wide-row false-positive guard, bead n2s) returns false for a flagless
21400    // packet that merely *ends* in 0x13. So the boundary loop tried to read
21401    // another packet that the server never sends (it is waiting for our
21402    // FLUSH_OUT_BINDS reply) and we blocked forever in recvfrom/epoll.
21403    //
21404    // THE FIX: a packet that arrives *after a RESET* inside the boundary loop is
21405    // message-byte framed, not request-boundary framed (mirroring the reference,
21406    // whose `_check_request_boundary` is off for post-reset packets). So once the
21407    // loop has run a RESET, a trailing FLUSH_OUT_BINDS / END_OF_RESPONSE message
21408    // byte terminates the response. The wide-row guard is untouched because it
21409    // applies only to the normal (no-reset) framing.
21410    //
21411    // This hermetic test replays that exact sequence and drives the real
21412    // execute-path reader (`read_data_response_flushing_out_binds`). Pre-fix it
21413    // hangs at step (4) below; the bounded timeout converts the hang into a test
21414    // failure instead of stalling the whole suite.
21415    #[test]
21416    fn dml_returning_error_flush_out_binds_after_reset_completes_without_hang() {
21417        // The real ORA-12899 error payload tail (end-of-response flagged).
21418        const ERROR_BODY: &[u8] = &[0x04, 0x01, 0x02, 0x37];
21419        // The FLUSH_OUT_BINDS *request* the server sends after the first reset:
21420        // flagless DATA packet whose payload ends in the FLUSH_OUT_BINDS byte.
21421        // Matches the live trace body `07 00 00 13`.
21422        const FLUSH_REQUEST_BODY: &[u8] = &[0x07, 0x00, 0x00, TNS_MSG_TYPE_FLUSH_OUT_BINDS];
21423
21424        let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener");
21425        let addr = listener.local_addr().expect("listener address");
21426        let server = thread::spawn(move || {
21427            let (mut socket, _) = listener.accept().expect("accept test client");
21428            socket
21429                .set_read_timeout(Some(Duration::from_secs(5)))
21430                .expect("set read timeout");
21431            use std::io::Write as _;
21432
21433            // 1) Server signals the RETURNING error out-of-band with a BREAK.
21434            socket
21435                .write_all(&marker_packet(TNS_MARKER_TYPE_BREAK))
21436                .expect("write break marker");
21437            // 2) Client answers with RESET; server confirms with a RESET marker.
21438            assert_eq!(
21439                read_marker_type(&mut socket),
21440                TNS_MARKER_TYPE_RESET,
21441                "client must answer the BREAK with a RESET"
21442            );
21443            socket
21444                .write_all(&marker_packet(TNS_MARKER_TYPE_RESET))
21445                .expect("write reset-confirm marker");
21446            // 3) Server sends the FLUSH_OUT_BINDS request: flagless DATA packet
21447            //    ending in 0x13. THIS is the packet the pre-fix loop refused to
21448            //    treat as a boundary, then blocked reading the next packet.
21449            socket
21450                .write_all(&data_packet(FLUSH_REQUEST_BODY, false))
21451                .expect("write flush-out-binds request");
21452            // 4) Client must reply with a FLUSH_OUT_BINDS message of its own
21453            //    (a DATA packet whose single-byte payload is 0x13).
21454            let mut header = [0u8; 8];
21455            socket
21456                .read_exact(&mut header)
21457                .expect("read flush-out-binds reply header");
21458            assert_eq!(
21459                header[4], TNS_PACKET_TYPE_DATA,
21460                "client's flush-out-binds reply must be a DATA packet"
21461            );
21462            let len = u32::from_be_bytes([header[0], header[1], header[2], header[3]]) as usize;
21463            let mut body = vec![0u8; len - 8];
21464            socket
21465                .read_exact(&mut body)
21466                .expect("read flush-out-binds reply body");
21467            assert_eq!(
21468                body.last().copied(),
21469                Some(TNS_MSG_TYPE_FLUSH_OUT_BINDS),
21470                "client must reply with a FLUSH_OUT_BINDS message"
21471            );
21472            // 5) Server sends another BREAK/RESET pair before the real error.
21473            socket
21474                .write_all(&marker_packet(TNS_MARKER_TYPE_BREAK))
21475                .expect("write second break marker");
21476            assert_eq!(
21477                read_marker_type(&mut socket),
21478                TNS_MARKER_TYPE_RESET,
21479                "client must answer the second BREAK with a RESET"
21480            );
21481            socket
21482                .write_all(&marker_packet(TNS_MARKER_TYPE_RESET))
21483                .expect("write second reset-confirm marker");
21484            // 6) Finally, the genuine ORA-12899 error packet (end-of-response).
21485            socket
21486                .write_all(&data_packet(ERROR_BODY, true))
21487                .expect("write trailing ORA-12899 error packet");
21488        });
21489
21490        let runtime = build_io_runtime().expect("asupersync runtime");
21491        let payload = runtime.block_on(async {
21492            let cx = Cx::current().expect("ambient Cx");
21493            let stream = TcpStream::connect(addr).await.expect("connect to listener");
21494            let (mut read, write) = transport::plain_split(stream);
21495            let write: SharedWriteHalf =
21496                Arc::new(AsyncMutex::with_name("returning_err_test_write", write));
21497
21498            // Drive the real execute-path reader. Bound it so the pre-fix hang
21499            // surfaces as a timeout error rather than stalling the whole suite.
21500            time::timeout(
21501                time::wall_now(),
21502                Duration::from_secs(10),
21503                read_data_response_flushing_out_binds(&mut read, &cx, &write, 8192),
21504            )
21505            .await
21506            .expect("must NOT hang on the DML-RETURNING error path (flush-out-binds after reset)")
21507            .expect("read must complete and yield the trailing error payload")
21508        });
21509
21510        // The fully reassembled response ends with the real error packet's bytes
21511        // (the FLUSH_OUT_BINDS request body is consumed/popped, not surfaced).
21512        assert!(
21513            payload.ends_with(ERROR_BODY),
21514            "the reassembled response must end with the ORA-12899 error payload, got {payload:?}"
21515        );
21516        server.join().expect("server thread joins");
21517    }
21518
21519    // ---- explicit cancel (bead rust-oracledb-wnz) -----------------------------
21520    //
21521    // Connection::cancel() must do for an EXPLICIT user cancel exactly what the
21522    // call-timeout path does for a timeout: send the BREAK, drain the server's
21523    // in-flight response + the break-ack MARKER + the RESET handshake + the
21524    // trailing ORA-01013 error, then leave the wire at a clean boundary so the
21525    // SAME connection is reusable for the next operation. It reuses the proven
21526    // `break_and_drain_wire` machinery (no duplicate drain loop). The only thing
21527    // that differs from the timeout path is the surfaced error semantics: a
21528    // successful cancel is `Ok(())` (the connection is clean), and the in-flight
21529    // operation observes `Error::Cancelled` (ORA-01013 user-requested-cancel),
21530    // which — like DPY-4024 — is NOT connection-lost (the session survives).
21531
21532    #[test]
21533    fn cancel_and_drain_wire_leaves_connection_reusable() {
21534        // The cancelled call's in-flight response (its own end-of-response): the
21535        // stale bytes that must be discarded, never mistaken for the next result.
21536        const INFLIGHT_BODY: &[u8] = &[0xCA, 0xFE];
21537        // The trailing ORA-01013-shaped error packet ending the cancel response.
21538        const ERROR_BODY: &[u8] = &[0x04, 0x01, 0x0d];
21539        // The genuine response to `select 7+5` on the reused connection.
21540        const FRESH_BODY: &[u8] = &[0x07, 0x05, 0x0c];
21541
21542        let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener");
21543        let addr = listener.local_addr().expect("listener address");
21544        let server = thread::spawn(move || {
21545            let (mut socket, _) = listener.accept().expect("accept test client");
21546            socket
21547                .set_read_timeout(Some(Duration::from_secs(5)))
21548                .expect("set read timeout");
21549            use std::io::Write as _;
21550
21551            // 1) Client sends BREAK to cancel the in-flight slow query.
21552            assert_eq!(
21553                read_marker_type(&mut socket),
21554                TNS_MARKER_TYPE_BREAK,
21555                "cancel must send a BREAK marker first"
21556            );
21557            // 2) Server flushes the cancelled call's in-flight response FIRST.
21558            socket
21559                .write_all(&data_packet(INFLIGHT_BODY, true))
21560                .expect("write in-flight response");
21561            // 3) Server's break-ack MARKER drives the client's RESET dance.
21562            socket
21563                .write_all(&marker_packet(TNS_MARKER_TYPE_BREAK))
21564                .expect("write break-ack marker");
21565            // 4) Client answers with RESET; server confirms.
21566            assert_eq!(
21567                read_marker_type(&mut socket),
21568                TNS_MARKER_TYPE_RESET,
21569                "cancel must answer the marker with a RESET"
21570            );
21571            socket
21572                .write_all(&marker_packet(TNS_MARKER_TYPE_RESET))
21573                .expect("write reset-confirm marker");
21574            // 5) Trailing ORA-01013 error ending the cancel response.
21575            socket
21576                .write_all(&data_packet(ERROR_BODY, true))
21577                .expect("write trailing error packet");
21578            // 6) The FRESH response to the next operation (select 7+5 -> 12).
21579            socket
21580                .write_all(&data_packet(FRESH_BODY, true))
21581                .expect("write fresh response");
21582        });
21583
21584        let runtime = build_io_runtime().expect("asupersync runtime");
21585        let next = runtime.block_on(async {
21586            let cx = Cx::current().expect("ambient Cx");
21587            let stream = TcpStream::connect(addr).await.expect("connect to listener");
21588            let (mut read, write) = transport::plain_split(stream);
21589            let write: SharedWriteHalf =
21590                Arc::new(AsyncMutex::with_name("cancel_test_write", write));
21591
21592            // The cancel: break + drain leaves the stream clean and reusable.
21593            cancel_and_drain_wire(&mut read, &write, Duration::from_secs(5))
21594                .await
21595                .expect("cancel drain must succeed and leave the stream clean");
21596
21597            // The next operation reads its OWN response on the SAME connection.
21598            read_data_response(&mut read, &cx, &write)
21599                .await
21600                .expect("next read after cancel must decode cleanly")
21601        });
21602
21603        assert_eq!(
21604            next, FRESH_BODY,
21605            "after cancel the reused connection must read the FRESH response, not the \
21606             stale in-flight response ({INFLIGHT_BODY:?}) or error body ({ERROR_BODY:?})"
21607        );
21608        server.join().expect("server thread joins");
21609    }
21610
21611    // A bare `fetch_rows_request` whose paired `fetch_rows_ref_response` is never
21612    // consumed leaves the recovery phase `InFlight` with a stranded page on the
21613    // wire (no response future was dropped to fire a `CancelDrainGuard`).
21614    // `ensure_clean_before_request` must treat that exactly like a dropped fetch:
21615    // break + drain the stranded page and reconcile to `Ready`, so the next
21616    // operation reads its OWN response instead of the connection wedging forever
21617    // on "operation attempted while a response is still in flight". Offline
21618    // regression for the live `stranded_prefetch_request_is_drained_before_reuse`
21619    // proof (bead rust-oracledb-004o).
21620    #[test]
21621    fn ensure_clean_drains_inflight_bare_request_before_reuse() {
21622        // The stranded speculative page (its own end-of-response): stale bytes
21623        // that must be discarded, never mistaken for the next result.
21624        const INFLIGHT_BODY: &[u8] = &[0xCA, 0xFE];
21625        // The trailing ORA-01013-shaped error packet ending the drain response.
21626        const ERROR_BODY: &[u8] = &[0x04, 0x01, 0x0d];
21627        // The genuine response to the reuse operation.
21628        const FRESH_BODY: &[u8] = &[0x07, 0x05, 0x0c];
21629
21630        let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener");
21631        let addr = listener.local_addr().expect("listener address");
21632        let server = thread::spawn(move || {
21633            let (mut socket, _) = listener.accept().expect("accept test client");
21634            socket
21635                .set_read_timeout(Some(Duration::from_secs(5)))
21636                .expect("set read timeout");
21637            use std::io::Write as _;
21638
21639            // The reuse op must break + drain the stranded page first. Without the
21640            // InFlight handling this BREAK never arrives (the op errors out) and
21641            // this read times out — the regression's failure mode.
21642            assert_eq!(
21643                read_marker_type(&mut socket),
21644                TNS_MARKER_TYPE_BREAK,
21645                "reuse after a bare request must send a BREAK to drain the stranded page"
21646            );
21647            socket
21648                .write_all(&data_packet(INFLIGHT_BODY, true))
21649                .expect("write stranded page");
21650            socket
21651                .write_all(&marker_packet(TNS_MARKER_TYPE_BREAK))
21652                .expect("write break-ack marker");
21653            assert_eq!(
21654                read_marker_type(&mut socket),
21655                TNS_MARKER_TYPE_RESET,
21656                "drain must answer the break-ack marker with a RESET"
21657            );
21658            socket
21659                .write_all(&marker_packet(TNS_MARKER_TYPE_RESET))
21660                .expect("write reset-confirm marker");
21661            socket
21662                .write_all(&data_packet(ERROR_BODY, true))
21663                .expect("write trailing error packet");
21664            socket
21665                .write_all(&data_packet(FRESH_BODY, true))
21666                .expect("write fresh response");
21667        });
21668
21669        let runtime = build_io_runtime().expect("asupersync runtime");
21670        let next = runtime
21671            .block_on(async {
21672                let cx = Cx::current().expect("ambient Cx");
21673                let stream = TcpStream::connect(addr).await.expect("connect to listener");
21674                let (read, write) = transport::plain_split(stream);
21675                let mut conn = loopback_connection(read, write);
21676
21677                // Simulate a bare `fetch_rows_request`: a speculative response is
21678                // outstanding, so the phase is `InFlight` with no guard armed.
21679                conn.core
21680                    .recovery
21681                    .begin_operation()
21682                    .expect("enter InFlight");
21683                assert_eq!(conn.core.recovery.phase(), SessionRecoveryPhase::InFlight);
21684
21685                // The next operation's pre-flight cleanup must reclaim the wire.
21686                conn.ensure_clean_before_request()
21687                    .await
21688                    .expect("a stranded bare request must be drained, not wedge the connection");
21689                assert_eq!(
21690                    conn.core.recovery.phase(),
21691                    SessionRecoveryPhase::Ready,
21692                    "after draining the stranded page the session is Ready for reuse"
21693                );
21694
21695                conn.core.read_data_response(&cx).await
21696            })
21697            .expect("the reused connection must read its fresh response");
21698
21699        assert_eq!(
21700            next, FRESH_BODY,
21701            "after draining the stranded bare request the reused connection must read \
21702             the FRESH response, not the stranded page ({INFLIGHT_BODY:?})"
21703        );
21704        server.join().expect("server thread joins");
21705    }
21706
21707    // The two-thread cancel path (a `CancelHandle` on another thread already
21708    // sent the BREAK while the main thread is blocked in the query) needs a
21709    // DRAIN-ONLY clean-up: it must NOT send a second BREAK, but it must still
21710    // consume the in-flight response + break-ack MARKER + RESET handshake +
21711    // trailing ORA-01013, exactly like the full break+drain. `drain_cancel_wire`
21712    // is that drain-only half, sharing `drain_break_response_recovery` with the
21713    // break+drain path. The server here sends NO marker before its in-flight
21714    // response is flushed and does NOT expect a BREAK from us (the handle thread
21715    // already sent it) — only the RESET in answer to the break-ack marker.
21716    #[test]
21717    fn drain_cancel_wire_drains_without_sending_a_break() {
21718        const INFLIGHT_BODY: &[u8] = &[0xCA, 0xFE];
21719        const ERROR_BODY: &[u8] = &[0x04, 0x01, 0x0d];
21720        const FRESH_BODY: &[u8] = &[0x07, 0x05, 0x0c];
21721
21722        let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener");
21723        let addr = listener.local_addr().expect("listener address");
21724        let server = thread::spawn(move || {
21725            let (mut socket, _) = listener.accept().expect("accept test client");
21726            socket
21727                .set_read_timeout(Some(Duration::from_secs(5)))
21728                .expect("set read timeout");
21729            use std::io::Write as _;
21730
21731            // The drain-only path sends NO BREAK first (the handle thread did).
21732            // Server flushes the in-flight response then the break-ack MARKER.
21733            socket
21734                .write_all(&data_packet(INFLIGHT_BODY, true))
21735                .expect("write in-flight response");
21736            socket
21737                .write_all(&marker_packet(TNS_MARKER_TYPE_BREAK))
21738                .expect("write break-ack marker");
21739            // The drain answers the marker with a RESET.
21740            assert_eq!(
21741                read_marker_type(&mut socket),
21742                TNS_MARKER_TYPE_RESET,
21743                "drain must answer the break-ack marker with a RESET"
21744            );
21745            socket
21746                .write_all(&marker_packet(TNS_MARKER_TYPE_RESET))
21747                .expect("write reset-confirm marker");
21748            socket
21749                .write_all(&data_packet(ERROR_BODY, true))
21750                .expect("write trailing error packet");
21751            socket
21752                .write_all(&data_packet(FRESH_BODY, true))
21753                .expect("write fresh response");
21754            // No BREAK marker may ever arrive on this path.
21755            socket
21756                .set_read_timeout(Some(Duration::from_millis(500)))
21757                .expect("set short read timeout");
21758            let mut extra = [0u8; 11];
21759            if let Ok(()) = socket.read_exact(&mut extra) {
21760                assert_ne!(
21761                    extra[10], TNS_MARKER_TYPE_BREAK,
21762                    "drain-only cancel must NOT send a BREAK marker"
21763                );
21764            }
21765        });
21766
21767        let runtime = build_io_runtime().expect("asupersync runtime");
21768        let next = runtime.block_on(async {
21769            let cx = Cx::current().expect("ambient Cx");
21770            let stream = TcpStream::connect(addr).await.expect("connect to listener");
21771            let (mut read, write) = transport::plain_split(stream);
21772            let write: SharedWriteHalf =
21773                Arc::new(AsyncMutex::with_name("drain_cancel_test_write", write));
21774
21775            drain_cancel_wire(&mut read, &write, Duration::from_secs(5))
21776                .await
21777                .expect("drain-only cancel must succeed and leave the stream clean");
21778
21779            read_data_response(&mut read, &cx, &write)
21780                .await
21781                .expect("next read after drain must decode cleanly")
21782        });
21783
21784        assert_eq!(
21785            next, FRESH_BODY,
21786            "after drain-only cancel the reused connection must read the FRESH response"
21787        );
21788        server.join().expect("server thread joins");
21789    }
21790
21791    // The Scope-based cancel-on-drop guard. While a cancellable round trip's
21792    // read future is in flight the guard owns the InFlight recovery phase; if the
21793    // future is dropped (cancelled — e.g. by a `select!`/`timeout` racing it)
21794    // before the read completes, the guard's Drop moves the phase to BreakSent,
21795    // so the NEXT operation on the connection breaks + drains the stranded
21796    // server call rather than reassembling its leftover bytes as its own
21797    // response. A CLEAN completion calls `disarm()` first, so a normal read
21798    // returns the phase to Ready.
21799    #[test]
21800    fn cancel_drain_guard_transitions_recovery_phase_only_when_dropped_in_flight() -> Result<()> {
21801        let recovery = Arc::new(SessionRecovery::new());
21802
21803        // A guard dropped WITHOUT disarming (the future was cancelled mid-read):
21804        // the recovery phase records that a break/drain is required.
21805        {
21806            let _guard = CancelDrainGuard::arm(Arc::clone(&recovery))?;
21807            assert_eq!(recovery.phase(), SessionRecoveryPhase::InFlight);
21808        }
21809        assert_eq!(
21810            recovery.phase(),
21811            SessionRecoveryPhase::BreakSent,
21812            "dropping an armed guard (cancelled in flight) must require recovery"
21813        );
21814        assert!(recovery.begin_pending_drain()?);
21815        assert_eq!(recovery.phase(), SessionRecoveryPhase::Draining);
21816        assert!(
21817            recovery.begin_pending_drain().is_err(),
21818            "a drain that is already running must not start a second drain"
21819        );
21820        recovery.finish_drain_ready();
21821        assert_eq!(recovery.phase(), SessionRecoveryPhase::Ready);
21822
21823        // Strict operation starts require Ready; a second operation cannot start
21824        // while an earlier response is still in flight.
21825        recovery.begin_operation()?;
21826        match recovery.begin_operation() {
21827            Err(Error::ConnectionClosed(message)) => {
21828                assert!(message.contains("still in flight"));
21829            }
21830            other => {
21831                return Err(Error::Runtime(format!(
21832                    "second operation start should fail while InFlight, got {other:?}"
21833                )));
21834            }
21835        }
21836        recovery.complete_operation();
21837        assert_eq!(recovery.phase(), SessionRecoveryPhase::Ready);
21838
21839        // A response reader may adopt an already-sent prefetch request's
21840        // InFlight phase and complete it back to Ready.
21841        recovery.begin_operation()?;
21842        {
21843            let mut guard = CancelDrainGuard::arm(Arc::clone(&recovery))?;
21844            assert_eq!(recovery.phase(), SessionRecoveryPhase::InFlight);
21845            guard.disarm();
21846        }
21847        assert_eq!(recovery.phase(), SessionRecoveryPhase::Ready);
21848
21849        // A guard that DISARMS before drop (the read completed normally): the
21850        // phase returns to Ready and no recovery is needed.
21851        {
21852            let mut guard = CancelDrainGuard::arm(Arc::clone(&recovery))?;
21853            assert_eq!(recovery.phase(), SessionRecoveryPhase::InFlight);
21854            guard.disarm();
21855        }
21856        assert_eq!(
21857            recovery.phase(),
21858            SessionRecoveryPhase::Ready,
21859            "a disarmed guard (clean completion) must NOT require recovery"
21860        );
21861        Ok(())
21862    }
21863
21864    #[test]
21865    fn cancelled_error_is_not_connection_lost_but_is_transient() {
21866        let cancelled = Error::Cancelled;
21867        assert!(
21868            !cancelled.is_connection_lost(),
21869            "a user cancel leaves the session alive (ORA-01013 / DPY-4024 semantics)"
21870        );
21871        assert!(
21872            cancelled.is_transient(),
21873            "a cancelled operation may be retried on the same clean connection"
21874        );
21875        // ORA-01013 is the server-side code for user-requested cancel.
21876        assert_eq!(cancelled.ora_code(), Some(1013));
21877    }
21878
21879    // ------------------------------------------------------------------
21880    // Listener REDIRECT handling (bead rust-oracledb-pre23ai-connect-z47u.6)
21881    // ------------------------------------------------------------------
21882
21883    /// Reads one TNS packet from a test-listener socket, returning
21884    /// `(packet_type, packet_flags, payload)`.
21885    fn read_tns_packet_sync(
21886        socket: &mut std::net::TcpStream,
21887    ) -> std::io::Result<(u8, u8, Vec<u8>)> {
21888        let mut header = [0u8; 8];
21889        socket.read_exact(&mut header)?;
21890        let declared = usize::from(u16::from_be_bytes([header[0], header[1]]));
21891        let mut payload = vec![0u8; declared.saturating_sub(header.len())];
21892        socket.read_exact(&mut payload)?;
21893        Ok((header[4], header[5], payload))
21894    }
21895
21896    fn send_tns_packet_sync(
21897        socket: &mut std::net::TcpStream,
21898        packet_type: u8,
21899        payload: &[u8],
21900    ) -> std::io::Result<()> {
21901        use std::io::Write as _;
21902        let packet = encode_packet(packet_type, 0, None, payload, PacketLengthWidth::Legacy16)
21903            .expect("encode test packet");
21904        socket.write_all(&packet)
21905    }
21906
21907    /// The REDIRECT packet payload for `redirect_data`: u16be length prefix
21908    /// followed by the data bytes.
21909    fn redirect_packet_payload(redirect_data: &str) -> Vec<u8> {
21910        let bytes = redirect_data.as_bytes();
21911        let mut payload = Vec::with_capacity(2 + bytes.len());
21912        payload.extend_from_slice(
21913            &u16::try_from(bytes.len())
21914                .expect("test data fits")
21915                .to_be_bytes(),
21916        );
21917        payload.extend_from_slice(bytes);
21918        payload
21919    }
21920
21921    #[test]
21922    fn redirect_payload_prefix_accepts_inline_partial_and_rejects_truncated() {
21923        // Well-formed: length prefix + full inline data.
21924        let payload = redirect_packet_payload("abc");
21925        assert_eq!(redirect_payload_prefix(&payload).unwrap(), (3, &b"abc"[..]));
21926        // Declared length exceeding the inline bytes: the remainder arrives in
21927        // follow-up packets; the prefix reports what is available.
21928        assert_eq!(
21929            redirect_payload_prefix(&[0x00, 0x10, b'x']).unwrap(),
21930            (16, &b"x"[..])
21931        );
21932        // Length-only payload (data entirely in follow-up packets).
21933        assert_eq!(
21934            redirect_payload_prefix(&[0x00, 0x05]).unwrap(),
21935            (5, &[][..])
21936        );
21937        // Trailing bytes beyond the declared length are ignored.
21938        assert_eq!(
21939            redirect_payload_prefix(&[0x00, 0x01, b'a', b'b']).unwrap(),
21940            (1, &b"a"[..])
21941        );
21942        // Truncated: too short to even carry the u16 length.
21943        for bad in [&[][..], &[0x00][..]] {
21944            assert!(
21945                matches!(
21946                    redirect_payload_prefix(bad),
21947                    Err(Error::InvalidRedirectData(_))
21948                ),
21949                "payload {bad:?} must be rejected as malformed"
21950            );
21951        }
21952    }
21953
21954    #[test]
21955    fn parse_redirect_target_well_formed_and_malformed() {
21956        // Well-formed: "<address>\0<connect data>".
21957        let data = "(ADDRESS=(PROTOCOL=tcp)(HOST=dispatcher.example)(PORT=1621))\0\
21958                    (DESCRIPTION=(ADDRESS=(PROTOCOL=tcp)(HOST=dispatcher.example)(PORT=1621))\
21959                    (CONNECT_DATA=(SERVICE_NAME=svc)))";
21960        let target = parse_redirect_target(data, NetProtocol::Tcp).expect("well-formed redirect");
21961        assert_eq!(target.host, "dispatcher.example");
21962        assert_eq!(target.port, 1621);
21963        assert!(target.connect_data.starts_with("(DESCRIPTION="));
21964        // Missing the NUL separator between address and connect data.
21965        assert!(matches!(
21966            parse_redirect_target("(ADDRESS=(PROTOCOL=tcp)(HOST=h)(PORT=1))", NetProtocol::Tcp),
21967            Err(Error::InvalidRedirectData(_))
21968        ));
21969        // Address part with no usable HOST.
21970        assert!(matches!(
21971            parse_redirect_target("(ADDRESS=(PROTOCOL=tcp)(PORT=1621))\0x", NetProtocol::Tcp),
21972            Err(Error::InvalidRedirectData(_))
21973        ));
21974        // Unparseable address part.
21975        assert!(matches!(
21976            parse_redirect_target("(((\0x", NetProtocol::Tcp),
21977            Err(Error::InvalidRedirectData(_))
21978        ));
21979    }
21980
21981    /// `Error::RedirectUnsupported` is kept ONLY for a redirect that demands a
21982    /// transport protocol change: a `tcps` connect is never downgraded to
21983    /// plain `tcp` (silent TLS strip), and a mid-connect `tcp` -> `tcps`
21984    /// upgrade is not supported. Same-protocol redirects are followed.
21985    #[test]
21986    fn parse_redirect_target_refuses_transport_protocol_change() {
21987        let tcp_addr = "(ADDRESS=(PROTOCOL=tcp)(HOST=h)(PORT=1521))\0cd";
21988        let tcps_addr = "(ADDRESS=(PROTOCOL=tcps)(HOST=h)(PORT=2484))\0cd";
21989        let no_protocol = "(ADDRESS=(HOST=h)(PORT=1521))\0cd";
21990        // tcps -> tcp downgrade refused, whether explicit or (fail closed)
21991        // because the redirect omitted the protocol.
21992        assert!(matches!(
21993            parse_redirect_target(tcp_addr, NetProtocol::Tcps),
21994            Err(Error::RedirectUnsupported)
21995        ));
21996        assert!(matches!(
21997            parse_redirect_target(no_protocol, NetProtocol::Tcps),
21998            Err(Error::RedirectUnsupported)
21999        ));
22000        // tcp -> tcps upgrade is not supported mid-connect.
22001        assert!(matches!(
22002            parse_redirect_target(tcps_addr, NetProtocol::Tcp),
22003            Err(Error::RedirectUnsupported)
22004        ));
22005        // Protocol preserved: followed.
22006        assert!(parse_redirect_target(tcps_addr, NetProtocol::Tcps).is_ok());
22007        assert!(parse_redirect_target(no_protocol, NetProtocol::Tcp).is_ok());
22008        assert!(parse_redirect_target(tcp_addr, NetProtocol::Tcp).is_ok());
22009    }
22010
22011    /// End-to-end redirect through real sockets: the first listener answers
22012    /// CONNECT with REDIRECT; the driver must reconnect to the redirected
22013    /// address and resend CONNECT there carrying TNS_PACKET_FLAG_REDIRECT and
22014    /// the redirect-supplied connect data. The target listener REFUSEs so the
22015    /// test ends before authentication; the surfaced ListenerRefused proves
22016    /// the whole redirect hop executed.
22017    #[test]
22018    fn connect_follows_listener_redirect_and_flags_the_new_connect() -> Result<()> {
22019        let redirect_listener = TcpListener::bind("127.0.0.1:0").expect("bind redirect listener");
22020        let target_listener = TcpListener::bind("127.0.0.1:0").expect("bind target listener");
22021        let redirect_addr = redirect_listener.local_addr().expect("redirect addr");
22022        let target_addr = target_listener.local_addr().expect("target addr");
22023        let redirect_connect_data = format!(
22024            "(DESCRIPTION=(ADDRESS=(PROTOCOL=tcp)(HOST=127.0.0.1)(PORT={}))\
22025             (CONNECT_DATA=(SERVICE_NAME=redirsvc)))",
22026            target_addr.port()
22027        );
22028        let redirect_data = format!(
22029            "(ADDRESS=(PROTOCOL=tcp)(HOST=127.0.0.1)(PORT={}))\0{}",
22030            target_addr.port(),
22031            redirect_connect_data
22032        );
22033
22034        let first = thread::spawn(move || -> std::io::Result<(u8, u8)> {
22035            let (mut socket, _) = redirect_listener.accept()?;
22036            socket.set_read_timeout(Some(Duration::from_secs(5)))?;
22037            let (packet_type, packet_flags, _payload) = read_tns_packet_sync(&mut socket)?;
22038            send_tns_packet_sync(
22039                &mut socket,
22040                TNS_PACKET_TYPE_REDIRECT,
22041                &redirect_packet_payload(&redirect_data),
22042            )?;
22043            Ok((packet_type, packet_flags))
22044        });
22045        let expected_connect_data = redirect_connect_data;
22046        let second = thread::spawn(move || -> std::io::Result<(u8, u8, bool)> {
22047            let (mut socket, _) = target_listener.accept()?;
22048            socket.set_read_timeout(Some(Duration::from_secs(5)))?;
22049            let (packet_type, packet_flags, payload) = read_tns_packet_sync(&mut socket)?;
22050            let carries_connect_data = payload
22051                .windows(expected_connect_data.len())
22052                .any(|window| window == expected_connect_data.as_bytes());
22053            send_tns_packet_sync(&mut socket, TNS_PACKET_TYPE_REFUSE, b"(ERR=12514)")?;
22054            Ok((packet_type, packet_flags, carries_connect_data))
22055        });
22056
22057        let options = ConnectOptions::new(
22058            format!("127.0.0.1:{}/redirsvc", redirect_addr.port()),
22059            "user",
22060            "password",
22061            identity(),
22062        );
22063        let runtime = build_io_runtime().expect("asupersync runtime");
22064        let err = runtime
22065            .block_on(async {
22066                let cx = Cx::current().expect("ambient Cx");
22067                Connection::connect(&cx, options).await
22068            })
22069            .expect_err("target listener refuses; the refusal must surface");
22070        assert!(
22071            matches!(&err, Error::ListenerRefused(msg) if msg.contains("ERR=12514")),
22072            "expected the TARGET listener's refusal, got {err:?}"
22073        );
22074        let (first_type, first_flags) = first.join().expect("redirect listener thread")?;
22075        assert_eq!(first_type, TNS_PACKET_TYPE_CONNECT);
22076        assert_eq!(first_flags, 0, "initial CONNECT carries no redirect flag");
22077        let (second_type, second_flags, carries_connect_data) =
22078            second.join().expect("target listener thread")?;
22079        assert_eq!(second_type, TNS_PACKET_TYPE_CONNECT);
22080        assert_eq!(
22081            second_flags, TNS_PACKET_FLAG_REDIRECT,
22082            "the CONNECT resent to the redirect target must carry the REDIRECT packet flag"
22083        );
22084        assert!(
22085            carries_connect_data,
22086            "the redirected CONNECT must carry the redirect-supplied connect data"
22087        );
22088        Ok(())
22089    }
22090
22091    /// Redirect/RESEND interplay plus chunked redirect data: the first
22092    /// listener asks for a RESEND before redirecting, and its REDIRECT packet
22093    /// carries only the u16 length (the data follows in a second REDIRECT
22094    /// packet). The redirected listener then ALSO asks for a RESEND — the
22095    /// resent CONNECT on the redirected connection must still carry the
22096    /// REDIRECT packet flag (reference keeps the flag on the recreated
22097    /// ConnectMessage across resends).
22098    #[test]
22099    fn connect_redirect_interleaves_with_resend_and_chunked_redirect_data() -> Result<()> {
22100        let redirect_listener = TcpListener::bind("127.0.0.1:0").expect("bind redirect listener");
22101        let target_listener = TcpListener::bind("127.0.0.1:0").expect("bind target listener");
22102        let redirect_addr = redirect_listener.local_addr().expect("redirect addr");
22103        let target_addr = target_listener.local_addr().expect("target addr");
22104        let redirect_data = format!(
22105            "(ADDRESS=(PROTOCOL=tcp)(HOST=127.0.0.1)(PORT={port}))\0\
22106             (DESCRIPTION=(ADDRESS=(PROTOCOL=tcp)(HOST=127.0.0.1)(PORT={port}))\
22107             (CONNECT_DATA=(SERVICE_NAME=redirsvc)))",
22108            port = target_addr.port()
22109        );
22110
22111        let first = thread::spawn(move || -> std::io::Result<()> {
22112            let (mut socket, _) = redirect_listener.accept()?;
22113            socket.set_read_timeout(Some(Duration::from_secs(5)))?;
22114            let _ = read_tns_packet_sync(&mut socket)?;
22115            // Ask for a resend BEFORE redirecting (pre-23ai listeners resend
22116            // routinely).
22117            send_tns_packet_sync(&mut socket, TNS_PACKET_TYPE_RESEND, &[])?;
22118            let _ = read_tns_packet_sync(&mut socket)?;
22119            // Chunked redirect: length-only REDIRECT packet, data in a
22120            // follow-up REDIRECT packet.
22121            let length = u16::try_from(redirect_data.len()).expect("test redirect data fits u16");
22122            send_tns_packet_sync(&mut socket, TNS_PACKET_TYPE_REDIRECT, &length.to_be_bytes())?;
22123            send_tns_packet_sync(
22124                &mut socket,
22125                TNS_PACKET_TYPE_REDIRECT,
22126                redirect_data.as_bytes(),
22127            )?;
22128            Ok(())
22129        });
22130        let second = thread::spawn(move || -> std::io::Result<(u8, u8)> {
22131            let (mut socket, _) = target_listener.accept()?;
22132            socket.set_read_timeout(Some(Duration::from_secs(5)))?;
22133            let (_, first_flags, _) = read_tns_packet_sync(&mut socket)?;
22134            // The redirected listener itself asks for a resend; the resent
22135            // CONNECT must still be redirect-flagged.
22136            send_tns_packet_sync(&mut socket, TNS_PACKET_TYPE_RESEND, &[])?;
22137            let (_, resent_flags, _) = read_tns_packet_sync(&mut socket)?;
22138            send_tns_packet_sync(&mut socket, TNS_PACKET_TYPE_REFUSE, b"(ERR=12514)")?;
22139            Ok((first_flags, resent_flags))
22140        });
22141
22142        let options = ConnectOptions::new(
22143            format!("127.0.0.1:{}/redirsvc", redirect_addr.port()),
22144            "user",
22145            "password",
22146            identity(),
22147        );
22148        let runtime = build_io_runtime().expect("asupersync runtime");
22149        let err = runtime
22150            .block_on(async {
22151                let cx = Cx::current().expect("ambient Cx");
22152                Connection::connect(&cx, options).await
22153            })
22154            .expect_err("target listener refuses; the refusal must surface");
22155        assert!(
22156            matches!(&err, Error::ListenerRefused(msg) if msg.contains("ERR=12514")),
22157            "expected the TARGET listener's refusal, got {err:?}"
22158        );
22159        first.join().expect("redirect listener thread")?;
22160        let (first_flags, resent_flags) = second.join().expect("target listener thread")?;
22161        assert_eq!(
22162            first_flags, TNS_PACKET_FLAG_REDIRECT,
22163            "redirected CONNECT must be flagged"
22164        );
22165        assert_eq!(
22166            resent_flags, TNS_PACKET_FLAG_REDIRECT,
22167            "a RESEND on the redirected connection must keep the redirect flag"
22168        );
22169        Ok(())
22170    }
22171
22172    /// A listener that answers every CONNECT with another REDIRECT (here: to
22173    /// itself) must terminate with a structured error instead of spinning
22174    /// forever — never a hang, never an opaque I/O error.
22175    #[test]
22176    fn connect_redirect_loop_is_bounded() -> Result<()> {
22177        let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
22178        let addr = listener.local_addr().expect("listener addr");
22179        let redirect_data = format!(
22180            "(ADDRESS=(PROTOCOL=tcp)(HOST=127.0.0.1)(PORT={port}))\0\
22181             (DESCRIPTION=(ADDRESS=(PROTOCOL=tcp)(HOST=127.0.0.1)(PORT={port}))\
22182             (CONNECT_DATA=(SERVICE_NAME=loopsvc)))",
22183            port = addr.port()
22184        );
22185        let hops = u32::from(MAX_CONNECT_REDIRECT_ROUNDS) + 1;
22186        let server = thread::spawn(move || -> std::io::Result<u32> {
22187            let mut served = 0;
22188            // Initial connection plus MAX redirected reconnects, each answered
22189            // with a self-redirect.
22190            for _ in 0..hops {
22191                let (mut socket, _) = listener.accept()?;
22192                socket.set_read_timeout(Some(Duration::from_secs(5)))?;
22193                let _ = read_tns_packet_sync(&mut socket)?;
22194                send_tns_packet_sync(
22195                    &mut socket,
22196                    TNS_PACKET_TYPE_REDIRECT,
22197                    &redirect_packet_payload(&redirect_data),
22198                )?;
22199                served += 1;
22200            }
22201            Ok(served)
22202        });
22203
22204        let options = ConnectOptions::new(
22205            format!("127.0.0.1:{}/loopsvc", addr.port()),
22206            "user",
22207            "password",
22208            identity(),
22209        );
22210        let runtime = build_io_runtime().expect("asupersync runtime");
22211        let err = runtime
22212            .block_on(async {
22213                let cx = Cx::current().expect("ambient Cx");
22214                Connection::connect(&cx, options).await
22215            })
22216            .expect_err("a redirect loop must terminate with a structured error");
22217        assert!(
22218            matches!(err, Error::ConnectRedirectLoop(rounds)
22219                if rounds == MAX_CONNECT_REDIRECT_ROUNDS + 1),
22220            "expected ConnectRedirectLoop, got {err:?}"
22221        );
22222        assert_eq!(err.kind(), ErrorKind::Protocol);
22223        let served = server.join().expect("listener thread")?;
22224        assert_eq!(served, hops, "every hop reached the listener");
22225        Ok(())
22226    }
22227
22228    /// A listener that answers every CONNECT with RESEND must terminate with
22229    /// a structured error instead of spinning forever, mirroring
22230    /// `connect_redirect_loop_is_bounded` above but for the RESEND bound.
22231    /// `Error::ConnectResendLoop` had zero test coverage before H5.
22232    #[test]
22233    fn connect_resend_loop_is_bounded() -> Result<()> {
22234        let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
22235        let addr = listener.local_addr().expect("listener addr");
22236        let hops = u32::from(MAX_CONNECT_RESEND_ROUNDS) + 1;
22237        // Unlike a REDIRECT (a new connection to a new target), a RESEND is
22238        // answered on the SAME socket: the client just retransmits CONNECT.
22239        let server = thread::spawn(move || -> std::io::Result<u32> {
22240            let (mut socket, _) = listener.accept()?;
22241            socket.set_read_timeout(Some(Duration::from_secs(5)))?;
22242            let mut served = 0;
22243            for _ in 0..hops {
22244                let _ = read_tns_packet_sync(&mut socket)?;
22245                send_tns_packet_sync(&mut socket, TNS_PACKET_TYPE_RESEND, &[])?;
22246                served += 1;
22247            }
22248            Ok(served)
22249        });
22250
22251        let options = ConnectOptions::new(
22252            format!("127.0.0.1:{}/resendsvc", addr.port()),
22253            "user",
22254            "password",
22255            identity(),
22256        );
22257        let runtime = build_io_runtime().expect("asupersync runtime");
22258        let err = runtime
22259            .block_on(async {
22260                let cx = Cx::current().expect("ambient Cx");
22261                Connection::connect(&cx, options).await
22262            })
22263            .expect_err("a resend loop must terminate with a structured error");
22264        assert!(
22265            matches!(err, Error::ConnectResendLoop(rounds)
22266                if rounds == MAX_CONNECT_RESEND_ROUNDS + 1),
22267            "expected ConnectResendLoop, got {err:?}"
22268        );
22269        assert_eq!(err.kind(), ErrorKind::Protocol);
22270        let served = server.join().expect("listener thread")?;
22271        assert_eq!(served, hops, "every round reached the listener");
22272        Ok(())
22273    }
22274
22275    /// A multi-address `ADDRESS_LIST` where every address refuses the
22276    /// transport must aggregate into `Error::AllAddressesFailed` rather than
22277    /// surfacing only the last address's raw I/O error. Both addresses are
22278    /// closed local ports (bound then immediately dropped so the OS answers
22279    /// ECONNREFUSED) — no live database needed, this is a pure transport
22280    /// failover test. `Error::AllAddressesFailed` had zero test coverage
22281    /// before H5.
22282    #[test]
22283    fn connect_all_addresses_failed_when_every_address_refuses() {
22284        let closed_port = |listener: TcpListener| -> u16 {
22285            let port = listener.local_addr().expect("addr").port();
22286            drop(listener);
22287            port
22288        };
22289        let port_a = closed_port(TcpListener::bind("127.0.0.1:0").expect("bind a"));
22290        let port_b = closed_port(TcpListener::bind("127.0.0.1:0").expect("bind b"));
22291
22292        let connect_string = format!(
22293            "(DESCRIPTION=(RETRY_COUNT=0)\
22294             (ADDRESS_LIST=(ADDRESS=(PROTOCOL=tcp)(HOST=127.0.0.1)(PORT={port_a}))\
22295             (ADDRESS=(PROTOCOL=tcp)(HOST=127.0.0.1)(PORT={port_b})))\
22296             (CONNECT_DATA=(SERVICE_NAME=svc)))"
22297        );
22298        let options = ConnectOptions::new(connect_string, "user", "password", identity());
22299        let runtime = build_io_runtime().expect("asupersync runtime");
22300        let err = runtime
22301            .block_on(async {
22302                let cx = Cx::current().expect("ambient Cx");
22303                Connection::connect(&cx, options).await
22304            })
22305            .expect_err("every address refuses; the aggregate must surface");
22306        assert!(
22307            matches!(&err, Error::AllAddressesFailed(detail) if detail.contains(&port_a.to_string())
22308                && detail.contains(&port_b.to_string())),
22309            "expected AllAddressesFailed naming both addresses, got {err:?}"
22310        );
22311        assert_eq!(err.kind(), ErrorKind::Network);
22312    }
22313
22314    /// Public-API regression for bead rust-oracledb-4sfc: deterministic TLS
22315    /// preparation must finish before the first address is dialled. The wallet
22316    /// fixture has a valid CA certificate plus a malformed PKCS#8 client key,
22317    /// reproducing the configuration failure that used to occur only after TCP
22318    /// connect and could therefore be retried into an unrelated diagnostic.
22319    #[test]
22320    fn connection_connect_returns_tls_configuration_error_without_dialling() {
22321        let listener = TcpListener::bind("127.0.0.1:0").expect("bind dial sentinel");
22322        listener
22323            .set_nonblocking(true)
22324            .expect("make dial sentinel nonblocking");
22325        let port = listener.local_addr().expect("dial sentinel address").port();
22326        let wallet = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
22327            .join("tests/fixtures/tls/invalid_client_key");
22328        let options = ConnectOptions::new(
22329            format!("tcps://127.0.0.1:{port}/svc"),
22330            "user",
22331            "password",
22332            identity(),
22333        )
22334        .with_wallet_location(wallet.display().to_string());
22335
22336        let runtime = build_io_runtime().expect("asupersync runtime");
22337        let error = runtime
22338            .block_on(async {
22339                let cx = Cx::current().expect("ambient Cx");
22340                Connection::connect(&cx, options).await
22341            })
22342            .expect_err("the invalid client key must fail TLS preparation");
22343        assert!(
22344            matches!(&error, Error::Tls(detail) if detail.contains("private key")),
22345            "the original typed TLS configuration error must surface, got {error:?}"
22346        );
22347
22348        match listener.accept() {
22349            Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {}
22350            Ok(_) => panic!("deterministic TLS configuration failure dialled the listener"),
22351            Err(error) => panic!("dial sentinel accept failed unexpectedly: {error}"),
22352        }
22353    }
22354
22355    // ------------------------------------------------------------------
22356    // Packet-layer vs TTC-layer error labelling
22357    // (bead rust-oracledb-pre23ai-connect-z47u.3)
22358    // ------------------------------------------------------------------
22359
22360    /// The packet-layer error text is self-triaging: it names known TNS
22361    /// packet types so a stray RESEND is never mistaken for TTC message 11
22362    /// (IO_VECTOR) — the mislabel that once steered a triage session toward
22363    /// SDU-reassembly hypotheses.
22364    #[test]
22365    fn unexpected_packet_error_names_tns_packet_types() {
22366        assert_eq!(
22367            Error::UnexpectedPacket(TNS_PACKET_TYPE_RESEND).to_string(),
22368            "unexpected TNS packet type 11 (RESEND)"
22369        );
22370        assert_eq!(
22371            Error::UnexpectedPacket(99).to_string(),
22372            "unexpected TNS packet type 99 (unknown)"
22373        );
22374    }
22375
22376    /// The flag-framed boundary reader's non-DATA arm reports the NETWORK
22377    /// packet type byte (header offset 4) as `Error::UnexpectedPacket`, not
22378    /// as a TTC `UnknownMessageType` (which names application-layer message
22379    /// types and previously mislabelled this byte with `position: 4`).
22380    #[test]
22381    fn flag_framed_reader_labels_non_data_packet_as_packet_layer_error() -> Result<()> {
22382        let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener");
22383        let addr = listener.local_addr().expect("listener address");
22384        let server = thread::spawn(move || {
22385            let (mut socket, _) = listener.accept().expect("accept test client");
22386            use std::io::Write as _;
22387            let packet = encode_packet(
22388                TNS_PACKET_TYPE_RESEND,
22389                0,
22390                None,
22391                &[],
22392                PacketLengthWidth::Large32,
22393            )
22394            .expect("encode unexpected packet");
22395            socket.write_all(&packet).expect("write unexpected packet");
22396        });
22397
22398        let runtime = build_io_runtime().expect("asupersync runtime");
22399        let err = runtime.block_on(async {
22400            let cx = Cx::current().expect("ambient Cx");
22401            let stream = TcpStream::connect(addr).await.expect("connect to listener");
22402            let (read, write) = transport::plain_split(stream);
22403            let mut conn = loopback_connection(read, write);
22404            conn.core
22405                .read_data_response(&cx)
22406                .await
22407                .expect_err("non-DATA packet must fail closed")
22408        });
22409
22410        assert!(
22411            matches!(err, Error::UnexpectedPacket(TNS_PACKET_TYPE_RESEND)),
22412            "expected the packet-layer error, got {err:?}"
22413        );
22414        assert!(
22415            err.to_string().contains("TNS packet type 11 (RESEND)"),
22416            "error text must name the TNS packet type: {err}"
22417        );
22418        server.join().expect("server thread joins");
22419        Ok(())
22420    }
22421
22422    /// The break-drain reader (phase A: discarding in-flight responses until
22423    /// the break-acknowledge MARKER) likewise reports a stray non-DATA /
22424    /// non-MARKER packet as `Error::UnexpectedPacket` — a packet-layer byte,
22425    /// not a TTC message type.
22426    #[test]
22427    fn break_drain_labels_non_data_packet_as_packet_layer_error() -> Result<()> {
22428        let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener");
22429        let addr = listener.local_addr().expect("listener address");
22430        let server = thread::spawn(move || {
22431            let (mut socket, _) = listener.accept().expect("accept test client");
22432            use std::io::Write as _;
22433            let packet = encode_packet(
22434                TNS_PACKET_TYPE_ACCEPT,
22435                0,
22436                None,
22437                &[0x00],
22438                PacketLengthWidth::Large32,
22439            )
22440            .expect("encode unexpected packet");
22441            socket.write_all(&packet).expect("write unexpected packet");
22442        });
22443
22444        let runtime = build_io_runtime().expect("asupersync runtime");
22445        let err = runtime.block_on(async {
22446            let stream = TcpStream::connect(addr).await.expect("connect to listener");
22447            let (mut read, write) = transport::plain_split(stream);
22448            let write = Arc::new(AsyncMutex::with_name("break_drain_test_write", write));
22449            drain_break_response_recovery(&mut read, &write)
22450                .await
22451                .expect_err("non-DATA/non-MARKER packet must fail closed")
22452        });
22453
22454        assert!(
22455            matches!(err, Error::UnexpectedPacket(TNS_PACKET_TYPE_ACCEPT)),
22456            "expected the packet-layer error, got {err:?}"
22457        );
22458        assert!(
22459            err.to_string().contains("TNS packet type 2 (ACCEPT)"),
22460            "error text must name the TNS packet type: {err}"
22461        );
22462        server.join().expect("server thread joins");
22463        Ok(())
22464    }
22465}