diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04f5083..24ddead 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -316,11 +316,22 @@ jobs: run: python3 scripts/ci/install-redis.py - name: Pull MongoDB before the bounded fixture startup wait run: docker pull mongo:8 + - name: Install WASI 0.2 adapter runtime and C toolchain + run: | + rustup target add --toolchain nightly-2026-08-20 wasm32-wasip2 + bash scripts/ci/install-wasmtime.sh + python3 scripts/ci/install-wasm-toolchain.py - name: Required real-server protocol suites with the shared fixture contract env: POSTGRES_CONTAINER: ${{ job.services.postgres.id }} MYSQL_CONTAINER: ${{ job.services.mysql.id }} run: scripts/test-servers.py --ci-services run python3 scripts/ci/run-tests.py protocol + - name: Required WASI 0.2 real-server adapters + if: '!cancelled()' + env: + POSTGRES_CONTAINER: ${{ job.services.postgres.id }} + MYSQL_CONTAINER: ${{ job.services.mysql.id }} + run: scripts/test-servers.py --ci-services run python3 scripts/ci/run-tests.py protocol-wasi --target wasm32-wasip2 --real-servers - name: Fixture logs and cleanup if: always() run: scripts/test-servers.py --ci-services stop diff --git a/Cargo.lock b/Cargo.lock index 6c3da85..fe487d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1827,6 +1827,7 @@ version = "0.1.0-alpha.1" dependencies = [ "futures-io", "turnloop", + "windows-sys 0.61.2", ] [[package]] @@ -1846,6 +1847,7 @@ dependencies = [ "sha2", "stringprep", "turnloop-io", + "turnloop-tls", "turnloop-wasi-random", ] @@ -1859,6 +1861,7 @@ dependencies = [ "mysql_common", "rustls", "turnloop-io", + "turnloop-tls", "turnloop-wasi-random", ] @@ -1866,11 +1869,14 @@ dependencies = [ name = "turnloop-postgres" version = "0.1.0-alpha.1" dependencies = [ + "base64", "bytes", "postgres-protocol", + "rcgen", "rustls", "sha2", "turnloop-io", + "turnloop-tls", "turnloop-wasi-random", ] @@ -1880,6 +1886,7 @@ version = "0.1.0-alpha.1" dependencies = [ "rustls", "turnloop-io", + "turnloop-tls", ] [[package]] @@ -1891,6 +1898,7 @@ dependencies = [ "lettre", "rustls", "turnloop-io", + "turnloop-tls", ] [[package]] diff --git a/LANE_REPORT.md b/LANE_REPORT.md new file mode 100644 index 0000000..077ee6b --- /dev/null +++ b/LANE_REPORT.md @@ -0,0 +1,335 @@ +# adapters-db — wave 3 part 2 + +Status: implementation and available native/WASI verification complete, with +the explicit integration/runtime limitations below. Based on c506775. The +integrator owns commits. Mandatory design, contribution, integration and relevant +lane reports have been read completely. + +## Implemented + +- Shared `turnloop-io` exchange cancellation guards, retained receive buffers, + TLS transport upgrades, resolution helpers and timer-driven pool scheduling. + SQL/CMAP policy stays in the existing sans-I/O machines. Physical close + acknowledgements are awaited before pool replacement/end completes. +- PostgreSQL TLS/authenticated Client, simple/extended queries, prepared cache, + notifications, streaming COPY, separate cancel connection and policy-backed + Pool (limits, acquire/idle timeouts and maxUses). +- MySQL TLS/caching_sha2/RSA Connection, text/binary commands, multiple results, + prepared statements, transaction guards and policy-backed Pool. +- Redis pipelines, borrowed subscribers, blocking deadlines, real-timer + reconnect/replay, ClusterClient slot routing/MOVED/ASK and Sentinel discovery. +- SMTP STARTTLS/implicit TLS, auth, pipelining and per-recipient send results. +- MongoDB authenticated TLS, real-timer SDAM heartbeats, CMAP, retained server + selection, retryable reads/writes, cursor streaming and drop cleanup. Native + SRV/TXT uses turnloop's blocking pool (Unix resolver / Windows DNS API). +- WASI 0.2 `ip-name-lookup` integrated with the backend poll set, including + cancellation and no-spin behavior. Native blocking jobs and physical-close + futures are exposed by the executor for shared adapter use. +- Per-crate examples and getting-started README sections. CI metadata selects + async socket suites and real fixture suites; required protocol CI also runs + WASI 0.2 real servers. The runner rejects zero executed tests. + +## Verification + +[docs/adapters-db-commands.md](docs/adapters-db-commands.md) records **every +verification command**, including superseded failures. Raw logs are in +`.tools/adapters-db/` (untracked). Superseded failures remain visible; the statuses +below describe final results. + +- PASS: `cargo fmt --check`, strict native default/all-feature Clippy, stable + all-feature check, examples, + no-tokio cross-target dependency checks, dependency soak, 93 CI script tests, pinned workflow lint + (actionlint, zizmor and shellcheck), and `git diff --check`. +- PASS: Linux Clippy (all targets), Windows library Clippy, WASI 0.2/0.3 and web + Clippy (all targets). These are compile checks, not Windows/Linux execution. +- PASS: `cargo test --workspace -- --test-threads=1` and + `cargo test --workspace --all-features -- --test-threads=1`. The exact default parallel + workspace run failed twice at the existing process/signal no-spin assertion + (`crates/turnloop-contract/src/native_surface.rs:440`, `zero <= 1`); retained + in the command ledger. CI uses serial tests. No assertion was weakened. +- PASS: all five scripted async socket suites (14 tests), including per-thread warmed + allocation gates. PG/MySQL/Redis/MongoDB reusable operations allocate zero; + SMTP allocates exactly its existing three owned SendInfo result allocations, + with zero additional adapter allocation. Counters prove they observe an + allocation; response/server counts prove commands ran. +- PASS: PostgreSQL/MySQL cancelled queued acquires, pooled idle parking and + MySQL idle-timeout retirement; MongoDB cancellation, replacement and SDAM idle + parking; SMTP future-drop EOF and idle parking; Redis real-timer reconnect and + cancellation. MongoDB tests cover all five read preferences and run retained + selection against the official selection/staleness corpus. Local CMAP discard + preserves the other lease and generation. +- PASS: native Redis fixtures: TLS, pipeline, pub/sub, blocking timeout, + connection kill, real ASK migration/MOVED, actual cluster node restart and + Sentinel. All pass under WASI 0.2 too. +- PASS: native MongoDB fixtures: auth/TLS, three-node SDAM, timed heartbeat, + primary step-down, cursors, forced read/write retry (exactly one retry), + dropped-cursor cleanup verified by CursorNotFound. The final WASI 0.2 real + suite passes the same checks, including retry counts and cleanup. +- PASS: native SMTP fixture and WASI SMTP delivery with dumped payload checked. +- PASS: full WASI 0.2 portable protocol suites and DNS resolve/cancel slot reuse; + final rerun includes all newest cancellation/selection/idle tests. +- UNRUN (sandbox): PostgreSQL real suite. Attempted + `python3 scripts/test-servers.py --services postgres run cargo test -p turnloop-postgres --features turnloop --test async_server -- --include-ignored --test-threads=1 --nocapture`. + `initdb` failed at `shmget`, Operation not permitted, before tests launched. +- UNRUN (sandbox): MySQL real suite. Attempted + `python3 scripts/test-servers.py --services mysql run cargo test -p turnloop-mysql --features turnloop --test async_server -- --include-ignored --test-threads=1 --nocapture`. + `mysqld --initialize-insecure` crashed with SIGSEGV before tests launched. + The ledger records these fixture commands as FAIL; adapter runtime is UNRUN. +- FAIL (inherited integration dependency): Windows all-target Clippy cannot + compile socket tests because `turnloop::backend::Platform` has no Windows + provider yet. Generic libraries, including Windows DNS bindings, pass strict + cross-Clippy. No tests were cfg-disabled to conceal this dependency. +- FAIL (pinned compiler): WASI 0.3 debug custom-allocator executable traps before + main in argument lowering. The documented WASI-lane release workaround is + declared explicitly in metadata; identical assertions and positive execution + counts remain required. PASS: full `protocol-wasi --target wasm32-wasip3`, + including all 14 database/mail async tests in release and existing portable + suites in their declared profiles. +- PASS: live native SRV/TXT through turnloop's blocking pool, asserting actual + XMPP service and SPF records. This external-DNS probe is explicitly invoked; + required deterministic tests also cover positive/malformed wire records, + off-loop worker completion and native resolver validation. +- UNRUN: Windows/Linux runtime (hosts unavailable). + +## Deviations / proposed DESIGN changes + +No gate, dependency soak or assertion has been weakened. The existing temporary +rustls security exception remains owned by the repository's security policy. + +Proposed additive backend revision: optional `Backend::resolve` plus +`Outcome::Resolved` allow WASI DNS pollables without native blocking workers. +Executor `blocking` and `close` futures share existing completion ownership. +The WASI backend includes DNS readiness in the same bounded OS wait/no-spin +contract; native backends retain the blocking DNS fallback. + +Streaming APIs use inherent async `next`/notification methods or borrowed +callbacks, following adapters-net; owned cursor/subscriber values necessarily +allocate when retained. Connection setup allocates buffers once. Dropping an +in-flight wire exchange closes it; the next pool lease can create a replacement. +Cursor drop queues best-effort kill while the Client lives; explicit close awaits +acknowledgement. + +Windows' production Platform backend remains the inherited WINDOWS_HANDOFF.md +integration dependency; generic adapters accept any conforming Backend. Web +browsers have no raw database TCP capability. WASI 0.2 supports TCP/A/AAAA; +SRV/TXT requires native DNS or explicitly resolved seeds. These capability limits +are documented rather than silently simulated. + +## Open questions / next steps + +- Integrator: investigate the parallel process/signal contract failure with + the core lane; serial default/all-feature suites pass without test changes. +- Integrator: run PostgreSQL/MySQL native and WASI fixture suites outside the + sandbox, then Linux/Windows runtimes when those hosts/providers are available. +- Confirm the additive DNS/physical-close API with the core integrator. +- A private MongoDB SRV DNS zone would let CI test the entire `mongodb+srv` + bootstrap against a controlled database fixture; live DNS resolution and core + SRV domain/TXT policy tests already pass separately. + +## Review and rerun entry points + +- Shared glue: `crates/turnloop-io/src/{lib,pool,dns}.rs`; executor close/worker + futures and WASI lookup ownership under `crates/turnloop/src/`. +- Protocol async modules re-export implementations in `client.rs`, + `connection.rs`, `transport.rs` and `async_pool.rs` as applicable. +- Deterministic suites: `tests/asynchronous.rs` in each of the five protocol + crates; real suites: `tests/async_server.rs`. Protocol fixture suites are + intentionally ignored without the unified runner, which requires execution. +- Native real runnable subset (PASS): + `python3 scripts/test-servers.py --services redis,mongodb,smtp run python3 scripts/ci/run-tests.py protocol --package turnloop-redis --package turnloop-mongodb --package turnloop-smtp`. +- WASI 0.2 real runnable subset (PASS, first source `.tools/wasm-env.sh`): + `python3 scripts/test-servers.py --services redis,mongodb,smtp run python3 scripts/ci/run-tests.py protocol-wasi --target wasm32-wasip2 --real-servers --package turnloop-redis --package turnloop-mongodb --package turnloop-smtp`. +- Out-of-sandbox SQL WASI rerun (UNRUN): source `.tools/wasm-env.sh`, then + `python3 scripts/test-servers.py --services postgres,mysql run python3 scripts/ci/run-tests.py protocol-wasi --target wasm32-wasip2 --real-servers --package turnloop-postgres --package turnloop-mysql`. +- CI retains `protocol` and `protocol-wasi` in required `ci-gate` dependencies. + No remote CI, release or publication was performed in this lane. + +## adb-fix1 — completed; SQL runtime awaits integrator + +### Root cause and fix + +TLS alone selected SCRAM-PLUS, but the async client never derived certificate +binding. It now hashes the verified peer leaf after upgrade, using the shared +`turnloop_tls::tls_server_end_point` helper and allocation-free peer-chain access. +The bounds-checked DER reader selects the **outer signature algorithm**: RSA and +ECDSA SHA-256/384/512, RSA-PSS parameters (including SHA-1 defaults), and RSA +MD5/SHA-1 mapped to SHA-256. An explicit `ConnectOptions::channel_binding` wins. +Unknown algorithms/absent certificates use plain SCRAM with `n,,`. + +The sans-I/O machine owns mechanism selection through the additive +`tls_established_with_channel_binding(available)` acknowledgement. The original +`tls_established()` means no binding available. Required binding fails clearly +without data or a PLUS offer and cannot be bypassed by bare AuthenticationOk. +The synchronous driver derives from its actual verified peer certificate and +uses the same helper and fallback. READMEs describe both paths. + +This follows [RFC 5929 §4.1](https://www.rfc-editor.org/rfc/rfc5929.html#section-4.1), +[RSA-PSS parameters](https://www.rfc-editor.org/rfc/rfc4055.html#section-3.1), and +[libpq prefer semantics](https://www.postgresql.org/docs/16/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING). +No new registry packages or versions: all **251** locked registry versions and +checksums are unchanged. Existing ring, rcgen and base64 workspace dependencies +are reused; no soak exception or override was added. + +### Tests and verification + +**Every verification invocation, including intermediate failures, is recorded in +[docs/adb-fix1-commands.md](docs/adb-fix1-commands.md)**; raw logs live in +`.tools/adb-fix1/`. Final results: + +| Command / scope | Result | +| --- | --- | +| `cargo fmt --all --check`; `git diff --check` | PASS | +| Strict `cargo clippy --locked --workspace --all-targets`, default/all features, `-D warnings -D clippy::undocumented_unsafe_blocks` | PASS | +| `cargo +stable check --locked --workspace --all-targets --all-features` | PASS | +| `cargo test --locked --workspace -- --test-threads=1` | PASS: 245 passed, 13 service tests ignored/UNRUN | +| Same workspace tests with `--all-features` | PASS: 300 passed, 20 service tests ignored/UNRUN | +| `cargo test --locked -p turnloop-postgres -p turnloop-tls --all-features -- --test-threads=1` | PASS: final 34 passed, 5 real SQL tests ignored/UNRUN | +| Strict all-target/all-feature Clippy for both touched crates: WASI p2/p3, web, Linux x86_64 | PASS; p3 uses nightly-2026-09-07 | +| `python3 scripts/ci/run-tests.py protocol-wasi --target wasm32-wasip2 --package turnloop-postgres --package turnloop-tls` | PASS: 22 tests, six positive-count suites | +| Same protocol command with `--target wasm32-wasip3` | PASS: 22 tests; existing declared p3 release profile for PG async | +| Windows strict library Clippy and explicit `channel_binding`, `protocol`, `server` test-target Clippy | PASS; execution UNRUN | +| Windows strict all-target Clippy | FAIL: inherited missing `backend::Platform` IOCP provider; no cfg exclusion added | +| `bash scripts/ci/no-tokio.sh`; `python3 scripts/ci/soak.py` | PASS: all policy targets; seven-day policy and existing rustls exception retained | +| Strict rustdoc for both crates; `python3 scripts/ci/feature_modes.py` | PASS | +| `python3 scripts/ci/check-paths.py` | FAIL only because new modules are untracked in the read-only Git index. Same unchanged checker over exact working-tree names PASS (233 references); integrator must stage and rerun | +| Source/lock audit and exact startup-rejection diagnostic test | PASS; original async real-server file preserved byte-for-byte, separate test appended | + +Scripted TLS peers execute five cases: rcgen ECDSA P-256 automatic PLUS, explicit +override, Ed25519 `n,,` fallback, missing required binding, and required PLUS. +They verify mechanism, GS2 flag, decoded cbind bytes against an independent SHA-256 +of the leaf, client proof, server signature, and a subsequent row. The rejected +connection must drop its owned stream exactly once and send zero startup bytes. +Pure wire tests cover offer/data combinations, legacy acknowledgement, required +binding errors and authentication bypass. Fourteen real OpenSSL certificates +cover every supported algorithm (including PSS with a different MGF1 hash), plus +Ed25519; independent hashlib digests, every truncated prefix and malformed DER/ +PSS parameters are checked. These suites are declared for WASI execution. + +Allocation gates prove **zero** allocations for 1,000 peer-chain/hash accesses +and 5 × 1,000 hash/fallback operations. Existing query/pool zero gates and TLS's +400 upstream allocations per 100 bidirectional records remain unchanged. + +Initial new-fixture failures were teardown assertions: native reset and WASI p2 +last-operation-failed do not always report EOF. The final rejection fixture adds +an independent exactly-once stream-drop probe and still rejects any startup +bytes and any client error other than the specified binding error. All SCRAM, +proof, work-count and allocation assertions remain active. No backend/gate was +changed to handle that platform difference. + +### Startup panic and real-server status + +The reported panic is **expected**, not a swallowed real-server failure. +`rejected_startup_reports_server_sqlstate_and_message` catches its synchronous +test-driver panic, asserts SQLSTATE 28000 and the missing-role message, and joins +the scripted peer. Its exact `--nocapture` rerun prints that panic and passes one +test. A comment now explains this behavior. Actual cancellation observers already +use `scram_user`, not an assumed `postgres` role. + +The original `real_async_tls_queries_copy_cancel_pool_and_connection_kill` is +unchanged. A separate ignored `real_async_required_channel_binding_authenticates_over_ssl` +uses auto-derived binding with the required policy and asserts one true `pg_stat_ssl` +row. PostgreSQL 16's `hostssl ... scram-sha-256` fixture offers both mechanisms; +[pg_stat_ssl](https://www.postgresql.org/docs/16/monitoring-stats.html#MONITORING-PG-STAT-SSL-VIEW) +reports TLS, not the SCRAM mechanism. No role-level PLUS-only enforcement was +added; the new test proves PLUS through the client's required/verified policy, +while scripted peers inspect the actual mechanism and binding bytes. + +Attempted integrator reproduction: +`python3 scripts/test-servers.py --services postgres,mysql run python3 scripts/ci/run-tests.py protocol --package turnloop-postgres --package turnloop-mysql`. +Fixture command FAIL at PostgreSQL initdb `shmget`, Operation not permitted; +**both SQL suites UNRUN (sandbox)** in this invocation. MySQL startup was never +reached. User-supplied pre-fix outside-sandbox results remain: MySQL server/async +PASS (3 + 1), PostgreSQL sync PASS (4), PostgreSQL async FAIL (binding bug). +No post-fix real SQL pass is claimed. + +### Deviations, open questions and next steps + +No DESIGN.md changes proposed; no backend, no-spin rule, existing test threshold, +dependency version or security/soak policy changed. No implementation question +remains. Integrator: stage/commit new modules and certificate fixtures, rerun the +index path gate, and rerun the SQL reproduction above outside the sandbox. Also +run `python3 scripts/test-servers.py --services postgres,mysql run python3 scripts/ci/run-tests.py protocol-wasi --target wasm32-wasip2 --real-servers --package turnloop-postgres --package turnloop-mysql` +after sourcing `.tools/wasm-env.sh` (UNRUN: sandbox). Linux/Windows runtime remains +UNRUN without those hosts; full Windows all-target compilation needs the provider +merge. Browser runtime is UNRUN; raw PostgreSQL TCP is not a browser capability. + +## adb-fix2 — SQL and parallel no-spin fixes complete + +Base `9862322`; no commits (`.git` is read-only). Integrator-owned PostgreSQL and +MySQL servers remained running: sourced `.tools/sql-env.sh`, never used fixture +start/run/stop or altered `.tools/sql`. No MySQL implementation changes were needed. + +### Root causes and fixes + +- PostgreSQL already recognized FATAL/PANIC, but replaced the diagnostic with + `Transport`; async `complete` accepted `Outcome::Aborted` as success. The core + now returns `Error::ConnectionAborted` immediately, retaining every error field + in one shared owned copy. It discards unsent output, rejects reuse, and drains + exactly one aborted completion per pending token followed by Closed. EOF cannot + overwrite the diagnostic. Async readers return `io::ErrorKind::ConnectionAborted` + with the typed error and close the stream. Ordinary ERROR remains borrowed and + becomes `Outcome::ServerError` only after ReadyForQuery. This agrees with + [PostgreSQL termination](https://www.postgresql.org/docs/16/protocol-flow.html#PROTOCOL-FLOW-TERMINATION) + and the [nonlocalized severity field](https://www.postgresql.org/docs/16/protocol-error-fields.html). +- The no-spin failure was SIGCHLD interference: every child registration subscribes + to the process-wide signal, so unrelated parallel child exits notify the quiet + loop. Controlled 64-child churn reproduced the failure with only this test + selected; tracing found zero-timeout waits, no EINTR. Its unchanged 60-expiry + contract now runs in a fresh fixture process, with exit-status and exact output + checks. Backend code, counters and all numerical bounds are unchanged. +- Repetition exposed a second fixture collision: two SIGUSR1 fan-out tests could + release each other's waiters, restore SIG_DFL, then kill the test process with + the other send (exit -30). A test-only mutex isolates those two fixtures. Each + still exercises four concurrent loops; stress retains all 256 rounds. + +### Coverage and verification + +Scripted FATAL/PANIC-then-EOF runs **12 cases across six async readers**, checking +kind, SQLSTATE/message, retained detail and exactly-once stream drop. Fragmented +core scripts check severity precedence, pending-token completion and EOF handling. +The real PostgreSQL kill assertion is strengthened to require 57P01 and the exact +message. Everything after it is unchanged and now executes successfully, including +max_uses, pool.end and cancel-future timeout; no later failure was exposed. + +The allocation gates retain the original workload and zero thresholds. Added +1,000 measured successful-query/statement-error pairs require zero allocations; +one terminal diagnostic allocation is shared by **64 zero-allocation aborts**. +PG's pure allocation suite is now mandatory on WASI too, using the existing p3 +release workaround. `Error`/`Outcome` are **Clone rather than Copy**, an intentional +pre-alpha API change documented in the README. No DESIGN.md change is proposed. + +**Every invocation, including failures, is in [docs/adb-fix2-commands.md](docs/adb-fix2-commands.md).** +Raw output, controlled reproducers and repetition commands are in `.tools/adb-fix2/`. + +| Verification | Result | +| --- | --- | +| Native real PostgreSQL async_server / server, SQL env sourced | PASS **2 / 4**; server includes one scripted startup rejection | +| Native real MySQL async_server / server, SQL env sourced | PASS **1 / 3** | +| `protocol-wasi --target wasm32-wasip2 --real-servers --package turnloop-postgres --package turnloop-mysql`, both env files sourced | PASS **24**, including all **3 real async SQL tests**, no ignored tests | +| `protocol-wasi --target wasm32-wasip3 --package turnloop-postgres`, WASM env sourced | PASS **18** wire/async/allocation tests | +| `cargo test --locked --workspace` (default parallel threads) | Earlier PASS **248**, 13 ignored; final rerun **FAIL** at unchanged UDP rebind test, detailed below | +| `cargo test --locked --workspace --all-features` (default parallel threads) | PASS **304**, 20 ignored, including both touched crates and MySQL | +| Full native_surface, 10 fresh parallel processes each in default/executor/all-feature modes | Final PASS **30/30**, **480 tests**, **1,800 no-spin expiries**; initial campaign FAIL from SIGUSR1 collision after 20 passes | +| Strict workspace Clippy, default/all features; touched-crate all-target Clippy on Linux x86_64, WASI p2/p3, web | PASS; warnings and undocumented unsafe blocks denied | +| Windows strict library and PG protocol/server/allocation test Clippy with existing Zig wrapper | PASS; runtime UNRUN | +| Windows all-target Clippy | FAIL: inherited absent `backend::Platform` IOCP provider. Initial runs without the wrapper also failed on missing C headers | +| Stable workspace/all-target/all-feature check; fmt; whitespace; path and feature gates | PASS | +| `bash scripts/ci/no-tokio.sh`; `python3 scripts/ci/soak.py` | PASS: eight targets plus union; **251** locked versions, seven-day policy and sole existing rustls exception unchanged | +| Source audit against 9862322 | PASS: production core, shared no-spin body, original PG allocation workload, later real-server assertions, lock/policies unchanged; no new unwrap or unsafe | + +### Remaining failures / next steps + +The final default workspace rerun hit an **independent, unchanged** core test: +`backend::unix::udp_tests::cancelled_udp_with_cached_events_survives_exact_fd_and_port_reuse`, +`crates/turnloop/src/backend/unix.rs:1073`, `libc::bind` returned -1 instead of 0. +The assertion does not record errno, so the cause is **unconfirmed**. That invocation +ran 10 successful core tests and one failure; subsequent suites were UNRUN in that +invocation. The earlier complete default run and both all-feature workspace runs +passed. No retry was used to replace this final FAIL, and no UDP assertion changed. +Integrator should investigate the bind/reuse fixture separately. + +Linux/Windows native runtime and browser runtime are **UNRUN** (hosts unavailable; +browsers have no raw SQL TCP). Full Windows test checking needs its existing +provider integration. The integrator should commit this coherent tree and run +those platforms. No other implementation question remains within adb-fix2; the +UDP default-workspace failure and Windows provider remain explicit quality limits. diff --git a/crates/turnloop-contract/src/bin/native_child.rs b/crates/turnloop-contract/src/bin/native_child.rs index 100a6f0..2e9a042 100644 --- a/crates/turnloop-contract/src/bin/native_child.rs +++ b/crates/turnloop-contract/src/bin/native_child.rs @@ -7,6 +7,19 @@ fn main() { let args: Vec<_> = std::env::args_os().collect(); let mode = args.get(1).and_then(|s| s.to_str()).expect("fixture mode"); match mode { + #[cfg(any( + target_vendor = "apple", + target_os = "linux", + target_os = "android", + target_os = "freebsd" + ))] + "services-no-spin" => { + turnloop_contract::native_surface::services_no_spin::( + &args[0], + turnloop::Signal::Usr2, + ); + println!("60 service timer expiries; no-spin bounds passed"); + } "exit" => std::process::exit(23), "sleep" => std::thread::sleep(Duration::from_secs(60)), "environment" => { diff --git a/crates/turnloop-contract/tests/native_surface.rs b/crates/turnloop-contract/tests/native_surface.rs index 8a74a64..c64b1c6 100644 --- a/crates/turnloop-contract/tests/native_surface.rs +++ b/crates/turnloop-contract/tests/native_surface.rs @@ -9,6 +9,10 @@ ))] use turnloop::*; +// A fan-out fixture must consume only its own SIGUSR1. Another fixture's send +// can release its workers before its own send, after they restore SIG_DFL. +static SIGNAL_FANOUT: std::sync::Mutex<()> = std::sync::Mutex::new(()); + #[test] fn local_echo_and_descriptor_ownership() { let path = std::env::temp_dir().join(format!("tl-ipc-{}.sock", std::process::id())); @@ -31,6 +35,7 @@ fn spawned_child_stdio_uses_the_driver() { } #[test] fn signals_reach_four_loops_on_four_threads() { + let _guard = SIGNAL_FANOUT.lock().expect("serialize SIGUSR1 fixtures"); turnloop_contract::native_surface::signal_fanout::(Signal::Usr1, || { // SAFETY: SIGUSR1 is subscribed by all four loops before the barrier opens. assert_eq!(unsafe { libc::kill(libc::getpid(), libc::SIGUSR1) }, 0); @@ -57,6 +62,7 @@ fn pending_signal_cannot_outlive_kqueue_unsubscribe() { #[test] fn signal_subscribe_raise_unsubscribe_stress() { + let _guard = SIGNAL_FANOUT.lock().expect("serialize SIGUSR1 fixtures"); // Each round proves delivery and Stopped/Closed on all four owning threads; // the next round exercises restoration followed by fresh subscriptions. for _ in 0..256 { @@ -78,9 +84,22 @@ fn kills_live_child_and_grandchild_as_a_group() { } #[test] fn registered_processes_and_signals_do_not_spin() { - turnloop_contract::native_surface::services_no_spin::( - std::ffi::OsStr::new(env!("CARGO_BIN_EXE_native_child")), - Signal::Usr2, + // Every owned child subscribes to process-wide SIGCHLD. Parallel tests + // exiting unrelated children notify this loop too, invalidating the quiet + // premise. A fresh process gives the unchanged contract its own dispatcher. + let output = std::process::Command::new(env!("CARGO_BIN_EXE_native_child")) + .arg("services-no-spin") + .output() + .expect("no-spin fixture"); + assert!( + output.status.success(), + "no-spin fixture: {:?}; {}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + output.stdout, + b"60 service timer expiries; no-spin bounds passed\n" ); } #[test] diff --git a/crates/turnloop-io/Cargo.toml b/crates/turnloop-io/Cargo.toml index f31762c..9b0e7ab 100644 --- a/crates/turnloop-io/Cargo.toml +++ b/crates/turnloop-io/Cargo.toml @@ -28,3 +28,6 @@ wasi-p3-experimental = ["turnloop/wasi-p3-experimental"] [[test]] name = "allocations" harness = false + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "=0.61.2", features = ["Win32_NetworkManagement_Dns", "Win32_Foundation", "Win32_Networking_WinSock"] } diff --git a/crates/turnloop-io/README.md b/crates/turnloop-io/README.md index 46b7421..58b1bc6 100644 --- a/crates/turnloop-io/README.md +++ b/crates/turnloop-io/README.md @@ -28,3 +28,17 @@ one absolute backend-clock deadline for a whole exchange. Native TCP/pipes and WASI sockets use the identical generic stream code. Browser raw sockets/listeners return Unsupported; browser HTTP uses host fetch. Windows uses the production Backend implementation when integrated. + +## Pools and DNS + +`pool::Pool` schedules the existing protocol pool policies with one manager task, +retained waiters and real executor deadlines. A lease owns the connection across +awaits. Cancelling a waiter removes its request; returning an incomplete exchange +retires the transport. Pool replacement/end observes the backend's physical close +acknowledgement through `ExecutorHandle::close`. + +`resolve` uses native blocking DNS or WASI 0.2 `ip-name-lookup`. `dns::query` returns +native SRV/TXT records through `ExecutorHandle::blocking`, keeping system resolver +calls off the loop thread. Native worker count/queue bounds remain those of the +host's turnloop configuration. WASI exposes no SRV/TXT capability; use resolved +seeds when the protocol needs those records. diff --git a/crates/turnloop-io/src/dns.rs b/crates/turnloop-io/src/dns.rs new file mode 100644 index 0000000..c60c21a --- /dev/null +++ b/crates/turnloop-io/src/dns.rs @@ -0,0 +1,368 @@ +//! Native SRV/TXT discovery on turnloop's bounded blocking pool. WASI 0.2 +//! exposes A/AAAA via ip-name-lookup; it does not expose SRV/TXT records. +use crate::{Backend, ExecutorHandle, Instant, deadline}; +use std::io; +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Record { + Srv { + target: String, + port: u16, + priority: u16, + weight: u16, + ttl: u32, + }, + Txt { + text: String, + ttl: u32, + }, +} +#[derive(Clone, Copy)] +pub enum Query { + Srv, + Txt, +} +#[cfg(any(test, windows, all(unix, not(target_arch = "wasm32"))))] +impl Query { + fn code(self) -> u16 { + match self { + Self::Srv => 33, + Self::Txt => 16, + } + } +} +pub async fn query( + executor: &ExecutorHandle, + name: String, + kind: Query, + at: Instant, +) -> io::Result> { + let result = deadline(executor, at, async { + executor + .blocking(move || { + Ok(turnloop::Payload::Boxed(Box::new(native_query( + &name, kind, + )))) + }) + .await + .map_err(crate::error) + }) + .await?; + match result { + turnloop::Payload::Boxed(result) => *result + .downcast::>>() + .map_err(|_| io::Error::other("invalid DNS worker result"))?, + _ => Err(io::Error::other("invalid DNS worker payload")), + } +} +#[cfg(all(unix, not(target_arch = "wasm32")))] +fn native_query(name: &str, kind: Query) -> io::Result> { + use std::sync::{ + Mutex, + atomic::{AtomicU16, Ordering}, + }; + // Serialize implementations whose resolver state is process-global. Only + // blocking workers take this mutex; the event-loop thread never does. + static RESOLVER: Mutex<()> = Mutex::new(()); + static ID: AtomicU16 = AtomicU16::new(1); + #[link(name = "resolv")] + unsafe extern "C" { + #[cfg_attr(target_vendor = "apple", link_name = "res_9_send")] + fn res_send( + msg: *const u8, + msglen: std::ffi::c_int, + answer: *mut u8, + answerlen: std::ffi::c_int, + ) -> std::ffi::c_int; + } + let mut packet = Vec::with_capacity(512); + packet.extend_from_slice(&ID.fetch_add(1, Ordering::Relaxed).to_be_bytes()); + packet.extend_from_slice(&[1, 0, 0, 1, 0, 0, 0, 0, 0, 0]); + for label in name.trim_end_matches('.').split('.') { + if label.is_empty() || label.len() > 63 || !label.is_ascii() { + return Err(io::ErrorKind::InvalidInput.into()); + } + packet.push(label.len() as u8); + packet.extend_from_slice(label.as_bytes()); + } + if packet.len() > 266 { + return Err(io::ErrorKind::InvalidInput.into()); + } + packet.push(0); + packet.extend_from_slice(&kind.code().to_be_bytes()); + packet.extend_from_slice(&[0, 1]); + let mut answer = vec![0; 65535]; + let _lock = RESOLVER + .lock() + .map_err(|_| io::Error::other("resolver lock poisoned"))?; + // SAFETY: both buffers are live for this synchronous call, with lengths + // bounded below c_int::MAX. The resolver copies data and retains no pointers. + let n = unsafe { + res_send( + packet.as_ptr(), + packet.len() as _, + answer.as_mut_ptr(), + answer.len() as _, + ) + }; + if n < 0 { + return Err(io::Error::last_os_error()); + } + if n as usize > answer.len() { + return Err(io::Error::other("truncated DNS packet")); + } + answer.truncate(n as usize); + if answer.get(..2) != packet.get(..2) { + return Err(io::Error::other("DNS query identity mismatch")); + } + parse(&answer, kind) +} +#[cfg(windows)] +fn native_query(name: &str, kind: Query) -> io::Result> { + use std::ffi::{CStr, CString}; + use windows_sys::Win32::NetworkManagement::Dns::*; + let name = CString::new(name).map_err(|_| io::ErrorKind::InvalidInput)?; + let mut first = std::ptr::null_mut(); + // SAFETY: NUL-terminated input and valid out-pointer, no custom server list. + let status = unsafe { + DnsQuery_UTF8( + name.as_ptr().cast(), + kind.code(), + DNS_QUERY_STANDARD, + std::ptr::null_mut(), + &mut first, + std::ptr::null_mut(), + ) + }; + if status == 9003 || status == 9501 { + return Ok(Vec::new()); + } + if status != 0 { + return Err(io::Error::from_raw_os_error(status as i32)); + } + struct Records(*mut DNS_RECORDA); + impl Drop for Records { + fn drop(&mut self) { + // SAFETY: this list is owned by the successful DnsQuery_UTF8 call. + unsafe { + DnsFree(self.0.cast(), DnsFreeRecordList); + } + } + } + let records = Records(first); + let mut p = records.0; + let mut output = Vec::new(); + while !p.is_null() { + // SAFETY: walking the OS-owned, valid linked list before Records drops. + let r = unsafe { &*p }; + if r.wType == 33 { + // SAFETY: wType selects SRV data; the OS owns a NUL-terminated UTF-8 name. + let srv = unsafe { &r.Data.SRV }; + if srv.pNameTarget.is_null() { + return Err(io::Error::other("empty SRV target")); + } + // SAFETY: validated non-null target pointer in the live SRV record. + let target = unsafe { CStr::from_ptr(srv.pNameTarget.cast()) } + .to_str() + .map_err(io::Error::other)? + .to_owned(); + output.push(Record::Srv { + target, + port: srv.wPort, + priority: srv.wPriority, + weight: srv.wWeight, + ttl: r.dwTtl, + }); + } else if r.wType == 16 { + // SAFETY: wType selects TXT; the OS allocates dwStringCount pointers + // in this flexible array, all valid until the list is freed. + let txt = unsafe { &r.Data.TXT }; + // SAFETY: flexible array extent is supplied by DnsQuery_UTF8. + let strings = unsafe { + std::slice::from_raw_parts(txt.pStringArray.as_ptr(), txt.dwStringCount as usize) + }; + let mut text = String::new(); + for &ptr in strings { + if ptr.is_null() { + return Err(io::Error::other("empty TXT pointer")); + } + // SAFETY: live non-null UTF-8 string owned by the DNS result. + text.push_str( + unsafe { CStr::from_ptr(ptr.cast()) } + .to_str() + .map_err(io::Error::other)?, + ); + } + output.push(Record::Txt { text, ttl: r.dwTtl }); + } + p = r.pNext; + } + Ok(output) +} +#[cfg(not(any(windows, all(unix, not(target_arch = "wasm32")))))] +fn native_query(_: &str, _: Query) -> io::Result> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "host has no SRV/TXT resolver capability", + )) +} +#[cfg(any(test, all(unix, not(target_arch = "wasm32"))))] +fn parse(bytes: &[u8], kind: Query) -> io::Result> { + fn invalid() -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, "invalid DNS packet") + } + fn u16_at(b: &[u8], at: usize) -> io::Result { + Ok(u16::from_be_bytes( + b.get(at..at + 2) + .ok_or_else(invalid)? + .try_into() + .map_err(|_| invalid())?, + )) + } + fn name(b: &[u8], at: &mut usize) -> io::Result { + let mut out = String::new(); + let mut p = *at; + let mut jumped = false; + for _ in 0..128 { + let len = *b.get(p).ok_or_else(invalid)?; + if len & 0xc0 == 0xc0 { + let dest = usize::from(u16_at(b, p)? & 0x3fff); + if dest >= p { + return Err(invalid()); + } + if !jumped { + *at = p + 2; + jumped = true; + } + p = dest; + } else if len == 0 { + if !jumped { + *at = p + 1; + } + return Ok(out); + } else { + if len > 63 { + return Err(invalid()); + } + p += 1; + let label = + std::str::from_utf8(b.get(p..p + usize::from(len)).ok_or_else(invalid)?) + .map_err(|_| invalid())?; + if !out.is_empty() { + out.push('.'); + } + out.push_str(label); + if out.len() > 253 { + return Err(invalid()); + } + p += usize::from(len); + } + } + Err(invalid()) + } + let flags = u16_at(bytes, 2)?; + if flags & 0x8000 == 0 || flags & 0x0200 != 0 { + return Err(invalid()); + } + match flags & 0xf { + 0 => {} + 3 => return Ok(Vec::new()), + _ => return Err(io::Error::other("DNS server failed query")), + } + let mut at = 12; + for _ in 0..u16_at(bytes, 4)? { + name(bytes, &mut at)?; + at += 4; + } + let mut records = Vec::new(); + for _ in 0..u16_at(bytes, 6)? { + name(bytes, &mut at)?; + let ty = u16_at(bytes, at)?; + let class = u16_at(bytes, at + 2)?; + let ttl = u32::from_be_bytes( + bytes + .get(at + 4..at + 8) + .ok_or_else(invalid)? + .try_into() + .map_err(|_| invalid())?, + ); + let size = usize::from(u16_at(bytes, at + 8)?); + at += 10; + let end = at.checked_add(size).ok_or_else(invalid)?; + let data = bytes.get(at..end).ok_or_else(invalid)?; + if class == 1 && ty == kind.code() { + if ty == 33 { + if size < 7 { + return Err(invalid()); + } + let priority = u16_at(bytes, at)?; + let weight = u16_at(bytes, at + 2)?; + let port = u16_at(bytes, at + 4)?; + let mut cursor = at + 6; + let target = name(bytes, &mut cursor)?; + if cursor != end || target.is_empty() || port == 0 { + return Err(invalid()); + } + records.push(Record::Srv { + target, + port, + priority, + weight, + ttl, + }); + } else { + let mut text = Vec::new(); + let mut p = 0; + while p < data.len() { + let n = usize::from(data[p]); + p += 1; + text.extend_from_slice(data.get(p..p + n).ok_or_else(invalid)?); + p += n; + } + records.push(Record::Txt { + text: String::from_utf8(text).map_err(|_| invalid())?, + ttl, + }); + } + } + at = end; + } + Ok(records) +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn srv_txt_compression_and_malformed_answers() { + let mut answer = vec![0, 1, 0x81, 0x80, 0, 1, 0, 1, 0, 0, 0, 0]; + answer.extend_from_slice(b"\x01x\x07example\x03com\0\0\x21\0\x01\xc0\x0c\0\x21\0\x01\0\0\0\x3c\0\x0b\0\x01\0\x02\x69\x89\x02db\xc0\x0e"); + assert_eq!( + parse(&answer, Query::Srv).expect("SRV"), + vec![Record::Srv { + target: "db.example.com".into(), + port: 27017, + priority: 1, + weight: 2, + ttl: 60 + }] + ); + assert!(parse(&answer[..answer.len() - 1], Query::Srv).is_err()); + let mut txt = vec![0, 1, 0x81, 0x80, 0, 0, 0, 1, 0, 0, 0, 0]; + txt.extend_from_slice(b"\0\0\x10\0\x01\0\0\0\x3c\0\x06\x02ab\x02cd"); + assert_eq!( + parse(&txt, Query::Txt).expect("TXT"), + vec![Record::Txt { + text: "abcd".into(), + ttl: 60 + }] + ); + let mut looped = answer.clone(); + let n = looped.len(); + looped[n - 2] = 0xc0; + looped[n - 1] = (n - 2) as u8; + assert!(parse(&looped, Query::Srv).is_err()); + txt[3] = 2; + assert!(parse(&txt, Query::Txt).is_err()); + txt[3] = 3; + assert!(parse(&txt, Query::Txt).expect("NXDOMAIN").is_empty()); + } +} diff --git a/crates/turnloop-io/src/lib.rs b/crates/turnloop-io/src/lib.rs index 83e4109..ce2a738 100644 --- a/crates/turnloop-io/src/lib.rs +++ b/crates/turnloop-io/src/lib.rs @@ -8,6 +8,8 @@ use std::{ pin::Pin, }; pub use turnloop::{self, AsyncIo, ExecutorHandle, Instant, backend::Backend}; +pub mod dns; +pub mod pool; /// A TCP, pipe, TLS or host stream with cancellation-safe futures-io operations. pub trait Stream: AsyncRead + AsyncWrite + Unpin {} @@ -59,6 +61,31 @@ pub async fn deadline>, T>( ) -> io::Result { executor.timeout_at(at, future).await.map_err(error)? } +/// Resolve a service endpoint using the loop's DNS capability. Literal IPs need +/// no resolver and work on every raw-socket backend. +pub async fn resolve( + executor: &ExecutorHandle, + host: &str, + port: u16, + at: Instant, +) -> io::Result { + if let Ok(ip) = host.parse() { + return Ok(SocketAddr::new(ip, port)); + } + deadline(executor, at, async { + executor + .resolve(turnloop::DnsRequest { + host: host.to_owned(), + port, + }) + .await + .map_err(error)? + .into_iter() + .next() + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "DNS returned no addresses")) + }) + .await +} /// Core-owned wire output. Acknowledged only after a successful complete flush. pub trait Output { fn output(&self) -> &[u8]; @@ -154,7 +181,7 @@ pub trait SansIo: Output { pub struct Driver { stream: Option, core: Option, - input: [u8; 16384], + input: Box<[u8]>, start: usize, end: usize, } @@ -163,7 +190,7 @@ impl Driver { Self { stream: Some(stream), core: Some(core), - input: [0; 16384], + input: vec![0; 16384].into_boxed_slice(), start: 0, end: 0, } @@ -174,6 +201,56 @@ impl Driver { pub fn core_mut(&mut self) -> &mut C { self.core.as_mut().expect("owned core") } + pub fn is_connected(&self) -> bool { + self.stream.is_some() + } + pub fn stream(&self) -> Option<&S> { + self.stream.as_ref() + } + /// Close immediately. Submitted transport storage remains owned by AsyncIo. + pub fn abort(&mut self) { + self.stream.take(); + self.core_mut().disconnected(); + } + /// Install a new transport after the protocol has entered its reconnect + /// state. Bytes from the failed transport are never fed to the new session. + pub fn replace_stream(&mut self, stream: S) -> io::Result<()> { + if self.stream.is_some() { + return Err(io::Error::other("transport is still connected")); + } + self.start = 0; + self.end = 0; + self.stream = Some(stream); + Ok(()) + } + /// Guard a complete operation, including pauses between protocol events. + pub fn exchange(&mut self) -> Exchange<'_, S, C> { + Exchange { + driver: self, + committed: false, + } + } + /// A TLS transition is legal only at a drained plaintext boundary. + pub fn upgrade_stream(&mut self) -> io::Result<&mut S> { + if self.start != self.end || !self.core().output().is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "undrained TLS boundary", + )); + } + self.stream + .as_mut() + .ok_or_else(|| io::ErrorKind::NotConnected.into()) + } + /// Flush COPY or other output that has no corresponding input event. + pub async fn flush(&mut self) -> io::Result<()> { + let mut transport = CloseOnDrop::new(&mut self.stream); + let result = drain(transport.stream()?, self.core.as_mut().expect("owned core")).await; + if result.is_ok() { + transport.commit(); + } + result + } /// Drive until one protocol event is delivered. Wrap this in `deadline` for /// the protocol's advertised timeout; cancellation closes and aborts the core. pub async fn next( @@ -240,6 +317,34 @@ impl Driver { )) } } +/// Dropping an unfinished operation aborts both its protocol and transport. +pub struct Exchange<'a, S: Stream, C: SansIo> { + driver: &'a mut Driver, + committed: bool, +} +impl Exchange<'_, S, C> { + pub fn commit(&mut self) { + self.committed = true; + } +} +impl std::ops::Deref for Exchange<'_, S, C> { + type Target = Driver; + fn deref(&self) -> &Self::Target { + self.driver + } +} +impl std::ops::DerefMut for Exchange<'_, S, C> { + fn deref_mut(&mut self) -> &mut Self::Target { + self.driver + } +} +impl Drop for Exchange<'_, S, C> { + fn drop(&mut self) { + if !self.committed { + self.driver.abort(); + } + } +} impl Drop for Driver { fn drop(&mut self) { if let Some(core) = self.core.as_mut() { diff --git a/crates/turnloop-io/src/pool.rs b/crates/turnloop-io/src/pool.rs new file mode 100644 index 0000000..87ab56a --- /dev/null +++ b/crates/turnloop-io/src/pool.rs @@ -0,0 +1,425 @@ +//! Shared executor scheduling for sans-I/O connection pools. Policies stay in +//! protocol crates; this module owns sockets, waiter wakeups and absolute timers. +use crate::{Backend, ExecutorHandle, Instant, error}; +use std::{ + cell::RefCell, + future::{Future, poll_fn}, + io, + ops::{Deref, DerefMut}, + pin::Pin, + rc::Rc, + task::{Context, Poll, Waker}, + time::Duration, +}; +use turnloop::{Handle, JoinHandle, Sleep}; + +pub enum Action { + Connect(I), + Close(I), + Remove(I), + Acquired { token: u64, lease: L }, + Failed { token: u64, error: io::Error }, + Ended, +} +pub trait Policy: 'static { + type Id: Copy + Eq; + type Lease: Copy; + fn id(lease: Self::Lease) -> Self::Id; + fn checkout(&mut self, token: u64, now: Instant, deadline: Instant) -> io::Result<()>; + fn cancel(&mut self, token: u64, now: Instant); + fn connected(&mut self, id: Self::Id, now: Instant) -> io::Result<()>; + fn connect_failed(&mut self, id: Self::Id, now: Instant) -> io::Result<()>; + fn checkin(&mut self, lease: Self::Lease, now: Instant, destroy: bool) -> io::Result<()>; + fn closed(&mut self, id: Self::Id, now: Instant) -> io::Result<()>; + fn event(&mut self) -> Option>; + fn next_deadline(&self) -> Option; + fn expire(&mut self, now: Instant); + fn end(&mut self) -> io::Result<()>; +} +pub trait Connection: 'static { + fn reusable(&self) -> bool; + fn handle(&self) -> Option; +} +pub trait Connector: 'static { + type Connection: Connection; + fn connect( + &self, + executor: &ExecutorHandle, + deadline: Instant, + ) -> impl Future> + 'static; +} +type Connecting = Pin>>>; +struct Entry { + id: I, + client: Option, + connecting: Option>, + handle: Option, + closing: Option>, + closed: bool, +} +struct Waiter { + token: u64, + result: Option>, + waker: Option, +} +struct State { + policy: P, + entries: Vec>, + waiters: Vec>, + token: u64, + changed: Option, + ended: bool, + end_wakers: Vec, + failure: Option, +} +struct Owner { + state: Rc>>, + executor: ExecutorHandle, + _task: JoinHandle<()>, +} +/// Cloneable pool. Checkout and release reuse retained slots after warm-up. +pub struct Pool { + owner: Rc>, +} +impl Clone for Pool { + fn clone(&self) -> Self { + Self { + owner: self.owner.clone(), + } + } +} +fn wake(waker: &Option) { + if let Some(w) = waker { + w.wake_by_ref(); + } +} +impl Pool { + pub fn new>( + executor: &ExecutorHandle, + policy: P, + connector: F, + connect_timeout: Duration, + capacity: usize, + ) -> io::Result { + let state = Rc::new(RefCell::new(State { + policy, + entries: Vec::with_capacity(capacity), + waiters: Vec::with_capacity(capacity), + token: 0, + changed: None, + ended: false, + end_wakers: Vec::new(), + failure: None, + })); + let shared = state.clone(); + let exec = executor.clone(); + let mut timer: Option<(Instant, Sleep)> = None; + let task = executor + .spawn_local(poll_fn(move |cx| { + let mut state = shared.borrow_mut(); + state.changed = Some(cx.waker().clone()); + let result = drive( + &mut state, + &exec, + &connector, + connect_timeout, + &mut timer, + cx, + ); + if let Err(e) = result { + state.failure = Some(e.to_string()); + for waiter in &state.waiters { + wake(&waiter.waker); + } + for w in &state.end_wakers { + w.wake_by_ref(); + } + // Physical sockets close even if their client is currently leased. + for entry in &state.entries { + if let Some(h) = entry.handle { + let _ = exec.driver().close(h, turnloop::Token(0)); + } + } + state.entries.clear(); + return Poll::Ready(()); + } + if state.ended && state.entries.is_empty() { + Poll::Ready(()) + } else { + Poll::Pending + } + })) + .map_err(error)?; + Ok(Self { + owner: Rc::new(Owner { + state, + executor: executor.clone(), + _task: task, + }), + }) + } + pub fn total(&self) -> usize { + self.owner.state.borrow().entries.len() + } + /// Apply a topology-driven policy change and wake the pool's scheduler. + pub fn update_policy(&self, update: impl FnOnce(&mut P)) { + let mut state = self.owner.state.borrow_mut(); + update(&mut state.policy); + wake(&state.changed); + } + pub async fn acquire(&self, at: Instant) -> io::Result> { + let token = { + let mut state = self.owner.state.borrow_mut(); + if let Some(e) = &state.failure { + return Err(io::Error::other(e.clone())); + } + state.token = state + .token + .checked_add(1) + .ok_or_else(|| io::Error::other("pool token exhausted"))?; + let token = state.token; + state + .policy + .checkout(token, self.owner.executor.now(), at)?; + state.waiters.push(Waiter { + token, + result: None, + waker: None, + }); + wake(&state.changed); + token + }; + let mut pending = Pending { + owner: &self.owner, + token, + done: false, + }; + let result = crate::deadline( + &self.owner.executor, + at, + poll_fn(|cx| { + let mut state = self.owner.state.borrow_mut(); + if let Some(e) = &state.failure { + return Poll::Ready(Err(io::Error::other(e.clone()))); + } + let Some(index) = state.waiters.iter().position(|w| w.token == token) else { + return Poll::Ready(Err(io::Error::other("missing pool waiter"))); + }; + if let Some(result) = state.waiters[index].result.take() { + state.waiters.swap_remove(index); + return Poll::Ready(result.and_then(|lease| { + let entry = state + .entries + .iter_mut() + .find(|e| e.id == P::id(lease)) + .ok_or_else(|| io::Error::other("missing pooled connection"))?; + let client = entry + .client + .take() + .ok_or_else(|| io::Error::other("connection already leased"))?; + Ok(Lease { + owner: self.owner.clone(), + client: Some(client), + lease, + }) + })); + } + state.waiters[index].waker = Some(cx.waker().clone()); + Poll::Pending + }), + ) + .await; + pending.done = result.is_ok(); + result + } + pub async fn end(&self) -> io::Result<()> { + { + let mut state = self.owner.state.borrow_mut(); + state.policy.end()?; + wake(&state.changed); + } + poll_fn(|cx| { + let mut state = self.owner.state.borrow_mut(); + if let Some(e) = &state.failure { + return Poll::Ready(Err(io::Error::other(e.clone()))); + } + if state.ended && state.entries.is_empty() { + return Poll::Ready(Ok(())); + } + if !state.end_wakers.iter().any(|w| w.will_wake(cx.waker())) { + state.end_wakers.push(cx.waker().clone()); + } + Poll::Pending + }) + .await + } +} +fn drive>( + state: &mut State, + executor: &ExecutorHandle, + connector: &F, + timeout: Duration, + timer: &mut Option<(Instant, Sleep)>, + cx: &mut Context<'_>, +) -> io::Result<()> { + let now = executor.now(); + state.policy.expire(now); + loop { + while let Some(action) = state.policy.event() { + match action { + Action::Connect(id) => state.entries.push(Entry { + id, + client: None, + connecting: Some(Box::pin(connector.connect(executor, now + timeout))), + handle: None, + closing: None, + closed: false, + }), + Action::Close(id) => { + if let Some(entry) = state.entries.iter_mut().find(|e| e.id == id) { + if let Some(h) = entry.handle { + // Register physical-close observation before dropping the + // client; its destructor may independently request close. + entry.closing = Some(executor.close(h)); + } + entry.client = None; + entry.connecting = None; + if entry.closing.is_none() { + entry.closed = true; + state.policy.closed(id, now)?; + } + } else { + state.policy.closed(id, now)?; + } + } + Action::Remove(id) => { + if let Some(i) = state.entries.iter().position(|e| e.id == id) { + state.entries.swap_remove(i); + } + } + Action::Acquired { token, lease } => { + if let Some(w) = state.waiters.iter_mut().find(|w| w.token == token) { + w.result = Some(Ok(lease)); + wake(&w.waker); + } else { + state.policy.checkin(lease, now, true)?; + } + } + Action::Failed { token, error } => { + if let Some(w) = state.waiters.iter_mut().find(|w| w.token == token) { + w.result = Some(Err(error)); + wake(&w.waker); + } + } + Action::Ended => { + state.ended = true; + for w in &state.end_wakers { + w.wake_by_ref(); + } + } + } + } + let mut progressed = false; + let closing = state.entries.iter().any(|entry| entry.closing.is_some()); + for entry in &mut state.entries { + if let Some(future) = &mut entry.closing + && let Poll::Ready(result) = Pin::new(future).poll(cx) + { + result.map_err(error)?; + entry.closing = None; + entry.closed = true; + progressed = true; + state.policy.closed(entry.id, now)?; + } + if !closing + && let Some(future) = &mut entry.connecting + && let Poll::Ready(result) = future.as_mut().poll(cx) + { + entry.connecting = None; + progressed = true; + match result { + Ok(client) => { + entry.handle = client.handle(); + entry.client = Some(client); + state.policy.connected(entry.id, now)?; + } + Err(_) => state.policy.connect_failed(entry.id, now)?, + } + } + } + state.entries.retain(|entry| !entry.closed); + if state.ended && state.entries.is_empty() { + for w in &state.end_wakers { + w.wake_by_ref(); + } + } + if !progressed { + break; + } + } + let at = state.policy.next_deadline(); + if timer.as_ref().map(|(at, _)| *at) != at { + *timer = at.map(|at| (at, executor.sleep_until(at))); + } + if let Some((_, sleep)) = timer + && let Poll::Ready(result) = Pin::new(sleep).poll(cx) + { + result.map_err(error)?; + *timer = None; + cx.waker().wake_by_ref(); + } + Ok(()) +} +struct Pending<'a, B: Backend, P: Policy, C> { + owner: &'a Rc>, + token: u64, + done: bool, +} +impl Drop for Pending<'_, B, P, C> { + fn drop(&mut self) { + if !self.done { + let mut state = self.owner.state.borrow_mut(); + if let Some(i) = state.waiters.iter().position(|w| w.token == self.token) { + if let Some(Ok(lease)) = state.waiters.swap_remove(i).result { + let _ = state.policy.checkin(lease, self.owner.executor.now(), true); + } else { + state.policy.cancel(self.token, self.owner.executor.now()); + } + } + wake(&state.changed); + } + } +} +/// Exclusive client ownership. A cancelled query makes reusable() false; release +/// then destroys the connection instead of handing wire state to another caller. +pub struct Lease { + owner: Rc>, + client: Option, + lease: P::Lease, +} +impl Deref for Lease { + type Target = C; + fn deref(&self) -> &C { + self.client.as_ref().expect("owned lease") + } +} +impl DerefMut for Lease { + fn deref_mut(&mut self) -> &mut C { + self.client.as_mut().expect("owned lease") + } +} +impl Drop for Lease { + fn drop(&mut self) { + let mut state = self.owner.state.borrow_mut(); + if let Some(client) = self.client.take() + && let Some(entry) = state.entries.iter_mut().find(|e| e.id == P::id(self.lease)) + { + let destroy = !client.reusable(); + entry.client = Some(client); + let _ = state + .policy + .checkin(self.lease, self.owner.executor.now(), destroy); + } + wake(&state.changed); + } +} diff --git a/crates/turnloop-io/tests/streams.rs b/crates/turnloop-io/tests/streams.rs index 14a8d65..1587d29 100644 --- a/crates/turnloop-io/tests/streams.rs +++ b/crates/turnloop-io/tests/streams.rs @@ -78,7 +78,10 @@ fn tcp_roundtrip_and_deadline() { finish(&mut timeout); } -#[cfg(not(target_arch = "wasm32"))] +#[cfg(any( + not(target_arch = "wasm32"), + all(target_os = "wasi", target_env = "p2") +))] #[test] fn resolve_and_cancel_reuse_executor_slots() { use turnloop::executor::ExecutorConfig; @@ -121,7 +124,7 @@ fn resolve_and_cancel_reuse_executor_slots() { assert_eq!(finish(&mut task), 16); } -#[cfg(target_os = "wasi")] +#[cfg(all(target_os = "wasi", target_env = "p3"))] #[test] fn unsupported_wasi_dns_releases_reserved_slots() { use turnloop::executor::ExecutorConfig; @@ -160,3 +163,94 @@ fn unsupported_wasi_dns_releases_reserved_slots() { } assert_eq!(finish(&mut task), 16); } + +#[cfg(all(unix, not(target_arch = "wasm32")))] +#[test] +fn blocking_worker_and_dns_validation_deliver_results() { + let mut executor = LocalExecutor::::new(Config::default()).expect("executor"); + let h = executor.handle(); + let main_thread = std::thread::current().id(); + let mut task = executor + .spawn_local(async move { + let result = h + .blocking(move || { + assert_ne!( + std::thread::current().id(), + main_thread, + "must execute off loop" + ); + Ok(turnloop::Payload::Boxed(Box::new(42u32))) + }) + .await + .expect("worker completion"); + match result { + turnloop::Payload::Boxed(v) => { + assert_eq!(*v.downcast::().expect("payload"), 42) + } + _ => panic!("wrong completion"), + } + // The native resolver validates this on its worker without network I/O. + let error = dns::query( + &h, + "invalid..name".into(), + dns::Query::Srv, + h.now() + Duration::from_secs(3), + ) + .await + .expect_err("invalid DNS label"); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); + 2 + }) + .expect("spawn"); + let at = executor.handle().now() + Duration::from_secs(5); + while !task.is_finished() { + assert!(executor.handle().now() < at); + executor.turn(Timeout::Until(at)).expect("turn"); + } + assert_eq!(finish(&mut task), 2); +} + +#[cfg(all(unix, not(target_arch = "wasm32")))] +#[test] +#[ignore = "requires external DNS; deterministic wire/worker tests run by default"] +fn native_srv_txt_records_through_blocking_pool() { + let mut executor = LocalExecutor::::new(Config::default()).expect("executor"); + let h = executor.handle(); + let mut task = executor + .spawn_local(async move { + let at = h.now() + Duration::from_secs(15); + let records = dns::query( + &h, + "_xmpp-server._tcp.jabber.org".into(), + dns::Query::Srv, + at, + ) + .await + .expect("native SRV query"); + assert!( + records + .iter() + .any(|r| matches!(r, dns::Record::Srv { target, port: 5269, .. } + if target.ends_with(".jabber.org"))), + "actual XMPP service record: {records:?}" + ); + let records = dns::query(&h, "example.com".into(), dns::Query::Txt, at) + .await + .expect("native TXT query"); + assert!( + records + .iter() + .any(|r| matches!(r, dns::Record::Txt { text, .. } + if text.starts_with("v=spf1"))), + "actual SPF record: {records:?}" + ); + 2 + }) + .expect("spawn"); + let at = executor.handle().now() + Duration::from_secs(20); + while !task.is_finished() { + assert!(executor.handle().now() < at); + executor.turn(Timeout::Until(at)).expect("turn"); + } + assert_eq!(finish(&mut task), 2); +} diff --git a/crates/turnloop-io/tests/support/count.rs b/crates/turnloop-io/tests/support/count.rs new file mode 100644 index 0000000..100518f --- /dev/null +++ b/crates/turnloop-io/tests/support/count.rs @@ -0,0 +1,82 @@ +//! Per-thread accounting around client future polls; unrelated server tasks and +//! test-harness work cannot hide or inflate the measured adapter allocations. +use std::{ + alloc::{GlobalAlloc, Layout, System}, + cell::Cell, + future::Future, + pin::Pin, + task::{Context, Poll}, +}; +thread_local! { + static ACTIVE: Cell = const { Cell::new(false) }; + static COUNT: Cell = const { Cell::new(0) }; +} +struct Counter; +fn record() { + if ACTIVE.try_with(Cell::get).unwrap_or(false) { + let _ = COUNT.try_with(|n| n.set(n.get() + 1)); + } +} +// SAFETY: all allocation contracts, layouts and pointers are forwarded to System. +unsafe impl GlobalAlloc for Counter { + unsafe fn alloc(&self, l: Layout) -> *mut u8 { + record(); + // SAFETY: unchanged valid caller layout. + unsafe { System.alloc(l) } + } + unsafe fn alloc_zeroed(&self, l: Layout) -> *mut u8 { + record(); + // SAFETY: unchanged valid caller layout. + unsafe { System.alloc_zeroed(l) } + } + unsafe fn realloc(&self, p: *mut u8, l: Layout, n: usize) -> *mut u8 { + record(); + // SAFETY: unchanged caller pointer/layout/new size. + unsafe { System.realloc(p, l, n) } + } + unsafe fn dealloc(&self, p: *mut u8, l: Layout) { + // SAFETY: unchanged pointer/layout from the caller. + unsafe { System.dealloc(p, l) } + } +} +#[global_allocator] +static ALLOCATOR: Counter = Counter; +struct Scope(bool); +impl Scope { + fn enter() -> Self { + Self(ACTIVE.with(|a| a.replace(true))) + } +} +impl Drop for Scope { + fn drop(&mut self) { + ACTIVE.with(|a| a.set(self.0)); + } +} +pub struct Measured { + future: F, + count: usize, +} +pub fn measure(future: F) -> Measured { + Measured { future, count: 0 } +} +impl Future for Measured { + type Output = (F::Output, usize); + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + // SAFETY: structural pin projection; future is never moved, including Drop. + let this = unsafe { self.get_unchecked_mut() }; + let before = COUNT.with(Cell::get); + let scope = Scope::enter(); + // SAFETY: future remains pinned for the lifetime of this wrapper. + let result = unsafe { Pin::new_unchecked(&mut this.future) }.poll(cx); + drop(scope); + this.count += COUNT.with(Cell::get) - before; + result.map(|v| (v, this.count)) + } +} +pub async fn prove_counter() { + let (_, n) = measure(async { + std::hint::black_box(Box::new([0u8; 128])); + }) + .await; + assert!(n > 0, "allocation counter must observe the subject thread"); +} diff --git a/crates/turnloop/src/backend/mod.rs b/crates/turnloop/src/backend/mod.rs index 09c5eab..a12ef9d 100644 --- a/crates/turnloop/src/backend/mod.rs +++ b/crates/turnloop/src/backend/mod.rs @@ -150,6 +150,8 @@ pub struct Event { #[derive(Debug)] /// Successful backend outcomes; accepted transports remain owned until attached. pub enum Outcome { + /// Addresses from an asynchronous host resolver. + Resolved(Vec), /// Reaped child termination. Exited(crate::ExitStatus), /// Coalesced signal delivery. @@ -275,6 +277,12 @@ pub unsafe trait Backend: Sized + 'static { fn local_addr(&self, handle: Handle) -> Result; /// Accept one operation or reject it before retaining native buffer access. fn submit(&mut self, request: Request) -> Result<()>; + /// Start a handle-free host name lookup. The default uses no resources and + /// lets the core fall back to its native blocking pool. Accepted requests + /// obey the same terminal/cancellation and no-spin rules as socket I/O. + fn resolve(&mut self, _op: OpId, _request: &crate::DnsRequest) -> Result<()> { + Err(Error::new(crate::ErrorKind::Unsupported)) + } /// Request cancellation; terminal acknowledgement must precede memory reuse. fn cancel(&mut self, op: OpId) -> Result<()>; /// Whether completions or immediately runnable cached operations are available. diff --git a/crates/turnloop/src/backend/wasi_p2.rs b/crates/turnloop/src/backend/wasi_p2.rs index d7d7923..5a7fb1f 100644 --- a/crates/turnloop/src/backend/wasi_p2.rs +++ b/crates/turnloop/src/backend/wasi_p2.rs @@ -19,8 +19,10 @@ use wasip2::{ }, sockets::{ instance_network::instance_network, + ip_name_lookup::{ResolveAddressStream, resolve_addresses}, network::{ - ErrorCode, IpAddressFamily, IpSocketAddress, Ipv4SocketAddress, Ipv6SocketAddress, + ErrorCode, IpAddress, IpAddressFamily, IpSocketAddress, Ipv4SocketAddress, + Ipv6SocketAddress, }, tcp::TcpSocket, tcp_create_socket::create_tcp_socket, @@ -94,6 +96,20 @@ impl Wake for WasiWake { 0 } } +// Children precede parents, including when cancellation drops a lookup. +struct Lookup { + poll: Pollable, + stream: ResolveAddressStream, + op: OpId, + port: u16, + addresses: Vec, + ready: bool, +} +#[derive(Clone, Copy)] +enum PollOwner { + Socket(Handle, usize), + Dns(usize), +} /// WASI 0.2 pollable driver with retained canonical buffers. pub struct WasiP2 { resources: Vec>, @@ -101,7 +117,8 @@ pub struct WasiP2 { ready: VecDeque, cancelled: VecDeque, handles: Vec, - owners: Vec<(Handle, usize)>, + owners: Vec, + lookups: Vec>, indices: Vec, poll_storage: Vec, scratch: Vec, @@ -300,6 +317,61 @@ impl WasiP2 { } } +impl WasiP2 { + fn run_dns(&mut self, events: &mut Vec>) { + for slot in &mut self.lookups { + if events.len() == events.capacity() { + break; + } + let Some(l) = slot.as_mut().filter(|l| l.ready) else { + continue; + }; + // Bound address collection per turn; ready stays true only while + // actual buffered resolver output may still be consumed. + for _ in 0..64 { + match l.stream.resolve_next_address() { + Ok(Some(ip)) => { + let ip = match ip { + IpAddress::Ipv4(a) => Ipv4Addr::new(a.0, a.1, a.2, a.3).into(), + IpAddress::Ipv6(a) => { + Ipv6Addr::new(a.0, a.1, a.2, a.3, a.4, a.5, a.6, a.7).into() + } + }; + l.addresses.push(SocketAddr::new(ip, l.port)); + } + Ok(None) => { + let result = if l.addresses.is_empty() { + Err(Error::new(ErrorKind::NotFound)) + } else { + Ok(Outcome::Resolved(std::mem::take(&mut l.addresses))) + }; + events.push(Event { + op: l.op, + terminal: true, + result, + }); + *slot = None; + break; + } + Err(ErrorCode::WouldBlock) => { + l.ready = false; + break; + } + Err(e) => { + events.push(Event { + op: l.op, + terminal: true, + result: Err(error(e)), + }); + *slot = None; + break; + } + } + } + } + } +} + // SAFETY: I/O imports synchronously copy buffers and never retain pointers. // Terminal events retire requests. Resource drop order releases subscriptions // before streams and sockets; accepted transports transfer ownership into core. @@ -310,6 +382,7 @@ unsafe impl Backend for WasiP2 { let polls = config .max_handles .checked_mul(2) + .and_then(|n| n.checked_add(config.max_operations)) .and_then(|n| n.checked_add(1)) .ok_or(Error::new(ErrorKind::ResourceLimit))?; Ok(Self { @@ -319,6 +392,7 @@ unsafe impl Backend for WasiP2 { cancelled: VecDeque::with_capacity(config.max_operations), handles: Vec::with_capacity(polls), owners: Vec::with_capacity(polls), + lookups: (0..config.max_operations).map(|_| None).collect(), indices: Vec::with_capacity(polls), poll_storage: vec![0; polls], scratch: vec![0; 16400], @@ -478,7 +552,36 @@ unsafe impl Backend for WasiP2 { self.schedule(h); Ok(()) } + fn resolve(&mut self, op: OpId, request: &DnsRequest) -> Result<()> { + let slot = self + .lookups + .get_mut(op.index()) + .ok_or(Error::new(ErrorKind::ResourceLimit))?; + if slot.is_some() || self.ops[op.index()].is_some() { + return Err(Error::new(ErrorKind::InvalidInput)); + } + let stream = resolve_addresses(&instance_network(), &request.host).map_err(error)?; + *slot = Some(Lookup { + poll: stream.subscribe(), + stream, + op, + port: request.port, + addresses: Vec::new(), + ready: true, + }); + Ok(()) + } fn cancel(&mut self, op: OpId) -> Result<()> { + if self + .lookups + .get(op.index()) + .and_then(Option::as_ref) + .is_some_and(|l| l.op == op) + { + self.lookups[op.index()] = None; + self.cancelled.push_back(op); + return Ok(()); + } let p = self .ops .get(op.index()) @@ -493,7 +596,9 @@ unsafe impl Backend for WasiP2 { Ok(()) } fn has_work(&self) -> bool { - !self.ready.is_empty() || !self.cancelled.is_empty() + !self.ready.is_empty() + || !self.cancelled.is_empty() + || self.lookups.iter().flatten().any(|l| l.ready) } fn poll( @@ -513,6 +618,7 @@ unsafe impl Backend for WasiP2 { }); } self.run_ready(events); + self.run_dns(events); if self.has_work() || !events.is_empty() || events.len() == events.capacity() { return Ok(PollInfo::default()); } @@ -537,7 +643,13 @@ unsafe impl Backend for WasiP2 { t.poll.as_ref().expect("socket pollable") }; self.handles.push(poll.handle()); - self.owners.push((r.handle, d)); + self.owners.push(PollOwner::Socket(r.handle, d)); + } + } + for (i, l) in self.lookups.iter().enumerate() { + if let Some(l) = l { + self.handles.push(l.poll.handle()); + self.owners.push(PollOwner::Dns(i)); } } let deadline = timeout @@ -551,15 +663,22 @@ unsafe impl Backend for WasiP2 { } abi::poll(&self.handles, &mut self.poll_storage, &mut self.indices); for i in 0..self.indices.len() { - if let Some(&(h, d)) = self.owners.get(self.indices[i]) { - self.resources[h.index()] - .as_mut() - .expect("live poll owner") - .ready[d] = true; - self.schedule(h); + match self.owners.get(self.indices[i]).copied() { + Some(PollOwner::Socket(h, d)) => { + self.resources[h.index()] + .as_mut() + .expect("live poll owner") + .ready[d] = true; + self.schedule(h); + } + Some(PollOwner::Dns(i)) => { + self.lookups[i].as_mut().expect("live lookup").ready = true + } + None => {} } } self.run_ready(events); + self.run_dns(events); Ok(PollInfo { waits: 1, zero_event_waits: u32::from(self.indices.is_empty()), diff --git a/crates/turnloop/src/driver.rs b/crates/turnloop/src/driver.rs index ce0ff60..e716dea 100644 --- a/crates/turnloop/src/driver.rs +++ b/crates/turnloop/src/driver.rs @@ -709,6 +709,10 @@ impl Driver { } true } + /// Whether close has begun and physical release is still pending. + pub fn is_closing(&self, h: Handle) -> bool { + self.resource(h).is_ok_and(|r| r.closing.is_some()) + } /// Cancel pending operations, then deliver Closed. The resource is released /// only after Closed is appended to the host's output buffer. pub fn close(&mut self, h: Handle, token: Token) -> Result<()> { @@ -793,9 +797,21 @@ impl Driver { ) -> Result { self.submit_work(crate::blocking::blocking(f), token) } - /// Resolve an owned hostname on the shared native blocking pool. + /// Resolve through a host resolver, or the shared native blocking pool. pub fn resolve(&mut self, request: crate::DnsRequest, token: Token) -> Result { - self.submit_work(crate::blocking::resolve(request), token) + let op = self.new_op(None, token)?; + match self.backend.resolve(op, &request) { + Ok(()) => Ok(op), + Err(e) => { + self.retire(op); + self.outstanding -= 1; + if e.kind == ErrorKind::Unsupported { + self.submit_work(crate::blocking::resolve(request), token) + } else { + Err(e) + } + } + } } fn submit_work( &mut self, @@ -889,6 +905,7 @@ impl Driver { } } else { match e.result { + Ok(Outcome::Resolved(addresses)) => OpResult::Resolved(addresses), Ok(Outcome::Exited(status)) => OpResult::Exited(status), Ok(Outcome::Signal(signal)) => OpResult::Signal(signal), Ok(Outcome::PipeAccepted(d)) => match self.attach(d, op.token) { diff --git a/crates/turnloop/src/executor.rs b/crates/turnloop/src/executor.rs index 785e904..f4f7e7b 100644 --- a/crates/turnloop/src/executor.rs +++ b/crates/turnloop/src/executor.rs @@ -70,6 +70,7 @@ struct IoSlot { abandoned: bool, op: Option, timer: Option, + closing: Option, result: Option, waker: Option, bytes: Box<[u8]>, @@ -138,6 +139,7 @@ impl Shared { slot.op = None; slot.waker = None; slot.timer = None; + slot.closing = None; drop(slots); if let Some( OpResult::Accepted { conn, .. } @@ -177,6 +179,16 @@ impl Shared { slot.result.take() } fn dispatch(&self, completion: Completion) { + if matches!(completion.result, OpResult::Closed) { + for slot in self.slots.borrow_mut().iter_mut() { + if slot.used && slot.closing.is_some() && slot.closing == completion.handle { + slot.result = Some(OpResult::Closed); + if let Some(w) = &slot.waker { + w.wake_by_ref(); + } + } + } + } if completion.token.0 & TAG == 0 { return; } @@ -238,6 +250,7 @@ impl LocalExecutor { abandoned: false, op: None, timer: None, + closing: None, result: None, waker: None, bytes: vec![0; executor.buffer_size].into_boxed_slice(), @@ -366,7 +379,7 @@ impl ExecutorHandle { pub fn now(&self) -> Instant { self.shared.driver.borrow().now() } - /// Resolve through the driver's native blocking pool. WASI/web return Unsupported. + /// Resolve through WASI ip-name-lookup or the native blocking pool. Web returns Unsupported. pub fn resolve(&self, request: DnsRequest) -> Resolve { Resolve { executor: self.clone(), @@ -374,6 +387,23 @@ impl ExecutorHandle { key: None, } } + /// Run owned work on the shared bounded blocking pool. Cancellation discards + /// its result; an already running closure retains all its owned data. + pub fn blocking BlockingResult + Send + 'static>(&self, work: F) -> Blocking { + Blocking { + executor: self.clone(), + work: Some(Box::new(work)), + key: None, + } + } + /// Close a resource and await physical release after its terminal I/O results. + pub fn close(&self, handle: Handle) -> Close { + Close { + executor: self.clone(), + handle: Some(handle), + key: None, + } + } /// Connect TCP without blocking the host. Dropping closes the pending socket. pub fn connect(&self, addr: SocketAddr, opts: TcpOpts) -> Connect { Connect { @@ -943,6 +973,123 @@ impl Drop for Resolve { } } } +/// Await acknowledgement of physical resource release. Dropping the future +/// abandons observation, while the close itself continues in the driver. +pub struct Close { + executor: ExecutorHandle, + handle: Option, + key: Option, +} +impl Unpin for Close {} +impl Future for Close { + type Output = Result<()>; + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let this = self.get_mut(); + let shared = &this.executor.shared; + let key = if let Some(key) = this.key { + key + } else { + let key = shared.reserve(cx)?; + let Some(handle) = this.handle.take() else { + shared.free(key); + return Poll::Ready(Err(Error::new(ErrorKind::InvalidInput))); + }; + let result = { + let mut driver = shared.driver.borrow_mut(); + if driver.is_closing(handle) { + Ok(()) + } else { + driver.close(handle, Token(0)) + } + }; + if let Err(e) = result { + shared.free(key); + return Poll::Ready(if e.kind == ErrorKind::NotFound { + Ok(()) + } else { + Err(e) + }); + } + shared.slots.borrow_mut()[key.index].closing = Some(handle); + this.key = Some(key); + key + }; + let Some(result) = shared.result(key, cx) else { + return Poll::Pending; + }; + shared.free(key); + this.key = None; + Poll::Ready(match result { + OpResult::Closed => Ok(()), + OpResult::Err(e) => Err(e), + _ => Err(Error::new(ErrorKind::Other)), + }) + } +} +impl Drop for Close { + fn drop(&mut self) { + if let Some(key) = self.key.take() { + self.executor.shared.abandon(key); + } + } +} + +/// Owned blocking work, cancelled on drop. +pub struct Blocking { + executor: ExecutorHandle, + work: Option BlockingResult + Send>>, + key: Option, +} +impl Unpin for Blocking {} +impl Future for Blocking { + type Output = Result; + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let this = self.get_mut(); + let shared = &this.executor.shared; + let key = match this.key { + Some(key) => key, + None => { + if this.work.is_none() { + return Poll::Ready(Err(Error::new(ErrorKind::InvalidInput))); + } + let key = shared.reserve(cx)?; + let request = this + .work + .take() + .ok_or(Error::new(ErrorKind::InvalidInput))?; + let result = shared.driver.borrow_mut().blocking(request, key.token()); + match result { + Ok(op) => { + shared.slots.borrow_mut()[key.index].op = Some(op); + this.key = Some(key); + key + } + Err(e) => { + shared.free(key); + return Poll::Ready(Err(e)); + } + } + } + }; + let Some(result) = shared.result(key, cx) else { + return Poll::Pending; + }; + shared.free(key); + this.key = None; + Poll::Ready(match result { + OpResult::Blocking(addresses) => Ok(addresses), + OpResult::Err(e) => Err(e), + _ => Err(Error::new(ErrorKind::Cancelled)), + }) + } +} +impl Drop for Blocking { + fn drop(&mut self) { + if let Some(key) = self.key.take() { + self.executor.shared.abandon(key); + } + } +} impl Unpin for Connect {} impl Future for Connect { type Output = Result>; diff --git a/crates/turnloop/src/lib.rs b/crates/turnloop/src/lib.rs index 50c2f22..e458f22 100644 --- a/crates/turnloop/src/lib.rs +++ b/crates/turnloop/src/lib.rs @@ -84,7 +84,8 @@ pub mod executor; #[cfg(feature = "executor")] pub use executor::{ - AsyncIo, ExecutorConfig, ExecutorHandle, JoinError, JoinHandle, LocalExecutor, Sleep, + AsyncIo, Blocking, Close, ExecutorConfig, ExecutorHandle, JoinError, JoinHandle, LocalExecutor, + Sleep, }; #[cfg(all(test, not(loom)))] diff --git a/docs/adapters-db-commands.md b/docs/adapters-db-commands.md new file mode 100644 index 0000000..ba6c71e --- /dev/null +++ b/docs/adapters-db-commands.md @@ -0,0 +1,99 @@ +# adapters-db command ledger + +| Command | Result | Log | +| --- | --- | --- | +| `cargo check -p turnloop-postgres --features turnloop` | FAIL | `initial-pg.log` | +| `cargo check -p turnloop-postgres -p turnloop-mysql --all-features` | FAIL | `sql-check.log` | +| `cargo check -p turnloop-postgres -p turnloop-mysql --all-features` | FAIL | `pools-check.log` | +| `cargo check -p turnloop-postgres -p turnloop-mysql -p turnloop-redis -p turnloop-smtp --all-features` | PASS | `four-check.log` | +| `cargo check -p turnloop-mongodb -p turnloop-redis --all-features` | FAIL | `mongo-check.log` | +| `cargo check --workspace --all-features` | PASS | `mongo-check-fixed.log` | +| `python3 scripts/ci/install-wasm-toolchain.py` | PASS | `wasm-toolchain.log` | +| `cargo test -p turnloop-postgres -p turnloop-redis --features turnloop --test asynchronous -- --test-threads=1` | PASS | `first-async-tests.log` | +| `cargo test -p turnloop-postgres -p turnloop-mysql -p turnloop-redis -p turnloop-smtp -p turnloop-mongodb --features turnloop --test asynchronous -- --test-threads=1` | PASS | `five-async-tests.log` | +| `python3 scripts/test-servers.py --services redis run cargo test -p turnloop-redis --features turnloop --test async_server -- --include-ignored --test-threads=1 --nocapture` | PASS | `redis-real.log` | +| `cargo fmt --all` | PASS | `format.log` | +| `cargo clippy --workspace --all-targets --all-features -- -D warnings -D clippy::undocumented_unsafe_blocks` | FAIL | `strict-first.log` | +| `python3 scripts/test-servers.py --services mongodb run sh .tools/adapters-db/mongo-servers.sh` | PASS | `mongodb-real.log` | +| `cargo clippy --workspace --all-targets --all-features --fix --allow-dirty --allow-staged -- -D warnings -D clippy::undocumented_unsafe_blocks` | PASS | `strict-fix.log` | +| `python3 scripts/test-servers.py --services smtp run cargo test -p turnloop-smtp --features turnloop --test async_server -- --include-ignored --test-threads=1 --nocapture` | PASS | `smtp-real.log` | +| `sh -c '. .tools/wasm-env.sh; cargo check -p turnloop --features executor --target wasm32-wasip2'` | PASS | `dns-p2-check.log` | +| `bash scripts/ci/install-wasmtime.sh` | FAIL | `install-wasmtime.log` | +| `cargo check --workspace --all-features` | FAIL | `pool-ack-check.log` | +| `cargo test -p turnloop-io --lib` | PASS | `dns-native.log` | +| `cargo test -p turnloop-postgres -p turnloop-mysql -p turnloop-mongodb --features turnloop --test asynchronous -- --test-threads=1` | PASS | `lifecycle-tests.log` | +| `sh -c '. .tools/wasm-env.sh; python3 scripts/ci/run-tests.py protocol-wasi --target wasm32-wasip2'` | FAIL | `p2-all.log` | +| `sh -c '. .tools/wasm-env.sh; python3 scripts/ci/run-tests.py protocol-wasi --target wasm32-wasip2'` | PASS | `p2-all-dns.log` | +| `cargo test -p turnloop-postgres -p turnloop-mysql --features turnloop --test asynchronous -- --test-threads=1` | PASS | `async-alloc-sql.log` | +| `cargo test -p turnloop-redis -p turnloop-mongodb -p turnloop-smtp --features turnloop --test asynchronous -- --test-threads=1` | PASS | `async-alloc-rest.log` | +| `sh -c '. .tools/wasm-env.sh; python3 scripts/test-servers.py --services redis run python3 scripts/ci/run-tests.py protocol-wasi --target wasm32-wasip2 --real-servers --package turnloop-redis'` | FAIL | `wasi-redis-real.log` | +| `cargo clippy --workspace --all-targets --all-features -- -D warnings -D clippy::undocumented_unsafe_blocks` | FAIL | `strict-second.log` | +| `sh -c '. .tools/wasm-env.sh; python3 scripts/test-servers.py --services mongodb run python3 scripts/ci/run-tests.py protocol-wasi --target wasm32-wasip2 --real-servers --package turnloop-mongodb'` | PASS | `wasi-mongo-real.log` | +| `cargo clippy --workspace --all-targets --all-features -- -D warnings -D clippy::undocumented_unsafe_blocks` | PASS | `strict-third.log` | +| `python3 scripts/test-servers.py --services redis run cargo test -p turnloop-redis --features turnloop --test async_server -- --include-ignored --test-threads=1 --nocapture` | FAIL | `redis-failover-native.log` | +| `sh -c '. .tools/wasm-env.sh; cargo clippy --workspace --all-targets --all-features --target wasm32-wasip2 -- -D warnings -D clippy::undocumented_unsafe_blocks'` | PASS | `p2-clippy.log` | +| `sh -c '. .tools/wasm-env.sh; cargo clippy --workspace --all-targets --all-features --target wasm32-unknown-unknown -- -D warnings -D clippy::undocumented_unsafe_blocks'` | PASS | `web-clippy.log` | +| `python3 scripts/test-servers.py --services redis run cargo test -p turnloop-redis --features turnloop --test async_server -- --include-ignored --test-threads=1 --nocapture` | FAIL | `redis-failover-restart.log` | +| `cargo +stable check --workspace --all-features` | PASS | `stable-all.log` | +| `bash scripts/ci/no-tokio.sh` | PASS | `no-tokio.log` | +| `python3 -m unittest discover -s scripts/ci -p 'test_*.py' -v` | PASS | `ci-unit.log` | +| `python3 scripts/ci/soak.py` | PASS | `soak.log` | +| `python3 scripts/test-servers.py --services mongodb run sh .tools/adapters-db/mongo-servers.sh` | PASS | `mongo-retry-cleanup.log` | +| `cargo check --workspace --all-features --examples` | PASS | `examples-check.log` | +| `env ZIG_GLOBAL_CACHE_DIR=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-cache CC_x86_64_unknown_linux_gnu=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-linux-cc AR_x86_64_unknown_linux_gnu=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-ar cargo clippy --workspace --all-targets --all-features --target x86_64-unknown-linux-gnu -- -D warnings -D clippy::undocumented_unsafe_blocks` | FAIL | `linux-clippy.log` | +| `env CC_x86_64_pc_windows_msvc=clang AR_x86_64_pc_windows_msvc=llvm-ar cargo clippy --workspace --lib --all-features --target x86_64-pc-windows-msvc -- -D warnings -D clippy::undocumented_unsafe_blocks` | FAIL | `windows-lib.log` | +| `cargo test --workspace` | FAIL | `workspace-default.log` | +| `python3 scripts/test-servers.py --services redis run cargo test -p turnloop-redis --features turnloop --test async_server -- --include-ignored --test-threads=1 --nocapture` | PASS | `redis-failover-final.log` | +| `env ZIG_GLOBAL_CACHE_DIR=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-cache CC_x86_64_unknown_linux_gnu=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-linux-cc AR_x86_64_unknown_linux_gnu=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-ar cargo clippy --workspace --all-targets --all-features --target x86_64-unknown-linux-gnu -- -D warnings -D clippy::undocumented_unsafe_blocks` | PASS | `linux-clippy-fixed.log` | +| `env ZIG_GLOBAL_CACHE_DIR=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-cache CC_x86_64_pc_windows_msvc=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-windows-cc AR_x86_64_pc_windows_msvc=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-ar cargo clippy --workspace --lib --all-features --target x86_64-pc-windows-msvc -- -D warnings -D clippy::undocumented_unsafe_blocks` | FAIL | `windows-lib-zig.log` | +| `sh -c '. .tools/wasm-env.sh; python3 scripts/test-servers.py --services redis run python3 scripts/ci/run-tests.py protocol-wasi --target wasm32-wasip2 --real-servers --package turnloop-redis'` | FAIL | `wasi-redis-failover.log` | +| `cargo test --workspace -- --test-threads=1` | PASS | `workspace-serial.log` | +| `env ZIG_GLOBAL_CACHE_DIR=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-cache CC_x86_64_pc_windows_msvc=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-windows-cc AR_x86_64_pc_windows_msvc=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-win-ar cargo clippy --workspace --lib --all-features --target x86_64-pc-windows-msvc -- -D warnings -D clippy::undocumented_unsafe_blocks` | FAIL | `windows-lib-fixed.log` | +| `cargo test -p turnloop-mongodb --features turnloop --test asynchronous --test spec_selection -- --test-threads=1` | FAIL | `retained-selection.log` | +| `env ZIG_GLOBAL_CACHE_DIR=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-cache CC_x86_64_pc_windows_msvc=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-windows-cc AR_x86_64_pc_windows_msvc=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-win-ar cargo clippy --workspace --lib --all-features --target x86_64-pc-windows-msvc -- -D warnings -D clippy::undocumented_unsafe_blocks` | PASS | `windows-lib-final.log` | +| `sh -c '. .tools/wasm-env.sh; python3 scripts/test-servers.py --services redis run python3 scripts/ci/run-tests.py protocol-wasi --target wasm32-wasip2 --real-servers --package turnloop-redis'` | PASS | `wasi-redis-stack-fixed.log` | +| `cargo test -p turnloop-mongodb --features turnloop --test asynchronous --test spec_selection -- --test-threads=1` | PASS | `retained-selection-fixed.log` | +| `cargo test -p turnloop-mongodb --features turnloop --test asynchronous -- --test-threads=1` | PASS | `mongo-cancel-idle.log` | +| `cargo fmt --all` | PASS | `format-all.log` | +| `sh -c '. .tools/wasm-env.sh; python3 scripts/test-servers.py --services smtp run python3 scripts/ci/run-tests.py protocol-wasi --target wasm32-wasip2 --real-servers --package turnloop-smtp'` | PASS | `smtp-wasi-real.log` | +| `python3 scripts/test-servers.py --services postgres run cargo test -p turnloop-postgres --features turnloop --test async_server -- --include-ignored --test-threads=1 --nocapture` | FAIL | `postgres-sandbox.log` | +| `python3 scripts/test-servers.py --services mysql run cargo test -p turnloop-mysql --features turnloop --test async_server -- --include-ignored --test-threads=1 --nocapture` | FAIL | `mysql-sandbox.log` | +| `cargo test -p turnloop-mongodb --features turnloop --test asynchronous --test protocol --test spec_selection -- --test-threads=1` | PASS | `mongo-final-regressions.log` | +| `python3 -m unittest discover -s scripts/ci -p 'test_*.py'` | PASS | `ci-final.log` | +| `cargo test -p turnloop-io --test streams -- --test-threads=1` | PASS | `io-worker.log` | +| `cargo fmt --all` | PASS | `format-final.log` | +| `cargo test -p turnloop-postgres -p turnloop-mysql --features turnloop --test asynchronous -- --test-threads=1` | PASS | `sql-final-regressions.log` | +| `cargo clippy --workspace --all-targets --all-features -- -D warnings -D clippy::undocumented_unsafe_blocks` | PASS | `strict-final.log` | +| `python3 scripts/test-servers.py --services redis,mongodb,smtp run python3 scripts/ci/run-tests.py protocol --package turnloop-redis --package turnloop-mongodb --package turnloop-smtp` | PASS | `native-real-final.log` | +| `cargo fmt --check` | PASS | `fmt-check.log` | +| `sh -c '. .tools/wasm-env.sh; cargo clippy --workspace --all-targets --all-features --target wasm32-wasip2 -- -D warnings -D clippy::undocumented_unsafe_blocks; result=$?; test "$result" -eq 0 \|\| exit "$result"; cargo clippy --workspace --all-targets --all-features --target wasm32-unknown-unknown -- -D warnings -D clippy::undocumented_unsafe_blocks; result=$?; test "$result" -eq 0 \|\| exit "$result"; cargo +nightly-2026-09-07 clippy --workspace --all-targets --all-features --target wasm32-wasip3 -- -D warnings -D clippy::undocumented_unsafe_blocks'` | PASS | `wasm-final-clippy.log` | +| `env ZIG_GLOBAL_CACHE_DIR=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-cache CC_x86_64_pc_windows_msvc=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-windows-cc AR_x86_64_pc_windows_msvc=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-win-ar cargo clippy --workspace --all-targets --all-features --target x86_64-pc-windows-msvc -- -D warnings -D clippy::undocumented_unsafe_blocks` | FAIL | `windows-all-targets.log` | +| `env ZIG_GLOBAL_CACHE_DIR=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-cache CC_x86_64_unknown_linux_gnu=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-linux-cc AR_x86_64_unknown_linux_gnu=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-ar cargo clippy --workspace --all-targets --all-features --target x86_64-unknown-linux-gnu -- -D warnings -D clippy::undocumented_unsafe_blocks` | PASS | `linux-final.log` | +| `cargo +stable check --workspace --all-features` | PASS | `stable-final.log` | +| `env ZIG_GLOBAL_CACHE_DIR=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-cache CC_x86_64_pc_windows_msvc=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-windows-cc AR_x86_64_pc_windows_msvc=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-win-ar cargo clippy --workspace --lib --all-features --target x86_64-pc-windows-msvc -- -D warnings -D clippy::undocumented_unsafe_blocks` | PASS | `windows-libraries-final.log` | +| `scripts/ci/no-tokio.sh` | PASS | `no-tokio-final.log` | +| `python3 scripts/ci/soak.py` | PASS | `soak-final.log` | +| `cargo test --workspace --all-features -- --test-threads=1` | PASS | `workspace-features-final.log` | +| `sh -c '. .tools/wasm-env.sh; python3 scripts/test-servers.py --services redis,mongodb,smtp run python3 scripts/ci/run-tests.py protocol-wasi --target wasm32-wasip2 --real-servers --package turnloop-redis --package turnloop-mongodb --package turnloop-smtp'` | PASS | `wasi-real-final.log` | +| `sh -c '. .tools/wasm-env.sh; python3 scripts/ci/run-tests.py protocol-wasi --target wasm32-wasip3'` | FAIL | `p3-portable-final.log` | +| `cargo fmt --all` | PASS | `smtp-cancel-format.log` | +| `cargo test -p turnloop-smtp --features turnloop --test asynchronous -- --test-threads=1` | PASS | `smtp-cancel.log` | +| `sh -c '. .tools/wasm-env.sh; python3 scripts/ci/run-tests.py protocol-wasi --target wasm32-wasip2'` | PASS | `p2-portable-final.log` | +| `sh -c '. .tools/wasm-env.sh; cargo +nightly-2026-09-07 test -p turnloop-mongodb --all-features --target wasm32-wasip3 --test asynchronous -- --test-threads=1'` | FAIL | `p3-mongo-repro.log` | +| `python3 -m unittest discover -s scripts/ci -p 'test_*.py'` | PASS | `ci-profile-tests.log` | +| `cargo fmt --check` | PASS | `final-source-format.log` | +| `cargo clippy --workspace --all-targets -- -D warnings` | PASS | `lint-last.log` | +| `sh -c '. .tools/wasm-env.sh; python3 scripts/ci/run-tests.py protocol-wasi --target wasm32-wasip3'` | PASS | `p3-release-fixed.log` | +| `cargo clippy -p turnloop-smtp --all-targets --all-features -- -D warnings -D clippy::undocumented_unsafe_blocks` | PASS | `smtp-last-strict.log` | +| `cargo test --workspace` | FAIL | `workspace-default-recheck.log` | +| `sh -c 'export PATH="$PWD/.tools/bin:$PATH"; python3 scripts/ci/lint-workflows.py'` | FAIL | `workflow-lint.log` | +| `python3 scripts/ci/install-tools.py actionlint zizmor shellcheck` | PASS | `install-workflow-tools.log` | +| `env ZIG_GLOBAL_CACHE_DIR=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-cache CC_x86_64_unknown_linux_gnu=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-linux-cc AR_x86_64_unknown_linux_gnu=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-ar cargo clippy -p turnloop-smtp --all-targets --all-features --target x86_64-unknown-linux-gnu -- -D warnings -D clippy::undocumented_unsafe_blocks` | PASS | `smtp-linux-last.log` | +| `sh -c '. .tools/wasm-env.sh; cargo clippy -p turnloop-smtp --all-targets --all-features --target wasm32-wasip2 -- -D warnings -D clippy::undocumented_unsafe_blocks; result=$?; test "$result" -eq 0 \|\| exit "$result"; cargo +nightly-2026-09-07 clippy -p turnloop-smtp --all-targets --all-features --target wasm32-wasip3 -- -D warnings -D clippy::undocumented_unsafe_blocks'` | PASS | `smtp-wasm-last.log` | +| `git diff --check` | PASS | `whitespace-final.log` | +| `sh -c 'export PATH="$PWD/.tools/bin:$PATH"; python3 scripts/ci/lint-workflows.py'` | PASS | `workflow-lint-final.log` | +| `cargo fmt --all` | PASS | `dns-probe-format.log` | +| `cargo test -p turnloop-io --test streams native_srv_txt_records_through_blocking_pool -- --ignored --test-threads=1 --nocapture` | PASS | `dns-native-live.log` | +| `cargo clippy -p turnloop-io --all-targets --all-features -- -D warnings -D clippy::undocumented_unsafe_blocks` | PASS | `io-last-strict.log` | +| `env ZIG_GLOBAL_CACHE_DIR=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-cache CC_x86_64_unknown_linux_gnu=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-linux-cc AR_x86_64_unknown_linux_gnu=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-ar cargo clippy -p turnloop-io --all-targets --all-features --target x86_64-unknown-linux-gnu -- -D warnings -D clippy::undocumented_unsafe_blocks` | PASS | `io-linux-last.log` | +| `cargo fmt --check` | PASS | `final-format-check.log` | diff --git a/docs/adb-fix1-commands.md b/docs/adb-fix1-commands.md new file mode 100644 index 0000000..d8c96e2 --- /dev/null +++ b/docs/adb-fix1-commands.md @@ -0,0 +1,70 @@ +# adb-fix1 command ledger + +All commands ran in the adapters-db clone. Logs are in `.tools/adb-fix1/`. +WASM commands source `.tools/wasm-env.sh` (wasi-sdk 34); Wasmtime 46 is provided +by the repository runner. The p3 toolchain is nightly-2026-09-07. Other Cargo +commands use pinned nightly-2026-08-20, except the explicit stable check. + +FAIL entries are retained. SQL startup failed before tests; their bodies are +UNRUN. The initial PG native/WASI failures came from the new rejection fixture's +OS error-category assumption; the final fixture proves exactly-once stream drop, +zero startup bytes and the exact binding error. Windows all-target checking lacks +the inherited Platform provider. The ordinary path gate needs staged new files; +a separate audit uses the same strict checker against exact working-tree names. + +Fixture generation (PASS, before logging): `openssl version` (3.6.3), +`python3 protocols/turnloop-tls/tests/certificates/generate.py`. The script records +independent hashlib digests and deletes temporary private keys. Read-only Git, +source, manifest and RFC/PostgreSQL documentation inspection also completed. + +| Command | Result | Log | +| --- | --- | --- | +| `cargo test -p turnloop-tls --test channel_binding` | PASS | `binding-tests.log` | +| `cargo test -p turnloop-postgres --all-features -- --test-threads=1` | FAIL | `pg-tests.log` | +| `cargo fmt --all` | PASS | `fmt-apply.log` | +| `cargo test -p turnloop-postgres -p turnloop-tls --all-features -- --test-threads=1` | PASS | `touched-tests.log` | +| `cargo fmt --all` | PASS | `fmt-apply-2.log` | +| `cargo clippy --locked --workspace --all-targets -- -D warnings -D clippy::undocumented_unsafe_blocks` | PASS | `native-clippy.log` | +| `python3 scripts/test-servers.py --services postgres,mysql run python3 scripts/ci/run-tests.py protocol --package turnloop-postgres --package turnloop-mysql` | FAIL | `sql-fixtures.log` | +| `bash scripts/ci/no-tokio.sh` | PASS | `no-tokio.log` | +| `python3 scripts/ci/soak.py` | PASS | `soak.log` | +| `cargo clippy --locked --workspace --all-targets --all-features -- -D warnings -D clippy::undocumented_unsafe_blocks` | PASS | `native-clippy-all.log` | +| `cargo +stable check --locked --workspace --all-targets --all-features` | PASS | `stable.log` | +| `cargo test --locked --workspace -- --test-threads=1` | PASS | `workspace-default.log` | +| `cargo test --locked --workspace --all-features -- --test-threads=1` | PASS | `workspace-all.log` | +| `cargo clippy --locked -p turnloop-postgres -p turnloop-tls --all-targets --all-features --target wasm32-wasip2 -- -D warnings -D clippy::undocumented_unsafe_blocks` | PASS | `clippy-p2.log` | +| `cargo +nightly-2026-09-07 clippy --locked -p turnloop-postgres -p turnloop-tls --all-targets --all-features --target wasm32-wasip3 -- -D warnings -D clippy::undocumented_unsafe_blocks` | PASS | `clippy-p3.log` | +| `cargo clippy --locked -p turnloop-postgres -p turnloop-tls --all-targets --all-features --target wasm32-unknown-unknown -- -D warnings -D clippy::undocumented_unsafe_blocks` | PASS | `clippy-web.log` | +| `cargo fmt --all --check` | PASS | `fmt-check.log` | +| `git diff --check` | PASS | `diff-check.log` | +| `env RUSTDOCFLAGS=-Dwarnings cargo doc --locked -p turnloop-postgres -p turnloop-tls --all-features --no-deps` | PASS | `rustdoc.log` | +| `python3 scripts/ci/run-tests.py protocol-wasi --target wasm32-wasip2 --package turnloop-postgres --package turnloop-tls` | FAIL | `protocol-p2.log` | +| `cargo test --locked -p turnloop-postgres --test asynchronous --features turnloop --target wasm32-wasip2 --config 'target.wasm32-wasip2.runner="scripts/ci/wasmtime-runner.sh"' tls_scram -- --test-threads=1 --nocapture` | FAIL | `pg-p2-diagnostic.log` | +| `python3 scripts/ci/run-tests.py protocol-wasi --target wasm32-wasip3 --package turnloop-postgres --package turnloop-tls` | PASS | `protocol-p3.log` | +| `cargo fmt --all` | PASS | `fmt-apply-3.log` | +| `python3 scripts/ci/run-tests.py protocol-wasi --target wasm32-wasip2 --package turnloop-postgres --package turnloop-tls` | PASS | `protocol-p2-fixed.log` | +| `env ZIG_GLOBAL_CACHE_DIR=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-cache CC_x86_64_unknown_linux_gnu=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-linux-cc AR_x86_64_unknown_linux_gnu=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-ar cargo clippy --locked -p turnloop-postgres -p turnloop-tls --all-targets --all-features --target x86_64-unknown-linux-gnu -- -D warnings -D clippy::undocumented_unsafe_blocks` | PASS | `linux-clippy.log` | +| `python3 scripts/ci/run-tests.py protocol-wasi --target wasm32-wasip3 --package turnloop-postgres --package turnloop-tls` | PASS | `protocol-p3-fixed.log` | +| `env ZIG_GLOBAL_CACHE_DIR=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-cache CC_x86_64_pc_windows_msvc=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-windows-cc AR_x86_64_pc_windows_msvc=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-win-ar cargo clippy --locked -p turnloop-postgres -p turnloop-tls --lib --all-features --target x86_64-pc-windows-msvc -- -D warnings -D clippy::undocumented_unsafe_blocks` | PASS | `windows-lib-clippy.log` | +| `env ZIG_GLOBAL_CACHE_DIR=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-cache CC_x86_64_pc_windows_msvc=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-windows-cc AR_x86_64_pc_windows_msvc=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-win-ar cargo clippy --locked -p turnloop-postgres -p turnloop-tls --all-targets --all-features --target x86_64-pc-windows-msvc -- -D warnings -D clippy::undocumented_unsafe_blocks` | FAIL | `windows-all-clippy.log` | +| `cargo test --locked -p turnloop-postgres -p turnloop-tls --all-features -- --test-threads=1` | PASS | `touched-tests-final.log` | +| `python3 scripts/ci/check-paths.py` | FAIL | `paths.log` | +| `python3 scripts/ci/feature_modes.py` | PASS | `feature-modes.log` | +| `cargo clippy --locked --workspace --all-targets --all-features -- -D warnings -D clippy::undocumented_unsafe_blocks` | PASS | `native-clippy-final.log` | +| `cargo clippy --locked -p turnloop-postgres -p turnloop-tls --all-targets --all-features --target wasm32-wasip2 -- -D warnings -D clippy::undocumented_unsafe_blocks` | PASS | `p2-clippy-final.log` | +| `env ZIG_GLOBAL_CACHE_DIR=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-cache CC_x86_64_pc_windows_msvc=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-windows-cc AR_x86_64_pc_windows_msvc=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-win-ar cargo clippy --locked -p turnloop-postgres -p turnloop-tls --test channel_binding --test protocol --test server --all-features --target x86_64-pc-windows-msvc -- -D warnings -D clippy::undocumented_unsafe_blocks` | PASS | `windows-deterministic.log` | +| `cargo +nightly-2026-09-07 clippy --locked -p turnloop-postgres -p turnloop-tls --all-targets --all-features --target wasm32-wasip3 -- -D warnings -D clippy::undocumented_unsafe_blocks` | PASS | `p3-clippy-final.log` | +| `cargo clippy --locked -p turnloop-postgres -p turnloop-tls --all-targets --all-features --target wasm32-unknown-unknown -- -D warnings -D clippy::undocumented_unsafe_blocks` | PASS | `web-clippy-final.log` | +| `python3 .tools/adb-fix1/audit.py` | PASS | `audit.log` | +| `cargo test --locked -p turnloop-postgres --test server rejected_startup_reports_server_sqlstate_and_message -- --exact --nocapture` | PASS | `expected-panic.log` | +| `cargo fmt --all --check` | PASS | `final-fmt.log` | +| `git diff --check` | PASS | `final-diff.log` | + +## Required external reruns + +| Command / environment | Status | +| --- | --- | +| `python3 scripts/test-servers.py --services postgres,mysql run python3 scripts/ci/run-tests.py protocol --package turnloop-postgres --package turnloop-mysql` outside the sandbox | UNRUN post-fix; sandbox invocation above failed at initdb | +| `python3 scripts/test-servers.py --services postgres,mysql run python3 scripts/ci/run-tests.py protocol-wasi --target wasm32-wasip2 --real-servers --package turnloop-postgres --package turnloop-mysql` | UNRUN (sandbox); source `.tools/wasm-env.sh` first | +| `python3 scripts/ci/run-tests.py native` on Linux/Windows | UNRUN (hosts unavailable); cross-Clippy results above are compilation only | +| Browser runtime | UNRUN; raw PostgreSQL TCP is unavailable in browsers | diff --git a/docs/adb-fix2-commands.md b/docs/adb-fix2-commands.md new file mode 100644 index 0000000..13614f3 --- /dev/null +++ b/docs/adb-fix2-commands.md @@ -0,0 +1,81 @@ +# adb-fix2 command ledger + +Commands ran from the clone root. Plain Cargo uses nightly-2026-08-20; p3 uses +nightly-2026-09-07. Logs are under `.tools/adb-fix2/`. The `run.py` wrapper records +child exit status without retries; `checks.py`, `windows.py` and `final.py` merely +sequence the individually recorded commands. Ignored test bodies are UNRUN. +The WASI runner used the existing environment and never started/stopped servers. +Its logs print each exact inner Cargo/Wasmtime invocation and positive test counts. + +Historical failures are retained: real PostgreSQL before the fix; parallel +SIGCHLD interference (natural and controlled); an invalid new-fixture read after +locally closing its stream (corrected to assert the close acknowledgement and +exactly-once client stream drop); Windows compiler headers, then the inherited +missing Platform provider. The initial repetition campaign passed 20 processes +before SIGUSR1 killed all-feature process 1 (exit -30); the final campaign passes +all 30 after isolating the two competing SIGUSR1 fixtures. The final default +workspace run independently failed the unchanged core UDP bind assertion at +unix.rs:1073 (-1, errno absent); the all-feature workspace passed. No retry or +assertion change replaces that FAIL. + +Additional PASS inspection/audit commands: `git status --short`, `git log -1`, +`git diff --stat`, source/diff reads with `rg`/`sed`/`cat`, all required full report +reads, environment/runner inspection, and positive-count log summation with Python. +One initial unlogged `cargo fmt --all` also passed before the recorded formatting +invocations. Initial guessed nonexistent source paths returned read errors; they +were replaced with paths from `rg --files`, with no verification claim. + + +| Command | Result | Log | +| --- | --- | --- | +| `zsh -c 'source .tools/sql-env.sh && cargo test -p turnloop-postgres --features turnloop --test async_server -- --include-ignored --test-threads=1 --nocapture'` | FAIL | `pg-real-before.log` | +| `cargo test --locked -p turnloop-contract --test native_surface -- --nocapture` | FAIL | `native-surface-before.log` | +| `cargo test --locked -p turnloop-postgres --features turnloop` | PASS | `pg-first.log` | +| `cargo test --locked -p turnloop-contract --test native_surface -- --nocapture` | PASS | `native-surface-eintr.log` | +| `zsh -c 'source .tools/sql-env.sh && cargo test -p turnloop-postgres --features turnloop --test async_server -- --include-ignored --test-threads=1 --nocapture'` | PASS | `pg-real-fixed.log` | +| `cargo test --locked -p turnloop-contract --test native_surface -- --nocapture` | PASS | `native-surface-early-waits.log` | +| `zsh -c 'source .tools/sql-env.sh && cargo test -p turnloop-mysql --features turnloop --test async_server -- --include-ignored --test-threads=1 --nocapture'` | PASS | `mysql-real-async.log` | +| `cargo test --locked -p turnloop-contract --test native_surface -- --nocapture` | FAIL | `native-surface-early-waits-2.log` | +| `cargo test --locked -p turnloop-contract --test native_surface registered_processes_and_signals_do_not_spin -- --exact --test-threads=1 --nocapture` | FAIL | `no-spin-controlled-sigchld.log` | +| `cargo test --locked -p turnloop-postgres --features turnloop --test protocol --test asynchronous` | FAIL | `pg-regressions.log` | +| `zsh -c 'source .tools/sql-env.sh && cargo test -p turnloop-mysql --features turnloop --test server -- --include-ignored --test-threads=1 --nocapture'` | PASS | `mysql-real-server.log` | +| `cargo test --locked -p turnloop-postgres --features turnloop` | PASS | `pg-regressions-fixed.log` | +| `zsh -c 'source .tools/sql-env.sh && cargo test -p turnloop-postgres --features turnloop --test server -- --include-ignored --test-threads=1 --nocapture'` | PASS | `pg-real-server.log` | +| `cargo test --locked -p turnloop-contract --test native_surface -- --nocapture` | PASS | `native-surface-isolated.log` | +| `cargo fmt --all` | PASS | `fmt-apply.log` | +| `cargo clippy --locked --workspace --all-targets --all-features -- -D warnings -D clippy::undocumented_unsafe_blocks` | PASS | `clippy-native.log` | +| `zsh -c 'source .tools/sql-env.sh && source .tools/wasm-env.sh && python3 scripts/ci/run-tests.py protocol-wasi --target wasm32-wasip2 --real-servers --package turnloop-postgres --package turnloop-mysql'` | PASS | `wasi-p2-real-sql.log` | +| `bash scripts/ci/no-tokio.sh` | PASS | `no-tokio.log` | +| `cargo test --locked --workspace` | PASS | `workspace-parallel.log` | +| `cargo clippy --locked --workspace --all-targets -- -D warnings -D clippy::undocumented_unsafe_blocks` | PASS | `clippy-native-default.log` | +| `cargo +stable check --locked --workspace --all-targets --all-features` | PASS | `stable.log` | +| `zsh -c 'source .tools/wasm-env.sh && cargo clippy --locked -p turnloop-postgres -p turnloop-contract --all-targets --all-features --target wasm32-wasip2 -- -D warnings -D clippy::undocumented_unsafe_blocks'` | PASS | `clippy-p2.log` | +| `zsh -c 'source .tools/wasm-env.sh && cargo +nightly-2026-09-07 clippy --locked -p turnloop-postgres -p turnloop-contract --all-targets --all-features --target wasm32-wasip3 -- -D warnings -D clippy::undocumented_unsafe_blocks'` | PASS | `clippy-p3.log` | +| `zsh -c 'source .tools/wasm-env.sh && cargo clippy --locked -p turnloop-postgres -p turnloop-contract --all-targets --all-features --target wasm32-unknown-unknown -- -D warnings -D clippy::undocumented_unsafe_blocks'` | PASS | `clippy-web.log` | +| `zsh -c 'source .tools/wasm-env.sh && cargo clippy --locked -p turnloop-postgres -p turnloop-contract --all-targets --all-features --target x86_64-unknown-linux-gnu -- -D warnings -D clippy::undocumented_unsafe_blocks'` | PASS | `clippy-linux.log` | +| `zsh -c 'source .tools/wasm-env.sh && cargo clippy --locked -p turnloop-postgres -p turnloop-contract --all-targets --all-features --target x86_64-pc-windows-msvc -- -D warnings -D clippy::undocumented_unsafe_blocks'` | FAIL | `clippy-windows-all.log` | +| `cargo clippy --locked -p turnloop-postgres -p turnloop-contract --lib --all-features --target x86_64-pc-windows-msvc -- -D warnings -D clippy::undocumented_unsafe_blocks` | FAIL | `clippy-windows-lib.log` | +| `cargo clippy --locked -p turnloop-postgres --test protocol --test server --test allocations --all-features --target x86_64-pc-windows-msvc -- -D warnings -D clippy::undocumented_unsafe_blocks` | FAIL | `clippy-windows-wire.log` | +| `python3 scripts/ci/soak.py` | PASS | `soak.log` | +| `python3 scripts/ci/check-paths.py` | PASS | `paths.log` | +| `python3 scripts/ci/feature_modes.py` | PASS | `feature-modes.log` | +| `cargo fmt --all --check` | PASS | `fmt.log` | +| `git diff --check` | PASS | `whitespace.log` | +| `zsh -c 'source .tools/sql-env.sh && source .tools/wasm-env.sh && python3 scripts/ci/run-tests.py protocol-wasi --target wasm32-wasip2 --real-servers --package turnloop-postgres --package turnloop-mysql'` | PASS | `wasi-p2-final.log` | +| `cargo test --locked --workspace --all-features` | PASS | `workspace-parallel-all.log` | +| `env ZIG_GLOBAL_CACHE_DIR=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-cache CC_x86_64_pc_windows_msvc=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-windows-cc AR_x86_64_pc_windows_msvc=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-win-ar cargo clippy --locked -p turnloop-postgres -p turnloop-contract --lib --all-features --target x86_64-pc-windows-msvc -- -D warnings -D clippy::undocumented_unsafe_blocks` | PASS | `windows-lib-zig.log` | +| `env ZIG_GLOBAL_CACHE_DIR=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-cache CC_x86_64_pc_windows_msvc=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-windows-cc AR_x86_64_pc_windows_msvc=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-win-ar cargo clippy --locked -p turnloop-postgres --test protocol --test server --test allocations --all-features --target x86_64-pc-windows-msvc -- -D warnings -D clippy::undocumented_unsafe_blocks` | PASS | `windows-wire-zig.log` | +| `env ZIG_GLOBAL_CACHE_DIR=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-cache CC_x86_64_pc_windows_msvc=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-windows-cc AR_x86_64_pc_windows_msvc=/Users/amlug/projects/perry/windlass-lanes/adapters-db/.tools/adapters-db/zig-win-ar cargo clippy --locked -p turnloop-postgres -p turnloop-contract --all-targets --all-features --target x86_64-pc-windows-msvc -- -D warnings -D clippy::undocumented_unsafe_blocks` | FAIL | `windows-all-zig.log` | +| `zsh -c 'source .tools/wasm-env.sh && python3 scripts/ci/run-tests.py protocol-wasi --target wasm32-wasip3 --package turnloop-postgres'` | PASS | `wasi-p3.log` | +| `zsh -c 'source .tools/sql-env.sh && cargo test -p turnloop-postgres --features turnloop --test async_server -- --include-ignored --test-threads=1 --nocapture'` | PASS | `pg-real-final.log` | +| `python3 .tools/adb-fix2/repeat.py` | FAIL | `parallel-repetitions.log` | +| `python3 .tools/adb-fix2/audit.py` | PASS | `final-audit.log` | +| `python3 .tools/adb-fix2/repeat.py` | PASS | `parallel-repetitions-final.log` | +| `cargo clippy --locked --workspace --all-targets --all-features -- -D warnings -D clippy::undocumented_unsafe_blocks` | PASS | `clippy-native-final.log` | +| `cargo clippy --locked -p turnloop-contract --all-targets --all-features --target x86_64-unknown-linux-gnu -- -D warnings -D clippy::undocumented_unsafe_blocks` | PASS | `clippy-contract-linux-final.log` | +| `cargo test --locked --workspace` | FAIL | `workspace-parallel-final.log` | +| `cargo test --locked --workspace --all-features` | PASS | `workspace-parallel-all-final.log` | +| `cargo +stable check --locked --workspace --all-targets --all-features` | PASS | `stable-final.log` | +| `git diff --check` | PASS | `whitespace-final.log` | +| `cargo fmt --all --check` | PASS | `fmt-final.log` | +| `python3 .tools/adb-fix2/audit.py` | PASS | `source-audit-final.log` | diff --git a/protocols/turnloop-mongodb/Cargo.toml b/protocols/turnloop-mongodb/Cargo.toml index f9137ff..cc06d71 100644 --- a/protocols/turnloop-mongodb/Cargo.toml +++ b/protocols/turnloop-mongodb/Cargo.toml @@ -12,6 +12,7 @@ documentation = "https://docs.rs/turnloop-mongodb" description = "Host-driven, sans-IO MongoDB protocol state machines" [dependencies] +turnloop-tls = { workspace = true, optional = true, features = ["turnloop"] } turnloop-io = { workspace = true, optional = true } bson.workspace = true base64.workspace = true @@ -37,11 +38,28 @@ workspace = true [package.metadata.turnloop-ci] role = "protocol" -integration-tests = ["real_mongodb"] +wasi-tests = ["asynchronous"] +# Pinned p3 compiler: custom allocator traps before main in debug (wasm2 report). +wasi-p3-release-tests = ["asynchronous"] +wasi-integration-tests = ["async_server"] +wasi-setup-tests = ["real_mongodb"] +integration-tests = ["real_mongodb", "async_server"] [target.'cfg(all(target_os = "wasi", target_env = "p3"))'.dependencies] turnloop-io = { workspace = true, optional = true, features = ["wasi-p3-experimental"] } turnloop-wasi-random.workspace = true [features] -turnloop = ["dep:turnloop-io"] +turnloop = ["dep:turnloop-io", "dep:turnloop-tls"] + +[[test]] +name = "asynchronous" +required-features = ["turnloop"] + +[[test]] +name = "async_server" +required-features = ["turnloop"] + +[[example]] +name = "turnloop" +required-features = ["turnloop"] diff --git a/protocols/turnloop-mongodb/README.md b/protocols/turnloop-mongodb/README.md index f6a8656..84c1d76 100644 --- a/protocols/turnloop-mongodb/README.md +++ b/protocols/turnloop-mongodb/README.md @@ -1,6 +1,6 @@ # turnloop-mongodb -Host-driven MongoDB protocol components. The library opens no sockets, reads no clock, +Host-driven MongoDB protocol components. The default sans-I/O library opens no sockets, reads no clock, starts no threads and uses no async runtime. It forbids unsafe code. BSON values stay BSON values; converting them to Perry's JSON strings or JS result shapes is the host's job. @@ -9,6 +9,41 @@ pool components, and explicit session/transaction/change-stream helpers. It is n drop-in implementation of every Node MongoClient feature. See the root LANE_REPORT.md for the precise coverage and remaining conformance work. +## Getting started on turnloop + +Enable `turnloop-mongodb = { version = "0.1.0-alpha.1", features = ["turnloop"] }` +and use the `asynchronous` module: `Client::connect/command/cursor and Cursor::next/close`. The default feature set remains sans-I/O. +The adapter reuses `turnloop-io`; the host owns `LocalExecutor` and calls `turn`. +Spawn local futures through its handle and keep their `JoinHandle`s until completion. + +```sh +cargo run -p turnloop-mongodb --features turnloop --example turnloop +``` + +[The complete example](examples/turnloop.rs) connects to `127.0.0.1:27017` by default; +`TURNLOOP_DB_ADDR` overrides that development endpoint. All operations take an +absolute `turnloop_io::Instant` deadline, shared across authentication, I/O and retries. +Callbacks borrow row/reply storage; copy values only when retaining them. +Dropping a pending operation closes its transport before a pool can reuse it. + +For TLS, set the protocol's TLS mode and provide +`turnloop_tls::asynchronous::ClientTls` with the trust configuration, verified +server name and current Unix seconds. Certificates are verified; a requested TLS +upgrade without a configuration fails. Native TCP and WASI 0.2 sockets share the +same driver. Browser raw TCP is unavailable; Windows accepts a host-provided +`Backend` until the repository's production IOCP provider is integrated. + +The client schedules SDAM heartbeats with absolute turnloop timers, owns CMAP +pools, and uses the existing Operation coordinator for retryable reads/writes. +`last_retry_count` reports actual retries of the last command. `cursor` returns +an async `next(deadline)` stream with server affinity and backpressure. `close` +awaits killCursors; drop queues bounded-deadline cleanup while the client remains +alive. Call `close` when acknowledgement is required. Native `mongodb+srv` looks +up SRV/TXT through turnloop's blocking pool and validates domains/TXT options in +the core. WASI 0.2 supports A/AAAA through ip-name-lookup; its standard interface +has no SRV/TXT capability, so use explicit resolved seed URIs there. + + ## Driving a connection 1. Parse `Options`. For `mongodb+srv`, execute both `resolution_requests()` and provide diff --git a/protocols/turnloop-mongodb/examples/turnloop.rs b/protocols/turnloop-mongodb/examples/turnloop.rs new file mode 100644 index 0000000..3516d50 --- /dev/null +++ b/protocols/turnloop-mongodb/examples/turnloop.rs @@ -0,0 +1,63 @@ +//! A local plaintext development-server example. For production TLS, supply +//! ClientTls and enable the protocol's TLS option; see the crate README. +use std::time::Duration; +use turnloop_io::{Backend, ExecutorHandle}; +pub async fn example(h: ExecutorHandle) -> std::io::Result<()> { + let address = std::env::var("TURNLOOP_DB_ADDR") + .unwrap_or_else(|_| "127.0.0.1:27017".into()) + .parse::() + .map_err(std::io::Error::other)?; + let at = h.now() + Duration::from_secs(30); + use turnloop_mongodb::{ + asynchronous::{Client, ConnectOptions}, + bson::{doc, raw::RawDocumentBuf}, + operation::OperationKind, + uri::Options, + }; + let uri = std::env::var("MONGODB_URI") + .unwrap_or_else(|_| format!("mongodb://{address}/?directConnection=true")); + let options = ConnectOptions { + protocol: Options::parse(&uri).map_err(std::io::Error::other)?, + tls: None, + }; + let mut client = Client::connect(&h, options, at).await?; + let command = + RawDocumentBuf::try_from(&doc! {"ping":1,"$db":"admin"}).map_err(std::io::Error::other)?; + client + .command(&command, &[], OperationKind::RunCommand, at, |reply| { + println!("{:?}", reply); + Ok(()) + }) + .await?; + Ok(()) +} +#[cfg(not(any(windows, all(target_arch = "wasm32", target_os = "unknown"))))] +fn main() -> std::io::Result<()> { + use std::{ + future::Future, + pin::Pin, + task::{Context, Poll, Waker}, + }; + use turnloop_io::turnloop::{Config, LocalExecutor, Timeout, backend::Platform}; + let mut executor = + LocalExecutor::::new(Config::default()).map_err(turnloop_io::error)?; + let h = executor.handle(); + let mut task = executor + .spawn_local(example(h)) + .map_err(turnloop_io::error)?; + while !task.is_finished() { + executor + .turn(Timeout::After(Duration::from_secs(30))) + .map_err(turnloop_io::error)?; + } + match Pin::new(&mut task).poll(&mut Context::from_waker(Waker::noop())) { + Poll::Ready(Ok(result)) => result, + _ => Err(std::io::Error::other("example task did not complete")), + } +} +#[cfg(any(windows, all(target_arch = "wasm32", target_os = "unknown")))] +fn main() { + eprintln!( + "Embed example(handle) with a host-provided TCP Backend; this target has no built-in TCP Platform provider." + ); +} diff --git a/protocols/turnloop-mongodb/src/async_connection.rs b/protocols/turnloop-mongodb/src/async_connection.rs new file mode 100644 index 0000000..34afe59 --- /dev/null +++ b/protocols/turnloop-mongodb/src/async_connection.rs @@ -0,0 +1,230 @@ +//! One authenticated MongoDB connection over the shared sans-I/O driver. +use super::AsyncConnection; +use crate::{connection::ConnectionEvent as Event, operation::Operation, uri::Options}; +use bson::raw::RawDocument; +use std::{io, net::SocketAddr}; +use turnloop_io::{AsyncIo, Backend, ExecutorHandle, Instant, deadline}; +use turnloop_tls::asynchronous::{ClientTls, Transport}; +pub(super) fn time(now: Instant) -> crate::Instant { + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + { + crate::Instant::from_duration(now.as_duration()) + } + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] + { + now + } +} +pub(super) fn instant(now: crate::Instant) -> Instant { + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + { + Instant::from_duration(now.as_duration()) + } + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] + { + now + } +} +pub struct Connection { + pub(super) driver: AsyncConnection>>, + executor: ExecutorHandle, +} +impl Connection { + pub async fn connect( + executor: &ExecutorHandle, + address: SocketAddr, + options: Options, + tls: Option<&ClientTls>, + at: Instant, + ) -> io::Result { + let at = if options.connect_timeout.is_zero() { + at + } else { + at.min(executor.now() + options.connect_timeout) + }; + let stream = deadline(executor, at, async { + executor + .connect(address, Default::default()) + .await + .map_err(turnloop_io::error) + }) + .await?; + let mut entropy = [0; 24]; + turnloop_tls::rustls::crypto::ring::default_provider() + .secure_random + .fill(&mut entropy) + .map_err(|_| io::Error::other("secure entropy unavailable"))?; + use base64::Engine; + let nonce = base64::engine::general_purpose::STANDARD.encode(entropy); + let mut core = crate::Connection::new(options); + core.connected(time(executor.now()), &nonce) + .map_err(io::Error::other)?; + let mut driver = AsyncConnection::new(Transport::Plain(stream), core); + deadline(executor, at, async { + loop { + let mut event = None; + driver + .next(executor, |e| { + event = Some(e); + Ok(()) + }) + .await?; + match event { + Some(Event::Ready) => return Ok(()), + Some(Event::UpgradeTls) => { + let tls = tls.ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "TLS configuration required", + ) + })?; + driver.upgrade_stream()?.upgrade(tls, executor, at).await?; + driver + .core_mut() + .tls_established() + .map_err(io::Error::other)?; + } + Some(Event::Failed { error, .. }) => return Err(io::Error::other(error)), + Some(Event::Closed) => return Err(io::ErrorKind::UnexpectedEof.into()), + _ => {} + } + } + }) + .await?; + Ok(Self { + driver, + executor: executor.clone(), + }) + } + pub fn is_reusable(&self) -> bool { + self.driver.is_connected() && self.driver.core().is_ready() + } + pub fn hello(&self) -> Option<&bson::Document> { + self.driver.core().hello.as_ref() + } + pub fn close(&mut self) { + self.driver.abort(); + } + pub async fn command( + &mut self, + body: &RawDocument, + sequences: &[(&str, &[&RawDocument])], + at: Instant, + mut receive: impl FnMut(&RawDocument) -> io::Result<()>, + ) -> io::Result<()> { + self.driver + .core_mut() + .command(1, body, sequences, time(self.executor.now())) + .map_err(io::Error::other)?; + let at = self + .driver + .core() + .next_timeout() + .map(instant) + .map_or(at, |d| at.min(d)); + let mut exchange = self.driver.exchange(); + deadline(&self.executor, at, async { + loop { + let mut event = None; + exchange + .next(&self.executor, |e| { + event = Some(e); + Ok(()) + }) + .await?; + match event { + Some(Event::Reply { .. }) => { + let reply = exchange.core().reply().map_err(io::Error::other)?; + let result = crate::Error::from_response(reply) + .map_err(io::Error::other) + .and_then(|_| receive(reply)); + exchange + .core_mut() + .release_reply() + .map_err(io::Error::other)?; + return result; + } + Some(Event::Unacknowledged { .. }) => return Ok(()), + Some(Event::Failed { error, .. }) => return Err(io::Error::other(error)), + Some(Event::Closed) => return Err(io::ErrorKind::UnexpectedEof.into()), + _ => {} + } + } + }) + .await?; + exchange.commit(); + Ok(()) + } + pub(super) async fn operation( + &mut self, + operation: &mut Operation, + at: Instant, + mut receive: impl FnMut(&RawDocument) -> io::Result<()>, + ) -> io::Result { + let mut exchange = self.driver.exchange(); + operation + .send(exchange.core_mut(), time(self.executor.now())) + .map_err(io::Error::other)?; + let at = exchange + .core() + .next_timeout() + .map(instant) + .map_or(at, |d| at.min(d)); + let result = deadline(&self.executor, at, async { + loop { + let mut event = None; + exchange + .next(&self.executor, |e| { + event = Some(e); + Ok(()) + }) + .await?; + match event { + Some(Event::Reply { .. }) => { + let reply = exchange.core().reply().map_err(io::Error::other)?; + let complete = operation.response(reply).map_err(io::Error::other)?; + if complete { + receive(reply)?; + } + exchange + .core_mut() + .release_reply() + .map_err(io::Error::other)?; + return Ok(complete); + } + Some(Event::Failed { error, .. }) => { + operation.failed(error); + return Ok(false); + } + Some(Event::Unacknowledged { .. }) => return Ok(true), + Some(Event::Closed) => return Err(io::ErrorKind::UnexpectedEof.into()), + _ => {} + } + } + }) + .await; + match result { + Ok(done) => { + if exchange.core().is_ready() { + exchange.commit(); + } + Ok(done) + } + Err(e) => { + if e.kind() == io::ErrorKind::TimedOut { + return Err(e); + } + operation.failed(crate::Error::new(crate::ErrorKind::Network, e.to_string())); + Ok(false) + } + } + } +} +impl turnloop_io::pool::Connection for Connection { + fn reusable(&self) -> bool { + self.is_reusable() + } + fn handle(&self) -> Option { + self.driver.stream()?.get_ref().map(AsyncIo::handle) + } +} diff --git a/protocols/turnloop-mongodb/src/async_pool.rs b/protocols/turnloop-mongodb/src/async_pool.rs new file mode 100644 index 0000000..014b85b --- /dev/null +++ b/protocols/turnloop-mongodb/src/async_pool.rs @@ -0,0 +1,132 @@ +use super::connection::{instant, time}; +use super::{ConnectOptions, Connection}; +use crate::pool::{Lease, Pool as Core, PoolEvent}; +use std::{io, time::Duration}; +use turnloop_io::{ + Backend, ExecutorHandle, Instant, + pool::{self as shared, Action, Policy}, +}; +impl Policy for Core { + type Id = Lease; + type Lease = Lease; + fn id(lease: Lease) -> Lease { + lease + } + fn checkout(&mut self, token: u64, now: Instant, _at: Instant) -> io::Result<()> { + self.checkout(token, time(now)).map_err(io::Error::other) + } + fn cancel(&mut self, token: u64, _now: Instant) { + self.cancel_checkout(token); + } + fn connected(&mut self, id: Lease, now: Instant) -> io::Result<()> { + self.connected(id, time(now)).map_err(io::Error::other) + } + fn connect_failed(&mut self, id: Lease, now: Instant) -> io::Result<()> { + self.connect_failed( + id, + crate::Error::new(crate::ErrorKind::Network, "connection failed"), + time(now), + ) + .map_err(io::Error::other) + } + fn checkin(&mut self, lease: Lease, now: Instant, destroy: bool) -> io::Result<()> { + if destroy { + return self.discard(lease, time(now)).map_err(io::Error::other); + } + self.checkin(lease, time(now)).map_err(io::Error::other) + } + fn closed(&mut self, _id: Lease, _now: Instant) -> io::Result<()> { + Ok(()) + } + fn event(&mut self) -> Option> { + loop { + match self.poll_event()? { + PoolEvent::Connect(id) => return Some(Action::Connect(id)), + PoolEvent::Close(id) => return Some(Action::Close(id)), + PoolEvent::CheckedOut { token, connection } => { + return Some(Action::Acquired { + token, + lease: connection, + }); + } + PoolEvent::CheckoutFailed { token, error } => { + return Some(Action::Failed { + token, + error: io::Error::other(error), + }); + } + PoolEvent::Closed => return Some(Action::Ended), + _ => {} + } + } + } + fn next_deadline(&self) -> Option { + self.next_timeout().map(instant) + } + fn expire(&mut self, now: Instant) { + self.handle_timeout(time(now)); + } + fn end(&mut self) -> io::Result<()> { + self.close(); + Ok(()) + } +} +struct Connector { + address: crate::uri::Address, + options: ConnectOptions, +} +impl shared::Connector for Connector { + type Connection = Connection; + fn connect( + &self, + executor: &ExecutorHandle, + at: Instant, + ) -> impl std::future::Future>> + 'static { + let executor = executor.clone(); + let address = self.address.clone(); + let options = self.options.clone(); + async move { + let socket = turnloop_io::resolve(&executor, &address.host, address.port, at).await?; + Connection::connect( + &executor, + socket, + options.protocol, + options.tls.as_ref(), + at, + ) + .await + } + } +} +pub type PooledConnection = shared::Lease>; +pub type Pool = shared::Pool>; +pub(super) fn create( + executor: &ExecutorHandle, + address: crate::uri::Address, + options: &ConnectOptions, +) -> io::Result> { + let config = crate::pool::PoolOptions { + min_size: options.protocol.min_pool_size, + max_size: options.protocol.max_pool_size, + max_connecting: options.protocol.max_connecting, + wait_timeout: options.protocol.wait_queue_timeout, + max_idle: options.protocol.max_idle_time, + }; + let mut core = Core::new(config).map_err(io::Error::other)?; + core.ready(time(executor.now())); + let timeout = if options.protocol.connect_timeout.is_zero() { + Duration::from_secs(30) + } else { + options.protocol.connect_timeout + }; + shared::Pool::new( + executor, + core, + Connector { + address, + options: options.clone(), + }, + timeout, + options.protocol.max_pool_size.max(1), + ) +} diff --git a/protocols/turnloop-mongodb/src/asynchronous.rs b/protocols/turnloop-mongodb/src/asynchronous.rs index d9eee89..399bb91 100644 --- a/protocols/turnloop-mongodb/src/asynchronous.rs +++ b/protocols/turnloop-mongodb/src/asynchronous.rs @@ -63,3 +63,13 @@ mod tests { ); } } + +#[path = "async_connection.rs"] +mod connection; +pub use connection::Connection; +#[path = "async_pool.rs"] +mod pool; +pub use pool::{Pool, PooledConnection}; +#[path = "client.rs"] +mod client; +pub use client::*; diff --git a/protocols/turnloop-mongodb/src/client.rs b/protocols/turnloop-mongodb/src/client.rs new file mode 100644 index 0000000..397a54c --- /dev/null +++ b/protocols/turnloop-mongodb/src/client.rs @@ -0,0 +1,697 @@ +//! MongoDB client with timer-driven SDAM, CMAP ownership and operation retries. +use super::connection::{instant, time}; +use super::{Connection, Pool}; +use crate::{ + operation::{ + Operation, OperationAction, OperationKind, OperationOptions, RetrySession, + ServerCapabilities, + }, + topology::{ApplicationError, ApplicationErrorKind, ServerType, Topology, TopologyEvent}, + uri::{Address, Options, ReadPreference}, +}; +use bson::{ + Document, doc, + raw::{RawDocument, RawDocumentBuf}, +}; +use std::{ + cell::RefCell, + collections::{BTreeMap, VecDeque}, + future::{Future, poll_fn}, + io, + pin::Pin, + rc::Rc, + task::{Poll, Waker}, + time::Duration, +}; +use turnloop_io::turnloop::{JoinHandle, Sleep}; +use turnloop_io::{Backend, ExecutorHandle, Instant, deadline}; +use turnloop_tls::asynchronous::ClientTls; +#[derive(Clone)] +pub struct ConnectOptions { + pub protocol: Options, + pub tls: Option, +} +struct State { + topology: Topology, + pools: BTreeMap>, + changed: Option, + waiters: Vec, + heartbeats: u64, + candidates: crate::topology::SelectionBuffer, + failure: Option, + cleanup: Vec>>>, +} +struct Owner { + state: Rc>>, + executor: ExecutorHandle, + options: ConnectOptions, + random: &'static dyn turnloop_tls::rustls::crypto::SecureRandom, + _monitor: JoinHandle<()>, +} +// Balance selection load accounting even when a checkout or command is cancelled. +struct ActiveOperation<'a, B: Backend + 'static> { + owner: &'a Owner, + address: &'a str, +} +impl Drop for ActiveOperation<'_, B> { + fn drop(&mut self) { + self.owner + .state + .borrow_mut() + .topology + .operation_finished(self.address); + } +} +pub struct Client { + owner: Rc>, + operation: Operation, + session: [u8; 16], + txn: i64, + selected: String, +} +type Probe = Pin>, io::Result)>>>; +struct Monitor { + address: String, + connection: Option>, + pending: Option>, + started: Instant, +} +fn wake(state: &State) { + for w in &state.waiters { + w.wake_by_ref(); + } +} +fn network_failure(state: &mut State, address: &str, now: Instant) { + if let Some(s) = state.topology.servers.get(address) { + let error = ApplicationError { + generation: s.generation, + max_wire_version: s.max_wire_version, + handshake_complete: true, + kind: ApplicationErrorKind::Network, + response: None, + }; + state.topology.application_error(address, error, time(now)); + } +} +impl Client { + pub async fn connect( + executor: &ExecutorHandle, + options: ConnectOptions, + at: Instant, + ) -> io::Result { + let mut options = options; + if let Some(name) = options.protocol.srv.clone() { + use turnloop_io::dns::{Query, Record, query}; + let srv = query(executor, format!("_mongodb._tcp.{name}"), Query::Srv, at).await?; + let txt = query(executor, name, Query::Txt, at).await?; + let records: Vec<_> = srv + .into_iter() + .filter_map(|r| match r { + Record::Srv { target, port, .. } => Some(Address { host: target, port }), + _ => None, + }) + .collect(); + let txt: Vec<_> = txt + .into_iter() + .filter_map(|r| match r { + Record::Txt { text, .. } => Some(text), + _ => None, + }) + .collect(); + options + .protocol + .resolve(&records, &txt) + .map_err(io::Error::other)?; + } + let state = Rc::new(RefCell::new(State { + topology: Topology::new(&options.protocol, time(executor.now())), + pools: BTreeMap::new(), + changed: None, + waiters: Vec::new(), + heartbeats: 0, + candidates: Default::default(), + failure: None, + cleanup: Vec::new(), + })); + let shared = state.clone(); + let exec = executor.clone(); + let config = options.clone(); + let mut monitors: Vec> = Vec::new(); + let mut timer: Option<(Instant, Sleep)> = None; + let monitor = executor + .spawn_local(poll_fn(move |cx| { + let mut state = shared.borrow_mut(); + state.changed = Some(cx.waker().clone()); + let now = exec.now(); + state + .cleanup + .retain_mut(|future| future.as_mut().poll(cx).is_pending()); + // Each probe owns one connection future. No waiting is done inside + // the driver turn and all servers progress independently. + for m in &mut monitors { + if let Some(future) = &mut m.pending + && let Poll::Ready((connection, result)) = future.as_mut().poll(cx) + { + m.pending = None; + m.connection = connection; + state.heartbeats += 1; + match result { + Ok(hello) => { + state.topology.update( + &m.address, + &hello, + time(now), + now.saturating_duration_since(m.started), + ); + if state + .topology + .servers + .get(&m.address) + .is_some_and(|s| s.kind.readable()) + { + if let Some(pool) = state.pools.get(&m.address) { + pool.update_policy(|p| p.ready(time(now))); + } else { + let result = Address::parse(&m.address) + .map_err(io::Error::other) + .and_then(|address| { + super::pool::create(&exec, address, &config) + }); + match result { + Ok(pool) => { + state.pools.insert(m.address.clone(), pool); + } + Err(e) => state.failure = Some(e.to_string()), + } + } + } + } + Err(_) => network_failure(&mut state, &m.address, now), + } + wake(&state); + } + } + state.topology.handle_timeout(time(now)); + while let Some(event) = state.topology.poll_event() { + match event { + TopologyEvent::Check { address, .. } => { + let index = if let Some(i) = + monitors.iter().position(|m| m.address == address) + { + i + } else { + monitors.push(Monitor { + address: address.clone(), + connection: None, + pending: None, + started: now, + }); + monitors.len() - 1 + }; + if monitors[index].pending.is_none() { + let old = monitors[index].connection.take(); + let exec = exec.clone(); + let options = config.clone(); + monitors[index].started = now; + monitors[index].pending = Some(Box::pin(async move { + let result = async { + let address = + Address::parse(&address).map_err(io::Error::other)?; + let timeout = if options.protocol.connect_timeout.is_zero() + { + Duration::from_secs(30) + } else { + options.protocol.connect_timeout + }; + let at = exec.now() + timeout; + let mut connection = match old { + Some(c) if c.is_reusable() => c, + _ => { + let socket = turnloop_io::resolve( + &exec, + &address.host, + address.port, + at, + ) + .await?; + let mut protocol = options.protocol.clone(); + protocol.credential = None; + let c = Connection::connect( + &exec, + socket, + protocol, + options.tls.as_ref(), + at, + ) + .await?; + let hello = + c.hello().cloned().ok_or_else(|| { + io::Error::other("missing hello") + })?; + return Ok((c, hello)); + } + }; + let command = RawDocumentBuf::try_from( + &doc! {"hello":1,"$db":"admin"}, + ) + .map_err(io::Error::other)?; + let mut hello = None; + connection + .command(&command, &[], at, |reply| { + hello = Some( + reply.try_into().map_err(io::Error::other)?, + ); + Ok(()) + }) + .await?; + Ok::<_, io::Error>(( + connection, + hello.ok_or_else(|| { + io::Error::other("missing heartbeat reply") + })?, + )) + } + .await; + match result { + Ok((connection, hello)) => (Some(connection), Ok(hello)), + Err(e) => (None, Err(e)), + } + })); + // Poll newly created work in the next executor pass. + cx.waker().wake_by_ref(); + } + } + TopologyEvent::ClearPool { address, .. } => { + if let Some(pool) = state.pools.get(&address) { + pool.update_policy(|p| p.clear()); + } + } + TopologyEvent::ServerRemoved(address) => { + state.pools.remove(&address); + monitors.retain(|m| m.address != address); + } + _ => {} + } + } + let at = state.topology.next_timeout().map(instant); + if timer.as_ref().map(|(at, _)| *at) != at { + timer = at.map(|at| (at, exec.sleep_until(at))); + } + if let Some((_, sleep)) = &mut timer + && let Poll::Ready(result) = Pin::new(sleep).poll(cx) + { + if let Err(e) = result { + state.failure = Some(e.to_string()); + wake(&state); + return Poll::Ready(()); + } + timer = None; + cx.waker().wake_by_ref(); + } + Poll::Pending + })) + .map_err(turnloop_io::error)?; + let mut session = [0; 16]; + let random = turnloop_tls::rustls::crypto::ring::default_provider().secure_random; + random + .fill(&mut session) + .map_err(|_| io::Error::other("secure entropy unavailable"))?; + let owner = Rc::new(Owner { + state, + executor: executor.clone(), + options, + random, + _monitor: monitor, + }); + let this = Self { + owner, + operation: Operation::new(), + session, + txn: 0, + selected: String::with_capacity(256), + }; + let mut address = String::new(); + select(&this.owner, ReadPreference::Primary, None, at, &mut address).await?; + Ok(this) + } + pub fn last_retry_count(&self) -> u8 { + self.operation.retry_count() + } + pub fn heartbeat_count(&self) -> u64 { + self.owner.state.borrow().heartbeats + } + pub fn server_count(&self) -> usize { + self.owner.state.borrow().topology.servers.len() + } + /// Retry classification comes from the sans-I/O Operation coordinator. A + /// retryable write keeps the same session/transaction identity on both sends. + pub async fn command( + &mut self, + body: &RawDocument, + sequences: &[(&str, &[&RawDocument])], + kind: OperationKind, + at: Instant, + mut receive: impl FnMut(&RawDocument) -> io::Result<()>, + ) -> io::Result<()> { + self.txn = self + .txn + .checked_add(1) + .ok_or_else(|| io::Error::other("transaction number exhausted"))?; + let options = &self.owner.options.protocol; + self.operation + .begin( + body, + sequences, + OperationOptions { + token: 1, + kind, + retry: match kind { + OperationKind::Read => options.retry_reads, + OperationKind::Write => options.retry_writes, + OperationKind::RunCommand => false, + }, + timeout: Some(at.saturating_duration_since(self.owner.executor.now())), + session: Some(RetrySession { + id: self.session, + txn_number: self.txn, + }), + read_preference: options.read_preference, + }, + time(self.owner.executor.now()), + ) + .map_err(io::Error::other)?; + struct Cancel<'a>(&'a mut Operation); + impl Drop for Cancel<'_> { + fn drop(&mut self) { + self.0.cancel(); + } + } + let cancel = Cancel(&mut self.operation); + run(&self.owner, cancel.0, at, &mut self.selected, &mut receive).await + } + /// Own batches while exposing one document at a time. getMore and killCursors + /// retain the selected server; neither operation is retried on another server. + pub async fn cursor(&mut self, body: &RawDocument, at: Instant) -> io::Result> { + let mut address = String::new(); + select( + &self.owner, + self.owner.options.protocol.read_preference, + None, + at, + &mut address, + ) + .await?; + let pool = self + .owner + .state + .borrow() + .pools + .get(&address) + .cloned() + .ok_or_else(|| io::Error::other("selected pool disappeared"))?; + let mut connection = pool.acquire(at).await?; + let mut reply = None; + connection + .command(body, &[], at, |r| { + reply = Some(r.to_owned()); + Ok(()) + }) + .await?; + let reply = reply.ok_or_else(|| io::Error::other("missing cursor reply"))?; + let batch = crate::command::CursorBatch::parse(&reply).map_err(io::Error::other)?; + let mut core = crate::command::Cursor::new(batch.namespace, address, None, None) + .map_err(io::Error::other)?; + core.accept(&batch).map_err(io::Error::other)?; + let rows = batch + .rows() + .map(|r| r.map(RawDocument::to_owned)) + .collect::>>() + .map_err(io::Error::other)?; + Ok(Cursor { + owner: self.owner.clone(), + core, + rows, + command: crate::command::Command::new(), + closed: false, + }) + } +} +async fn select( + owner: &Owner, + preference: ReadPreference, + excluded: Option<&str>, + at: Instant, + address: &mut String, +) -> io::Result { + let timeout = owner.options.protocol.server_selection_timeout; + let at = if timeout.is_zero() { + at + } else { + at.min(owner.executor.now() + timeout) + }; + deadline( + &owner.executor, + at, + poll_fn(|cx| { + let mut state = owner.state.borrow_mut(); + if let Some(e) = &state.failure { + return Poll::Ready(Err(io::Error::other(e.clone()))); + } + let options = &owner.options.protocol; + let mut entropy = [0; 16]; + owner + .random + .fill(&mut entropy) + .map_err(|_| io::Error::other("secure entropy unavailable"))?; + let excluded = excluded.map_or([""], |e| [e]); + let State { + topology, + candidates, + .. + } = &mut *state; + let chosen = topology + .select_reusing( + preference, + &options.read_preference_tags, + options.max_staleness, + &excluded, + [ + u64::from_le_bytes(entropy[..8].try_into().expect("eight bytes")), + u64::from_le_bytes(entropy[8..].try_into().expect("eight bytes")), + ], + candidates, + ) + .map_err(io::Error::other)?; + if let Some(chosen) = chosen { + let s = &topology.servers[chosen]; + let caps = ServerCapabilities { + wire_version: s.max_wire_version, + sessions: s.session_timeout.is_some(), + standalone: s.kind == ServerType::Standalone, + direct: options.direct, + }; + address.clear(); + address.push_str(chosen); + return Poll::Ready(Ok(caps)); + } + if !state.waiters.iter().any(|w| w.will_wake(cx.waker())) { + state.waiters.push(cx.waker().clone()); + } + state.topology.request_check(time(owner.executor.now())); + if let Some(w) = &state.changed { + w.wake_by_ref(); + } + Poll::Pending + }), + ) + .await +} +async fn run( + owner: &Owner, + operation: &mut Operation, + at: Instant, + selected: &mut String, + receive: &mut impl FnMut(&RawDocument) -> io::Result<()>, +) -> io::Result<()> { + loop { + operation.handle_timeout(time(owner.executor.now())); + match operation.action() { + OperationAction::Select { + read_preference, + deprioritized, + } => { + let caps = select(owner, read_preference, deprioritized, at, selected).await?; + operation + .selected(selected, caps) + .map_err(io::Error::other)?; + } + OperationAction::Checkout { address } => { + let pool = owner + .state + .borrow() + .pools + .get(address) + .cloned() + .ok_or_else(|| io::Error::other("selected pool disappeared"))?; + owner + .state + .borrow_mut() + .topology + .operation_started(selected) + .map_err(io::Error::other)?; + let _active = ActiveOperation { + owner, + address: selected, + }; + match pool.acquire(at).await { + Ok(mut connection) => { + operation.checked_out().map_err(io::Error::other)?; + let complete = connection.operation(operation, at, &mut *receive).await?; + if complete { + return Ok(()); + } + let mut state = owner.state.borrow_mut(); + if let (Some(error), Some(server)) = ( + operation.last_error(), + state.topology.servers.get(selected.as_str()), + ) { + let application = ApplicationError { + generation: server.generation, + max_wire_version: server.max_wire_version, + handshake_complete: true, + kind: match error.kind { + crate::ErrorKind::Network => ApplicationErrorKind::Network, + crate::ErrorKind::Timeout => ApplicationErrorKind::Timeout, + _ => ApplicationErrorKind::Command, + }, + response: error.response.as_deref(), + }; + state.topology.application_error( + selected, + application, + time(owner.executor.now()), + ); + } + state.topology.request_check(time(owner.executor.now())); + if let Some(w) = &state.changed { + w.wake_by_ref(); + } + } + Err(e) => operation + .failed(crate::Error::new(crate::ErrorKind::Network, e.to_string())), + } + } + OperationAction::Complete { .. } => return Ok(()), + OperationAction::Failed { error, .. } => return Err(io::Error::other(error.clone())), + _ => return Err(io::Error::other("unexpected operation coordinator state")), + } + } +} +pub struct Cursor { + owner: Rc>, + core: crate::command::Cursor, + rows: VecDeque, + command: crate::command::Command, + closed: bool, +} +impl Cursor { + pub fn id(&self) -> i64 { + self.core.id + } + pub async fn next(&mut self, at: Instant) -> io::Result> { + loop { + if let Some(row) = self.rows.pop_front() { + return Ok(Some(row)); + } + if self.closed + || !self + .core + .get_more(&mut self.command) + .map_err(io::Error::other)? + { + return Ok(None); + } + let pool = self + .owner + .state + .borrow() + .pools + .get(&self.core.server) + .cloned() + .ok_or_else(|| io::Error::other("cursor server removed"))?; + let mut connection = pool.acquire(at).await?; + connection + .command(self.command.raw(), &[], at, |reply| { + let batch = + crate::command::CursorBatch::parse(reply).map_err(io::Error::other)?; + self.core.accept(&batch).map_err(io::Error::other)?; + for row in batch.rows() { + self.rows + .push_back(row.map_err(io::Error::other)?.to_owned()); + } + Ok(()) + }) + .await?; + // A live cursor may return an empty batch. Await another real getMore; + // only id=0 terminates the stream, and the same deadline bounds the loop. + } + } + pub async fn close(&mut self, at: Instant) -> io::Result<()> { + if self.core.id != 0 && !self.closed { + self.command + .kill_cursor(&self.core.database, &self.core.collection, self.core.id) + .map_err(io::Error::other)?; + let pool = self + .owner + .state + .borrow() + .pools + .get(&self.core.server) + .cloned() + .ok_or_else(|| io::Error::other("cursor server removed"))?; + pool.acquire(at) + .await? + .command(self.command.raw(), &[], at, |_| Ok(())) + .await?; + } + self.closed = true; + self.core.id = 0; + self.rows.clear(); + Ok(()) + } +} + +impl Drop for Cursor { + fn drop(&mut self) { + if self.closed || self.core.id == 0 { + return; + } + let pool = self + .owner + .state + .borrow() + .pools + .get(&self.core.server) + .cloned(); + let Some(pool) = pool else { + return; + }; + let mut command = crate::command::Command::new(); + if command + .kill_cursor(&self.core.database, &self.core.collection, self.core.id) + .is_err() + { + return; + } + let at = self.owner.executor.now() + Duration::from_secs(5); + let mut state = self.owner.state.borrow_mut(); + state.cleanup.push(Box::pin(async move { + if let Ok(mut connection) = pool.acquire(at).await { + let _ = connection.command(command.raw(), &[], at, |_| Ok(())).await; + } + })); + if let Some(w) = &state.changed { + w.wake_by_ref(); + } + } +} diff --git a/protocols/turnloop-mongodb/src/operation.rs b/protocols/turnloop-mongodb/src/operation.rs index 45d1d40..fa5efe9 100644 --- a/protocols/turnloop-mongodb/src/operation.rs +++ b/protocols/turnloop-mongodb/src/operation.rs @@ -424,6 +424,14 @@ impl Operation { self.state = State::Failed; self.deadline = None; } + /// Number of retries actually scheduled for the current operation. + pub fn retry_count(&self) -> u8 { + self.policy.attempts + } + /// The failure which triggered the current retry, or the terminal failure. + pub fn last_error(&self) -> Option<&Error> { + self.error.as_ref().or(self.first_error.as_ref()) + } pub fn next_timeout(&self) -> Option { self.deadline } diff --git a/protocols/turnloop-mongodb/src/pool.rs b/protocols/turnloop-mongodb/src/pool.rs index 1fd5b70..1a8e0c9 100644 --- a/protocols/turnloop-mongodb/src/pool.rs +++ b/protocols/turnloop-mongodb/src/pool.rs @@ -182,6 +182,19 @@ impl Pool { self.drive(now); Ok(()) } + /// Discard one checked-out transport after local cancellation or corruption. + /// Unlike a topology clear, this preserves other leases and the pool generation. + pub fn discard(&mut self, lease: Lease, now: Instant) -> Result<()> { + let at = self + .entries + .iter() + .position(|e| e.lease == lease && e.status == Status::Out) + .ok_or_else(|| Error::protocol("Unknown checked-out connection"))?; + self.entries.swap_remove(at); + self.events.push_back(PoolEvent::Close(lease)); + self.drive(now); + Ok(()) + } pub fn clear(&mut self) { if self.closed { return; diff --git a/protocols/turnloop-mongodb/src/topology.rs b/protocols/turnloop-mongodb/src/topology.rs index e570f73..5efec86 100644 --- a/protocols/turnloop-mongodb/src/topology.rs +++ b/protocols/turnloop-mongodb/src/topology.rs @@ -178,6 +178,66 @@ pub struct Topology { seed_count: usize, events: VecDeque, } +/// Retained candidate names for async callers which cannot keep references to a +/// mutating topology between polls. Capacities survive selection and filtering. +#[derive(Default)] +pub struct SelectionBuffer { + names: Vec, + len: usize, +} +trait CandidateList<'a> { + fn clear(&mut self); + fn push(&mut self, name: &'a str); + fn names(&self) -> impl Iterator; + fn retain(&mut self, keep: impl FnMut(&str) -> bool); + fn is_empty(&self) -> bool; +} +impl<'a> CandidateList<'a> for Vec<&'a str> { + fn clear(&mut self) { + Vec::clear(self); + } + fn push(&mut self, name: &'a str) { + Vec::push(self, name); + } + fn names(&self) -> impl Iterator { + self.iter().copied() + } + fn retain(&mut self, mut keep: impl FnMut(&str) -> bool) { + Vec::retain(self, |s| keep(s)); + } + fn is_empty(&self) -> bool { + Vec::is_empty(self) + } +} +impl<'a> CandidateList<'a> for SelectionBuffer { + fn clear(&mut self) { + self.len = 0; + } + fn push(&mut self, name: &'a str) { + if self.len == self.names.len() { + self.names.push(String::with_capacity(name.len())); + } + self.names[self.len].clear(); + self.names[self.len].push_str(name); + self.len += 1; + } + fn names(&self) -> impl Iterator { + self.names[..self.len].iter().map(String::as_str) + } + fn retain(&mut self, mut keep: impl FnMut(&str) -> bool) { + let mut to = 0; + for from in 0..self.len { + if keep(&self.names[from]) { + self.names.swap(to, from); + to += 1; + } + } + self.len = to; + } + fn is_empty(&self) -> bool { + self.len == 0 + } +} impl Topology { pub fn new(options: &Options, now: Instant) -> Self { let kind = if options.direct { @@ -498,6 +558,38 @@ impl Topology { } Ok(()) } + /// Select with retained owned scratch space, using the same filtering policy + /// as candidates_deprioritized. No allocations after topology warm-up. + pub fn select_reusing<'a>( + &'a self, + pref: ReadPreference, + tags: &[BTreeMap], + max_staleness: Option, + excluded: &[&str], + entropy: [u64; 2], + scratch: &mut SelectionBuffer, + ) -> Result> { + self.candidates_inner(pref, tags, max_staleness, excluded, scratch)?; + if scratch.is_empty() && !excluded.is_empty() { + self.candidates_inner(pref, tags, max_staleness, &[], scratch)?; + } + if scratch.len == 0 { + return Ok(None); + } + let a = ((u128::from(entropy[0]) * scratch.len as u128) >> 64) as usize; + let mut b = if scratch.len == 1 { + a + } else { + ((u128::from(entropy[1]) * (scratch.len - 1) as u128) >> 64) as usize + }; + if scratch.len > 1 && b >= a { + b += 1; + } + let two = [scratch.names[a].as_str(), scratch.names[b].as_str()]; + Ok(self + .choose(&two, [0, 0]) + .and_then(|s| self.servers.get_key_value(s).map(|(a, _)| a.as_str()))) + } /// Power-of-two choice; supply independent uniformly distributed host entropy. pub fn choose<'a>(&self, candidates: &[&'a str], entropy: [u64; 2]) -> Option<&'a str> { if candidates.is_empty() { @@ -543,7 +635,7 @@ impl Topology { tags: &[BTreeMap], max_staleness: Option, excluded: &[&str], - out: &mut Vec<&'a str>, + out: &mut impl CandidateList<'a>, ) -> Result<()> { out.clear(); if !self.compatible() { @@ -641,33 +733,30 @@ impl Topology { && !tags.is_empty() { let matching = tags.iter().find(|t| { - out.iter().any(|a| { + out.names().any(|a| { t.iter() - .all(|(k, v)| self.servers[*a].tags.get(k) == Some(v)) + .all(|(k, v)| self.servers[a].tags.get(k) == Some(v)) }) }); if let Some(t) = matching { out.retain(|a| { t.iter() - .all(|(k, v)| self.servers[*a].tags.get(k) == Some(v)) + .all(|(k, v)| self.servers[a].tags.get(k) == Some(v)) }); } else { out.clear(); } } if out.is_empty() && pref == ReadPreference::SecondaryPreferred { - out.extend( - self.servers - .iter() - .filter(|(a, s)| { - !excluded.contains(&a.as_str()) && s.kind == ServerType::RSPrimary - }) - .map(|(a, _)| a.as_str()), - ); - } - if let Some(min) = out.iter().filter_map(|a| self.servers[*a].rtt).min() { + for (a, s) in &self.servers { + if !excluded.contains(&a.as_str()) && s.kind == ServerType::RSPrimary { + out.push(a); + } + } + } + if let Some(min) = out.names().filter_map(|a| self.servers[a].rtt).min() { out.retain(|a| { - self.servers[*a] + self.servers[a] .rtt .is_some_and(|r| r <= min + self.local_threshold) }); diff --git a/protocols/turnloop-mongodb/tests/async_server.rs b/protocols/turnloop-mongodb/tests/async_server.rs new file mode 100644 index 0000000..27d95d3 --- /dev/null +++ b/protocols/turnloop-mongodb/tests/async_server.rs @@ -0,0 +1,108 @@ +#![cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] +use std::{ + future::Future, + pin::Pin, + task::{Context, Poll, Waker}, + time::Duration, +}; +use turnloop_io::{ + turnloop::{Config as LoopConfig, LocalExecutor, Timeout, backend::Platform}, + *, +}; +fn finish(task: &mut turnloop::JoinHandle) -> T { + match Pin::new(task).poll(&mut Context::from_waker(Waker::noop())) { + Poll::Ready(Ok(v)) => v, + _ => panic!("task must finish"), + } +} +fn drive(executor: &mut LocalExecutor, task: &mut turnloop::JoinHandle) -> T { + let at = executor.handle().now() + Duration::from_secs(90); + while !task.is_finished() { + assert!(executor.handle().now() < at, "test deadline"); + executor.turn(Timeout::Until(at)).expect("turn"); + } + finish(task) +} +use turnloop_mongodb::{ + asynchronous::{Client, ConnectOptions}, + bson::{Document, doc, raw::RawDocumentBuf}, + operation::OperationKind, + uri::Options, +}; +fn raw(d: Document) -> RawDocumentBuf { + RawDocumentBuf::try_from(&d).expect("BSON") +} +#[test] +#[ignore = "private MongoDB fixture, after real_mongodb bootstrap"] +fn real_async_auth_tls_sdam_pool_cursor_retry_and_primary_stepdown() { + let port = std::env::var("TURNLOOP_TEST_MONGODB_PORT").expect("standalone port"); + let tls_port = std::env::var("TURNLOOP_TEST_MONGODB_TLS_PORT").expect("TLS port"); + let replica = std::env::var("TURNLOOP_TEST_MONGODB_REPLICA_PORTS").expect("replica ports"); + let seeds = replica + .split(',') + .map(|p| format!("127.0.0.1:{p}")) + .collect::>() + .join(","); + use turnloop_tls::rustls::pki_types::{CertificateDer, pem::PemObject}; + let certs = std::fs::read( + std::path::PathBuf::from(std::env::var_os("TURNLOOP_TEST_MONGODB_TOOLS").expect("tools")) + .join("cert.pem"), + ) + .expect("certs"); + let ca = CertificateDer::pem_slice_iter(&certs) + .collect::, _>>() + .expect("certificates"); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("wall time") + .as_secs(); + let tls = turnloop_tls::asynchronous::ClientTls { + config: turnloop_tls::ClientConfig::new( + turnloop_tls::ClientOptions { + ca: Some(ca), + alpn: vec![], + ..Default::default() + }, + now, + ) + .expect("TLS config"), + server_name: "localhost".try_into().expect("name"), + unix_seconds: now, + }; + let mut ex = LocalExecutor::::new(LoopConfig::default()).expect("executor"); + let h = ex.handle(); + let mut task=ex.spawn_local(async move{ + let at=h.now()+Duration::from_secs(75); + for (uri,tls) in [(format!("mongodb://lane:pencil@127.0.0.1:{port}/admin?directConnection=true"),None),(format!("mongodb://lane:pencil@127.0.0.1:{tls_port}/admin?directConnection=true&tls=true"),Some(tls))]{ + let mut c=Client::connect(&h,ConnectOptions{protocol:Options::parse(&uri).expect("options"),tls},at).await.expect("client"); + let mut auth=false;c.command(&raw(doc!{"connectionStatus":1,"$db":"admin"}),&[],OperationKind::RunCommand,at,|r|{let r=Document::try_from(r).expect("document");assert_eq!(r.get_document("authInfo").expect("authInfo").get_array("authenticatedUsers").expect("users").len(),1);auth=true;Ok(())}).await.expect("authenticated command");assert!(auth); + } + let mut options=Options::parse(&format!("mongodb://lane:pencil@{seeds}/admin?replicaSet=turnloop_test&heartbeatFrequencyMS=500")).expect("replica options");options.max_pool_size=2; + let mut c=Client::connect(&h,ConnectOptions{protocol:options,tls:None},at).await.expect("replica client"); + c.command(&raw(doc!{"dropDatabase":1,"$db":"async_lane"}),&[],OperationKind::RunCommand,at,|_|Ok(())).await.expect("drop database"); + c.command(&raw(doc!{"insert":"items","documents":[{"_id":1,"value":42},{"_id":2,"value":43},{"_id":3,"value":44}],"writeConcern":{"w":"majority"},"$db":"async_lane"}),&[],OperationKind::Write,at,|r|{assert_eq!(r.get_i32("n").expect("n"),3);Ok(())}).await.expect("insert"); + let mut cursor=c.cursor(&raw(doc!{"find":"items","filter":{},"batchSize":1,"$db":"async_lane"}),at).await.expect("cursor");let mut rows=0;while let Some(row)=cursor.next(at).await.expect("cursor next"){assert!(row.get_i32("value").expect("value")>=42);rows+=1;}assert_eq!(rows,3);cursor.close(at).await.expect("close cursor"); + let before=c.heartbeat_count();h.sleep(Duration::from_millis(1100)).await.expect("heartbeat timer");assert!(c.heartbeat_count()>before);assert_eq!(c.server_count(),3); + let mut before_primary=String::new();c.command(&raw(doc!{"hello":1,"$db":"admin"}),&[],OperationKind::RunCommand,at,|r|{before_primary=r.get_str("me").expect("primary me").to_owned();Ok(())}).await.expect("hello"); + let step=c.command(&raw(doc!{"replSetStepDown":60,"force":true,"$db":"admin"}),&[],OperationKind::RunCommand,at,|_|Ok(())).await;if let Err(e) = step { + let mongo = e.get_ref().and_then(|e| e.downcast_ref::()).expect("MongoDB step-down error"); + assert!(matches!(mongo.kind, turnloop_mongodb::ErrorKind::Network) || matches!(mongo.code,Some(91|189|11600|11602)), "unexpected step-down failure: {e}"); + } + h.sleep(Duration::from_secs(1)).await.expect("election wait"); + let mut after_primary=String::new();c.command(&raw(doc!{"hello":1,"$db":"admin"}),&[],OperationKind::Read,at,|r|{assert!(r.get_bool("isWritablePrimary").expect("primary"));after_primary=r.get_str("me").expect("me").to_owned();Ok(())}).await.expect("reselection");assert_ne!(before_primary,after_primary); + let mut rows=0;c.command(&raw(doc!{"find":"items","filter":{},"$db":"async_lane"}),&[],OperationKind::Read,at,|r|{rows=turnloop_mongodb::command::CursorBatch::parse(r).expect("batch").rows().count();Ok(())}).await.expect("persisted read");assert_eq!(rows,3); + for kind in [OperationKind::Write, OperationKind::Read] { + let name=if kind==OperationKind::Write {"insert"} else {"find"}; + c.command(&raw(doc!{"configureFailPoint":"failCommand","mode":{"times":1},"data":{"failCommands":[name],"errorCode":6,"errorLabels":["RetryableWriteError"]},"$db":"admin"}),&[],OperationKind::RunCommand,at,|_|Ok(())).await.expect("arm one retry"); + let body=if kind==OperationKind::Write {doc!{"insert":"retry_items","documents":[{"_id":99}],"$db":"async_lane"}}else{doc!{"find":"retry_items","filter":{},"$db":"async_lane"}}; + let mut replies=0; + c.command(&raw(body),&[],kind,at,|r| {if kind==OperationKind::Write {assert_eq!(r.get_i32("n").expect("insert count"),1);}else{assert_eq!(turnloop_mongodb::command::CursorBatch::parse(r).expect("batch").rows().count(),1);}replies+=1;Ok(())}).await.expect("retry command"); + assert_eq!(replies,1);assert_eq!(c.last_retry_count(),1,"failpoint must force one retry"); + } + let cursor=c.cursor(&raw(doc!{"find":"items","filter":{},"batchSize":1,"$db":"async_lane"}),at).await.expect("cleanup cursor");let id=cursor.id();assert_ne!(id,0);drop(cursor); + h.sleep(Duration::from_millis(50)).await.expect("cursor cleanup deadline"); + let error=c.command(&raw(doc!{"getMore":id,"collection":"items","$db":"async_lane"}),&[],OperationKind::RunCommand,at,|_|panic!("dropped cursor must be killed")).await.expect_err("CursorNotFound"); + assert_eq!(error.get_ref().and_then(|e|e.downcast_ref::()).expect("MongoDB error").code,Some(43)); + }).expect("spawn"); + drive(&mut ex, &mut task); +} diff --git a/protocols/turnloop-mongodb/tests/asynchronous.rs b/protocols/turnloop-mongodb/tests/asynchronous.rs new file mode 100644 index 0000000..aa9071a --- /dev/null +++ b/protocols/turnloop-mongodb/tests/asynchronous.rs @@ -0,0 +1,270 @@ +#![cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] +#[path = "../../../crates/turnloop-io/tests/support/count.rs"] +mod count; +use std::{ + future::Future, + pin::Pin, + task::{Context, Poll, Waker}, + time::Duration, +}; +use turnloop_io::{ + turnloop::{Config as LoopConfig, LocalExecutor, Timeout, backend::Platform}, + *, +}; +fn finish(task: &mut turnloop::JoinHandle) -> T { + match Pin::new(task).poll(&mut Context::from_waker(Waker::noop())) { + Poll::Ready(Ok(v)) => v, + _ => panic!("task must finish"), + } +} +fn drive(executor: &mut LocalExecutor, task: &mut turnloop::JoinHandle) -> T { + let at = executor.handle().now() + Duration::from_secs(90); + while !task.is_finished() { + assert!(executor.handle().now() < at, "test deadline"); + executor.turn(Timeout::Until(at)).expect("turn"); + } + finish(task) +} +async fn exact(s: &mut S, mut bytes: &mut [u8]) { + while !bytes.is_empty() { + let n = read(s, bytes).await.expect("read"); + assert!(n > 0, "unexpected EOF"); + bytes = &mut bytes[n..]; + } +} +use turnloop_mongodb::{ + asynchronous::Connection, + bson::{doc, raw::RawDocumentBuf}, + uri::Options, + wire, +}; +async fn mongo_packet(s: &mut S) -> Vec { + let mut length = [0; 4]; + exact(s, &mut length).await; + let n = i32::from_le_bytes(length) as usize; + assert!((16..16384).contains(&n)); + let mut frame = vec![0; n]; + frame[..4].copy_from_slice(&length); + exact(s, &mut frame[4..]).await; + frame +} +#[test] +fn command_reply_and_cancel_close() { + let mut ex = LocalExecutor::::new(LoopConfig::default()).expect("executor"); + let h = ex.handle(); + let listener = Listener::bind(&h, "127.0.0.1:0".parse().expect("address")).expect("listen"); + let address = listener.local_addr().expect("address"); + let mut server = ex + .spawn_local(async move { + let mut s = listener.accept().await.expect("accept"); + let mut reply = Vec::new(); + for round in 0..3 { + let bytes = mongo_packet(&mut s).await; + let message = wire::Message::parse(&bytes, 16384).expect("OP_MSG"); + if round == 2 { + assert_eq!(message.body.get_i32("ping").expect("ping"), 1); + let mut b = [0]; + assert_eq!(read(&mut s, &mut b).await.expect("EOF"), 0); + break; + } + let body = RawDocumentBuf::try_from(&if round == 0 { + doc! {"ok":1,"maxWireVersion":27} + } else { + doc! {"ok":1,"answer":42} + }) + .expect("BSON"); + wire::encode(&mut reply, 99, message.request_id, 0, &body, &[], 16384) + .expect("encode"); + write_all(&mut s, &reply).await.expect("reply"); + } + }) + .expect("spawn"); + let mut client = ex + .spawn_local(async move { + let at = h.now() + Duration::from_secs(5); + let mut c = Connection::connect( + &h, + address, + Options::parse("mongodb://127.0.0.1/?directConnection=true").expect("options"), + None, + at, + ) + .await + .expect("connect"); + let command = + RawDocumentBuf::try_from(&doc! {"ping":1,"$db":"admin"}).expect("command"); + let mut replies = 0; + c.command(&command, &[], at, |reply| { + assert_eq!(reply.get_i32("answer").expect("answer"), 42); + replies += 1; + Ok(()) + }) + .await + .expect("command"); + assert_eq!(replies, 1); + assert_eq!( + c.command( + &command, + &[], + h.now() + Duration::from_millis(2), + |_| panic!("no reply expected") + ) + .await + .expect_err("timeout") + .kind(), + std::io::ErrorKind::TimedOut + ); + assert!(!c.is_reusable()); + }) + .expect("spawn"); + drive(&mut ex, &mut client); + drive(&mut ex, &mut server); +} +#[test] +fn warmed_async_client_selection_pool_and_reply_allocate_zero() { + for preference in [ + "primary", + "primaryPreferred", + "secondary", + "secondaryPreferred", + "nearest", + ] { + warmed_selection(preference); + } +} +fn warmed_selection(preference: &str) { + use turnloop_mongodb::{ + asynchronous::{Client, ConnectOptions}, + operation::OperationKind, + }; + let mut ex = LocalExecutor::::new(LoopConfig::default()).expect("executor"); + let h = ex.handle(); + let listener = Listener::bind(&h, "127.0.0.1:0".parse().expect("address")).expect("listen"); + let address = listener.local_addr().expect("address"); + let mut server=ex.spawn_local(async move { + // Dedicated monitor socket remains idle for the 60-second heartbeat. + let mut monitor=listener.accept().await.expect("monitor");let m=mongo_packet(&mut monitor).await;let m=wire::Message::parse(&m,16384).expect("hello"); + let hello=RawDocumentBuf::try_from(&doc!{"ok":1,"ismaster":true,"isWritablePrimary":true,"minWireVersion":0,"maxWireVersion":27}).expect("hello");let mut reply=Vec::new();wire::encode(&mut reply,1,m.request_id,0,&hello,&[],16384).expect("encode");write_all(&mut monitor,&reply).await.expect("hello"); + let mut s=listener.accept().await.expect("application");let m=mongo_packet(&mut s).await;let m=wire::Message::parse(&m,16384).expect("hello");wire::encode(&mut reply,1,m.request_id,0,&hello,&[],16384).expect("encode");write_all(&mut s,&reply).await.expect("hello"); + let body=RawDocumentBuf::try_from(&doc!{"ok":1,"answer":42}).expect("reply"); + for _ in 0..1001 {let m=mongo_packet(&mut s).await;let m=wire::Message::parse(&m,16384).expect("command");assert_eq!(m.body.get_i32("ping").expect("ping"),1);wire::encode(&mut reply,1,m.request_id,0,&body,&[],16384).expect("encode");write_all(&mut s,&reply).await.expect("reply");} + 1001 + }).expect("server"); + let preference = preference.to_owned(); + let mut client = ex + .spawn_local(async move { + count::prove_counter().await; + let at = h.now() + Duration::from_secs(30); + let mut c = Client::connect( + &h, + ConnectOptions { + protocol: Options::parse(&format!( + "mongodb://{address}/?directConnection=true&heartbeatFrequencyMS=60000&readPreference={preference}" + )) + .expect("options"), + tls: None, + }, + at, + ) + .await + .expect("client"); + let command = + RawDocumentBuf::try_from(&doc! {"ping":1,"$db":"admin"}).expect("command"); + let mut replies = 0; + for i in 0..1001 { + let (result, n) = count::measure(c.command( + &command, + &[], + OperationKind::RunCommand, + at, + |reply| { + assert_eq!(reply.get_i32("answer").expect("answer"), 42); + replies += 1; + Ok(()) + }, + )) + .await; + result.expect("command"); + if i > 0 { + assert_eq!(n, 0, "selection, CMAP checkout, coordinator and reply"); + } + } + assert_eq!(replies, 1001); + assert_eq!(c.heartbeat_count(), 1); + }) + .expect("client"); + drive(&mut ex, &mut client); + assert_eq!(drive(&mut ex, &mut server), 1001); +} +#[test] +fn cancelled_client_operation_reconnects_and_sdam_idle_parks() { + use turnloop_mongodb::{ + asynchronous::{Client, ConnectOptions}, + operation::OperationKind, + }; + async fn hello(s: &mut AsyncIo) { + let bytes = mongo_packet(s).await; + let message = wire::Message::parse(&bytes, 16384).expect("hello"); + let body=RawDocumentBuf::try_from(&doc!{"ok":1,"ismaster":true,"isWritablePrimary":true,"minWireVersion":0,"maxWireVersion":27}).expect("body"); + let mut reply = Vec::new(); + wire::encode(&mut reply, 1, message.request_id, 0, &body, &[], 16384).expect("encode"); + write_all(s, &reply).await.expect("hello reply"); + } + let mut ex = LocalExecutor::::new(LoopConfig::default()).expect("executor"); + let h = ex.handle(); + let listener = Listener::bind(&h, "127.0.0.1:0".parse().expect("address")).expect("listen"); + let address = listener.local_addr().expect("address"); + let mut server = ex + .spawn_local(async move { + let mut monitor = listener.accept().await.expect("monitor"); + hello(&mut monitor).await; + let mut first = listener.accept().await.expect("first"); + hello(&mut first).await; + let bytes = mongo_packet(&mut first).await; + let message = wire::Message::parse(&bytes, 16384).expect("command"); + assert_eq!(message.body.get_i32("ping").expect("ping"), 1); + let mut b = [0]; + assert_eq!(read(&mut first, &mut b).await.expect("cancel EOF"), 0); + let mut next = listener.accept().await.expect("replacement"); + hello(&mut next).await; + let bytes = mongo_packet(&mut next).await; + let message = wire::Message::parse(&bytes, 16384).expect("command"); + assert_eq!(message.body.get_i32("ping").expect("ping"), 1); + let body = RawDocumentBuf::try_from(&doc! {"ok":1,"answer":42}).expect("body"); + let mut reply = Vec::new(); + wire::encode(&mut reply, 1, message.request_id, 0, &body, &[], 16384).expect("encode"); + write_all(&mut next, &reply).await.expect("reply"); + assert_eq!(read(&mut next, &mut b).await.expect("client drop EOF"), 0); + 2 + }) + .expect("server"); + let idle = std::rc::Rc::new(std::cell::Cell::new(false)); + let client_idle = idle.clone(); + let mut client=ex.spawn_local(async move { + let at=h.now()+Duration::from_secs(5); + let mut c=Client::connect(&h,ConnectOptions{protocol:Options::parse(&format!("mongodb://{address}/?directConnection=true&heartbeatFrequencyMS=60000&maxPoolSize=1")).expect("options"),tls:None},at).await.expect("client"); + let body=RawDocumentBuf::try_from(&doc!{"ping":1,"$db":"admin"}).expect("command"); + let result=deadline(&h,h.now()+Duration::from_millis(20),c.command(&body,&[],OperationKind::RunCommand,at,|_|panic!("stalled command replied"))).await; + assert_eq!(result.expect_err("outer future cancelled").kind(),std::io::ErrorKind::TimedOut); + let mut replies=0;c.command(&body,&[],OperationKind::RunCommand,at,|r|{assert_eq!(r.get_i32("answer").expect("answer"),42);replies+=1;Ok(())}).await.expect("client reusable after cancellation");assert_eq!(replies,1);assert_eq!(c.heartbeat_count(),1); + client_idle.set(true);h.sleep(Duration::from_millis(60)).await.expect("idle"); + }).expect("client"); + let end = ex.handle().now() + Duration::from_secs(10); + while !idle.get() { + assert!(ex.handle().now() < end); + ex.turn(Timeout::Until(end)).expect("turn"); + } + let mut waited = false; + for _ in 0..8 { + let before = ex.handle().now(); + let info = ex.turn(Timeout::Until(end)).expect("turn"); + if ex.handle().now().duration_since(before) >= Duration::from_millis(10) { + assert_eq!(info.os_waits, 1); + waited = true; + break; + } + } + assert!(waited, "SDAM and idle CMAP must park"); + drive(&mut ex, &mut client); + assert_eq!(drive(&mut ex, &mut server), 2); +} diff --git a/protocols/turnloop-mongodb/tests/protocol.rs b/protocols/turnloop-mongodb/tests/protocol.rs index 5b52823..4f16e0d 100644 --- a/protocols/turnloop-mongodb/tests/protocol.rs +++ b/protocols/turnloop-mongodb/tests/protocol.rs @@ -722,3 +722,58 @@ fn incompatible_handshake_and_change_stream_empty_batch_resume_budget() { stream.finish_batch(None).unwrap(); assert!(stream.should_resume(&e, 27)); } + +#[test] +fn discarded_lease_preserves_other_connections_and_generation() { + let now = clock::now(); + let mut pool = Pool::new(PoolOptions { + min_size: 2, + max_size: 2, + ..Default::default() + }) + .unwrap(); + pool.ready(now); + let first = match pool.poll_event().unwrap() { + PoolEvent::Connect(l) => l, + e => panic!("{e:?}"), + }; + let second = match pool.poll_event().unwrap() { + PoolEvent::Connect(l) => l, + e => panic!("{e:?}"), + }; + pool.connected(first, now).unwrap(); + pool.connected(second, now).unwrap(); + pool.checkout(1, now).unwrap(); + let discarded = match pool.poll_event().unwrap() { + PoolEvent::CheckedOut { + token: 1, + connection, + } => connection, + e => panic!("{e:?}"), + }; + pool.checkout(2, now).unwrap(); + let retained = match pool.poll_event().unwrap() { + PoolEvent::CheckedOut { + token: 2, + connection, + } => connection, + e => panic!("{e:?}"), + }; + pool.discard(discarded, now).unwrap(); + assert_eq!(pool.generation(), 0); + assert_eq!(pool.checked_out(), 1); + assert!(matches!(pool.poll_event(), Some(PoolEvent::Close(l)) if l == discarded)); + assert!( + matches!(pool.poll_event(), Some(PoolEvent::Connect(l)) if l != retained && l != discarded) + ); + assert!(pool.poll_event().is_none(), "no pool-wide clear"); + pool.checkin(retained, now).unwrap(); + pool.checkout(3, now).unwrap(); + assert!( + matches!(pool.poll_event(), Some(PoolEvent::CheckedOut { token: 3, connection }) if connection == retained) + ); + assert!( + pool.discard(discarded, now).is_err(), + "stale lease rejected" + ); +} diff --git a/protocols/turnloop-mongodb/tests/spec_selection.rs b/protocols/turnloop-mongodb/tests/spec_selection.rs index 397d5e9..e1ce3f1 100644 --- a/protocols/turnloop-mongodb/tests/spec_selection.rs +++ b/protocols/turnloop-mongodb/tests/spec_selection.rs @@ -4,7 +4,7 @@ use serde_json::Value; use std::{collections::BTreeMap, fs, path::Path, time::Duration}; use turnloop_mongodb::{ bson::doc, - topology::{ServerType, Topology, TopologyType}, + topology::{SelectionBuffer, ServerType, Topology, TopologyType}, uri::{Options, ReadPreference}, }; fn kind(s: &str) -> ServerType { @@ -103,22 +103,47 @@ fn run(path: &Path) { }) .collect() }; + let max_staleness = p["maxStalenessSeconds"] + .as_i64() + .filter(|v| *v >= 0) + .map(|v| Duration::from_secs(v as u64)); + let excluded: Vec<_> = v["deprioritized_servers"] + .as_array() + .into_iter() + .flatten() + .map(|s| s["address"].as_str().unwrap()) + .collect(); let mut actual = Vec::new(); - let result = t.candidates_deprioritized( - pref, - &tags, - p["maxStalenessSeconds"] - .as_i64() - .filter(|v| *v >= 0) - .map(|v| Duration::from_secs(v as u64)), - &v["deprioritized_servers"] - .as_array() - .into_iter() - .flatten() - .map(|s| s["address"].as_str().unwrap()) - .collect::>(), - &mut actual, - ); + let result = t.candidates_deprioritized(pref, &tags, max_staleness, &excluded, &mut actual); + let mut scratch = SelectionBuffer::default(); + // Every official fixture also executes the retained async selection path. + // Sweep both choices and reuse the same scratch through every filter pass. + for first in [0, u64::MAX / 2, u64::MAX] { + for second in [0, u64::MAX / 2, u64::MAX] { + let selected = t.select_reusing( + pref, + &tags, + max_staleness, + &excluded, + [first, second], + &mut scratch, + ); + if v["error"] == true { + assert!( + selected.is_err(), + "retained selection must reject {}", + path.display() + ); + } else { + assert_eq!( + selected.unwrap(), + t.choose(&actual, [first, second]), + "retained selection differs for {}", + path.display() + ); + } + } + } if v["error"] == true { assert!(result.is_err()); return; diff --git a/protocols/turnloop-mysql/Cargo.toml b/protocols/turnloop-mysql/Cargo.toml index 137e356..711a2e0 100644 --- a/protocols/turnloop-mysql/Cargo.toml +++ b/protocols/turnloop-mysql/Cargo.toml @@ -12,6 +12,7 @@ documentation = "https://docs.rs/turnloop-mysql" description = "Pull-driven sans-I/O MySQL client protocol" [dependencies] +turnloop-tls = { workspace = true, optional = true, features = ["turnloop"] } turnloop-io = { workspace = true, optional = true } bytes.workspace = true mysql_common.workspace = true @@ -29,11 +30,27 @@ workspace = true [package.metadata.turnloop-ci] role = "protocol" -integration-tests = ["server"] +wasi-tests = ["asynchronous"] +# Pinned p3 compiler: custom allocator traps before main in debug (wasm2 report). +wasi-p3-release-tests = ["asynchronous"] +wasi-integration-tests = ["async_server"] +integration-tests = ["server", "async_server"] [target.'cfg(all(target_os = "wasi", target_env = "p3"))'.dependencies] turnloop-io = { workspace = true, optional = true, features = ["wasi-p3-experimental"] } turnloop-wasi-random.workspace = true [features] -turnloop = ["dep:turnloop-io"] +turnloop = ["dep:turnloop-io", "dep:turnloop-tls"] + +[[test]] +name = "asynchronous" +required-features = ["turnloop"] + +[[test]] +name = "async_server" +required-features = ["turnloop"] + +[[example]] +name = "turnloop" +required-features = ["turnloop"] diff --git a/protocols/turnloop-mysql/README.md b/protocols/turnloop-mysql/README.md index 0f19ea6..a4647d7 100644 --- a/protocols/turnloop-mysql/README.md +++ b/protocols/turnloop-mysql/README.md @@ -2,7 +2,39 @@ A sans-I/O MySQL v10 client, built on `mysql_common` handshake/auth/value packets. No sockets, filesystem access, threads, implicit clocks, timers or entropy reads -in production. The crate forbids unsafe. TLS and entropy are explicit requests. +in the default sans-I/O feature set. The crate forbids unsafe. TLS and entropy are explicit requests. + +## Getting started on turnloop + +Enable `turnloop-mysql = { version = "0.1.0-alpha.1", features = ["turnloop"] }` +and use the `asynchronous` module: `Connection::connect, query, prepare/execute, begin and Pool::acquire`. The default feature set remains sans-I/O. +The adapter reuses `turnloop-io`; the host owns `LocalExecutor` and calls `turn`. +Spawn local futures through its handle and keep their `JoinHandle`s until completion. + +```sh +cargo run -p turnloop-mysql --features turnloop --example turnloop +``` + +[The complete example](examples/turnloop.rs) connects to `127.0.0.1:3306` by default; +`TURNLOOP_DB_ADDR` overrides that development endpoint. All operations take an +absolute `turnloop_io::Instant` deadline, shared across authentication, I/O and retries. +Callbacks borrow row/reply storage; copy values only when retaining them. +Dropping a pending operation closes its transport before a pool can reuse it. + +For TLS, set the protocol's TLS mode and provide +`turnloop_tls::asynchronous::ClientTls` with the trust configuration, verified +server name and current Unix seconds. Certificates are verified; a requested TLS +upgrade without a configuration fails. Native TCP and WASI 0.2 sockets share the +same driver. Browser raw TCP is unavailable; Windows accepts a host-provided +`Backend` until the repository's production IOCP provider is integrated. + +`Pool::new` uses `pool::Config` with mysql2 queue/max/idle policy. Leases own their +connection until drop, and never return unfinished transactions to the pool. +`begin` returns a transaction guard with explicit commit/rollback; dropping an +unfinished guard closes its connection. Multi-statements require +`Config::multiple_statements`; callbacks receive every result set. Full +caching_sha2 RSA authentication obtains fresh OAEP entropy from the TLS provider. + ## Host contract diff --git a/protocols/turnloop-mysql/examples/turnloop.rs b/protocols/turnloop-mysql/examples/turnloop.rs new file mode 100644 index 0000000..b3c20b9 --- /dev/null +++ b/protocols/turnloop-mysql/examples/turnloop.rs @@ -0,0 +1,67 @@ +//! A local plaintext development-server example. For production TLS, supply +//! ClientTls and enable the protocol's TLS option; see the crate README. +use std::time::Duration; +use turnloop_io::{Backend, ExecutorHandle}; +pub async fn example(h: ExecutorHandle) -> std::io::Result<()> { + let address = std::env::var("TURNLOOP_DB_ADDR") + .unwrap_or_else(|_| "127.0.0.1:3306".into()) + .parse::() + .map_err(std::io::Error::other)?; + let at = h.now() + Duration::from_secs(30); + use turnloop_mysql::{ + Event, + asynchronous::{ConnectOptions, Connection}, + }; + let options = ConnectOptions { + address, + protocol: turnloop_mysql::Config { + user: std::env::var("MYSQL_USER").unwrap_or_else(|_| "root".into()), + password: std::env::var("MYSQL_PASSWORD") + .unwrap_or_default() + .into_bytes(), + database: std::env::var("MYSQL_DATABASE").ok(), + ..Default::default() + }, + tls: None, + }; + let mut connection = Connection::connect(&h, &options, at).await?; + connection + .query("SELECT 42", at, |event| { + if matches!(event, Event::Row { .. }) { + println!("received a row"); + } + Ok(()) + }) + .await?; + Ok(()) +} +#[cfg(not(any(windows, all(target_arch = "wasm32", target_os = "unknown"))))] +fn main() -> std::io::Result<()> { + use std::{ + future::Future, + pin::Pin, + task::{Context, Poll, Waker}, + }; + use turnloop_io::turnloop::{Config, LocalExecutor, Timeout, backend::Platform}; + let mut executor = + LocalExecutor::::new(Config::default()).map_err(turnloop_io::error)?; + let h = executor.handle(); + let mut task = executor + .spawn_local(example(h)) + .map_err(turnloop_io::error)?; + while !task.is_finished() { + executor + .turn(Timeout::After(Duration::from_secs(30))) + .map_err(turnloop_io::error)?; + } + match Pin::new(&mut task).poll(&mut Context::from_waker(Waker::noop())) { + Poll::Ready(Ok(result)) => result, + _ => Err(std::io::Error::other("example task did not complete")), + } +} +#[cfg(any(windows, all(target_arch = "wasm32", target_os = "unknown")))] +fn main() { + eprintln!( + "Embed example(handle) with a host-provided TCP Backend; this target has no built-in TCP Platform provider." + ); +} diff --git a/protocols/turnloop-mysql/src/async_pool.rs b/protocols/turnloop-mysql/src/async_pool.rs new file mode 100644 index 0000000..6e9c4c3 --- /dev/null +++ b/protocols/turnloop-mysql/src/async_pool.rs @@ -0,0 +1,134 @@ +//! Executor ownership for the existing sans-I/O pool policy. +use super::{ConnectOptions, Connection}; +use crate::pool::{ConnectionId, Event, Lease as CoreLease, Pool as Core}; +use std::{io, time::Duration}; +use turnloop_io::{ + Backend, ExecutorHandle, Instant, + pool::{self as shared, Action, Policy}, +}; +impl Policy for Core { + type Id = ConnectionId; + type Lease = CoreLease; + fn id(lease: CoreLease) -> ConnectionId { + lease.connection + } + fn checkout(&mut self, token: u64, now: Instant, at: Instant) -> io::Result<()> { + self.checkout( + token, + super::client::time(now), + Some(super::client::time(at)), + ) + .map_err(io::Error::other) + } + fn cancel(&mut self, token: u64, now: Instant) { + self.cancel_checkout(token, super::client::time(now)); + } + fn connected(&mut self, id: ConnectionId, now: Instant) -> io::Result<()> { + self.connected(id, super::client::time(now)) + .map_err(io::Error::other) + } + fn connect_failed(&mut self, id: ConnectionId, now: Instant) -> io::Result<()> { + self.connect_failed(id, super::client::time(now)) + .map_err(io::Error::other) + } + fn checkin(&mut self, lease: CoreLease, now: Instant, destroy: bool) -> io::Result<()> { + self.checkin(lease, super::client::time(now), destroy) + .map_err(io::Error::other) + } + fn closed(&mut self, id: ConnectionId, now: Instant) -> io::Result<()> { + self.closed(id, super::client::time(now)) + .map_err(io::Error::other) + } + fn event(&mut self) -> Option> { + loop { + match self.next_event()? { + Event::Connect(id) => return Some(Action::Connect(id)), + Event::Close(id) => return Some(Action::Close(id)), + Event::Removed(id) => return Some(Action::Remove(id)), + Event::Acquired(lease) => { + return Some(Action::Acquired { + token: lease.token, + lease, + }); + } + Event::CheckoutFailed { token, error } => { + return Some(Action::Failed { + token, + error: io::Error::other(error), + }); + } + Event::Ended => return Some(Action::Ended), + _ => {} + } + } + } + fn next_deadline(&self) -> Option { + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + { + self.next_timeout() + .map(|t| Instant::from_duration(t.as_duration())) + } + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] + { + self.next_timeout() + } + } + fn expire(&mut self, now: Instant) { + self.handle_timeout(super::client::time(now)); + } + fn end(&mut self) -> io::Result<()> { + self.end().map_err(io::Error::other) + } +} +struct Connector(ConnectOptions); +impl shared::Connector for Connector { + type Connection = Connection; + fn connect( + &self, + executor: &ExecutorHandle, + at: Instant, + ) -> impl std::future::Future>> + 'static { + let executor = executor.clone(); + let options = self.0.clone(); + async move { Connection::connect(&executor, &options, at).await } + } +} +pub type PooledConnection = shared::Lease>; +pub struct Pool { + inner: shared::Pool>, +} +impl Clone for Pool { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + } + } +} +impl Pool { + pub fn new( + executor: &ExecutorHandle, + options: ConnectOptions, + config: crate::pool::Config, + connect_timeout: Duration, + ) -> io::Result { + let capacity = config.max; + Ok(Self { + inner: shared::Pool::new( + executor, + Core::new(config).map_err(io::Error::other)?, + Connector(options), + connect_timeout, + capacity, + )?, + }) + } + pub async fn acquire(&self, at: Instant) -> io::Result> { + self.inner.acquire(at).await + } + pub fn total(&self) -> usize { + self.inner.total() + } + pub async fn end(&self) -> io::Result<()> { + self.inner.end().await + } +} diff --git a/protocols/turnloop-mysql/src/asynchronous.rs b/protocols/turnloop-mysql/src/asynchronous.rs index cb663dc..3b02972 100644 --- a/protocols/turnloop-mysql/src/asynchronous.rs +++ b/protocols/turnloop-mysql/src/asynchronous.rs @@ -62,3 +62,11 @@ mod tests { ); } } + +#[path = "client.rs"] +mod client; +pub use client::*; + +#[path = "async_pool.rs"] +mod pool; +pub use pool::{Pool, PooledConnection}; diff --git a/protocols/turnloop-mysql/src/client.rs b/protocols/turnloop-mysql/src/client.rs new file mode 100644 index 0000000..e0948ed --- /dev/null +++ b/protocols/turnloop-mysql/src/client.rs @@ -0,0 +1,282 @@ +//! Sequential mysql2-shaped sessions over the shared stream driver. +use crate::asynchronous::AsyncConnection; +use crate::{Config, Event, Outcome, Statement, Value}; +use std::{io, net::SocketAddr}; +use turnloop_io::{AsyncIo, Backend, ExecutorHandle, Instant, Stream, deadline}; +use turnloop_tls::asynchronous::{ClientTls, Transport}; + +#[derive(Clone)] +pub struct ConnectOptions { + pub address: SocketAddr, + pub protocol: Config, + pub tls: Option, +} +pub struct Connection> { + executor: ExecutorHandle, + driver: AsyncConnection>, + pub connection_id: u32, + /// Whether full caching_sha2 authentication used RSA-OAEP on this session. + pub rsa_authenticated: bool, +} +pub(super) fn time(now: Instant) -> crate::Instant { + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + { + crate::Instant::from_duration(now.as_duration()) + } + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] + { + now + } +} +impl Connection { + pub async fn connect( + executor: &ExecutorHandle, + options: &ConnectOptions, + at: Instant, + ) -> io::Result { + let stream = deadline(executor, at, async { + executor + .connect(options.address, Default::default()) + .await + .map_err(turnloop_io::error) + }) + .await?; + Self::from_stream( + executor, + stream, + options.protocol.clone(), + options.tls.as_ref(), + at, + ) + .await + } +} +impl Connection { + pub async fn from_stream( + executor: &ExecutorHandle, + stream: S, + config: Config, + tls: Option<&ClientTls>, + at: Instant, + ) -> io::Result { + let mut driver = AsyncConnection::new( + Transport::Plain(stream), + crate::Connection::new(config).map_err(io::Error::other)?, + ); + let mut rsa_authenticated = false; + let connection_id = deadline(executor, at, async { + loop { + let mut upgrade = false; + let mut seed = false; + let mut ready = None; + driver + .next(executor, |event| { + match event { + Event::UpgradeTls => upgrade = true, + Event::RsaSeedNeeded => seed = true, + Event::Connected { connection_id } => ready = Some(connection_id), + Event::Error { error, .. } => { + return Err(io::Error::other(format!("{error:?}"))); + } + Event::Closed { reason } => return Err(io::Error::other(reason)), + _ => {} + } + Ok(()) + }) + .await?; + if upgrade { + let tls = tls.ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "TLS configuration required") + })?; + driver.flush().await?; + driver.upgrade_stream()?.upgrade(tls, executor, at).await?; + driver + .core_mut() + .tls_established() + .map_err(io::Error::other)?; + } + if seed { + rsa_authenticated = true; + let mut entropy = [0; 20]; + turnloop_tls::rustls::crypto::ring::default_provider() + .secure_random + .fill(&mut entropy) + .map_err(|_| io::Error::other("secure entropy unavailable"))?; + driver + .core_mut() + .rsa_seed(entropy) + .map_err(io::Error::other)?; + } + if let Some(id) = ready { + return Ok(id); + } + } + }) + .await?; + Ok(Self { + executor: executor.clone(), + driver, + connection_id, + rsa_authenticated, + }) + } + pub fn is_reusable(&self) -> bool { + self.driver.is_connected() + && self.driver.core().is_ready() + && !self + .driver + .core() + .status() + .contains(crate::StatusFlags::SERVER_STATUS_IN_TRANS) + } + pub fn close(&mut self) { + self.driver.abort(); + } + async fn complete( + &mut self, + at: Instant, + mut receive: impl FnMut(Event<'_>) -> io::Result<()>, + ) -> io::Result { + let mut exchange = self.driver.exchange(); + let outcome = deadline(&self.executor, at, async { + loop { + let mut done = None; + exchange + .next(&self.executor, |event| { + match &event { + Event::Completed { outcome, .. } => done = Some(*outcome), + Event::Closed { reason } => return Err(io::Error::other(*reason)), + Event::LocalInfile { .. } => { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "LOCAL INFILE needs an explicit source", + )); + } + _ => {} + } + receive(event) + }) + .await?; + if let Some(outcome) = done { + return Ok(outcome); + } + } + }) + .await?; + exchange.commit(); + Ok(outcome) + } + /// Streams every text row, OK packet and field, including all multi-results. + pub async fn query( + &mut self, + sql: &str, + at: Instant, + receive: impl FnMut(Event<'_>) -> io::Result<()>, + ) -> io::Result { + self.driver + .core_mut() + .query(1, sql, Some(time(at))) + .map_err(io::Error::other)?; + self.complete(at, receive).await + } + pub async fn prepare(&mut self, sql: &str, at: Instant) -> io::Result { + self.driver + .core_mut() + .prepare(1, sql, Some(time(at))) + .map_err(io::Error::other)?; + let mut prepared = None; + let outcome = self + .complete(at, |event| { + if let Event::Prepared { statement, .. } = event { + prepared = Some(statement); + } + Ok(()) + }) + .await?; + if outcome != Outcome::Success { + return Err(io::Error::other("prepare failed")); + } + prepared.ok_or_else(|| io::Error::other("missing prepared statement")) + } + pub async fn execute( + &mut self, + statement: Statement, + params: &[Value], + at: Instant, + receive: impl FnMut(Event<'_>) -> io::Result<()>, + ) -> io::Result { + self.driver + .core_mut() + .execute(1, statement.id, params, Some(time(at))) + .map_err(io::Error::other)?; + self.complete(at, receive).await + } + pub async fn close_statement( + &mut self, + statement: Statement, + at: Instant, + ) -> io::Result { + self.driver + .core_mut() + .close_statement(1, statement.id) + .map_err(io::Error::other)?; + self.complete(at, |_| Ok(())).await + } + pub async fn ping(&mut self, at: Instant) -> io::Result { + self.driver.core_mut().ping(1).map_err(io::Error::other)?; + self.complete(at, |_| Ok(())).await + } + pub async fn begin(&mut self, at: Instant) -> io::Result> { + if self.query("START TRANSACTION", at, |_| Ok(())).await? != Outcome::Success { + return Err(io::Error::other("START TRANSACTION failed")); + } + Ok(Transaction { + connection: self, + finished: false, + }) + } +} +/// An unfinished transaction is closed on drop, letting MySQL roll it back. +pub struct Transaction<'a, B: Backend, S: Stream> { + connection: &'a mut Connection, + finished: bool, +} +impl std::ops::Deref for Transaction<'_, B, S> { + type Target = Connection; + fn deref(&self) -> &Self::Target { + self.connection + } +} +impl std::ops::DerefMut for Transaction<'_, B, S> { + fn deref_mut(&mut self) -> &mut Self::Target { + self.connection + } +} +impl Transaction<'_, B, S> { + pub async fn commit(mut self, at: Instant) -> io::Result { + let result = self.connection.query("COMMIT", at, |_| Ok(())).await?; + self.finished = result == Outcome::Success; + Ok(result) + } + pub async fn rollback(mut self, at: Instant) -> io::Result { + let result = self.connection.query("ROLLBACK", at, |_| Ok(())).await?; + self.finished = result == Outcome::Success; + Ok(result) + } +} +impl Drop for Transaction<'_, B, S> { + fn drop(&mut self) { + if !self.finished { + self.connection.close(); + } + } +} + +impl turnloop_io::pool::Connection for Connection { + fn reusable(&self) -> bool { + self.is_reusable() + } + fn handle(&self) -> Option { + self.driver.stream()?.get_ref().map(AsyncIo::handle) + } +} diff --git a/protocols/turnloop-mysql/src/pool.rs b/protocols/turnloop-mysql/src/pool.rs index e07fa0f..beb71d9 100644 --- a/protocols/turnloop-mysql/src/pool.rs +++ b/protocols/turnloop-mysql/src/pool.rs @@ -189,6 +189,21 @@ impl Pool { self.schedule(now); Ok(()) } + /// Remove a pending async checkout without retaining its waiter or connection. + pub fn cancel_checkout(&mut self, token: u64, now: Instant) { + if let Some(i) = self.waiting.iter().position(|r| r.token == token) { + self.waiting.remove(i); + } + for i in 0..self.slots.len() { + if matches!(self.slots[i].state, State::Connecting(r) if r.token == token) { + self.close(ConnectionId { + slot: i, + generation: self.slots[i].generation, + }); + } + } + self.schedule(now); + } fn schedule(&mut self, now: Instant) { if self.ending { return; diff --git a/protocols/turnloop-mysql/tests/async_server.rs b/protocols/turnloop-mysql/tests/async_server.rs new file mode 100644 index 0000000..1f7e0a8 --- /dev/null +++ b/protocols/turnloop-mysql/tests/async_server.rs @@ -0,0 +1,180 @@ +#![cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] +use std::{ + future::Future, + pin::Pin, + task::{Context, Poll, Waker}, + time::Duration, +}; +use turnloop_io::{ + turnloop::{Config as LoopConfig, LocalExecutor, Timeout, backend::Platform}, + *, +}; +fn finish(task: &mut turnloop::JoinHandle) -> T { + match Pin::new(task).poll(&mut Context::from_waker(Waker::noop())) { + Poll::Ready(Ok(v)) => v, + _ => panic!("task must finish"), + } +} +fn drive(executor: &mut LocalExecutor, task: &mut turnloop::JoinHandle) -> T { + let at = executor.handle().now() + Duration::from_secs(90); + while !task.is_finished() { + assert!(executor.handle().now() < at, "test deadline"); + executor.turn(Timeout::Until(at)).expect("turn"); + } + finish(task) +} + +fn port(name: &str) -> u16 { + std::env::var(name) + .expect("required private fixture port; run scripts/test-servers.py") + .parse() + .expect("port") +} +fn tls(ca: Vec) -> turnloop_tls::asynchronous::ClientTls { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("wall time") + .as_secs(); + turnloop_tls::asynchronous::ClientTls { + config: turnloop_tls::ClientConfig::new( + turnloop_tls::ClientOptions { + ca: Some(vec![turnloop_tls::rustls::pki_types::CertificateDer::from( + ca, + )]), + alpn: vec![], + ..Default::default() + }, + now, + ) + .expect("TLS config"), + server_name: "localhost".try_into().expect("name"), + unix_seconds: now, + } +} +use turnloop_mysql::{ + Event, Outcome, Value, + asynchronous::{ConnectOptions, Connection, Pool}, +}; +#[test] +#[ignore = "private MySQL fixture; sandbox initializer crash is UNRUN"] +fn real_async_tls_rsa_text_binary_multi_results_transactions_pool() { + let ca = std::fs::read( + std::path::PathBuf::from(std::env::var_os("TURNLOOP_TEST_SQL_TOOLS").expect("SQL tools")) + .join("server.der"), + ) + .expect("CA"); + let options = ConnectOptions { + address: ([127, 0, 0, 1], port("TURNLOOP_TEST_MYSQL_PORT")).into(), + protocol: turnloop_mysql::Config { + user: "tls_user".into(), + password: b"fixture-password".to_vec(), + database: Some("turnloop_test".into()), + tls: true, + multiple_statements: true, + ..Default::default() + }, + tls: Some(tls(ca)), + }; + let mut ex = LocalExecutor::::new(LoopConfig::default()).expect("executor"); + let h = ex.handle(); + let mut task = ex + .spawn_local(async move { + let at = h.now() + Duration::from_secs(30); + let mut c = Connection::connect(&h, &options, at) + .await + .expect("TLS auth"); + let mut rows = 0; + assert_eq!( + c.query("SELECT 42; SELECT 43", at, |e| { + if let Event::Row { .. } = e { + rows += 1; + } + Ok(()) + }) + .await + .expect("multi results"), + Outcome::Success + ); + assert_eq!(rows, 2); + let stmt = c.prepare("SELECT ?", at).await.expect("prepare"); + assert_eq!(stmt.parameters, 1); + let mut rows = 0; + assert_eq!( + c.execute(stmt, &[Value::Int(42)], at, |e| { + if let Event::Row { .. } = e { + rows += 1; + } + Ok(()) + }) + .await + .expect("binary"), + Outcome::Success + ); + assert_eq!(rows, 1); + c.close_statement(stmt, at).await.expect("close statement"); + assert_eq!( + c.begin(at) + .await + .expect("begin") + .rollback(at) + .await + .expect("rollback"), + Outcome::Success + ); + let mut admin_options = options.clone(); + admin_options.protocol.user = "auth_admin".into(); + let mut admin = Connection::connect(&h, &admin_options, at) + .await + .expect("auth admin"); + assert_eq!( + admin + .query("FLUSH PRIVILEGES", at, |_| Ok(())) + .await + .expect("clear SHA2 cache"), + Outcome::Success + ); + let mut rsa = options.clone(); + rsa.protocol.user = "auth_rsa_user".into(); + rsa.protocol.tls = false; + rsa.tls = None; + let mut rsa = Connection::connect(&h, &rsa, at).await.expect("RSA auth"); + assert!( + rsa.rsa_authenticated, + "full RSA path must execute after cache flush" + ); + assert_eq!(rsa.ping(at).await.expect("RSA ping"), Outcome::Success); + let pool = Pool::new( + &h, + options, + turnloop_mysql::pool::Config { + max: 1, + max_idle: 1, + ..Default::default() + }, + Duration::from_secs(5), + ) + .expect("pool"); + let id = { + let c = pool.acquire(at).await.expect("checkout"); + c.connection_id + }; + let pooled = pool.acquire(at).await.expect("reuse"); + assert_eq!(pooled.connection_id, id); + drop(pooled); + pool.end().await.expect("pool end"); + assert_eq!( + c.query( + "SELECT SLEEP(10)", + h.now() + Duration::from_millis(2), + |_| Ok(()) + ) + .await + .expect_err("cancel future") + .kind(), + std::io::ErrorKind::TimedOut + ); + assert!(!c.is_reusable()); + }) + .expect("spawn"); + drive(&mut ex, &mut task); +} diff --git a/protocols/turnloop-mysql/tests/asynchronous.rs b/protocols/turnloop-mysql/tests/asynchronous.rs new file mode 100644 index 0000000..007c6a8 --- /dev/null +++ b/protocols/turnloop-mysql/tests/asynchronous.rs @@ -0,0 +1,306 @@ +#![cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] +#[path = "../../../crates/turnloop-io/tests/support/count.rs"] +mod count; +use std::{ + future::Future, + pin::Pin, + task::{Context, Poll, Waker}, + time::Duration, +}; +use turnloop_io::{ + turnloop::{Config as LoopConfig, LocalExecutor, Timeout, backend::Platform}, + *, +}; +fn finish(task: &mut turnloop::JoinHandle) -> T { + match Pin::new(task).poll(&mut Context::from_waker(Waker::noop())) { + Poll::Ready(Ok(v)) => v, + _ => panic!("task must finish"), + } +} +fn drive(executor: &mut LocalExecutor, task: &mut turnloop::JoinHandle) -> T { + let at = executor.handle().now() + Duration::from_secs(90); + while !task.is_finished() { + assert!(executor.handle().now() < at, "test deadline"); + executor.turn(Timeout::Until(at)).expect("turn"); + } + finish(task) +} +async fn exact(s: &mut S, mut bytes: &mut [u8]) { + while !bytes.is_empty() { + let n = read(s, bytes).await.expect("read"); + assert!(n > 0, "unexpected EOF"); + bytes = &mut bytes[n..]; + } +} + +use mysql_common::constants::CapabilityFlags as Caps; +use turnloop_mysql::{ + Outcome, + asynchronous::{ConnectOptions, Connection, Pool}, +}; +fn frame(seq: u8, body: &[u8]) -> Vec { + let mut b = (body.len() as u32).to_le_bytes()[..3].to_vec(); + b.push(seq); + b.extend_from_slice(body); + b +} +fn handshake(plugin: &str, extra: Caps) -> Vec { + let caps = Caps::CLIENT_PROTOCOL_41 + | Caps::CLIENT_SECURE_CONNECTION + | Caps::CLIENT_PLUGIN_AUTH + | Caps::CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA + | Caps::CLIENT_LONG_PASSWORD + | Caps::CLIENT_MULTI_RESULTS + | Caps::CLIENT_PS_MULTI_RESULTS + | Caps::CLIENT_TRANSACTIONS + | extra; + let mut b = vec![10]; + b.extend_from_slice(b"9.6.0\0"); + b.extend_from_slice(&7u32.to_le_bytes()); + b.extend_from_slice(b"12345678\0"); + b.extend_from_slice(&(caps.bits() as u16).to_le_bytes()); + b.push(45); + b.extend_from_slice(&2u16.to_le_bytes()); + b.extend_from_slice(&((caps.bits() >> 16) as u16).to_le_bytes()); + b.push(21); + b.extend_from_slice(&[0; 10]); + b.extend_from_slice(b"901234567890\0"); + b.extend_from_slice(plugin.as_bytes()); + b.push(0); + frame(0, &b) +} +fn ok(seq: u8, affected: u8, status: u16) -> Vec { + let mut b = vec![0, affected, 0]; + b.extend_from_slice(&status.to_le_bytes()); + b.extend_from_slice(&[0, 0]); + frame(seq, &b) +} +async fn packet(s: &mut S) -> Vec { + let mut header = [0; 4]; + exact(s, &mut header).await; + let n = u32::from_le_bytes([header[0], header[1], header[2], 0]) as usize; + assert!(n < 1024); + let mut body = vec![0; n]; + exact(s, &mut body).await; + body +} +#[test] +fn prepare_binary_execute_transaction_and_cancel() { + let mut ex = LocalExecutor::::new(LoopConfig::default()).expect("executor"); + let h = ex.handle(); + let listener = Listener::bind(&h, "127.0.0.1:0".parse().expect("address")).expect("listen"); + let address = listener.local_addr().expect("address"); + let mut server = ex + .spawn_local(async move { + let mut s = listener.accept().await.expect("accept"); + write_all(&mut s, &handshake("mysql_native_password", Caps::empty())) + .await + .expect("hello"); + assert!(!packet(&mut s).await.is_empty()); + write_all(&mut s, &ok(2, 0, 2)).await.expect("auth"); + assert_eq!(packet(&mut s).await, b"\x16SELECT 42"); + write_all(&mut s, &frame(1, &[0, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])) + .await + .expect("prepared"); + let execute = packet(&mut s).await; + assert_eq!(execute[0], 0x17); + assert_eq!(&execute[1..5], &9u32.to_le_bytes()); + write_all(&mut s, &ok(1, 5, 2)).await.expect("execute"); + assert_eq!(packet(&mut s).await, b"\x03START TRANSACTION"); + write_all(&mut s, &ok(1, 0, 3)).await.expect("begin"); + assert_eq!(packet(&mut s).await, b"\x03ROLLBACK"); + write_all(&mut s, &ok(1, 0, 2)).await.expect("rollback"); + assert_eq!(packet(&mut s).await, b"\x03SELECT stall"); + let mut b = [0]; + assert_eq!(read(&mut s, &mut b).await.expect("EOF"), 0); + 5 + }) + .expect("spawn"); + let mut client = ex + .spawn_local(async move { + let at = h.now() + Duration::from_secs(5); + let mut c = Connection::connect( + &h, + &ConnectOptions { + address, + protocol: Default::default(), + tls: None, + }, + at, + ) + .await + .expect("connect"); + assert_eq!(c.connection_id, 7); + let stmt = c.prepare("SELECT 42", at).await.expect("prepare"); + assert_eq!(stmt.id, 9); + assert_eq!( + c.execute(stmt, &[], at, |_| Ok(())).await.expect("execute"), + Outcome::Success + ); + assert_eq!( + c.begin(at) + .await + .expect("begin") + .rollback(at) + .await + .expect("rollback"), + Outcome::Success + ); + assert!(c.is_reusable()); + assert_eq!( + c.query("SELECT stall", h.now() + Duration::from_millis(2), |_| Ok( + () + )) + .await + .expect_err("timeout") + .kind(), + std::io::ErrorKind::TimedOut + ); + assert!(!c.is_reusable()); + }) + .expect("spawn"); + drive(&mut ex, &mut client); + assert_eq!(drive(&mut ex, &mut server), 5); +} +#[test] +fn pool_max_uses_opens_replacement() { + let mut ex = LocalExecutor::::new(LoopConfig::default()).expect("executor"); + let h = ex.handle(); + let listener = Listener::bind(&h, "127.0.0.1:0".parse().expect("address")).expect("listen"); + let address = listener.local_addr().expect("address"); + let mut server = ex + .spawn_local(async move { + for _ in 0..2 { + let mut s = listener.accept().await.expect("accept"); + write_all(&mut s, &handshake("mysql_native_password", Caps::empty())) + .await + .expect("hello"); + assert!(!packet(&mut s).await.is_empty()); + write_all(&mut s, &ok(2, 0, 2)).await.expect("auth"); + assert_eq!(packet(&mut s).await, [14]); + write_all(&mut s, &ok(1, 0, 2)).await.expect("ping"); + let mut b = [0]; + assert_eq!(read(&mut s, &mut b).await.expect("EOF"), 0); + } + }) + .expect("spawn"); + let mut client = ex + .spawn_local(async move { + let at = h.now() + Duration::from_secs(5); + let pool = Pool::new( + &h, + ConnectOptions { + address, + protocol: Default::default(), + tls: None, + }, + turnloop_mysql::pool::Config { + max: 1, + max_idle: 1, + max_uses: Some(1), + ..Default::default() + }, + Duration::from_secs(2), + ) + .expect("pool"); + for _ in 0..2 { + let mut c = pool.acquire(at).await.expect("checkout"); + assert_eq!(c.ping(at).await.expect("ping"), Outcome::Success); + } + pool.end().await.expect("end"); + }) + .expect("spawn"); + drive(&mut ex, &mut client); + drive(&mut ex, &mut server); +} +#[test] +fn warmed_async_pool_ping_allocates_zero() { + let mut ex = LocalExecutor::::new(LoopConfig::default()).expect("executor"); + let h = ex.handle(); + let listener = Listener::bind(&h, "127.0.0.1:0".parse().expect("address")).expect("listen"); + let address = listener.local_addr().expect("address"); + let mut server = ex + .spawn_local(async move { + let mut s = listener.accept().await.expect("accept"); + write_all(&mut s, &handshake("mysql_native_password", Caps::empty())) + .await + .expect("hello"); + assert!(!packet(&mut s).await.is_empty()); + write_all(&mut s, &ok(2, 0, 2)).await.expect("auth"); + for _ in 0..1001 { + assert_eq!(packet(&mut s).await, [14]); + write_all(&mut s, &ok(1, 0, 2)).await.expect("ping"); + } + let mut b = [0]; + assert_eq!(read(&mut s, &mut b).await.expect("EOF"), 0); + 1001 + }) + .expect("server"); + let idle = std::rc::Rc::new(std::cell::Cell::new(false)); + let client_idle = idle.clone(); + let mut client = ex + .spawn_local(async move { + count::prove_counter().await; + let at = h.now() + Duration::from_secs(30); + let pool = Pool::new( + &h, + ConnectOptions { + address, + protocol: Default::default(), + tls: None, + }, + turnloop_mysql::pool::Config { + max: 1, + max_idle: 1, + idle_timeout: Some(Duration::from_millis(100)), + ..Default::default() + }, + Duration::from_secs(5), + ) + .expect("pool"); + let held = pool.acquire(at).await.expect("hold only slot"); + let blocked = pool.acquire(h.now() + Duration::from_millis(10)).await; + assert!( + matches!(blocked, Err(e) if e.kind() == std::io::ErrorKind::TimedOut), + "queued acquire deadline must run" + ); + drop(held); + for i in 0..1001 { + let (result, n) = count::measure(async { + let mut c = pool.acquire(at).await.expect("acquire"); + c.ping(at).await + }) + .await; + assert_eq!(result.expect("ping"), Outcome::Success); + if i > 0 { + assert_eq!(n, 0, "async ping + checkout/release"); + } + } + assert_eq!(pool.total(), 1); + client_idle.set(true); + h.sleep(Duration::from_millis(150)) + .await + .expect("idle expiry timer"); + assert_eq!(pool.total(), 0, "real idle deadline must retire the socket"); + pool.end().await.expect("end"); + }) + .expect("client"); + let end = ex.handle().now() + Duration::from_secs(30); + while !idle.get() { + assert!(ex.handle().now() < end); + ex.turn(Timeout::Until(end)).expect("turn"); + } + let mut waited = false; + for _ in 0..8 { + let before = ex.handle().now(); + let info = ex.turn(Timeout::Until(end)).expect("idle turn"); + if ex.handle().now().duration_since(before) >= Duration::from_millis(10) { + assert_eq!(info.os_waits, 1); + waited = true; + break; + } + } + assert!(waited, "idle MySQL pool spun instead of parking"); + drive(&mut ex, &mut client); + assert_eq!(drive(&mut ex, &mut server), 1001); +} diff --git a/protocols/turnloop-postgres/Cargo.toml b/protocols/turnloop-postgres/Cargo.toml index 8b87048..bbef4cd 100644 --- a/protocols/turnloop-postgres/Cargo.toml +++ b/protocols/turnloop-postgres/Cargo.toml @@ -12,6 +12,7 @@ documentation = "https://docs.rs/turnloop-postgres" description = "Pull-driven sans-I/O PostgreSQL client protocol" [dependencies] +turnloop-tls = { workspace = true, optional = true, features = ["turnloop"] } turnloop-io = { workspace = true, optional = true } bytes.workspace = true postgres-protocol.workspace = true @@ -28,14 +29,33 @@ workspace = true [package.metadata.turnloop-ci] role = "protocol" -integration-tests = ["server"] +wasi-tests = ["asynchronous", "protocol", "allocations"] +# Pinned p3 compiler: custom allocator traps before main in debug (wasm2 report). +wasi-p3-release-tests = ["asynchronous", "allocations"] +wasi-integration-tests = ["async_server"] +integration-tests = ["server", "async_server"] [dev-dependencies] sha2.workspace = true +rcgen.workspace = true +base64.workspace = true +turnloop-tls.workspace = true [target.'cfg(all(target_os = "wasi", target_env = "p3"))'.dependencies] turnloop-io = { workspace = true, optional = true, features = ["wasi-p3-experimental"] } turnloop-wasi-random.workspace = true [features] -turnloop = ["dep:turnloop-io"] +turnloop = ["dep:turnloop-io", "dep:turnloop-tls"] + +[[test]] +name = "asynchronous" +required-features = ["turnloop"] + +[[test]] +name = "async_server" +required-features = ["turnloop"] + +[[example]] +name = "turnloop" +required-features = ["turnloop"] diff --git a/protocols/turnloop-postgres/README.md b/protocols/turnloop-postgres/README.md index 4ca9b7a..b169449 100644 --- a/protocols/turnloop-postgres/README.md +++ b/protocols/turnloop-postgres/README.md @@ -2,7 +2,47 @@ A pull-driven PostgreSQL v3 client core, built on `postgres-protocol` frontend messages, framing and SCRAM. It never opens a socket, starts a thread, reads a -clock, schedules a timer or generates entropy. Production code forbids unsafe. +clock, schedules a timer or generates entropy. The sans-I/O code forbids unsafe. + +## Getting started on turnloop + +Enable `turnloop-postgres = { version = "0.1.0-alpha.1", features = ["turnloop"] }` +and use the `asynchronous` module: `Client::connect, query, execute, copy_in/copy_out and notification`. The default feature set remains sans-I/O. +The adapter reuses `turnloop-io`; the host owns `LocalExecutor` and calls `turn`. +Spawn local futures through its handle and keep their `JoinHandle`s until completion. + +```sh +cargo run -p turnloop-postgres --features turnloop --example turnloop +``` + +[The complete example](examples/turnloop.rs) connects to `127.0.0.1:5432` by default; +`TURNLOOP_DB_ADDR` overrides that development endpoint. All operations take an +absolute `turnloop_io::Instant` deadline, shared across authentication, I/O and retries. +Callbacks borrow row/reply storage; copy values only when retaining them. +Dropping a pending operation closes its transport before a pool can reuse it. + +For TLS, set the protocol's TLS mode and provide +`turnloop_tls::asynchronous::ClientTls` with the trust configuration, verified +server name and current Unix seconds. Certificates are verified; a requested TLS +upgrade without a configuration fails. Native TCP and WASI 0.2 sockets share the +same driver. Browser raw TCP is unavailable; Windows accepts a host-provided +`Backend` until the repository's production IOCP provider is integrated. + +`Pool::new` accepts the existing `pool::Config` (max, max_idle, idle/acquire +limits, max_uses); `acquire(deadline)` returns an exclusive lease. A transaction +still open at release destroys the connection. Named `ExtendedQuery` values use +the core prepared-statement cache. `notification(deadline)` is the async next +operation after `LISTEN`; query callbacks also receive interleaved notifications. +`copy_in` lends a chunk writer with `finish`; dropping it closes the session. +`copy_out` lends each chunk to a callback without buffering the complete transfer. +`cancel_token().cancel(...)` sends CancelRequest over a separate connection. +TLS authentication prefers SCRAM-SHA-256-PLUS. The client derives RFC 5929 +`tls-server-end-point` data from the verified peer leaf; an explicit +`ConnectOptions::channel_binding` overrides that digest. RSA/ECDSA SHA-256/384/512 +and RSA-PSS are supported; MD5/SHA-1 signatures use SHA-256. Unsupported algorithms +(such as Ed25519) or absent peer certificates fall back to SCRAM-SHA-256 with the +`n,,` GS2 header. `Config::channel_binding_required` rejects that fallback. + ## Driving a connection @@ -14,12 +54,16 @@ clock, schedules a timer or generates entropy. Production code forbids unsafe. 3. Feed plaintext with `receive(bytes)`, then repeatedly pull `next_event()` until it returns `None`. Flush any newly generated output before reading again. 4. `UpgradeTls` is a hard boundary. Finish the existing plaintext write, perform - and verify TLS in the host, then call `tls_established`. TLS records never go - into `receive`. Prefer mode can fall back after `N`; Require cannot. + and verify TLS in the host. Derive binding with + `turnloop_tls::tls_server_end_point` from the verified peer leaf, then call + `tls_established_with_channel_binding(binding.is_some())`. The original + `tls_established()` acknowledges TLS without binding data. TLS records never + go into `receive`. Prefer mode can fall back after `N`; Require cannot. 5. For `ScramNeeded`, construct the reexported upstream `ScramSha256` **in the host** (its constructor reads entropy), then call `start_scram`. PLUS needs `ChannelBinding::tls_server_end_point` containing the certificate digest - defined in RFC 5929. The core checks the mechanism/binding selection and + defined in RFC 5929. For plain SCRAM use `ChannelBinding::unsupported()`. + The core checks the mechanism/binding selection and verifies the server signature. Iterations are capped by configuration. 6. Schedule `next_timeout()` in the host. Call `handle_timeout(now)` with supplied monotonic time. No method obtains time implicitly. @@ -139,3 +183,21 @@ the core. For an upgrade, `into_parts` preserves the stream, core and unread byt Apply `turnloop_io::deadline` using the core's next deadline. Dropping a pending drive future closes the stream and aborts the core; terminal core events remain available for draining. See `turnloop-io` for the shared adapter ownership pattern. + +### Terminal server failures + +`ErrorResponse` with nonlocalized severity `FATAL`/`PANIC` (or `S` when `V` is +absent) terminates the session immediately. `Connection::next_event()` returns +`Error::ConnectionAborted(ConnectionFailure)`; its `server_error()` view retains +SQLSTATE, message and all other fields. Close the transport and drain the core's +one `Outcome::Aborted` per pending token, followed by `Closed`. EOF/abort does not +replace the original diagnostic. Startup rejection is terminal too. + +The async client closes its stream and returns `io::ErrorKind::ConnectionAborted` +with the typed `turnloop_postgres::Error` as its inner error. Such a client is not +reusable. Ordinary statement `ERROR` stays borrowed and becomes +`Outcome::ServerError` only after `ReadyForQuery`; the session can then continue. + +`Error` and `Outcome` are `Clone`, no longer `Copy`. Terminal diagnostics share one +owned copy of the wire fields; cloning pipeline aborts allocates nothing. +Successful queries and reusable statement errors retain zero steady allocations. diff --git a/protocols/turnloop-postgres/examples/turnloop.rs b/protocols/turnloop-postgres/examples/turnloop.rs new file mode 100644 index 0000000..79eb0dc --- /dev/null +++ b/protocols/turnloop-postgres/examples/turnloop.rs @@ -0,0 +1,69 @@ +//! A local plaintext development-server example. For production TLS, supply +//! ClientTls and enable the protocol's TLS option; see the crate README. +use std::time::Duration; +use turnloop_io::{Backend, ExecutorHandle}; +pub async fn example(h: ExecutorHandle) -> std::io::Result<()> { + let address = std::env::var("TURNLOOP_DB_ADDR") + .unwrap_or_else(|_| "127.0.0.1:5432".into()) + .parse::() + .map_err(std::io::Error::other)?; + let at = h.now() + Duration::from_secs(30); + use turnloop_postgres::{ + Event, + asynchronous::{Client, ConnectOptions}, + }; + let options = ConnectOptions { + address, + protocol: turnloop_postgres::Config { + user: std::env::var("PGUSER").unwrap_or_else(|_| "postgres".into()), + password: std::env::var("PGPASSWORD").unwrap_or_default().into_bytes(), + database: std::env::var("PGDATABASE").unwrap_or_else(|_| "postgres".into()), + ..Default::default() + }, + tls: None, + channel_binding: None, + }; + let mut client = Client::connect(&h, &options, at).await?; + client + .query("SELECT 42", at, |event| { + if let Event::Row { mut row, .. } = event { + println!( + "{:?}", + row.next().transpose().map_err(std::io::Error::other)? + ); + } + Ok(()) + }) + .await?; + Ok(()) +} +#[cfg(not(any(windows, all(target_arch = "wasm32", target_os = "unknown"))))] +fn main() -> std::io::Result<()> { + use std::{ + future::Future, + pin::Pin, + task::{Context, Poll, Waker}, + }; + use turnloop_io::turnloop::{Config, LocalExecutor, Timeout, backend::Platform}; + let mut executor = + LocalExecutor::::new(Config::default()).map_err(turnloop_io::error)?; + let h = executor.handle(); + let mut task = executor + .spawn_local(example(h)) + .map_err(turnloop_io::error)?; + while !task.is_finished() { + executor + .turn(Timeout::After(Duration::from_secs(30))) + .map_err(turnloop_io::error)?; + } + match Pin::new(&mut task).poll(&mut Context::from_waker(Waker::noop())) { + Poll::Ready(Ok(result)) => result, + _ => Err(std::io::Error::other("example task did not complete")), + } +} +#[cfg(any(windows, all(target_arch = "wasm32", target_os = "unknown")))] +fn main() { + eprintln!( + "Embed example(handle) with a host-provided TCP Backend; this target has no built-in TCP Platform provider." + ); +} diff --git a/protocols/turnloop-postgres/src/async_pool.rs b/protocols/turnloop-postgres/src/async_pool.rs new file mode 100644 index 0000000..146f9e2 --- /dev/null +++ b/protocols/turnloop-postgres/src/async_pool.rs @@ -0,0 +1,134 @@ +//! Executor ownership for the existing sans-I/O pool policy. +use super::{Client, ConnectOptions}; +use crate::pool::{ConnectionId, Event, Lease as CoreLease, Pool as Core}; +use std::{io, time::Duration}; +use turnloop_io::{ + Backend, ExecutorHandle, Instant, + pool::{self as shared, Action, Policy}, +}; +impl Policy for Core { + type Id = ConnectionId; + type Lease = CoreLease; + fn id(lease: CoreLease) -> ConnectionId { + lease.connection + } + fn checkout(&mut self, token: u64, now: Instant, at: Instant) -> io::Result<()> { + self.checkout( + token, + super::client::time(now), + Some(super::client::time(at)), + ) + .map_err(io::Error::other) + } + fn cancel(&mut self, token: u64, now: Instant) { + self.cancel_checkout(token, super::client::time(now)); + } + fn connected(&mut self, id: ConnectionId, now: Instant) -> io::Result<()> { + self.connected(id, super::client::time(now)) + .map_err(io::Error::other) + } + fn connect_failed(&mut self, id: ConnectionId, now: Instant) -> io::Result<()> { + self.connect_failed(id, super::client::time(now)) + .map_err(io::Error::other) + } + fn checkin(&mut self, lease: CoreLease, now: Instant, destroy: bool) -> io::Result<()> { + self.checkin(lease, super::client::time(now), destroy) + .map_err(io::Error::other) + } + fn closed(&mut self, id: ConnectionId, now: Instant) -> io::Result<()> { + self.closed(id, super::client::time(now)) + .map_err(io::Error::other) + } + fn event(&mut self) -> Option> { + loop { + match self.next_event()? { + Event::Connect(id) => return Some(Action::Connect(id)), + Event::Close(id) => return Some(Action::Close(id)), + Event::Removed(id) => return Some(Action::Remove(id)), + Event::Acquired(lease) => { + return Some(Action::Acquired { + token: lease.token, + lease, + }); + } + Event::CheckoutFailed { token, error } => { + return Some(Action::Failed { + token, + error: io::Error::other(error), + }); + } + Event::Ended => return Some(Action::Ended), + _ => {} + } + } + } + fn next_deadline(&self) -> Option { + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + { + self.next_timeout() + .map(|t| Instant::from_duration(t.as_duration())) + } + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] + { + self.next_timeout() + } + } + fn expire(&mut self, now: Instant) { + self.handle_timeout(super::client::time(now)); + } + fn end(&mut self) -> io::Result<()> { + self.end().map_err(io::Error::other) + } +} +struct Connector(ConnectOptions); +impl shared::Connector for Connector { + type Connection = Client; + fn connect( + &self, + executor: &ExecutorHandle, + at: Instant, + ) -> impl std::future::Future>> + 'static { + let executor = executor.clone(); + let options = self.0.clone(); + async move { Client::connect(&executor, &options, at).await } + } +} +pub type PooledConnection = shared::Lease>; +pub struct Pool { + inner: shared::Pool>, +} +impl Clone for Pool { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + } + } +} +impl Pool { + pub fn new( + executor: &ExecutorHandle, + options: ConnectOptions, + config: crate::pool::Config, + connect_timeout: Duration, + ) -> io::Result { + let capacity = config.max; + Ok(Self { + inner: shared::Pool::new( + executor, + Core::new(config).map_err(io::Error::other)?, + Connector(options), + connect_timeout, + capacity, + )?, + }) + } + pub async fn acquire(&self, at: Instant) -> io::Result> { + self.inner.acquire(at).await + } + pub fn total(&self) -> usize { + self.inner.total() + } + pub async fn end(&self) -> io::Result<()> { + self.inner.end().await + } +} diff --git a/protocols/turnloop-postgres/src/asynchronous.rs b/protocols/turnloop-postgres/src/asynchronous.rs index cb663dc..61af4c6 100644 --- a/protocols/turnloop-postgres/src/asynchronous.rs +++ b/protocols/turnloop-postgres/src/asynchronous.rs @@ -19,7 +19,7 @@ impl SansIo for crate::Connection { &mut self, mut receive: impl FnMut(Self::Event<'_>) -> io::Result<()>, ) -> io::Result { - if let Some(event) = self.next_event().map_err(io::Error::other)? { + if let Some(event) = self.next_event().map_err(io::Error::from)? { receive(event)?; Ok(true) } else { @@ -62,3 +62,11 @@ mod tests { ); } } + +#[path = "client.rs"] +mod client; +pub use client::*; + +#[path = "async_pool.rs"] +mod pool; +pub use pool::{Pool, PooledConnection}; diff --git a/protocols/turnloop-postgres/src/client.rs b/protocols/turnloop-postgres/src/client.rs new file mode 100644 index 0000000..8c00d1e --- /dev/null +++ b/protocols/turnloop-postgres/src/client.rs @@ -0,0 +1,392 @@ +//! Async PostgreSQL sessions. Borrowed row callbacks keep the steady path allocation-free. +use crate::asynchronous::AsyncConnection; +use crate::{ChannelBinding, Config, Event, ExtendedQuery, Outcome, ScramSha256}; +use std::{io, net::SocketAddr}; +use turnloop_io::{AsyncIo, Backend, ExecutorHandle, Instant, Stream, deadline}; +use turnloop_tls::asynchronous::{ClientTls, Transport}; + +#[derive(Clone)] +pub struct ConnectOptions { + pub address: SocketAddr, + pub protocol: Config, + pub tls: Option, + /// Override the tls-server-end-point digest. By default it is derived from + /// the verified TLS leaf; unsupported certificate algorithms use plain SCRAM. + pub channel_binding: Option>, +} + +pub struct Client> { + executor: ExecutorHandle, + driver: AsyncConnection>, + cancel: Option, +} +#[derive(Clone, Copy)] +pub struct CancelToken { + address: SocketAddr, + packet: [u8; 16], +} +impl CancelToken { + /// PostgreSQL cancellation uses a fresh connection, never the query stream. + pub async fn cancel( + &self, + executor: &ExecutorHandle, + at: Instant, + ) -> io::Result<()> { + deadline(executor, at, async { + let mut stream = executor + .connect(self.address, Default::default()) + .await + .map_err(turnloop_io::error)?; + turnloop_io::write_all(&mut stream, &self.packet).await?; + // The backend closes the cancellation connection after processing it. + let mut byte = [0]; + if turnloop_io::read(&mut stream, &mut byte).await? != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unexpected CancelRequest response", + )); + } + Ok(()) + }) + .await + } +} +pub(super) fn time(now: Instant) -> crate::Instant { + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + { + crate::Instant::from_duration(now.as_duration()) + } + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] + { + now + } +} +impl Client { + pub async fn connect( + executor: &ExecutorHandle, + options: &ConnectOptions, + at: Instant, + ) -> io::Result { + let stream = deadline(executor, at, async { + executor + .connect(options.address, Default::default()) + .await + .map_err(turnloop_io::error) + }) + .await?; + let mut client = Self::from_stream( + executor, + stream, + options.protocol.clone(), + options.tls.as_ref(), + options.channel_binding.as_deref(), + at, + ) + .await?; + client.cancel = client + .driver + .core() + .cancel_request() + .map(|packet| CancelToken { + address: options.address, + packet, + }); + Ok(client) + } +} +impl Client { + pub async fn from_stream( + executor: &ExecutorHandle, + stream: S, + config: Config, + tls: Option<&ClientTls>, + binding: Option<&[u8]>, + at: Instant, + ) -> io::Result { + let password = config.password.clone(); + let mut driver = AsyncConnection::new( + Transport::Plain(stream), + crate::Connection::new(config).map_err(io::Error::other)?, + ); + let mut derived_binding = None; + deadline(executor, at, async { + loop { + let mut upgrade = false; + let mut scram = None; + let mut ready = false; + driver + .next(executor, |event| { + match event { + Event::UpgradeTls => upgrade = true, + Event::ScramNeeded { plus } => scram = Some(plus), + Event::Connected => ready = true, + Event::Error { error, .. } => { + return Err(io::Error::other(error.message().to_owned())); + } + Event::Closed { reason } => return Err(reason.into()), + _ => {} + } + Ok(()) + }) + .await?; + if upgrade { + let tls = tls.ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "TLS configuration required") + })?; + driver.upgrade_stream()?.upgrade(tls, executor, at).await?; + if binding.is_none() { + derived_binding = driver + .stream() + .and_then(Transport::peer_certificates) + .and_then(|chain| chain.first()) + .and_then(|leaf| turnloop_tls::tls_server_end_point(leaf.as_ref())); + } + driver + .core_mut() + .tls_established_with_channel_binding( + binding.is_some() || derived_binding.is_some(), + ) + .map_err(io::Error::other)?; + } + if let Some(plus) = scram { + let channel = if plus { + ChannelBinding::tls_server_end_point( + binding + .or_else(|| derived_binding.as_ref().map(|digest| digest.as_ref())) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "SCRAM-PLUS requires certificate binding", + ) + })? + .to_vec(), + ) + } else { + ChannelBinding::unsupported() + }; + driver + .core_mut() + .start_scram(ScramSha256::new(&password, channel)) + .map_err(io::Error::other)?; + } + if ready { + return Ok(()); + } + } + }) + .await?; + Ok(Self { + executor: executor.clone(), + driver, + cancel: None, + }) + } + pub fn is_reusable(&self) -> bool { + self.driver.is_connected() + && self.driver.core().is_ready() + && self.driver.core().pending_count() == 0 + && self.driver.core().transaction_status() == crate::TransactionStatus::Idle + } + pub fn cancel_token(&self) -> Option { + self.cancel + } + pub fn close(&mut self) { + self.driver.abort(); + } + /// Each callback consumes a borrowed event before another read can occur. + pub async fn query( + &mut self, + sql: &str, + at: Instant, + receive: impl FnMut(Event<'_>) -> io::Result<()>, + ) -> io::Result { + self.driver + .core_mut() + .query(1, sql, Some(time(at))) + .map_err(io::Error::other)?; + self.complete(at, receive).await + } + /// Named queries use the core's prepared statement cache, including SQL/OID validation. + pub async fn execute( + &mut self, + query: ExtendedQuery<'_>, + at: Instant, + receive: impl FnMut(Event<'_>) -> io::Result<()>, + ) -> io::Result { + self.driver + .core_mut() + .execute(1, query, Some(time(at))) + .map_err(io::Error::other)?; + self.complete(at, receive).await + } + async fn complete( + &mut self, + at: Instant, + mut receive: impl FnMut(Event<'_>) -> io::Result<()>, + ) -> io::Result { + let mut exchange = self.driver.exchange(); + let outcome = deadline(&self.executor, at, async { + loop { + let mut done = None; + exchange + .next(&self.executor, |event| { + match &event { + Event::Completed { + outcome: Outcome::Aborted(reason), + .. + } => return Err(reason.clone().into()), + Event::Completed { outcome, .. } => done = Some(outcome.clone()), + Event::CopyIn { .. } => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "use copy_in for COPY FROM STDIN", + )); + } + Event::Closed { reason } => return Err(reason.clone().into()), + _ => {} + } + receive(event) + }) + .await?; + if let Some(outcome) = done { + return Ok(outcome); + } + } + }) + .await?; + exchange.commit(); + Ok(outcome) + } + /// COPY OUT chunks borrow the receive buffer and provide natural backpressure. + pub async fn copy_out( + &mut self, + sql: &str, + at: Instant, + mut chunk: impl FnMut(&[u8]) -> io::Result<()>, + ) -> io::Result { + self.query(sql, at, |event| { + if let Event::CopyData { data, .. } = event { + chunk(data)?; + } + Ok(()) + }) + .await + } + pub async fn copy_in(&mut self, sql: &str, at: Instant) -> io::Result> { + self.driver + .core_mut() + .query(1, sql, Some(time(at))) + .map_err(io::Error::other)?; + let mut exchange = self.driver.exchange(); + deadline(&self.executor, at, async { + loop { + let mut ready = false; + exchange + .next(&self.executor, |event| { + match event { + Event::CopyIn { .. } => ready = true, + Event::Error { error, .. } => { + return Err(io::Error::other(error.message().to_owned())); + } + Event::Completed { .. } | Event::Closed { .. } => { + return Err(io::Error::other("COPY input not accepted")); + } + _ => {} + } + Ok(()) + }) + .await?; + if ready { + return Ok(()); + } + } + }) + .await?; + exchange.commit(); + drop(exchange); + Ok(CopyIn { + client: self, + at, + finished: false, + }) + } + /// Await an unsolicited notification. Cancelling this read closes the session. + pub async fn notification(&mut self, at: Instant) -> io::Result { + deadline(&self.executor, at, async { + loop { + let mut notification = None; + self.driver + .next(&self.executor, |event| { + match event { + Event::Notification { + process_id, + channel, + payload, + } => { + notification = Some(Notification { + process_id, + channel: channel.into(), + payload: payload.into(), + }) + } + Event::Closed { reason } => return Err(reason.into()), + _ => {} + } + Ok(()) + }) + .await?; + if let Some(n) = notification { + return Ok(n); + } + } + }) + .await + } +} +#[derive(Debug, PartialEq, Eq)] +pub struct Notification { + pub process_id: i32, + pub channel: String, + pub payload: String, +} +pub struct CopyIn<'a, B: Backend, S: Stream> { + client: &'a mut Client, + at: Instant, + finished: bool, +} +impl CopyIn<'_, B, S> { + pub async fn write(&mut self, bytes: &[u8]) -> io::Result<()> { + self.client + .driver + .core_mut() + .copy_data(bytes) + .map_err(io::Error::other)?; + deadline(&self.client.executor, self.at, self.client.driver.flush()).await + } + pub async fn finish(mut self) -> io::Result { + self.client + .driver + .core_mut() + .copy_finish(None) + .map_err(io::Error::other)?; + let outcome = self.client.complete(self.at, |_| Ok(())).await?; + self.finished = true; + Ok(outcome) + } +} +impl Drop for CopyIn<'_, B, S> { + fn drop(&mut self) { + if !self.finished { + self.client.close(); + } + } +} + +impl turnloop_io::pool::Connection for Client { + fn reusable(&self) -> bool { + self.is_reusable() + } + fn handle(&self) -> Option { + self.driver.stream()?.get_ref().map(AsyncIo::handle) + } +} diff --git a/protocols/turnloop-postgres/src/lib.rs b/protocols/turnloop-postgres/src/lib.rs index 227657c..435e5ea 100644 --- a/protocols/turnloop-postgres/src/lib.rs +++ b/protocols/turnloop-postgres/src/lib.rs @@ -28,7 +28,7 @@ pub mod pool; pub mod types; mod wire; use wire::Cursor; -pub use wire::{Field, Fields, Row, ServerError}; +pub use wire::{ConnectionFailure, Field, Fields, Row, ServerError}; #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] mod host_time; @@ -39,13 +39,15 @@ pub use std::time::Instant; pub type Token = u64; pub type Result = std::result::Result; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum Error { Protocol(&'static str), State(&'static str), Limit, Timeout, Transport, + /// The server terminated the session; preserves SQLSTATE and every field. + ConnectionAborted(ConnectionFailure), Cancelled, } impl fmt::Display for Error { @@ -55,11 +57,22 @@ impl fmt::Display for Error { Self::Limit => f.write_str("protocol buffer limit exceeded"), Self::Timeout => f.write_str("Connection terminated due to timeout"), Self::Transport => f.write_str("Connection terminated unexpectedly"), + Self::ConnectionAborted(error) => write!(f, "Connection aborted: {error}"), Self::Cancelled => f.write_str("Connection terminated"), } } } impl std::error::Error for Error {} +impl From for std::io::Error { + fn from(error: Error) -> Self { + let kind = match error { + Error::ConnectionAborted(_) | Error::Transport => std::io::ErrorKind::ConnectionAborted, + Error::Timeout => std::io::ErrorKind::TimedOut, + _ => std::io::ErrorKind::Other, + }; + Self::new(kind, error) + } +} impl From for Error { fn from(_: std::io::Error) -> Self { Self::Protocol("invalid PostgreSQL message") @@ -108,9 +121,10 @@ pub enum TransactionStatus { InTransaction, Failed, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum Outcome { Success, + /// A statement failed and ReadyForQuery confirmed the session can continue. ServerError, Aborted(Error), } @@ -228,6 +242,8 @@ pub struct Connection { scram: Option, auth_ok: bool, tls: bool, + binding_available: bool, + scram_plus: bool, copy_in: bool, transaction: TransactionStatus, key: Option<(i32, i32)>, @@ -262,6 +278,8 @@ impl Connection { scram: None, auth_ok: false, tls: false, + binding_available: false, + scram_plus: false, copy_in: false, transaction: TransactionStatus::Idle, key: None, @@ -320,11 +338,23 @@ impl Connection { self.input.extend_from_slice(bytes); Ok(()) } + /// Acknowledge TLS without channel-binding data (plain SCRAM fallback). pub fn tls_established(&mut self) -> Result<()> { + self.tls_established_with_channel_binding(false) + } + /// Acknowledge verified TLS and whether the host can supply tls-server-end-point + /// data to `start_scram`. The core prefers PLUS only when data is available. + pub fn tls_established_with_channel_binding(&mut self, available: bool) -> Result<()> { if self.state != State::Tls || !self.output().is_empty() { return Err(Error::State("TLS transition not ready")); } + if self.config.channel_binding_required && !available { + return Err(Error::State( + "channel binding required but certificate binding is unavailable", + )); + } self.tls = true; + self.binding_available = available; self.startup() } pub fn start_scram(&mut self, scram: ScramSha256) -> Result<()> { @@ -592,12 +622,16 @@ impl Connection { .map(|p| p.token) .ok_or(Error::Protocol("unsolicited query response")) } + /// Pull the next borrowed event. A terminal server ErrorResponse returns + /// `Error::ConnectionAborted` immediately and makes the session unusable. + /// Continue pulling to drain one Aborted completion per pending token, then + /// Closed; a later transport abort cannot replace the server diagnostic. pub fn next_event(&mut self) -> Result>> { if self.state == State::Closing { if let Some(p) = self.pending.pop_front() { return Ok(Some(Event::Completed { token: p.token, - outcome: Outcome::Aborted(self.reason), + outcome: Outcome::Aborted(self.reason.clone()), transaction: self.transaction, })); } @@ -606,7 +640,7 @@ impl Connection { } self.state = State::Closed; return Ok(Some(Event::Closed { - reason: self.reason, + reason: self.reason.clone(), })); } if matches!(self.state, State::Closed | State::Tls | State::Scram(_)) { @@ -662,6 +696,11 @@ impl Connection { if self.scram.is_some() { return Err(Error::Protocol("SCRAM final verification missing")); } + if self.config.channel_binding_required && !self.scram_plus { + return Err(Error::Protocol( + "channel binding required but SCRAM-PLUS was not authenticated", + )); + } self.auth_ok = true; } 3 => { @@ -693,10 +732,16 @@ impl Connection { } } c.end()?; - let plus = self.tls && plus; - if (!plus && !plain) || (self.config.channel_binding_required && !plus) { + let plus = self.tls && self.binding_available && plus; + if self.config.channel_binding_required && !plus { + return Err(Error::Protocol( + "channel binding required but server did not offer SCRAM-SHA-256-PLUS", + )); + } + if !plus && !plain { return Err(Error::Protocol("unsupported SASL mechanisms")); } + self.scram_plus = plus; self.state = State::Scram(plus); return Ok(Some(Event::ScramNeeded { plus })); } @@ -814,9 +859,13 @@ impl Connection { ) { self.state = State::Closing; - self.reason = Error::Transport; + self.reason = Error::ConnectionAborted(ConnectionFailure::new(error)); self.output.clear(); self.output_at = 0; + self.copy_in = false; + // FATAL/PANIC needs no ReadyForQuery or EOF confirmation. + // Subsequent pulls still complete every queued token once. + return Err(self.reason.clone()); } return Ok(Some(Event::Error { token, error })); } diff --git a/protocols/turnloop-postgres/src/pool.rs b/protocols/turnloop-postgres/src/pool.rs index 3027678..d7b63a6 100644 --- a/protocols/turnloop-postgres/src/pool.rs +++ b/protocols/turnloop-postgres/src/pool.rs @@ -189,6 +189,21 @@ impl Pool { self.schedule(now); Ok(()) } + /// Remove a pending async checkout without retaining its waiter or connection. + pub fn cancel_checkout(&mut self, token: u64, now: Instant) { + if let Some(i) = self.waiting.iter().position(|r| r.token == token) { + self.waiting.remove(i); + } + for i in 0..self.slots.len() { + if matches!(self.slots[i].state, State::Connecting(r) if r.token == token) { + self.close(ConnectionId { + slot: i, + generation: self.slots[i].generation, + }); + } + } + self.schedule(now); + } fn schedule(&mut self, now: Instant) { if self.ending { return; diff --git a/protocols/turnloop-postgres/src/wire.rs b/protocols/turnloop-postgres/src/wire.rs index af2f28b..c67ab30 100644 --- a/protocols/turnloop-postgres/src/wire.rs +++ b/protocols/turnloop-postgres/src/wire.rs @@ -141,6 +141,30 @@ impl ExactSizeIterator for Row<'_> {} /// ErrorResponse and NoticeResponse retain every field, including future fields. #[derive(Clone, Copy, Debug)] pub struct ServerError<'a>(pub(crate) &'a [u8]); + +/// Owned terminal server diagnostic. Clones share one copy of all wire fields. +/// Only connection failure allocates; ordinary statement errors remain borrowed. +#[derive(Clone, PartialEq, Eq)] +pub struct ConnectionFailure(std::sync::Arc<[u8]>); +impl ConnectionFailure { + pub(crate) fn new(error: ServerError<'_>) -> Self { + Self(error.0.into()) + } + pub fn server_error(&self) -> ServerError<'_> { + ServerError(&self.0) + } +} +impl std::fmt::Display for ConnectionFailure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let error = self.server_error(); + write!(f, "{}: {}", error.code(), error.message()) + } +} +impl std::fmt::Debug for ConnectionFailure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_map().entries(self.server_error().fields()).finish() + } +} impl<'a> ServerError<'a> { pub(crate) fn parse(body: &'a [u8]) -> Result { let mut c = Cursor(body); diff --git a/protocols/turnloop-postgres/tests/allocations.rs b/protocols/turnloop-postgres/tests/allocations.rs index 5abc6e4..b062b49 100644 --- a/protocols/turnloop-postgres/tests/allocations.rs +++ b/protocols/turnloop-postgres/tests/allocations.rs @@ -102,3 +102,64 @@ fn warmed_query_row_and_named_execute_allocate_nothing() { }); assert_eq!(count, 0); } + +#[test] +fn terminal_diagnostic_allocates_once_and_pipeline_abort_clones_allocate_zero() { + assert_eq!( + allocation::allocations(|| { + std::hint::black_box(Box::new(42)); + }), + 1 + ); + let mut c = Connection::new(Config::default()).expect("core"); + c.consume_output(c.output().len()).expect("startup sent"); + c.receive(b"R\0\0\0\x08\0\0\0\0Z\0\0\0\x05I").expect("auth"); + assert!(matches!( + c.next_event().expect("connected"), + Some(Event::Connected) + )); + for token in 0..64 { + c.query(token, "SELECT 1", None).expect("pipeline"); + } + let body = b"SFATAL\0C57P01\0Mterminated by administrator\0\0"; + let packet = [ + b"E".as_slice(), + &((body.len() + 4) as u32).to_be_bytes(), + body, + ] + .concat(); + c.receive(&packet).expect("fatal response"); + let mut diagnostic = None; + assert_eq!( + allocation::allocations(|| { + diagnostic = Some(c.next_event().expect_err("fatal")); + }), + 1, + "one shared copy of the terminal fields" + ); + let diagnostic = diagnostic.expect("owned error"); + assert_eq!( + allocation::allocations(|| { + c.abort(Error::Transport); + for token in 0..64 { + match c.next_event().expect("completion") { + Some(Event::Completed { + token: actual, + outcome: Outcome::Aborted(error), + .. + }) => { + assert_eq!(actual, token); + assert_eq!(error, diagnostic); + } + event => panic!("missing pipeline abort: {event:?}"), + } + } + assert!( + matches!(c.next_event().expect("close"), Some(Event::Closed { reason }) if reason == diagnostic) + ); + assert!(c.next_event().expect("no duplicate").is_none()); + }), + 0 + ); + assert_eq!(c.pending_count(), 0); +} diff --git a/protocols/turnloop-postgres/tests/async_server.rs b/protocols/turnloop-postgres/tests/async_server.rs new file mode 100644 index 0000000..a84411f --- /dev/null +++ b/protocols/turnloop-postgres/tests/async_server.rs @@ -0,0 +1,327 @@ +#![cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] +use std::{ + future::Future, + pin::Pin, + task::{Context, Poll, Waker}, + time::Duration, +}; +use turnloop_io::{ + turnloop::{Config as LoopConfig, LocalExecutor, Timeout, backend::Platform}, + *, +}; +fn finish(task: &mut turnloop::JoinHandle) -> T { + match Pin::new(task).poll(&mut Context::from_waker(Waker::noop())) { + Poll::Ready(Ok(v)) => v, + _ => panic!("task must finish"), + } +} +fn drive(executor: &mut LocalExecutor, task: &mut turnloop::JoinHandle) -> T { + let at = executor.handle().now() + Duration::from_secs(90); + while !task.is_finished() { + assert!(executor.handle().now() < at, "test deadline"); + executor.turn(Timeout::Until(at)).expect("turn"); + } + finish(task) +} + +fn port(name: &str) -> u16 { + std::env::var(name) + .expect("required private fixture port; run scripts/test-servers.py") + .parse() + .expect("port") +} +fn tls(ca: Vec) -> turnloop_tls::asynchronous::ClientTls { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("wall time") + .as_secs(); + turnloop_tls::asynchronous::ClientTls { + config: turnloop_tls::ClientConfig::new( + turnloop_tls::ClientOptions { + ca: Some(vec![turnloop_tls::rustls::pki_types::CertificateDer::from( + ca, + )]), + alpn: vec![], + ..Default::default() + }, + now, + ) + .expect("TLS config"), + server_name: "localhost".try_into().expect("name"), + unix_seconds: now, + } +} +use turnloop_postgres::{ + Event, ExtendedQuery, Outcome, Parameter, SslMode, + asynchronous::{Client, ConnectOptions, Pool}, +}; +#[test] +#[ignore = "private PostgreSQL fixture; sandbox shmget is UNRUN"] +fn real_async_tls_queries_copy_cancel_pool_and_connection_kill() { + let ca = std::fs::read( + std::path::PathBuf::from(std::env::var_os("TURNLOOP_TEST_SQL_TOOLS").expect("SQL tools")) + .join("server.der"), + ) + .expect("CA"); + let options = ConnectOptions { + address: ([127, 0, 0, 1], port("TURNLOOP_TEST_POSTGRES_PORT")).into(), + protocol: turnloop_postgres::Config { + user: "tls_user".into(), + password: b"fixture-password".to_vec(), + ssl: SslMode::Require, + ..Default::default() + }, + tls: Some(tls(ca)), + channel_binding: None, + }; + let mut ex = LocalExecutor::::new(LoopConfig::default()).expect("executor"); + let h = ex.handle(); + let mut task = ex + .spawn_local(async move { + let at = h.now() + Duration::from_secs(30); + let mut c = Client::connect(&h, &options, at).await.expect("TLS auth"); + let mut observed = 0; + assert_eq!( + c.query("SELECT 42; SELECT 43", at, |e| { + if let Event::Row { .. } = e { + observed += 1; + } + Ok(()) + }) + .await + .expect("simple"), + Outcome::Success + ); + assert_eq!(observed, 2); + let q = ExtendedQuery { + name: "answer", + sql: "SELECT $1::int4", + oids: &[23], + params: &[Parameter { + value: Some(b"42"), + format: 0, + }], + result_formats: &[0], + }; + let mut prepared_rows = 0; + for _ in 0..2 { + assert_eq!( + c.execute(q, at, |e| { + if let Event::Row { mut row, .. } = e { + prepared_rows += 1; + assert_eq!( + row.next().expect("row").expect("value"), + Some(b"42".as_slice()) + ); + } + Ok(()) + }) + .await + .expect("prepared"), + Outcome::Success + ); + } + assert_eq!(prepared_rows, 2); + assert_eq!( + c.query("CREATE TEMP TABLE async_copy(v int)", at, |_| Ok(())) + .await + .expect("create"), + Outcome::Success + ); + let mut copy = c + .copy_in("COPY async_copy FROM STDIN", at) + .await + .expect("COPY IN"); + copy.write(b"1\n2\n").await.expect("chunk"); + assert_eq!(copy.finish().await.expect("finish"), Outcome::Success); + let mut copied = Vec::new(); + assert_eq!( + c.copy_out("COPY async_copy TO STDOUT", at, |b| { + copied.extend_from_slice(b); + Ok(()) + }) + .await + .expect("COPY OUT"), + Outcome::Success + ); + assert_eq!(copied, b"1\n2\n"); + c.query("LISTEN async_channel", at, |_| Ok(())) + .await + .expect("LISTEN"); + let mut other = Client::connect(&h, &options, at).await.expect("other"); + other + .query("NOTIFY async_channel, 'payload'", at, |_| Ok(())) + .await + .expect("NOTIFY"); + let n = c.notification(at).await.expect("notification"); + assert_eq!(n.channel, "async_channel"); + assert_eq!(n.payload, "payload"); + let cancel = c.cancel_token().expect("backend cancel key"); + let cancel_h = h.clone(); + let mut canceller = h + .spawn_local(async move { + cancel_h + .sleep(Duration::from_millis(20)) + .await + .expect("timer"); + cancel.cancel(&cancel_h, at).await.expect("CancelRequest"); + }) + .expect("cancel task"); + let mut cancelled = false; + assert_eq!( + c.query("SELECT pg_sleep(10)", at, |e| { + if let Event::Error { error, .. } = e { + assert_eq!(error.code(), "57014"); + cancelled = true; + } + Ok(()) + }) + .await + .expect("cancel result"), + Outcome::ServerError + ); + assert!(cancelled); + (&mut canceller).await.expect("cancel finished"); + assert_eq!( + c.query("SELECT 1", at, |_| Ok(())).await.expect("reuse"), + Outcome::Success + ); + let mut pid = String::new(); + other + .query("SELECT pg_backend_pid()", at, |e| { + if let Event::Row { mut row, .. } = e { + pid = std::str::from_utf8( + row.next().expect("row").expect("value").expect("pid"), + ) + .expect("UTF-8") + .to_owned(); + } + Ok(()) + }) + .await + .expect("victim pid"); + assert!(pid.parse::().expect("backend PID") > 0); + let mut killed = false; + c.query(&format!("SELECT pg_terminate_backend({pid})"), at, |e| { + if let Event::Row { mut row, .. } = e { + assert_eq!( + row.next().expect("row").expect("value"), + Some(b"t".as_slice()) + ); + killed = true; + } + Ok(()) + }) + .await + .expect("terminate backend"); + assert!(killed); + let failure = other + .query("SELECT 1", at, |_| Ok(())) + .await + .expect_err("terminated session"); + assert_eq!(failure.kind(), std::io::ErrorKind::ConnectionAborted); + let Some(turnloop_postgres::Error::ConnectionAborted(diagnostic)) = + failure.get_ref().and_then(|e| e.downcast_ref()) + else { + panic!("missing server diagnostic: {failure:?}"); + }; + assert_eq!(diagnostic.server_error().code(), "57P01"); + assert_eq!( + diagnostic.server_error().message(), + "terminating connection due to administrator command" + ); + assert!(!other.is_reusable()); + let pool = Pool::new( + &h, + options, + turnloop_postgres::pool::Config { + max: 1, + max_idle: 1, + max_uses: Some(2), + ..Default::default() + }, + Duration::from_secs(5), + ) + .expect("pool"); + for _ in 0..3 { + let mut c = pool.acquire(at).await.expect("checkout"); + assert_eq!( + c.query("SELECT 42", at, |_| Ok(())) + .await + .expect("pooled query"), + Outcome::Success + ); + } + pool.end().await.expect("end"); + assert_eq!( + c.query( + "SELECT pg_sleep(10)", + h.now() + Duration::from_millis(2), + |_| Ok(()) + ) + .await + .expect_err("cancel future") + .kind(), + std::io::ErrorKind::TimedOut + ); + assert!(!c.is_reusable()); + }) + .expect("spawn"); + drive(&mut ex, &mut task); +} + +#[test] +#[ignore = "private PostgreSQL fixture; sandbox shmget is UNRUN"] +fn real_async_required_channel_binding_authenticates_over_ssl() { + let ca = std::fs::read( + std::path::PathBuf::from(std::env::var_os("TURNLOOP_TEST_SQL_TOOLS").expect("SQL tools")) + .join("server.der"), + ) + .expect("CA"); + let options = ConnectOptions { + address: ([127, 0, 0, 1], port("TURNLOOP_TEST_POSTGRES_PORT")).into(), + protocol: turnloop_postgres::Config { + user: "tls_user".into(), + password: b"fixture-password".to_vec(), + ssl: SslMode::Require, + channel_binding_required: true, + ..Default::default() + }, + tls: Some(tls(ca)), + channel_binding: None, + }; + let mut ex = LocalExecutor::::new(LoopConfig::default()).expect("executor"); + let h = ex.handle(); + let mut task = ex + .spawn_local(async move { + let at = h.now() + Duration::from_secs(10); + // The core rejects AuthenticationOk unless SCRAM-PLUS verified. No + // override: this must derive binding from the actual server leaf. + let mut c = Client::connect(&h, &options, at) + .await + .expect("required SCRAM-PLUS"); + let mut rows = 0; + assert_eq!( + c.query( + "SELECT ssl FROM pg_stat_ssl WHERE pid=pg_backend_pid()", + at, + |event| { + if let Event::Row { mut row, .. } = event { + assert_eq!( + row.next().expect("SSL row").expect("SSL value"), + Some(b"t".as_slice()) + ); + rows += 1; + } + Ok(()) + } + ) + .await + .expect("SSL query"), + Outcome::Success + ); + assert_eq!(rows, 1); + }) + .expect("spawn"); + drive(&mut ex, &mut task); +} diff --git a/protocols/turnloop-postgres/tests/asynchronous.rs b/protocols/turnloop-postgres/tests/asynchronous.rs new file mode 100644 index 0000000..db1e509 --- /dev/null +++ b/protocols/turnloop-postgres/tests/asynchronous.rs @@ -0,0 +1,787 @@ +#![cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] +#[path = "../../../crates/turnloop-io/tests/support/count.rs"] +mod count; +use std::{ + future::Future, + pin::Pin, + task::{Context, Poll, Waker}, + time::Duration, +}; +use turnloop_io::{ + turnloop::{Config as LoopConfig, LocalExecutor, Timeout, backend::Platform}, + *, +}; +fn finish(task: &mut turnloop::JoinHandle) -> T { + match Pin::new(task).poll(&mut Context::from_waker(Waker::noop())) { + Poll::Ready(Ok(v)) => v, + _ => panic!("task must finish"), + } +} +fn drive(executor: &mut LocalExecutor, task: &mut turnloop::JoinHandle) -> T { + let at = executor.handle().now() + Duration::from_secs(90); + while !task.is_finished() { + assert!(executor.handle().now() < at, "test deadline"); + executor.turn(Timeout::Until(at)).expect("turn"); + } + finish(task) +} +async fn exact(s: &mut S, mut bytes: &mut [u8]) { + while !bytes.is_empty() { + let n = read(s, bytes).await.expect("read"); + assert!(n > 0, "unexpected EOF"); + bytes = &mut bytes[n..]; + } +} +use turnloop_postgres::{ + Event, Outcome, + asynchronous::{Client, ConnectOptions, Pool}, +}; +const AUTH: &[u8] = b"R\0\0\0\x08\0\0\0\0Z\0\0\0\x05I"; +const STATEMENT_ERROR: &[u8] = b"E\0\0\0\x1fSERROR\0C42601\0Mbad syntax\0\0Z\0\0\0\x05I"; +const RESULT: &[u8] = b"D\0\0\0\x0c\0\x01\0\0\0\x0242C\0\0\0\x0dSELECT 1\0Z\0\0\0\x05I"; +async fn startup_packet(s: &mut S) { + let mut length = [0; 4]; + exact(s, &mut length).await; + let n = u32::from_be_bytes(length) as usize; + assert!((8..1024).contains(&n)); + let mut body = [0; 1024]; + exact(s, &mut body[..n - 4]).await; + assert_eq!(&body[..4], &[0, 3, 0, 0]); +} +async fn startup(s: &mut S) { + startup_packet(s).await; + write_all(s, AUTH).await.expect("auth"); +} +async fn query(s: &mut S) -> Vec { + let mut header = [0; 5]; + exact(s, &mut header).await; + assert_eq!(header[0], b'Q'); + let n = u32::from_be_bytes(header[1..].try_into().expect("length")) as usize; + let mut b = vec![0; n - 4]; + exact(s, &mut b).await; + b +} +#[test] +fn simple_query_timeout_and_pool_reuse() { + let mut ex = LocalExecutor::::new(LoopConfig::default()).expect("executor"); + let h = ex.handle(); + let server_h = h.clone(); + let listener = Listener::bind(&h, "127.0.0.1:0".parse().expect("address")).expect("listen"); + let address = listener.local_addr().expect("address"); + let mut server = ex + .spawn_local(async move { + let mut s = listener.accept().await.expect("accept"); + startup(&mut s).await; + for _ in 0..2 { + assert_eq!(query(&mut s).await, b"SELECT 42\0"); + write_all(&mut s, RESULT).await.expect("result"); + } + assert_eq!(query(&mut s).await, b"SELECT stall\0"); + let mut b = [0]; + assert_eq!(read(&mut s, &mut b).await.expect("cancel EOF"), 0); + let mut next = listener.accept().await.expect("replacement"); + startup(&mut next).await; + assert_eq!(query(&mut next).await, b"SELECT 42\0"); + write_all(&mut next, RESULT).await.expect("result"); + let mut b = [0]; + assert_eq!(read(&mut next, &mut b).await.expect("end EOF"), 0); + drop(server_h); + 4 + }) + .expect("spawn"); + let mut client = ex + .spawn_local(async move { + let at = h.now() + Duration::from_secs(5); + let options = ConnectOptions { + address, + protocol: Default::default(), + tls: None, + channel_binding: None, + }; + let pool = Pool::new( + &h, + options, + turnloop_postgres::pool::Config { + max: 1, + max_idle: 1, + ..Default::default() + }, + Duration::from_secs(2), + ) + .expect("pool"); + let mut rows = 0; + for _ in 0..2 { + let mut c = pool.acquire(at).await.expect("checkout"); + assert_eq!( + c.query("SELECT 42", at, |e| { + if let Event::Row { mut row, .. } = e { + assert_eq!( + row.next().expect("row").expect("value"), + Some(b"42".as_slice()) + ); + rows += 1; + } + Ok(()) + }) + .await + .expect("query"), + Outcome::Success + ); + } + let mut c = pool.acquire(at).await.expect("checkout"); + assert_eq!( + c.query("SELECT stall", h.now() + Duration::from_millis(2), |_| Ok( + () + )) + .await + .expect_err("timeout") + .kind(), + std::io::ErrorKind::TimedOut + ); + assert!(!c.is_reusable()); + drop(c); + let mut c = pool.acquire(at).await.expect("replacement"); + assert_eq!( + c.query("SELECT 42", at, |_| Ok(())).await.expect("query"), + Outcome::Success + ); + drop(c); + pool.end().await.expect("end"); + assert_eq!(rows, 2); + rows + }) + .expect("spawn"); + assert_eq!(drive(&mut ex, &mut client), 2); + assert_eq!(drive(&mut ex, &mut server), 4); +} +#[test] +fn copy_stream_drop_closes_session() { + let mut ex = LocalExecutor::::new(LoopConfig::default()).expect("executor"); + let h = ex.handle(); + let listener = Listener::bind(&h, "127.0.0.1:0".parse().expect("address")).expect("listen"); + let address = listener.local_addr().expect("address"); + let mut server = ex + .spawn_local(async move { + let mut s = listener.accept().await.expect("accept"); + startup(&mut s).await; + assert_eq!(query(&mut s).await, b"COPY t FROM STDIN\0"); + write_all(&mut s, b"G\0\0\0\x07\0\0\0") + .await + .expect("copy ready"); + let mut b = [0]; + assert_eq!(read(&mut s, &mut b).await.expect("EOF"), 0); + }) + .expect("spawn"); + let mut client = ex + .spawn_local(async move { + let at = h.now() + Duration::from_secs(3); + let mut c = Client::connect( + &h, + &ConnectOptions { + address, + protocol: Default::default(), + tls: None, + channel_binding: None, + }, + at, + ) + .await + .expect("connect"); + drop(c.copy_in("COPY t FROM STDIN", at).await.expect("COPY")); + assert!(!c.is_reusable()); + }) + .expect("spawn"); + drive(&mut ex, &mut client); + drive(&mut ex, &mut server); +} + +#[test] +fn warmed_async_pool_queries_allocate_zero_and_idle_waits() { + let mut ex = LocalExecutor::::new(LoopConfig::default()).expect("executor"); + let h = ex.handle(); + let listener = Listener::bind(&h, "127.0.0.1:0".parse().expect("address")).expect("listen"); + let address = listener.local_addr().expect("address"); + let mut server = ex + .spawn_local(async move { + let mut s = listener.accept().await.expect("accept"); + startup(&mut s).await; + for _ in 0..1001 { + assert_eq!(query(&mut s).await, b"SELECT 42\0"); + write_all(&mut s, RESULT).await.expect("reply"); + assert_eq!(query(&mut s).await, b"bad\0"); + write_all(&mut s, STATEMENT_ERROR) + .await + .expect("statement error"); + } + let mut b = [0]; + assert_eq!(read(&mut s, &mut b).await.expect("EOF"), 0); + 2002 + }) + .expect("server"); + let idle = std::rc::Rc::new(std::cell::Cell::new(false)); + let client_idle = idle.clone(); + let mut client = ex + .spawn_local(async move { + count::prove_counter().await; + let at = h.now() + Duration::from_secs(30); + let pool = Pool::new( + &h, + ConnectOptions { + address, + protocol: Default::default(), + tls: None, + channel_binding: None, + }, + turnloop_postgres::pool::Config { + max: 1, + max_idle: 1, + idle_timeout: Some(Duration::from_secs(60)), + ..Default::default() + }, + Duration::from_secs(5), + ) + .expect("pool"); + let held = pool.acquire(at).await.expect("hold only slot"); + let blocked = pool.acquire(h.now() + Duration::from_millis(10)).await; + assert!( + matches!(blocked, Err(e) if e.kind() == std::io::ErrorKind::TimedOut), + "queued acquire deadline must run" + ); + drop(held); + for i in 0..1001 { + let (rows, n) = count::measure(async { + let mut c = pool.acquire(at).await.expect("acquire"); + let mut rows = 0; + assert_eq!( + c.query("SELECT 42", at, |e| { + if let Event::Row { mut row, .. } = e { + assert_eq!( + row.next().expect("row").expect("value"), + Some(b"42".as_slice()) + ); + rows += 1; + } + Ok(()) + }) + .await + .expect("query"), + Outcome::Success + ); + let mut errors = 0; + assert_eq!( + c.query("bad", at, |event| { + if let Event::Error { error, .. } = event { + assert_eq!(error.code(), "42601"); + errors += 1; + } + Ok(()) + }) + .await + .expect("statement failure"), + Outcome::ServerError + ); + assert_eq!(errors, 1); + assert!(c.is_reusable(), "ReadyForQuery permits reuse after ERROR"); + rows + }) + .await; + assert_eq!(rows, 1); + if i > 0 { + assert_eq!(n, 0, "query + checkout/release must allocate zero"); + } + } + client_idle.set(true); + h.sleep(Duration::from_millis(60)) + .await + .expect("idle timer"); + pool.end().await.expect("end"); + }) + .expect("client"); + let end = ex.handle().now() + Duration::from_secs(30); + while !idle.get() { + assert!(ex.handle().now() < end); + ex.turn(Timeout::Until(end)).expect("turn"); + } + // Drain finite completions; then the pooled idle socket has no readiness + // subscription, and a turn must actually wait for the timer. + let mut waited = false; + for _ in 0..8 { + let before = ex.handle().now(); + let info = ex.turn(Timeout::Until(end)).expect("idle turn"); + if ex.handle().now().duration_since(before) >= Duration::from_millis(10) { + assert_eq!(info.os_waits, 1); + waited = true; + break; + } + } + assert!(waited, "idle pooled connection spun instead of parking"); + drive(&mut ex, &mut client); + assert_eq!(drive(&mut ex, &mut server), 2002); +} + +#[path = "support/scram.rs"] +mod scram; + +async fn sasl_message(s: &mut S) -> Vec { + let mut header = [0; 5]; + exact(s, &mut header).await; + assert_eq!(header[0], b'p'); + let len = u32::from_be_bytes(header[1..].try_into().expect("length")) as usize; + assert!((4..4096).contains(&len)); + let mut body = vec![0; len - 4]; + exact(s, &mut body).await; + body +} +async fn sasl_auth(s: &mut S, code: u32, body: &[u8]) { + let packet = [ + b"R".as_slice(), + &((body.len() + 8) as u32).to_be_bytes(), + &code.to_be_bytes(), + body, + ] + .concat(); + write_all(s, &packet).await.expect("SCRAM challenge"); +} + +#[derive(Clone, Copy, Debug)] +enum BindingCase { + Derived, + Override, + Unsupported, + RequiredUnsupported, + Required, +} + +struct DropObserved { + stream: S, + drops: std::rc::Rc>, +} +impl Drop for DropObserved { + fn drop(&mut self) { + self.drops.set(self.drops.get() + 1); + } +} +impl AsyncRead for DropObserved { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + bytes: &mut [u8], + ) -> Poll> { + Pin::new(&mut self.get_mut().stream).poll_read(cx, bytes) + } +} +impl AsyncWrite for DropObserved { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + bytes: &[u8], + ) -> Poll> { + Pin::new(&mut self.get_mut().stream).poll_write(cx, bytes) + } + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().stream).poll_flush(cx) + } + fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().stream).poll_close(cx) + } +} + +async fn scram_server(s: &mut S, binding: Option<&[u8]>) { + use base64::Engine; + startup_packet(s).await; + sasl_auth(s, 10, b"SCRAM-SHA-256-PLUS\0SCRAM-SHA-256\0\0").await; + let initial = sasl_message(s).await; + let end = initial.iter().position(|b| *b == 0).expect("mechanism NUL"); + assert_eq!( + &initial[..end], + if binding.is_some() { + b"SCRAM-SHA-256-PLUS".as_slice() + } else { + b"SCRAM-SHA-256" + } + ); + let len = + u32::from_be_bytes(initial[end + 1..end + 5].try_into().expect("SASL length")) as usize; + let first = std::str::from_utf8(&initial[end + 5..]).expect("client first"); + assert_eq!(len, first.len()); + let gs2 = if binding.is_some() { + "p=tls-server-end-point,," + } else { + "n,," + }; + let bare = first.strip_prefix(gs2).expect("exact GS2 flag"); + let nonce = bare.strip_prefix("n=,r=").expect("client nonce"); + assert!(!nonce.is_empty()); + let server_first = format!("r={nonce}server,s=c2FsdA==,i=4096"); + sasl_auth(s, 11, server_first.as_bytes()).await; + let final_bytes = sasl_message(s).await; + let final_message = std::str::from_utf8(&final_bytes).expect("client final"); + let (without_proof, proof) = final_message.split_once(",p=").expect("proof"); + let (cbind, final_nonce) = without_proof.split_once(",r=").expect("cbind and nonce"); + assert_eq!(final_nonce, format!("{nonce}server")); + let decoded = base64::engine::general_purpose::STANDARD + .decode(cbind.strip_prefix("c=").expect("cbind")) + .expect("base64"); + let mut expected = gs2.as_bytes().to_vec(); + expected.extend_from_slice(binding.unwrap_or_default()); + assert_eq!( + decoded, expected, + "cbind must contain exact certificate digest/override" + ); + let auth = format!("{bare},{server_first},{without_proof}"); + let salted = scram::hex_salted_password(); + let client_key = scram::hmac(&salted, b"Client Key"); + let signature = scram::hmac(&scram::sha256(&client_key), auth.as_bytes()); + let expected_proof: Vec<_> = client_key + .iter() + .zip(signature) + .map(|(k, s)| k ^ s) + .collect(); + assert_eq!( + proof, + scram::base64(&expected_proof), + "authenticated client proof" + ); + let server_signature = scram::hmac(&scram::hmac(&salted, b"Server Key"), auth.as_bytes()); + sasl_auth( + s, + 12, + format!("v={}", scram::base64(&server_signature)).as_bytes(), + ) + .await; + write_all(s, AUTH).await.expect("authenticated ready"); + assert_eq!(query(s).await, b"SELECT 42\0"); + write_all(s, RESULT).await.expect("result"); +} + +#[test] +fn tls_scram_uses_verified_leaf_override_or_unsupported_and_enforces_required() { + use turnloop_tls::{ + ClientConfig, ClientOptions, ServerConfig, TlsStream, asynchronous::ClientTls, + rustls::pki_types::PrivatePkcs8KeyDer, + }; + const NOW: u64 = 1_789_344_000; + let mut ran = 0; + for case in [ + BindingCase::Derived, + BindingCase::Override, + BindingCase::Unsupported, + BindingCase::RequiredUnsupported, + BindingCase::Required, + ] { + let unsupported = matches!( + case, + BindingCase::Unsupported | BindingCase::RequiredUnsupported + ); + let key = rcgen::KeyPair::generate_for(if unsupported { + &rcgen::PKCS_ED25519 + } else { + &rcgen::PKCS_ECDSA_P256_SHA256 + }) + .expect("key"); + let cert = rcgen::CertificateParams::new(vec!["localhost".into()]) + .expect("params") + .self_signed(&key) + .expect("certificate"); + let der = cert.der().clone(); + let expected_digest = scram::sha256(der.as_ref()); + let binding = match case { + BindingCase::Override => Some(vec![0xa5; 48]), + BindingCase::Unsupported | BindingCase::RequiredUnsupported => None, + _ => Some(expected_digest.to_vec()), + }; + let server_config = ServerConfig::new( + vec![der.clone()], + PrivatePkcs8KeyDer::from(key.serialize_der()).into(), + vec![], + NOW, + ) + .expect("server config"); + let client_tls = ClientTls { + config: ClientConfig::new( + ClientOptions { + ca: Some(vec![der.clone()]), + ..Default::default() + }, + NOW, + ) + .expect("client config"), + server_name: "localhost".try_into().expect("name"), + unix_seconds: NOW, + }; + let mut ex = LocalExecutor::::new(LoopConfig::default()).expect("executor"); + let h = ex.handle(); + let server_h = h.clone(); + let listener = Listener::bind(&h, "127.0.0.1:0".parse().expect("address")).expect("listen"); + let address = listener.local_addr().expect("address"); + let drops = std::rc::Rc::new(std::cell::Cell::new(0)); + let client_drops = drops.clone(); + let mut server = ex.spawn_local(async move { + let at = server_h.now() + Duration::from_secs(10); + let mut socket = listener.accept().await.expect("accept"); + let mut request = [0; 8]; + exact(&mut socket, &mut request).await; + assert_eq!(request, [0, 0, 0, 8, 4, 210, 22, 47]); + write_all(&mut socket, b"S").await.expect("SSL accepted"); + let mut tls = TlsStream::accept(socket, &server_config, &server_h, at, NOW).await.expect("verified TLS"); + assert!(tls.peer_certificates().is_none(), "no client certificate was requested"); + if matches!(case, BindingCase::RequiredUnsupported) { + let result = read(&mut tls, &mut [0; 1]).await; + // The rejected client's drop can surface as EOF/reset or WASI + // last-operation-failed (which has no portable OS error kind). + // Require zero startup bytes here; below, independently require + // the exact binding error and one actual owned-stream drop. + assert!(matches!(result, Ok(0) | Err(_)), "startup data after required binding failure: {result:?}"); + } else { + scram_server(&mut tls, binding.as_deref()).await; + // Flush the result, then keep the connection alive until client drop. + let result = read(&mut tls, &mut [0; 1]).await; + assert!(matches!(result, Ok(0)) || matches!(result, Err(ref e) if matches!(e.kind(), std::io::ErrorKind::UnexpectedEof | std::io::ErrorKind::ConnectionReset)), "client closes: {result:?}"); + } + 1 + }).expect("server"); + let mut client = ex + .spawn_local(async move { + count::prove_counter().await; + let (digests, allocations) = count::measure(async { + for _ in 0..1000 { + let digest = turnloop_tls::tls_server_end_point(der.as_ref()); + if unsupported { + assert!(digest.is_none()); + } else { + assert_eq!(digest.expect("binding").as_ref(), expected_digest); + } + } + 1000 + }) + .await; + assert_eq!(digests, 1000); + assert_eq!( + allocations, 0, + "certificate binding allocates no scratch storage" + ); + let at = h.now() + Duration::from_secs(10); + let options = ConnectOptions { + address, + protocol: turnloop_postgres::Config { + password: b"secret".to_vec(), + ssl: turnloop_postgres::SslMode::Require, + channel_binding_required: matches!( + case, + BindingCase::Required | BindingCase::RequiredUnsupported + ), + ..Default::default() + }, + tls: Some(client_tls), + channel_binding: matches!(case, BindingCase::Override).then(|| vec![0xa5; 48]), + }; + if matches!(case, BindingCase::RequiredUnsupported) { + let stream = h + .connect(address, Default::default()) + .await + .expect("connect"); + let result = Client::from_stream( + &h, + DropObserved { + stream, + drops: client_drops.clone(), + }, + options.protocol, + options.tls.as_ref(), + None, + at, + ) + .await; + let error = result.err().expect("required binding must fail"); + assert_eq!( + client_drops.get(), + 1, + "failed connection must drop its stream once" + ); + assert!( + error.to_string().contains( + "channel binding required but certificate binding is unavailable" + ), + "{error}" + ); + } else { + let mut c = Client::connect(&h, &options, at) + .await + .expect("TLS SCRAM login"); + let mut rows = 0; + assert_eq!( + c.query("SELECT 42", at, |event| { + if let Event::Row { mut row, .. } = event { + assert_eq!( + row.next().expect("row").expect("value"), + Some(b"42".as_slice()) + ); + rows += 1; + } + Ok(()) + }) + .await + .expect("query"), + Outcome::Success + ); + assert_eq!(rows, 1); + } + }) + .expect("client"); + drive(&mut ex, &mut client); + ran += drive(&mut ex, &mut server); + assert_eq!( + drops.get(), + usize::from(matches!(case, BindingCase::RequiredUnsupported)) + ); + } + assert_eq!(ran, 5, "all TLS SCRAM cases must run"); +} + +#[derive(Clone, Copy, Debug)] +enum FatalDuring { + Startup, + Query, + Execute, + CopyIn, + CopyOut, + Notification, +} + +#[test] +fn fatal_then_eof_is_a_connection_error_for_every_async_reader() { + let cases = [ + FatalDuring::Startup, + FatalDuring::Query, + FatalDuring::Execute, + FatalDuring::CopyIn, + FatalDuring::CopyOut, + FatalDuring::Notification, + ]; + let mut ran = 0; + for during in cases { + for severity in ["FATAL", "PANIC"] { + let mut ex = LocalExecutor::::new(LoopConfig::default()).expect("executor"); + let h = ex.handle(); + let listener = + Listener::bind(&h, "127.0.0.1:0".parse().expect("address")).expect("listen"); + let address = listener.local_addr().expect("address"); + let drops = std::rc::Rc::new(std::cell::Cell::new(0)); + let client_drops = drops.clone(); + let mut server = ex.spawn_local(async move { + let mut s = listener.accept().await.expect("accept"); + startup_packet(&mut s).await; + if !matches!(during, FatalDuring::Startup) { + write_all(&mut s, AUTH).await.expect("auth"); + match during { + FatalDuring::Execute => { + let mut tags = Vec::new(); + loop { + let mut header = [0; 5]; + exact(&mut s, &mut header).await; + tags.push(header[0]); + let n = u32::from_be_bytes(header[1..].try_into().expect("length")) as usize; + assert!((4..1024).contains(&n)); + exact(&mut s, &mut vec![0; n - 4]).await; + if header[0] == b'S' { break; } + } + assert_eq!(tags, b"PBDES"); + } + FatalDuring::Notification => {} + _ => { assert_eq!(query(&mut s).await, b"SELECT 1\0"); } + } + } + let body = format!("SERREUR\0V{severity}\0C57P01\0Mterminated by administrator\0Dretained detail\0\0"); + let packet = [b"E".as_slice(), &((body.len() + 4) as u32).to_be_bytes(), body.as_bytes()].concat(); + write_all(&mut s, &packet).await.expect("fatal response"); + close(&mut s).await.expect("server EOF"); + 1 + }).expect("server"); + let mut client = ex + .spawn_local(async move { + let at = h.now() + Duration::from_secs(5); + let stream = h + .connect(address, Default::default()) + .await + .expect("connect"); + let connected = Client::from_stream( + &h, + DropObserved { + stream, + drops: client_drops, + }, + Default::default(), + None, + None, + at, + ) + .await; + let error = if matches!(during, FatalDuring::Startup) { + match connected { + Err(e) => e, + Ok(_) => panic!("fatal startup accepted"), + } + } else { + let mut c = connected.expect("authenticated"); + let result = match during { + FatalDuring::Query => { + c.query("SELECT 1", at, |_| Ok(())).await.map(|_| ()) + } + FatalDuring::Execute => c + .execute( + turnloop_postgres::ExtendedQuery { + name: "", + sql: "SELECT 1", + oids: &[], + params: &[], + result_formats: &[], + }, + at, + |_| Ok(()), + ) + .await + .map(|_| ()), + FatalDuring::CopyIn => c.copy_in("SELECT 1", at).await.map(drop), + FatalDuring::CopyOut => { + c.copy_out("SELECT 1", at, |_| Ok(())).await.map(|_| ()) + } + FatalDuring::Notification => c.notification(at).await.map(|_| ()), + FatalDuring::Startup => unreachable!(), + }; + let error = result.expect_err("fatal must not be an Outcome"); + assert!(!c.is_reusable()); + assert!(c.query("SELECT 1", at, |_| Ok(())).await.is_err()); + error + }; + assert_eq!( + error.kind(), + std::io::ErrorKind::ConnectionAborted, + "{during:?}" + ); + let Some(turnloop_postgres::Error::ConnectionAborted(failure)) = + error.get_ref().and_then(|e| e.downcast_ref()) + else { + panic!("missing typed diagnostic: {error:?}"); + }; + assert_eq!(failure.server_error().code(), "57P01"); + assert_eq!( + failure.server_error().message(), + "terminated by administrator" + ); + assert_eq!(failure.server_error().get(b'V'), Some(severity)); + assert_eq!(failure.server_error().detail(), Some("retained detail")); + assert!( + error + .to_string() + .contains("57P01: terminated by administrator") + ); + }) + .expect("client"); + drive(&mut ex, &mut client); + assert_eq!(drive(&mut ex, &mut server), 1); + assert_eq!(drops.get(), 1, "fatal reader must drop its transport once"); + ran += 1; + } + } + assert_eq!(ran, 12); +} diff --git a/protocols/turnloop-postgres/tests/protocol.rs b/protocols/turnloop-postgres/tests/protocol.rs index 2b02f2a..c788aea 100644 --- a/protocols/turnloop-postgres/tests/protocol.rs +++ b/protocols/turnloop-postgres/tests/protocol.rs @@ -301,44 +301,9 @@ fn invalid_input_is_bounded_and_commands_are_atomic() { assert!(c.next_event().is_err()); } -fn sha256(b: &[u8]) -> [u8; 32] { - use sha2::Digest; - sha2::Sha256::digest(b).into() -} - -fn hmac(key: &[u8], message: &[u8]) -> [u8; 32] { - let mut inner = vec![0x36; 64]; - let mut outer = vec![0x5c; 64]; - for (i, b) in key.iter().enumerate() { - inner[i] ^= *b; - outer[i] ^= *b; - } - inner.extend_from_slice(message); - outer.extend_from_slice(&sha256(&inner)); - sha256(&outer) -} -fn base64(bytes: &[u8]) -> String { - let alphabet = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let mut s = String::new(); - for chunk in bytes.chunks(3) { - let a = chunk[0] as usize; - let b = chunk.get(1).copied().unwrap_or(0) as usize; - let c = chunk.get(2).copied().unwrap_or(0) as usize; - s.push(alphabet[a >> 2] as char); - s.push(alphabet[((a & 3) << 4) | (b >> 4)] as char); - s.push(if chunk.len() > 1 { - alphabet[((b & 15) << 2) | (c >> 6)] as char - } else { - '=' - }); - s.push(if chunk.len() > 2 { - alphabet[c & 63] as char - } else { - '=' - }); - } - s -} +#[path = "support/scram.rs"] +mod scram; +use scram::{base64, hex_salted_password, hmac}; #[test] fn scram_and_plus_verify_server_signature_and_reject_bad_verifier() { // PBKDF2-HMAC-SHA256('secret','salt',4096), independently generated with Python hashlib. @@ -361,7 +326,8 @@ fn scram_and_plus_verify_server_signature_and_reject_bad_verifier() { c.next_event().expect("fixture operation must succeed"), Some(Event::UpgradeTls) )); - c.tls_established().expect("fixture operation must succeed"); + c.tls_established_with_channel_binding(true) + .expect("fixture operation must succeed"); flush(&mut c); } let mut mechanisms = 10u32.to_be_bytes().to_vec(); @@ -435,12 +401,6 @@ fn scram_and_plus_verify_server_signature_and_reject_bad_verifier() { } } } -fn hex_salted_password() -> [u8; 32] { - [ - 96, 154, 98, 181, 182, 135, 186, 101, 146, 177, 42, 85, 44, 121, 254, 59, 241, 158, 78, 20, - 90, 22, 123, 121, 91, 122, 202, 181, 232, 159, 160, 246, - ] -} #[test] fn extended_copy_resynchronizes_after_copy_done() { @@ -497,3 +457,253 @@ fn extended_copy_resynchronizes_after_copy_done() { }) )); } + +fn tls_authentication(required: bool, available: bool) -> Result { + let mut c = Connection::new(Config { + ssl: SslMode::Require, + channel_binding_required: required, + ..Default::default() + })?; + flush(&mut c); + c.receive(b"S")?; + assert!(matches!(c.next_event()?, Some(Event::UpgradeTls))); + c.tls_established_with_channel_binding(available)?; + flush(&mut c); + Ok(c) +} + +#[test] +fn scram_preference_is_decided_by_tls_binding_availability_and_server_offer() { + let mut ran = 0; + for available in [false, true] { + for offer in [ + b"SCRAM-SHA-256-PLUS\0SCRAM-SHA-256\0\0".as_slice(), + b"SCRAM-SHA-256\0\0", + b"SCRAM-SHA-256-PLUS\0\0", + ] { + let mut c = tls_authentication(false, available).expect("TLS"); + c.receive(&frame( + b'R', + &[10u32.to_be_bytes().as_slice(), offer].concat(), + )) + .expect("mechanisms"); + let offers_plus = offer.starts_with(b"SCRAM-SHA-256-PLUS\0"); + let only_plus = offer == b"SCRAM-SHA-256-PLUS\0\0"; + if only_plus && !available { + assert_eq!( + c.next_event().expect_err("no usable mechanism"), + Error::Protocol("unsupported SASL mechanisms") + ); + } else { + let plus = available && offers_plus; + assert!( + matches!(c.next_event().expect("selection"), Some(Event::ScramNeeded { plus: selected }) if selected == plus) + ); + let binding = if plus { + ChannelBinding::tls_server_end_point(vec![1; 32]) + } else { + ChannelBinding::unsupported() + }; + c.start_scram(ScramSha256::new(b"secret", binding)) + .expect("SCRAM start"); + let packet = flush(&mut c); + let name = if plus { + b"SCRAM-SHA-256-PLUS\0".as_slice() + } else { + b"SCRAM-SHA-256\0" + }; + assert_eq!(&packet[5..5 + name.len()], name); + let initial = &packet[5 + name.len() + 4..]; + assert!(initial.starts_with(if plus { + b"p=tls-server-end-point,,".as_slice() + } else { + b"n,," + })); + } + ran += 1; + } + } + assert_eq!(ran, 6); +} + +#[test] +fn required_binding_rejects_missing_data_missing_plus_and_authentication_bypass() { + assert!(matches!( + tls_authentication(true, false), + Err(Error::State( + "channel binding required but certificate binding is unavailable" + )) + )); + let mut c = tls_authentication(true, true).expect("binding available"); + c.receive(&frame( + b'R', + &[10u32.to_be_bytes().as_slice(), b"SCRAM-SHA-256\0\0"].concat(), + )) + .expect("plain-only offer"); + assert_eq!( + c.next_event().expect_err("PLUS required"), + Error::Protocol("channel binding required but server did not offer SCRAM-SHA-256-PLUS") + ); + assert!(c.output().is_empty()); + let mut c = tls_authentication(true, true).expect("binding available"); + c.receive(&frame(b'R', &0u32.to_be_bytes())) + .expect("AuthenticationOk without SCRAM"); + assert_eq!( + c.next_event() + .expect_err("trust cannot satisfy required binding"), + Error::Protocol("channel binding required but SCRAM-PLUS was not authenticated") + ); + assert!(!c.is_ready()); +} + +#[test] +fn legacy_tls_acknowledgement_has_no_binding_and_uses_n_gs2_flag() { + let mut c = Connection::new(Config { + ssl: SslMode::Require, + ..Default::default() + }) + .expect("core"); + flush(&mut c); + c.receive(b"S").expect("TLS accepted"); + assert!(matches!( + c.next_event().expect("event"), + Some(Event::UpgradeTls) + )); + c.tls_established().expect("no binding acknowledgement"); + flush(&mut c); + c.receive(&frame( + b'R', + &[ + 10u32.to_be_bytes().as_slice(), + b"SCRAM-SHA-256-PLUS\0SCRAM-SHA-256\0\0", + ] + .concat(), + )) + .expect("mechanisms"); + assert!(matches!( + c.next_event().expect("selection"), + Some(Event::ScramNeeded { plus: false }) + )); + c.start_scram(ScramSha256::new(b"secret", ChannelBinding::unsupported())) + .expect("plain SCRAM"); + let packet = flush(&mut c); + assert_eq!(&packet[5..19], b"SCRAM-SHA-256\0"); + assert!(packet[23..].starts_with(b"n,,")); +} + +#[test] +fn fatal_errors_abort_every_pending_token_and_preserve_diagnostics_after_eof() { + let mut ran = 0; + for severity in [ + b"SFATAL\0".as_slice(), + b"SPANIQUE\0VPANIC\0", + b"SERREUR\0VFATAL\0", + ] { + for pending in [0, 3] { + let mut c = ready(); + for token in 1..=pending { + c.query(token, "SELECT 1", None).expect("queue query"); + } + let response = frame( + b'E', + &[ + severity, + b"C57P01\0Mterminated by administrator\0Dretained detail\0\0", + ] + .concat(), + ); + // A fragmented terminal response must not need ReadyForQuery or EOF. + for byte in &response[..response.len() - 1] { + c.receive(&[*byte]).expect("fragment"); + assert!(c.next_event().expect("incomplete frame").is_none()); + } + c.receive(&response[response.len() - 1..]) + .expect("last fragment"); + let error = c.next_event().expect_err("connection failure"); + let Error::ConnectionAborted(ref failure) = error else { + panic!("lost server failure: {error:?}"); + }; + assert_eq!(failure.server_error().code(), "57P01"); + assert_eq!( + failure.server_error().message(), + "terminated by administrator" + ); + assert_eq!(failure.server_error().detail(), Some("retained detail")); + assert!(!c.is_ready()); + assert!(c.output().is_empty(), "discard unsent pipeline output"); + assert!(c.query(99, "SELECT 1", None).is_err()); + c.abort(Error::Transport); // Scripted peer closes immediately after FATAL. + for token in 1..=pending { + match c.next_event().expect("terminal completion") { + Some(Event::Completed { + token: actual, + outcome: Outcome::Aborted(reason), + .. + }) => { + assert_eq!(actual, token); + assert_eq!(reason, error); + } + event => panic!("missing aborted token: {event:?}"), + } + } + assert!( + matches!(c.next_event().expect("close"), Some(Event::Closed { reason }) if reason == error) + ); + c.abort(Error::Cancelled); + assert!(c.next_event().expect("no duplicate").is_none()); + assert_eq!(c.pending_count(), 0); + let io = std::io::Error::from(error); + assert_eq!(io.kind(), std::io::ErrorKind::ConnectionAborted); + assert!( + io.to_string() + .contains("57P01: terminated by administrator") + ); + ran += 1; + } + } + assert_eq!(ran, 6); +} + +#[test] +fn nonlocalized_severity_controls_statement_error_recovery() { + let mut c = ready(); + c.query(1, "bad", None).expect("query"); + flush(&mut c); + // V is authoritative even if S resembles a terminal severity. + c.receive( + &[ + frame(b'E', b"SFATAL\0VERROR\0C42601\0Msyntax error\0\0"), + frame(b'Z', b"I"), + ] + .concat(), + ) + .expect("response"); + assert!(matches!( + c.next_event().expect("statement error"), + Some(Event::Error { token: Some(1), .. }) + )); + assert!(matches!( + c.next_event().expect("ready"), + Some(Event::Completed { + outcome: Outcome::ServerError, + .. + }) + )); + assert!(c.is_ready()); + c.query(2, "SELECT 1", None).expect("reuse"); + flush(&mut c); + c.receive(&[frame(b'C', b"SELECT 1\0"), frame(b'Z', b"I")].concat()) + .expect("response"); + assert!(matches!( + c.next_event().expect("tag"), + Some(Event::CommandComplete { token: 2, .. }) + )); + assert!(matches!( + c.next_event().expect("success"), + Some(Event::Completed { + token: 2, + outcome: Outcome::Success, + .. + }) + )); +} diff --git a/protocols/turnloop-postgres/tests/server.rs b/protocols/turnloop-postgres/tests/server.rs index ba9ba64..1104824 100644 --- a/protocols/turnloop-postgres/tests/server.rs +++ b/protocols/turnloop-postgres/tests/server.rs @@ -28,6 +28,7 @@ struct Driver { scram: bool, plus: bool, parameters: usize, + binding: Option>, } impl Driver { fn connect(user: &str, ssl: SslMode) -> Self { @@ -56,6 +57,7 @@ impl Driver { scram: false, plus: false, parameters: 0, + binding: None, }; while !d.core.is_ready() { d.step(&mut Results::default(), None); @@ -85,32 +87,21 @@ impl Driver { match event { Event::UpgradeTls => { self.io.upgrade(); + self.binding = self.io.channel_binding(); self.core - .tls_established() + .tls_established_with_channel_binding(self.binding.is_some()) .expect("fixture operation must succeed"); self.ssl = true; } Event::ScramNeeded { plus } => { - // RFC 5929 tls-server-end-point for the SHA256 fixture certificate. let binding = if plus { - let cert = std::fs::read(support::tools().join("server.der")) - .expect("fixture operation must succeed"); - let digest = rustls::crypto::ring::default_provider() - .cipher_suites - .iter() - .find(|suite| { - suite.suite() == rustls::CipherSuite::TLS13_AES_128_GCM_SHA256 - }) - .and_then(|suite| suite.tls13()) - .expect("fixture operation must succeed") - .common - .hash_provider - .hash(&cert); - // First ring TLS1.3 suite uses SHA256 in this provider. - assert_eq!(digest.as_ref().len(), 32); - ChannelBinding::tls_server_end_point(digest.as_ref().to_vec()) + ChannelBinding::tls_server_end_point( + self.binding + .clone() + .expect("core selected available binding"), + ) } else { - ChannelBinding::unrequested() + ChannelBinding::unsupported() }; self.core .start_scram(ScramSha256::new(&self.password, binding)) @@ -216,6 +207,9 @@ fn rejected_startup_reports_server_sqlstate_and_message() { .expect("ErrorResponse length"); io.write_all(body).expect("ErrorResponse body"); }); + // This deliberately catches the diagnostic panic from our synchronous test + // driver, not a real-server failure. The panic hook prints under --nocapture; + // SQLSTATE/message assertions and the joined peer below must still succeed. let error = std::panic::catch_unwind(|| Driver::connect_to(port, "postgres", SslMode::Disable)) .err() .expect("missing role must fail startup"); diff --git a/protocols/turnloop-postgres/tests/support/mod.rs b/protocols/turnloop-postgres/tests/support/mod.rs index 8a854d4..195837e 100644 --- a/protocols/turnloop-postgres/tests/support/mod.rs +++ b/protocols/turnloop-postgres/tests/support/mod.rs @@ -24,6 +24,15 @@ impl Transport { .expect("fixture operation must succeed"); Self::Plain(stream) } + pub fn channel_binding(&self) -> Option> { + let Self::Tls(stream) = self else { return None }; + let leaf = stream.conn.peer_certificates()?.first()?; + Some( + turnloop_tls::tls_server_end_point(leaf.as_ref())? + .as_ref() + .to_vec(), + ) + } pub fn upgrade(&mut self) { let Self::Plain(socket) = self else { panic!("duplicate TLS upgrade") diff --git a/protocols/turnloop-postgres/tests/support/scram.rs b/protocols/turnloop-postgres/tests/support/scram.rs new file mode 100644 index 0000000..7f9b9d0 --- /dev/null +++ b/protocols/turnloop-postgres/tests/support/scram.rs @@ -0,0 +1,44 @@ +pub fn sha256(b: &[u8]) -> [u8; 32] { + use sha2::Digest; + sha2::Sha256::digest(b).into() +} + +pub fn hmac(key: &[u8], message: &[u8]) -> [u8; 32] { + let mut inner = vec![0x36; 64]; + let mut outer = vec![0x5c; 64]; + for (i, b) in key.iter().enumerate() { + inner[i] ^= *b; + outer[i] ^= *b; + } + inner.extend_from_slice(message); + outer.extend_from_slice(&sha256(&inner)); + sha256(&outer) +} +pub fn base64(bytes: &[u8]) -> String { + let alphabet = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut s = String::new(); + for chunk in bytes.chunks(3) { + let a = chunk[0] as usize; + let b = chunk.get(1).copied().unwrap_or(0) as usize; + let c = chunk.get(2).copied().unwrap_or(0) as usize; + s.push(alphabet[a >> 2] as char); + s.push(alphabet[((a & 3) << 4) | (b >> 4)] as char); + s.push(if chunk.len() > 1 { + alphabet[((b & 15) << 2) | (c >> 6)] as char + } else { + '=' + }); + s.push(if chunk.len() > 2 { + alphabet[c & 63] as char + } else { + '=' + }); + } + s +} +pub fn hex_salted_password() -> [u8; 32] { + [ + 96, 154, 98, 181, 182, 135, 186, 101, 146, 177, 42, 85, 44, 121, 254, 59, 241, 158, 78, 20, + 90, 22, 123, 121, 91, 122, 202, 181, 232, 159, 160, 246, + ] +} diff --git a/protocols/turnloop-redis/Cargo.toml b/protocols/turnloop-redis/Cargo.toml index 72af9f3..af16d5c 100644 --- a/protocols/turnloop-redis/Cargo.toml +++ b/protocols/turnloop-redis/Cargo.toml @@ -19,13 +19,30 @@ workspace = true [package.metadata.turnloop-ci] role = "protocol" -integration-tests = ["real"] +wasi-tests = ["asynchronous"] +# Pinned p3 compiler: custom allocator traps before main in debug (wasm2 report). +wasi-p3-release-tests = ["asynchronous"] +wasi-integration-tests = ["async_server"] +integration-tests = ["real", "async_server"] [features] -turnloop = ["dep:turnloop-io"] +turnloop = ["dep:turnloop-io", "dep:turnloop-tls"] [dependencies] +turnloop-tls = { workspace = true, optional = true, features = ["turnloop"] } turnloop-io = { workspace = true, optional = true } [target.'cfg(all(target_os = "wasi", target_env = "p3"))'.dependencies] turnloop-io = { workspace = true, optional = true, features = ["wasi-p3-experimental"] } + +[[test]] +name = "asynchronous" +required-features = ["turnloop"] + +[[test]] +name = "async_server" +required-features = ["turnloop"] + +[[example]] +name = "turnloop" +required-features = ["turnloop"] diff --git a/protocols/turnloop-redis/README.md b/protocols/turnloop-redis/README.md index e7fa235..8518006 100644 --- a/protocols/turnloop-redis/README.md +++ b/protocols/turnloop-redis/README.md @@ -28,6 +28,40 @@ write twice if its first reply was lost. Set `auto_resend_unfulfilled=false` whe that policy is inappropriate. Connection failures during AUTH are terminal; transport failures use retry policy. The host reports transport/DNS/TLS errors. +## Getting started on turnloop + +Enable `turnloop-redis = { version = "0.1.0-alpha.1", features = ["turnloop"] }` +and use the `asynchronous` module: `Client::command/pipeline, Subscriber::next, ClusterClient and sentinel`. The default feature set remains sans-I/O. +The adapter reuses `turnloop-io`; the host owns `LocalExecutor` and calls `turn`. +Spawn local futures through its handle and keep their `JoinHandle`s until completion. + +```sh +cargo run -p turnloop-redis --features turnloop --example turnloop +``` + +[The complete example](examples/turnloop.rs) connects to `127.0.0.1:6379` by default; +`TURNLOOP_DB_ADDR` overrides that development endpoint. All operations take an +absolute `turnloop_io::Instant` deadline, shared across authentication, I/O and retries. +Callbacks borrow row/reply storage; copy values only when retaining them. +Dropping a pending operation closes its transport before a pool can reuse it. + +For TLS, set the protocol's TLS mode and provide +`turnloop_tls::asynchronous::ClientTls` with the trust configuration, verified +server name and current Unix seconds. Certificates are verified; a requested TLS +upgrade without a configuration fails. Native TCP and WASI 0.2 sockets share the +same driver. Browser raw TCP is unavailable; Windows accepts a host-provided +`Backend` until the repository's production IOCP provider is integrated. + +Pipelines encode the entire batch before awaiting ordered replies. Per-command +Redis errors are returned to the pipeline callback while later replies continue. +`subscribe` lends an exclusive subscriber with async `next(deadline)`; dropping +it closes the subscribed session. Reconnects use real turnloop timers, restore +subscriptions, and follow the core's `auto_resend_unfulfilled` policy. An ambiguous +write can execute twice if that policy permits replay. `ClusterClient` follows +MOVED/ASK and reuses per-node sessions; `sentinel` verifies the discovered primary's +ROLE, with separate Sentinel/data-node authentication settings. + + ## Reply conversion boundary | Wire/core result | ioredis/Perry conversion in the host | diff --git a/protocols/turnloop-redis/examples/turnloop.rs b/protocols/turnloop-redis/examples/turnloop.rs new file mode 100644 index 0000000..6cbfd07 --- /dev/null +++ b/protocols/turnloop-redis/examples/turnloop.rs @@ -0,0 +1,56 @@ +//! A local plaintext development-server example. For production TLS, supply +//! ClientTls and enable the protocol's TLS option; see the crate README. +use std::time::Duration; +use turnloop_io::{Backend, ExecutorHandle}; +pub async fn example(h: ExecutorHandle) -> std::io::Result<()> { + let address = std::env::var("TURNLOOP_DB_ADDR") + .unwrap_or_else(|_| "127.0.0.1:6379".into()) + .parse::() + .map_err(std::io::Error::other)?; + let at = h.now() + Duration::from_secs(30); + use turnloop_redis::asynchronous::{Client, ConnectOptions}; + let options = ConnectOptions { + address, + protocol: turnloop_redis::Config { + username: std::env::var("REDIS_USER").ok(), + password: std::env::var("REDIS_PASSWORD").ok(), + ..Default::default() + }, + tls: None, + retry_delay: Duration::from_millis(100), + max_reconnects: 3, + }; + let mut client = Client::connect(&h, &options, at).await?; + println!("{:?}", client.command(&[b"PING"], at).await?); + Ok(()) +} +#[cfg(not(any(windows, all(target_arch = "wasm32", target_os = "unknown"))))] +fn main() -> std::io::Result<()> { + use std::{ + future::Future, + pin::Pin, + task::{Context, Poll, Waker}, + }; + use turnloop_io::turnloop::{Config, LocalExecutor, Timeout, backend::Platform}; + let mut executor = + LocalExecutor::::new(Config::default()).map_err(turnloop_io::error)?; + let h = executor.handle(); + let mut task = executor + .spawn_local(example(h)) + .map_err(turnloop_io::error)?; + while !task.is_finished() { + executor + .turn(Timeout::After(Duration::from_secs(30))) + .map_err(turnloop_io::error)?; + } + match Pin::new(&mut task).poll(&mut Context::from_waker(Waker::noop())) { + Poll::Ready(Ok(result)) => result, + _ => Err(std::io::Error::other("example task did not complete")), + } +} +#[cfg(any(windows, all(target_arch = "wasm32", target_os = "unknown")))] +fn main() { + eprintln!( + "Embed example(handle) with a host-provided TCP Backend; this target has no built-in TCP Platform provider." + ); +} diff --git a/protocols/turnloop-redis/src/asynchronous.rs b/protocols/turnloop-redis/src/asynchronous.rs index 5ea70fd..7a88537 100644 --- a/protocols/turnloop-redis/src/asynchronous.rs +++ b/protocols/turnloop-redis/src/asynchronous.rs @@ -63,3 +63,11 @@ mod tests { ); } } + +#[path = "client.rs"] +mod client; +pub use client::*; + +#[path = "cluster.rs"] +mod cluster; +pub use cluster::{ClusterClient, sentinel}; diff --git a/protocols/turnloop-redis/src/client.rs b/protocols/turnloop-redis/src/client.rs new file mode 100644 index 0000000..1f78495 --- /dev/null +++ b/protocols/turnloop-redis/src/client.rs @@ -0,0 +1,339 @@ +//! Redis client, pipelines and subscriber reads with timer-driven reconnects. +use crate::{Config, Event, State, resp::Value}; +use std::{collections::VecDeque, io, net::SocketAddr, time::Duration}; +use turnloop_io::{AsyncIo, Backend, Driver, ExecutorHandle, Instant, Output, SansIo, deadline}; +use turnloop_tls::asynchronous::{ClientTls, Transport}; +#[derive(Clone)] +pub struct ConnectOptions { + pub address: SocketAddr, + pub protocol: Config, + pub tls: Option, + pub retry_delay: Duration, + pub max_reconnects: u32, +} +struct Core(crate::Connection); +impl Output for Core { + fn output(&self) -> &[u8] { + self.0.output() + } + fn consume_output(&mut self, n: usize) -> io::Result<()> { + self.0.consume_output(n); + Ok(()) + } +} +impl SansIo for Core { + type Event<'a> = Event; + fn event(&mut self, mut receive: impl FnMut(Event) -> io::Result<()>) -> io::Result { + if let Some(e) = self.0.poll_event() { + receive(e)?; + Ok(true) + } else { + Ok(false) + } + } + fn ingest(&mut self, bytes: &[u8], _: Instant) -> io::Result { + self.0.receive(bytes).map_err(io::Error::other)?; + Ok(bytes.len()) + } + fn disconnected(&mut self) { + self.0.transport_lost(); + } +} +pub struct Client { + executor: ExecutorHandle, + driver: Driver>, Core>, + options: ConnectOptions, + messages: VecDeque, + deferred: VecDeque, + token: u64, + reconnects: u64, +} +pub(super) fn time(now: Instant) -> io::Result { + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + { + let _ = now; + Err(io::ErrorKind::Unsupported.into()) + } + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] + { + Ok(now) + } +} +impl Client { + pub async fn connect( + executor: &ExecutorHandle, + options: &ConnectOptions, + at: Instant, + ) -> io::Result { + let mut core = crate::Connection::new(options.protocol.clone()); + core.connect(time(executor.now())?) + .map_err(io::Error::other)?; + if !matches!(core.poll_event(), Some(Event::Connect)) { + return Err(io::Error::other("missing Redis connect action")); + } + let stream = deadline(executor, at, async { + executor + .connect(options.address, Default::default()) + .await + .map_err(turnloop_io::error) + }) + .await?; + core.transport_connected().map_err(io::Error::other)?; + let mut client = Self { + executor: executor.clone(), + driver: Driver::new(Transport::Plain(stream), Core(core)), + options: options.clone(), + messages: VecDeque::new(), + deferred: VecDeque::new(), + token: 0, + reconnects: 0, + }; + while !matches!(client.next_event(at).await?, Event::Ready { .. }) {} + Ok(client) + } + pub fn is_connected(&self) -> bool { + self.driver.is_connected() && self.driver.core().0.state() == State::Ready + } + pub fn reconnect_count(&self) -> u64 { + self.reconnects + } + pub fn close(&mut self) { + self.driver.core_mut().0.close(); + self.driver.abort(); + } + async fn reconnect(&mut self, at: Instant) -> io::Result<()> { + loop { + while let Some(event) = self.driver.core_mut().0.poll_event() { + match event { + Event::Retry { attempt } => { + let delay = if attempt <= self.options.max_reconnects { + Some(self.options.retry_delay) + } else { + None + }; + self.driver + .core_mut() + .0 + .retry(time(self.executor.now())?, delay) + .map_err(io::Error::other)?; + } + Event::Connect => { + match deadline(&self.executor, at, async { + self.executor + .connect(self.options.address, Default::default()) + .await + .map_err(turnloop_io::error) + }) + .await + { + Ok(stream) => { + self.driver.replace_stream(Transport::Plain(stream))?; + self.driver + .core_mut() + .0 + .transport_connected() + .map_err(io::Error::other)?; + self.reconnects += 1; + return Ok(()); + } + Err(e) => { + self.driver.core_mut().0.transport_lost(); + if self.executor.now() >= at { + return Err(e); + } + } + } + } + Event::CloseTransport => {} + Event::Closed => return Err(io::ErrorKind::NotConnected.into()), + event => self.deferred.push_back(event), + } + } + let until = self + .driver + .core() + .0 + .next_timeout() + .ok_or_else(|| io::Error::other("Redis reconnect has no deadline"))?; + // Browser raw TCP is Unsupported before reaching this point. + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] + let sleep_at = until.min(at); + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + let sleep_at = { + let _ = until; + at + }; + self.executor + .sleep_until(sleep_at) + .await + .map_err(turnloop_io::error)?; + if self.executor.now() >= at { + return Err(io::ErrorKind::TimedOut.into()); + } + self.driver + .core_mut() + .0 + .handle_timeout(time(self.executor.now())?); + } + } + async fn next_event(&mut self, at: Instant) -> io::Result { + loop { + if let Some(e) = self.deferred.pop_front() { + return Ok(e); + } + if !self.driver.is_connected() { + self.reconnect(at).await?; + continue; + } + let mut event = None; + let result = deadline( + &self.executor, + at, + self.driver.next(&self.executor, |e| { + event = Some(e); + Ok(()) + }), + ) + .await; + if let Err(e) = result { + if e.kind() == io::ErrorKind::TimedOut || self.options.max_reconnects == 0 { + return Err(e); + } + self.reconnect(at).await?; + continue; + } + match event.ok_or_else(|| io::Error::other("missing Redis event"))? { + Event::UpgradeTls => { + let tls = self.options.tls.as_ref().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "TLS configuration required") + })?; + self.driver + .upgrade_stream()? + .upgrade(tls, &self.executor, at) + .await?; + self.driver + .core_mut() + .0 + .tls_established() + .map_err(io::Error::other)?; + } + Event::CloseTransport => self.driver.abort(), + Event::Error(e) => return Err(io::Error::other(e)), + e => return Ok(e), + } + } + } + /// All requests are encoded before reading; ordered replies include Redis + /// ReplyError values without losing the remainder of a pipeline. + pub async fn pipeline( + &mut self, + commands: &[&[&[u8]]], + at: Instant, + mut receive: impl FnMut(usize, Result) -> io::Result<()>, + ) -> io::Result<()> { + let mut operation = Operation { + client: self, + done: false, + }; + if !operation.client.is_connected() { + while !operation.client.is_connected() { + operation.client.next_event(at).await?; + } + } + let first = operation + .client + .token + .checked_add(1) + .ok_or_else(|| io::Error::other("Redis token exhausted"))?; + for command in commands { + operation.client.token = operation + .client + .token + .checked_add(1) + .ok_or_else(|| io::Error::other("Redis token exhausted"))?; + let token = operation.client.token; + operation + .client + .driver + .core_mut() + .0 + .command(token, command, Some(time(at)?)) + .map_err(io::Error::other)?; + } + let mut replies = 0; + while replies < commands.len() { + match operation.client.next_event(at).await? { + Event::Reply { token, result } => { + if token != first + replies as u64 { + return Err(io::Error::other("Redis pipeline reply order")); + } + receive(replies, result)?; + replies += 1; + } + e @ (Event::Message { .. } | Event::Push(_)) => { + operation.client.messages.push_back(e) + } + Event::Closed => return Err(io::ErrorKind::NotConnected.into()), + _ => {} + } + } + operation.done = true; + Ok(()) + } + pub async fn command(&mut self, args: &[&[u8]], at: Instant) -> io::Result { + let mut result = None; + self.pipeline(&[args], at, |_, reply| { + result = Some(reply); + Ok(()) + }) + .await?; + result + .ok_or_else(|| io::Error::other("missing Redis reply"))? + .map_err(io::Error::other) + } + pub async fn subscribe( + &mut self, + channels: &[&[u8]], + at: Instant, + ) -> io::Result> { + let mut args = Vec::with_capacity(channels.len() + 1); + args.push(b"SUBSCRIBE".as_slice()); + args.extend_from_slice(channels); + self.command(&args, at).await?; + Ok(Subscriber { client: self }) + } +} +struct Operation<'a, B: Backend> { + client: &'a mut Client, + done: bool, +} +impl Drop for Operation<'_, B> { + fn drop(&mut self) { + if !self.done { + self.client.close(); + } + } +} +/// Subscriber ownership is exclusive. Dropping it closes the subscribed session. +pub struct Subscriber<'a, B: Backend> { + client: &'a mut Client, +} +impl Subscriber<'_, B> { + pub async fn next(&mut self, at: Instant) -> io::Result { + if let Some(event) = self.client.messages.pop_front() { + return Ok(event); + } + loop { + match self.client.next_event(at).await? { + e @ (Event::Message { .. } | Event::Push(_)) => return Ok(e), + Event::Closed => return Err(io::ErrorKind::NotConnected.into()), + _ => {} + } + } + } +} +impl Drop for Subscriber<'_, B> { + fn drop(&mut self) { + self.client.close(); + } +} diff --git a/protocols/turnloop-redis/src/cluster.rs b/protocols/turnloop-redis/src/cluster.rs new file mode 100644 index 0000000..fdb17e2 --- /dev/null +++ b/protocols/turnloop-redis/src/cluster.rs @@ -0,0 +1,192 @@ +//! Cluster slot routing and Sentinel discovery using the existing state machines. +use super::{Client, ConnectOptions}; +use crate::{ + resp::Value, + routing::{DiscoveryAction, Endpoint, Redirect, SentinelDiscovery, SlotMap}, +}; +use std::io; +use turnloop_io::{Backend, ExecutorHandle, Instant}; +pub struct ClusterClient { + executor: ExecutorHandle, + options: ConnectOptions, + slots: SlotMap, + nodes: Vec<(Endpoint, Client)>, + pub max_redirects: usize, +} +impl ClusterClient { + pub async fn connect( + executor: &ExecutorHandle, + options: ConnectOptions, + at: Instant, + ) -> io::Result { + let seed = Endpoint { + host: options.address.ip().to_string(), + port: options.address.port(), + }; + let client = Client::connect(executor, &options, at).await?; + let mut this = Self { + executor: executor.clone(), + options, + slots: SlotMap::new(), + nodes: vec![(seed, client)], + max_redirects: 16, + }; + this.refresh(at).await?; + Ok(this) + } + pub fn reconnect_count(&self) -> u64 { + self.nodes.iter().map(|(_, c)| c.reconnect_count()).sum() + } + pub fn slots(&self) -> &SlotMap { + &self.slots + } + pub async fn refresh(&mut self, at: Instant) -> io::Result<()> { + let value = self.nodes[0].1.command(&[b"CLUSTER", b"SLOTS"], at).await?; + self.slots + .update_slots(&value, &self.nodes[0].0.host) + .map_err(io::Error::other) + } + async fn node(&mut self, endpoint: Endpoint, at: Instant) -> io::Result { + if let Some(i) = self.nodes.iter().position(|(e, _)| *e == endpoint) { + return Ok(i); + } + let mut options = self.options.clone(); + options.address = + turnloop_io::resolve(&self.executor, &endpoint.host, endpoint.port, at).await?; + let client = Client::connect(&self.executor, &options, at).await?; + self.nodes.push((endpoint, client)); + Ok(self.nodes.len() - 1) + } + /// Known key layouts are validated before any bytes are written. ASKING and + /// its command share one exclusive node connection; ASK never changes slots. + pub async fn command(&mut self, args: &[&[u8]], at: Instant) -> io::Result { + let endpoint = self.slots.route_command(args).map_err(io::Error::other)?; + let mut index = match endpoint { + Some(e) => match self.nodes.iter().position(|(n, _)| n == e) { + Some(i) => i, + None => { + let e = e.clone(); + self.node(e, at).await? + } + }, + None => 0, + }; + for _ in 0..=self.max_redirects { + match self.nodes[index].1.command(args, at).await { + Ok(value) => return Ok(value), + Err(error) => { + let Some(redirect) = error + .get_ref() + .and_then(|e| e.downcast_ref::()) + .and_then(Redirect::parse) + else { + return Err(error); + }; + self.slots + .apply_redirect(&redirect) + .map_err(io::Error::other)?; + index = self.node(redirect.endpoint, at).await?; + if redirect.asking { + let value = self.nodes[index].1.command(&[b"ASKING"], at).await?; + if value.bytes() != Some(b"OK") { + return Err(io::Error::other("ASKING refused")); + } + } + } + } + } + Err(io::Error::other("Redis Cluster redirect limit")) + } +} +/// Sentinel and data-node options deliberately remain separate: their ACL and +/// TLS credentials can differ. Discovery validates ROLE before returning a client. +pub async fn sentinel( + executor: &ExecutorHandle, + seeds: Vec, + name: String, + sentinel_options: &ConnectOptions, + data_options: &ConnectOptions, + at: Instant, +) -> io::Result> { + let mut discovery = SentinelDiscovery::new(seeds, name).map_err(io::Error::other)?; + let mut candidate = None; + loop { + if executor.now() >= at { + return Err(io::ErrorKind::TimedOut.into()); + } + match discovery.poll_action() { + Some(DiscoveryAction::QueryMaster { sentinel, name }) => { + let mut options = sentinel_options.clone(); + let result = async { + options.address = + turnloop_io::resolve(executor, &sentinel.host, sentinel.port, at).await?; + let mut client = Client::connect(executor, &options, at).await?; + client + .command( + &[b"SENTINEL", b"get-master-addr-by-name", name.as_bytes()], + at, + ) + .await + } + .await; + match result { + Ok(reply) => discovery.reply(&reply).map_err(io::Error::other)?, + Err(_) => discovery.failed(), + } + } + Some(DiscoveryAction::VerifyRole { + candidate: endpoint, + }) => { + let mut options = data_options.clone(); + let result = async { + options.address = + turnloop_io::resolve(executor, &endpoint.host, endpoint.port, at).await?; + let mut client = Client::connect(executor, &options, at).await?; + let reply = client.command(&[b"ROLE"], at).await?; + Ok::<_, io::Error>((client, reply)) + } + .await; + match result { + Ok((client, reply)) => { + discovery.reply(&reply).map_err(io::Error::other)?; + candidate = Some(client); + } + Err(_) => discovery.failed(), + } + } + Some(DiscoveryAction::Discovered(_)) => { + return candidate.ok_or_else(|| io::Error::other("missing Sentinel candidate")); + } + Some(DiscoveryAction::Retry { attempt }) => { + let delay = if attempt <= sentinel_options.max_reconnects { + Some(sentinel_options.retry_delay) + } else { + None + }; + discovery + .retry(super::client::time(executor.now())?, delay) + .map_err(io::Error::other)?; + } + Some(DiscoveryAction::Stopped) => { + return Err(io::Error::other("Sentinel discovery exhausted")); + } + None => { + let until = discovery + .next_timeout() + .ok_or_else(|| io::Error::other("Sentinel has no pending deadline"))?; + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] + let sleep_at = until.min(at); + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + let sleep_at = { + let _ = until; + at + }; + executor + .sleep_until(sleep_at) + .await + .map_err(turnloop_io::error)?; + discovery.handle_timeout(super::client::time(executor.now())?); + } + } + } +} diff --git a/protocols/turnloop-redis/tests/async_server.rs b/protocols/turnloop-redis/tests/async_server.rs new file mode 100644 index 0000000..5be056c --- /dev/null +++ b/protocols/turnloop-redis/tests/async_server.rs @@ -0,0 +1,310 @@ +#![cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] +use std::{ + future::Future, + pin::Pin, + task::{Context, Poll, Waker}, + time::Duration, +}; +use turnloop_io::{ + turnloop::{Config as LoopConfig, LocalExecutor, Timeout, backend::Platform}, + *, +}; +fn finish(task: &mut turnloop::JoinHandle) -> T { + match Pin::new(task).poll(&mut Context::from_waker(Waker::noop())) { + Poll::Ready(Ok(v)) => v, + _ => panic!("task must finish"), + } +} +fn drive(executor: &mut LocalExecutor, task: &mut turnloop::JoinHandle) -> T { + let at = executor.handle().now() + Duration::from_secs(90); + while !task.is_finished() { + assert!(executor.handle().now() < at, "test deadline"); + executor.turn(Timeout::Until(at)).expect("turn"); + } + finish(task) +} + +fn port(name: &str) -> u16 { + std::env::var(name) + .expect("required private fixture port; run scripts/test-servers.py") + .parse() + .expect("port") +} +fn tls(ca: Vec) -> turnloop_tls::asynchronous::ClientTls { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("wall time") + .as_secs(); + turnloop_tls::asynchronous::ClientTls { + config: turnloop_tls::ClientConfig::new( + turnloop_tls::ClientOptions { + ca: Some(vec![turnloop_tls::rustls::pki_types::CertificateDer::from( + ca, + )]), + alpn: vec![], + ..Default::default() + }, + now, + ) + .expect("TLS config"), + server_name: "localhost".try_into().expect("name"), + unix_seconds: now, + } +} +use turnloop_redis::{ + Event, + asynchronous::{Client, ClusterClient, ConnectOptions, sentinel}, + resp::Value, + routing::Endpoint, +}; +fn options(port: u16) -> ConnectOptions { + ConnectOptions { + address: ([127, 0, 0, 1], port).into(), + protocol: turnloop_redis::Config { + username: Some("lane".into()), + password: Some( + std::env::var("TURNLOOP_TEST_REDIS_PASSWORD").expect("fixture password"), + ), + ..Default::default() + }, + tls: None, + retry_delay: Duration::from_millis(5), + max_reconnects: 3, + } +} +#[test] +#[ignore = "private Redis fixture"] +fn real_async_tls_pipeline_pubsub_deadlines_reconnect_cluster_sentinel() { + let mut ex = LocalExecutor::::new(LoopConfig::default()).expect("executor"); + let h = ex.handle(); + let plain = options(port("TURNLOOP_TEST_REDIS_PORT")); + let mut secure = options(port("TURNLOOP_TEST_REDIS_TLS_PORT")); + secure.protocol.tls = true; + secure.tls = Some(tls(include_bytes!("fixtures/ca.der").to_vec())); + let mut cluster_options = options(port("TURNLOOP_TEST_REDIS_CLUSTER_PORT")); + cluster_options.protocol.username = None; + cluster_options.protocol.password = None; + let mut sentinel_options = cluster_options.clone(); + sentinel_options + .address + .set_port(port("TURNLOOP_TEST_REDIS_SENTINEL_PORT")); + let mut task=ex.spawn_local(async move{ + let at=h.now()+Duration::from_secs(30);let mut c=Client::connect(&h,&plain,at).await.expect("connect");let mut secure=Client::connect(&h,&secure,at).await.expect("TLS connect"); + assert_eq!(secure.command(&[b"PING"],at).await.expect("TLS PING").bytes(),Some(b"PONG".as_slice())); + c.command(&[b"DEL",b"async-counter"],at).await.expect("DEL");let mut replies=0; + c.pipeline(&[&[b"INCR",b"async-counter"],&[b"INCR",b"async-counter"]],at,|i,r|{assert_eq!(r.expect("reply"),Value::Integer(i as i64+1));replies+=1;Ok(())}).await.expect("pipeline");assert_eq!(replies,2); + let id=c.command(&[b"CLIENT",b"ID"],at).await.expect("id");let Value::Integer(id)=id else{panic!("client id")}; + let id=id.to_string();assert_eq!(secure.command(&[b"CLIENT",b"KILL",b"ID",id.as_bytes()],at).await.expect("kill"),Value::Integer(1)); + assert_eq!(c.command(&[b"GET",b"async-counter"],at).await.expect("reconnect GET").bytes(),Some(b"2".as_slice()));assert_eq!(c.reconnect_count(),1); + let mut subscriber=c.subscribe(&[b"async-channel"],at).await.expect("subscribe");assert_eq!(secure.command(&[b"PUBLISH",b"async-channel",b"payload"],at).await.expect("publish"),Value::Integer(1));assert!(matches!(subscriber.next(at).await.expect("message"),Event::Message{channel,payload,..}if channel==b"async-channel"&&payload==b"payload"));drop(subscriber); + assert_eq!(secure.command(&[b"BLPOP",b"missing-async-key",b"0"],h.now()+Duration::from_millis(10)).await.expect_err("blocking timeout").kind(),std::io::ErrorKind::TimedOut);assert!(!secure.is_connected()); + let mut cluster=ClusterClient::connect(&h,cluster_options.clone(),at).await.expect("cluster");assert_eq!(cluster.command(&[b"SET",b"{async}key",b"value"],at).await.expect("cluster SET").bytes(),Some(b"OK".as_slice()));assert_eq!(cluster.command(&[b"GET",b"{async}key"],at).await.expect("cluster GET").bytes(),Some(b"value".as_slice())); + exercise_cluster(&h, &cluster_options, &mut cluster, at).await; + let mut master=sentinel(&h,vec![Endpoint{host:"127.0.0.1".into(),port:sentinel_options.address.port()}],"turnloop".into(),&sentinel_options,&plain,at).await.expect("Sentinel ROLE");assert_eq!(master.command(&[b"PING"],at).await.expect("discovered PING").bytes(),Some(b"PONG".as_slice())); + 9 + }).expect("spawn"); + assert_eq!(drive(&mut ex, &mut task), 9); +} +async fn exercise_cluster( + h: &ExecutorHandle, + options: &ConnectOptions, + cluster: &mut ClusterClient, + at: Instant, +) { + use turnloop_redis::routing::key_slot; + let key = (0..10000) + .map(|i| format!("async-migrate-{i}")) + .find(|k| { + cluster + .slots() + .endpoint(key_slot(k.as_bytes())) + .expect("slot") + .port + != options.address.port() + }) + .expect("foreign key"); + let slot = key_slot(key.as_bytes()); + let slot_text = slot.to_string(); + let source = cluster.slots().endpoint(slot).expect("source").clone(); + let mut source_options = options.clone(); + source_options.address.set_port(source.port); + let mut src = Client::connect(h, &source_options, at) + .await + .expect("source"); + let mut dst = Client::connect(h, options, at).await.expect("destination"); + src.command(&[b"DEL", key.as_bytes()], at) + .await + .expect("empty slot key"); + let source_id = src + .command(&[b"CLUSTER", b"MYID"], at) + .await + .expect("source id"); + let dest_id = dst + .command(&[b"CLUSTER", b"MYID"], at) + .await + .expect("destination id"); + dst.command( + &[ + b"CLUSTER", + b"SETSLOT", + slot_text.as_bytes(), + b"IMPORTING", + source_id.bytes().expect("id"), + ], + at, + ) + .await + .expect("importing"); + src.command( + &[ + b"CLUSTER", + b"SETSLOT", + slot_text.as_bytes(), + b"MIGRATING", + dest_id.bytes().expect("id"), + ], + at, + ) + .await + .expect("migrating"); + assert_eq!( + cluster + .command(&[b"SET", key.as_bytes(), b"asked"], at) + .await + .expect("ASK routed SET") + .bytes(), + Some(b"OK".as_slice()) + ); + assert_eq!( + cluster.slots().endpoint(slot).expect("ASK keeps slot").port, + source.port + ); + assert_eq!( + cluster + .command(&[b"GET", key.as_bytes()], at) + .await + .expect("ASK GET") + .bytes(), + Some(b"asked".as_slice()) + ); + dst.command( + &[ + b"CLUSTER", + b"SETSLOT", + slot_text.as_bytes(), + b"NODE", + dest_id.bytes().expect("id"), + ], + at, + ) + .await + .expect("destination owns slot"); + src.command( + &[ + b"CLUSTER", + b"SETSLOT", + slot_text.as_bytes(), + b"NODE", + dest_id.bytes().expect("id"), + ], + at, + ) + .await + .expect("source releases slot"); + assert_eq!( + cluster + .command(&[b"GET", key.as_bytes()], at) + .await + .expect("MOVED GET") + .bytes(), + Some(b"asked".as_slice()) + ); + assert_eq!( + cluster + .slots() + .endpoint(slot) + .expect("MOVED updates slot") + .port, + options.address.port() + ); + // Fixture-only graceful restart (save config/data and exec). Send once via + // raw I/O, so reconnect cannot replay a second restart. + // https://github.com/redis/redis/blob/8.4/src/debug.c + let before = dst + .command(&[b"INFO", b"SERVER"], at) + .await + .expect("before restart"); + let run_id = |v: &Value| { + std::str::from_utf8(v.bytes().expect("INFO")) + .expect("utf8") + .lines() + .find_map(|l| l.strip_prefix("run_id:")) + .expect("run id") + .to_owned() + }; + let before = run_id(&before); + let reconnects = cluster.reconnect_count(); + assert_eq!( + dst.command(&[b"SAVE"], at) + .await + .expect("persist restart data") + .bytes(), + Some(b"OK".as_slice()) + ); + let mut control = h + .connect(options.address, Default::default()) + .await + .expect("restart socket"); + write_all( + &mut control, + b"*3\r\n$5\r\nDEBUG\r\n$7\r\nRESTART\r\n$2\r\n50\r\n", + ) + .await + .expect("restart command"); + drop(control); + h.sleep(Duration::from_secs(3)) + .await + .expect("restart timer"); + let after = dst + .command(&[b"INFO", b"SERVER"], at) + .await + .expect("restarted INFO"); + assert_ne!(before, run_id(&after)); + assert_eq!( + cluster + .command(&[b"GET", key.as_bytes()], at) + .await + .expect("cluster reconnect") + .bytes(), + Some(b"asked".as_slice()) + ); + assert!(cluster.reconnect_count() > reconnects); + cluster + .command(&[b"DEL", key.as_bytes()], at) + .await + .expect("cleanup key"); + src.command( + &[ + b"CLUSTER", + b"SETSLOT", + slot_text.as_bytes(), + b"NODE", + source_id.bytes().expect("id"), + ], + at, + ) + .await + .expect("restore source"); + dst.command( + &[ + b"CLUSTER", + b"SETSLOT", + slot_text.as_bytes(), + b"NODE", + source_id.bytes().expect("id"), + ], + at, + ) + .await + .expect("restore destination"); +} diff --git a/protocols/turnloop-redis/tests/asynchronous.rs b/protocols/turnloop-redis/tests/asynchronous.rs new file mode 100644 index 0000000..a473b63 --- /dev/null +++ b/protocols/turnloop-redis/tests/asynchronous.rs @@ -0,0 +1,155 @@ +#![cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] +#[path = "../../../crates/turnloop-io/tests/support/count.rs"] +mod count; +use std::{ + future::Future, + pin::Pin, + task::{Context, Poll, Waker}, + time::Duration, +}; +use turnloop_io::{ + turnloop::{Config as LoopConfig, LocalExecutor, Timeout, backend::Platform}, + *, +}; +fn finish(task: &mut turnloop::JoinHandle) -> T { + match Pin::new(task).poll(&mut Context::from_waker(Waker::noop())) { + Poll::Ready(Ok(v)) => v, + _ => panic!("task must finish"), + } +} +fn drive(executor: &mut LocalExecutor, task: &mut turnloop::JoinHandle) -> T { + let at = executor.handle().now() + Duration::from_secs(90); + while !task.is_finished() { + assert!(executor.handle().now() < at, "test deadline"); + executor.turn(Timeout::Until(at)).expect("turn"); + } + finish(task) +} +async fn exact(s: &mut S, mut bytes: &mut [u8]) { + while !bytes.is_empty() { + let n = read(s, bytes).await.expect("read"); + assert!(n > 0, "unexpected EOF"); + bytes = &mut bytes[n..]; + } +} +use turnloop_redis::{ + asynchronous::{Client, ConnectOptions}, + resp::Value, +}; +fn options(address: std::net::SocketAddr) -> ConnectOptions { + ConnectOptions { + address, + protocol: turnloop_redis::Config { + prefer_resp3: false, + ..Default::default() + }, + tls: None, + retry_delay: Duration::from_millis(2), + max_reconnects: 2, + } +} +async fn incr(s: &mut S) { + let mut b = [0; 27]; + exact(s, &mut b).await; + assert_eq!(&b, b"*2\r\n$4\r\nINCR\r\n$7\r\ncounter\r\n"); +} +#[test] +fn pipeline_reconnect_replays_and_timeout_closes() { + let mut ex = LocalExecutor::::new(LoopConfig::default()).expect("executor"); + let h = ex.handle(); + let listener = Listener::bind(&h, "127.0.0.1:0".parse().expect("address")).expect("listen"); + let address = listener.local_addr().expect("address"); + let mut server = ex + .spawn_local(async move { + let mut first = listener.accept().await.expect("accept"); + incr(&mut first).await; + drop(first); + let mut s = listener.accept().await.expect("reconnect"); + incr(&mut s).await; + incr(&mut s).await; + write_all(&mut s, b":1\r\n:2\r\n").await.expect("replies"); + incr(&mut s).await; + let mut b = [0]; + assert_eq!(read(&mut s, &mut b).await.expect("cancel EOF"), 0); + }) + .expect("spawn"); + let mut client = ex + .spawn_local(async move { + let at = h.now() + Duration::from_secs(3); + let mut c = Client::connect(&h, &options(address), at) + .await + .expect("connect"); + let mut replies = 0; + c.pipeline( + &[&[b"INCR", b"counter"], &[b"INCR", b"counter"]], + at, + |i, value| { + assert_eq!(value.expect("reply"), Value::Integer(i as i64 + 1)); + replies += 1; + Ok(()) + }, + ) + .await + .expect("pipeline"); + assert_eq!(replies, 2); + assert_eq!(c.reconnect_count(), 1); + assert_eq!( + c.command(&[b"INCR", b"counter"], h.now() + Duration::from_millis(2)) + .await + .expect_err("deadline") + .kind(), + std::io::ErrorKind::TimedOut + ); + assert!(!c.is_connected()); + }) + .expect("spawn"); + drive(&mut ex, &mut client); + drive(&mut ex, &mut server); +} +#[test] +fn warmed_async_pipeline_allocates_zero() { + let mut ex = LocalExecutor::::new(LoopConfig::default()).expect("executor"); + let h = ex.handle(); + let listener = Listener::bind(&h, "127.0.0.1:0".parse().expect("address")).expect("listen"); + let address = listener.local_addr().expect("address"); + let mut server = ex + .spawn_local(async move { + let mut s = listener.accept().await.expect("accept"); + for _ in 0..1001 { + incr(&mut s).await; + incr(&mut s).await; + write_all(&mut s, b":41\r\n:42\r\n").await.expect("reply"); + } + 2002 + }) + .expect("server"); + let mut client = ex + .spawn_local(async move { + count::prove_counter().await; + let at = h.now() + Duration::from_secs(30); + let mut c = Client::connect(&h, &options(address), at) + .await + .expect("connect"); + let mut replies = 0; + for i in 0..1001 { + let (result, n) = count::measure(c.pipeline( + &[&[b"INCR", b"counter"], &[b"INCR", b"counter"]], + at, + |j, v| { + assert_eq!(v.expect("value"), Value::Integer(41 + j as i64)); + replies += 1; + Ok(()) + }, + )) + .await; + result.expect("pipeline"); + if i > 0 { + assert_eq!(n, 0, "pipeline async overhead"); + } + } + assert_eq!(replies, 2002); + }) + .expect("client"); + drive(&mut ex, &mut client); + assert_eq!(drive(&mut ex, &mut server), 2002); +} diff --git a/protocols/turnloop-smtp/Cargo.toml b/protocols/turnloop-smtp/Cargo.toml index 50baa38..8c9dff0 100644 --- a/protocols/turnloop-smtp/Cargo.toml +++ b/protocols/turnloop-smtp/Cargo.toml @@ -12,6 +12,7 @@ documentation = "https://docs.rs/turnloop-smtp" description = "Pull-driven SMTP client and MIME message builder" [dependencies] +turnloop-tls = { workspace = true, optional = true, features = ["turnloop"] } turnloop-io = { workspace = true, optional = true } base64.workspace = true lettre.workspace = true @@ -26,10 +27,26 @@ workspace = true [package.metadata.turnloop-ci] role = "protocol" -integration-tests = ["smtp"] +wasi-tests = ["asynchronous"] +# Pinned p3 compiler: custom allocator traps before main in debug (wasm2 report). +wasi-p3-release-tests = ["asynchronous"] +wasi-integration-tests = ["async_server"] +integration-tests = ["smtp", "async_server"] [features] -turnloop = ["dep:turnloop-io"] +turnloop = ["dep:turnloop-io", "dep:turnloop-tls"] [target.'cfg(all(target_os = "wasi", target_env = "p3"))'.dependencies] turnloop-io = { workspace = true, optional = true, features = ["wasi-p3-experimental"] } + +[[test]] +name = "asynchronous" +required-features = ["turnloop"] + +[[test]] +name = "async_server" +required-features = ["turnloop"] + +[[example]] +name = "turnloop" +required-features = ["turnloop"] diff --git a/protocols/turnloop-smtp/README.md b/protocols/turnloop-smtp/README.md index 3b2557b..722c181 100644 --- a/protocols/turnloop-smtp/README.md +++ b/protocols/turnloop-smtp/README.md @@ -57,6 +57,37 @@ runs an installed `/usr/libexec/postfix/smtp-sink` on a private loopback port an asserts the saved message bytes. It starts/stops its own child and writes only under workspace `.tools/`. Test certificate keys are public fixture data. +## Getting started on turnloop + +Enable `turnloop-smtp = { version = "0.1.0-alpha.1", features = ["turnloop"] }` +and use the `asynchronous` module: `Transport::connect and send`. The default feature set remains sans-I/O. +The adapter reuses `turnloop-io`; the host owns `LocalExecutor` and calls `turn`. +Spawn local futures through its handle and keep their `JoinHandle`s until completion. + +```sh +cargo run -p turnloop-smtp --features turnloop --example turnloop +``` + +[The complete example](examples/turnloop.rs) connects to `127.0.0.1:2525` by default; +`TURNLOOP_DB_ADDR` overrides that development endpoint. All operations take an +absolute `turnloop_io::Instant` deadline, shared across authentication, I/O and retries. +Callbacks borrow row/reply storage; copy values only when retaining them. +Dropping a pending operation closes its transport before a pool can reuse it. + +For TLS, set the protocol's TLS mode and provide +`turnloop_tls::asynchronous::ClientTls` with the trust configuration, verified +server name and current Unix seconds. Certificates are verified; a requested TLS +upgrade without a configuration fails. Native TCP and WASI 0.2 sockets share the +same driver. Browser raw TCP is unavailable; Windows accepts a host-provided +`Backend` until the repository's production IOCP provider is integrated. + +`send` returns accepted/rejected recipient details from the server, including +partial success; errors preserve the full envelope in `SendFailure`. Authentication, +STARTTLS/implicit TLS and advertised PIPELINING are handled by the existing core. +The allocation gate counts only the three existing owned-result allocations for +a one-recipient delivery; the async transport adds zero allocations after warm-up. + + ## Getting started on turnloop Enable the `turnloop` feature and construct `asynchronous::AsyncConnection::new` diff --git a/protocols/turnloop-smtp/examples/turnloop.rs b/protocols/turnloop-smtp/examples/turnloop.rs new file mode 100644 index 0000000..ef72e9d --- /dev/null +++ b/protocols/turnloop-smtp/examples/turnloop.rs @@ -0,0 +1,70 @@ +//! A local plaintext development-server example. For production TLS, supply +//! ClientTls and enable the protocol's TLS option; see the crate README. +use std::time::Duration; +use turnloop_io::{Backend, ExecutorHandle}; +pub async fn example(h: ExecutorHandle) -> std::io::Result<()> { + let address = std::env::var("TURNLOOP_DB_ADDR") + .unwrap_or_else(|_| "127.0.0.1:2525".into()) + .parse::() + .map_err(std::io::Error::other)?; + let at = h.now() + Duration::from_secs(30); + use turnloop_smtp::{ + Envelope, Tls, + asynchronous::{ConnectOptions, Transport}, + }; + let options = ConnectOptions { + address, + protocol: turnloop_smtp::Config { + tls: Tls::None, + ..Default::default() + }, + tls: None, + }; + let mut transport = Transport::connect(&h, &options, at).await?; + let info = transport + .send( + Envelope { + from: "sender@example.test".into(), + to: vec!["recipient@example.test".into()], + }, + "turnloop-example".into(), + b"Subject: turnloop example\r\n\r\nHello from the host-driven loop.\r\n", + at, + ) + .await?; + println!( + "accepted: {:?}; rejected: {:?}", + info.accepted, info.rejected + ); + Ok(()) +} +#[cfg(not(any(windows, all(target_arch = "wasm32", target_os = "unknown"))))] +fn main() -> std::io::Result<()> { + use std::{ + future::Future, + pin::Pin, + task::{Context, Poll, Waker}, + }; + use turnloop_io::turnloop::{Config, LocalExecutor, Timeout, backend::Platform}; + let mut executor = + LocalExecutor::::new(Config::default()).map_err(turnloop_io::error)?; + let h = executor.handle(); + let mut task = executor + .spawn_local(example(h)) + .map_err(turnloop_io::error)?; + while !task.is_finished() { + executor + .turn(Timeout::After(Duration::from_secs(30))) + .map_err(turnloop_io::error)?; + } + match Pin::new(&mut task).poll(&mut Context::from_waker(Waker::noop())) { + Poll::Ready(Ok(result)) => result, + _ => Err(std::io::Error::other("example task did not complete")), + } +} +#[cfg(any(windows, all(target_arch = "wasm32", target_os = "unknown")))] +fn main() { + eprintln!( + "Embed example(handle) with a host-provided TCP Backend; this target has no built-in TCP Platform provider." + ); +} diff --git a/protocols/turnloop-smtp/src/asynchronous.rs b/protocols/turnloop-smtp/src/asynchronous.rs index 2a34ee5..0d08d31 100644 --- a/protocols/turnloop-smtp/src/asynchronous.rs +++ b/protocols/turnloop-smtp/src/asynchronous.rs @@ -71,3 +71,7 @@ mod tests { ); } } + +#[path = "transport.rs"] +mod transport; +pub use transport::*; diff --git a/protocols/turnloop-smtp/src/transport.rs b/protocols/turnloop-smtp/src/transport.rs new file mode 100644 index 0000000..0ce1652 --- /dev/null +++ b/protocols/turnloop-smtp/src/transport.rs @@ -0,0 +1,179 @@ +//! SMTP delivery on the host's executor with certificate-verified TLS upgrades. +use super::AsyncConnection; +use crate::{Config, Envelope, Event, Rejection, SendInfo}; +use std::{io, net::SocketAddr}; +use turnloop_io::{AsyncIo, Backend, ExecutorHandle, Instant, Stream, deadline}; +use turnloop_tls::asynchronous::{ClientTls, Transport as Wire}; +#[derive(Clone)] +pub struct ConnectOptions { + pub address: SocketAddr, + pub protocol: Config, + pub tls: Option, +} +/// Complete failure details, including recipient responses already received. +#[derive(Debug)] +pub struct SendFailure { + pub error: crate::Error, + pub envelope: Option, + pub accepted: Vec, + pub rejected: Vec, +} +impl std::fmt::Display for SendFailure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.error.fmt(f) + } +} +impl std::error::Error for SendFailure {} +pub struct Transport> { + executor: ExecutorHandle, + driver: AsyncConnection>, +} +fn now(executor: &ExecutorHandle) -> io::Result { + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + { + let _ = executor; + Err(io::ErrorKind::Unsupported.into()) + } + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] + { + Ok(executor.now()) + } +} +impl Transport { + pub async fn connect( + executor: &ExecutorHandle, + options: &ConnectOptions, + at: Instant, + ) -> io::Result { + now(executor)?; + let stream = deadline(executor, at, async { + executor + .connect(options.address, Default::default()) + .await + .map_err(turnloop_io::error) + }) + .await?; + Self::from_stream( + executor, + stream, + options.protocol.clone(), + options.tls.as_ref(), + at, + ) + .await + } +} +impl Transport { + /// Returns only after greeting, EHLO, TLS and authentication have completed. + pub async fn from_stream( + executor: &ExecutorHandle, + stream: S, + config: Config, + tls: Option<&ClientTls>, + at: Instant, + ) -> io::Result { + let mut core = crate::Connection::new(config).map_err(io::Error::other)?; + core.connected(now(executor)?).map_err(io::Error::other)?; + let mut driver = AsyncConnection::new(Wire::Plain(stream), core); + deadline(executor, at, async { + loop { + let mut upgrade = false; + let mut ready = false; + driver + .next(executor, |event| { + match event { + Event::UpgradeTls => upgrade = true, + Event::Ready => ready = true, + Event::Failed { error, .. } => return Err(io::Error::other(error)), + Event::Closed | Event::CloseTransport => { + return Err(io::ErrorKind::UnexpectedEof.into()); + } + _ => {} + } + Ok(()) + }) + .await?; + if upgrade { + let tls = tls.ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "TLS configuration required") + })?; + driver.flush().await?; + driver.upgrade_stream()?.upgrade(tls, executor, at).await?; + driver + .core_mut() + .tls_established(now(executor)?) + .map_err(io::Error::other)?; + } + if ready { + return Ok(()); + } + } + }) + .await?; + Ok(Self { + executor: executor.clone(), + driver, + }) + } + pub fn capabilities(&self) -> &crate::Capabilities { + self.driver.core().capabilities() + } + pub fn is_reusable(&self) -> bool { + self.driver.is_connected() && self.driver.core().state() == crate::State::Ready + } + pub fn close(&mut self) { + self.driver.abort(); + } + /// MAIL/RCPT use PIPELINING when negotiated. Per-recipient failures are + /// preserved both on successful partial delivery and on total failure. + pub async fn send( + &mut self, + envelope: Envelope, + message_id: String, + message: &[u8], + at: Instant, + ) -> io::Result { + self.driver + .core_mut() + .send(1, envelope, message_id, message, now(&self.executor)?) + .map_err(io::Error::other)?; + let mut exchange = self.driver.exchange(); + let result = deadline(&self.executor, at, async { + loop { + let mut result = None; + exchange + .next(&self.executor, |event| { + match event { + Event::Sent { info, .. } => result = Some(Ok(info)), + Event::Failed { + error, + envelope, + accepted, + rejected, + .. + } => { + result = Some(Err(io::Error::other(SendFailure { + error, + envelope, + accepted, + rejected, + }))) + } + Event::Closed | Event::CloseTransport => { + return Err(io::ErrorKind::UnexpectedEof.into()); + } + _ => {} + } + Ok(()) + }) + .await?; + if let Some(result) = result { + return result; + } + } + }) + .await?; + exchange.commit(); + Ok(result) + } +} diff --git a/protocols/turnloop-smtp/tests/async_server.rs b/protocols/turnloop-smtp/tests/async_server.rs new file mode 100644 index 0000000..f370402 --- /dev/null +++ b/protocols/turnloop-smtp/tests/async_server.rs @@ -0,0 +1,106 @@ +#![cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] +use std::{ + future::Future, + pin::Pin, + task::{Context, Poll, Waker}, + time::Duration, +}; +use turnloop_io::{ + turnloop::{Config as LoopConfig, LocalExecutor, Timeout, backend::Platform}, + *, +}; +fn finish(task: &mut turnloop::JoinHandle) -> T { + match Pin::new(task).poll(&mut Context::from_waker(Waker::noop())) { + Poll::Ready(Ok(v)) => v, + _ => panic!("task must finish"), + } +} +fn drive(executor: &mut LocalExecutor, task: &mut turnloop::JoinHandle) -> T { + let at = executor.handle().now() + Duration::from_secs(90); + while !task.is_finished() { + assert!(executor.handle().now() < at, "test deadline"); + executor.turn(Timeout::Until(at)).expect("turn"); + } + finish(task) +} +use turnloop_smtp::{ + Envelope, Tls, + asynchronous::{ConnectOptions, Transport}, +}; +#[test] +#[ignore = "private Postfix smtp-sink fixture"] +fn real_async_postfix_delivery() { + let port = std::env::var("TURNLOOP_TEST_SMTP_PORT") + .expect("private SMTP port") + .parse::() + .expect("port"); + let mut ex = LocalExecutor::::new(LoopConfig::default()).expect("executor"); + let h = ex.handle(); + let mut task = ex + .spawn_local(async move { + let at = h.now() + Duration::from_secs(5); + let mut t = Transport::connect( + &h, + &ConnectOptions { + address: ([127, 0, 0, 1], port).into(), + protocol: turnloop_smtp::Config { + tls: Tls::None, + ..Default::default() + }, + tls: None, + }, + at, + ) + .await + .expect("SMTP connect"); + let info = t + .send( + Envelope { + from: "sender@example.test".into(), + to: vec!["recipient@example.test".into()], + }, + "async-fixture".into(), + b"Subject: async-turnloop\r\n\r\nasync delivery bytes\r\n", + at, + ) + .await + .expect("delivery"); + assert_eq!(info.response_code, 250); + assert_eq!(info.accepted, ["recipient@example.test"]); + assert!(info.rejected.is_empty()); + }) + .expect("spawn"); + drive(&mut ex, &mut task); + let root = std::path::PathBuf::from( + std::env::var_os("TURNLOOP_TEST_SMTP_TOOLS").expect("dump directory"), + ); + let matching = std::fs::read_dir(root) + .expect("dumps") + .filter_map(|e| { + let p = e.expect("entry").path(); + if !p + .file_name() + .expect("filename") + .to_string_lossy() + .starts_with("message-") + { + return None; + } + let bytes = std::fs::read(p).expect("message"); + bytes + .windows(b"Subject: async-turnloop".len()) + .any(|b| b == b"Subject: async-turnloop") + .then_some(bytes) + }) + .collect::>(); + assert_eq!( + matching.len(), + 1, + "async delivery must reach the actual sink" + ); + assert!( + matching[0] + .windows(b"async delivery bytes".len()) + .any(|b| b == b"async delivery bytes") + ); +} diff --git a/protocols/turnloop-smtp/tests/asynchronous.rs b/protocols/turnloop-smtp/tests/asynchronous.rs new file mode 100644 index 0000000..cc32fa6 --- /dev/null +++ b/protocols/turnloop-smtp/tests/asynchronous.rs @@ -0,0 +1,298 @@ +#![cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] +#[path = "../../../crates/turnloop-io/tests/support/count.rs"] +mod count; +use std::{ + future::Future, + pin::Pin, + task::{Context, Poll, Waker}, + time::Duration, +}; +use turnloop_io::{ + turnloop::{Config as LoopConfig, LocalExecutor, Timeout, backend::Platform}, + *, +}; +fn finish(task: &mut turnloop::JoinHandle) -> T { + match Pin::new(task).poll(&mut Context::from_waker(Waker::noop())) { + Poll::Ready(Ok(v)) => v, + _ => panic!("task must finish"), + } +} +fn drive(executor: &mut LocalExecutor, task: &mut turnloop::JoinHandle) -> T { + let at = executor.handle().now() + Duration::from_secs(90); + while !task.is_finished() { + assert!(executor.handle().now() < at, "test deadline"); + executor.turn(Timeout::Until(at)).expect("turn"); + } + finish(task) +} +async fn exact(s: &mut S, mut bytes: &mut [u8]) { + while !bytes.is_empty() { + let n = read(s, bytes).await.expect("read"); + assert!(n > 0, "unexpected EOF"); + bytes = &mut bytes[n..]; + } +} +use turnloop_smtp::{ + Auth, Envelope, Tls, + asynchronous::{ConnectOptions, Transport}, +}; +use turnloop_tls::asynchronous::ClientTls; +use turnloop_tls::{ + ClientConfig, ClientOptions, ServerConfig, TlsStream, + rustls::pki_types::{CertificateDer, PrivateKeyDer}, +}; +async fn line(s: &mut S) -> Vec { + let mut bytes = Vec::new(); + loop { + let mut b = [0]; + exact(s, &mut b).await; + bytes.push(b[0]); + assert!(bytes.len() < 8192); + if bytes.ends_with(b"\r\n") { + return bytes; + } + } +} +#[test] +fn starttls_auth_pipeline_and_recipient_results() { + for implicit in [false, true] { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("wall time") + .as_secs(); + let server = ServerConfig::new( + vec![CertificateDer::from( + include_bytes!("fixtures/server.der").to_vec(), + )], + PrivateKeyDer::try_from(include_bytes!("fixtures/server-key.der").to_vec()) + .expect("key"), + vec![], + now, + ) + .expect("TLS config"); + let tls = ClientTls { + config: ClientConfig::new( + ClientOptions { + ca: Some(vec![CertificateDer::from( + include_bytes!("fixtures/ca.der").to_vec(), + )]), + alpn: vec![], + ..Default::default() + }, + now, + ) + .expect("client TLS"), + server_name: "localhost".try_into().expect("name"), + unix_seconds: now, + }; + let mut ex = LocalExecutor::::new(LoopConfig::default()).expect("executor"); + let h = ex.handle(); + let sh = h.clone(); + let listener = Listener::bind(&h, "127.0.0.1:0".parse().expect("address")).expect("listen"); + let address = listener.local_addr().expect("address"); + let mut server = ex + .spawn_local(async move { + let mut s = listener.accept().await.expect("accept"); + let at = sh.now() + Duration::from_secs(5); + if !implicit { + write_all(&mut s, b"220 test\r\n").await.expect("greeting"); + assert!(line(&mut s).await.starts_with(b"EHLO")); + write_all(&mut s, b"250-test\r\n250 STARTTLS\r\n") + .await + .expect("EHLO"); + assert_eq!(line(&mut s).await, b"STARTTLS\r\n"); + write_all(&mut s, b"220 ready\r\n").await.expect("STARTTLS"); + } + let mut s = TlsStream::accept(s, &server, &sh, at, now) + .await + .expect("TLS handshake"); + if implicit { + write_all(&mut s, b"220 test\r\n").await.expect("greeting"); + } + assert!(line(&mut s).await.starts_with(b"EHLO")); + write_all(&mut s, b"250-test\r\n250-PIPELINING\r\n250 AUTH PLAIN\r\n") + .await + .expect("EHLO"); + assert!(line(&mut s).await.starts_with(b"AUTH PLAIN ")); + write_all(&mut s, b"235 authenticated\r\n") + .await + .expect("auth"); + assert!(line(&mut s).await.starts_with(b"MAIL FROM:")); + assert_eq!(line(&mut s).await, b"RCPT TO:\r\n"); + assert_eq!(line(&mut s).await, b"RCPT TO:\r\n"); + write_all(&mut s, b"250 sender\r\n250 recipient\r\n550 rejected\r\n") + .await + .expect("envelope"); + assert_eq!(line(&mut s).await, b"DATA\r\n"); + write_all(&mut s, b"354 go\r\n").await.expect("DATA"); + assert_eq!(line(&mut s).await, b"..payload\r\n"); + assert_eq!(line(&mut s).await, b".\r\n"); + write_all(&mut s, b"250 queued\r\n").await.expect("sent"); + }) + .expect("spawn"); + let mut client = ex + .spawn_local(async move { + let at = h.now() + Duration::from_secs(5); + let mut t = Transport::connect( + &h, + &ConnectOptions { + address, + protocol: turnloop_smtp::Config { + tls: if implicit { + Tls::Implicit + } else { + Tls::Required + }, + auth: Some(Auth::Plain { + user: "lane".into(), + password: "test".into(), + }), + ..Default::default() + }, + tls: Some(tls), + }, + at, + ) + .await + .expect("connect"); + assert!(t.capabilities().pipelining); + let info = t + .send( + Envelope { + from: "a@example.test".into(), + to: vec!["ok@example.test".into(), "bad@example.test".into()], + }, + "message-id".into(), + b".payload\n", + at, + ) + .await + .expect("send"); + assert_eq!(info.accepted, ["ok@example.test"]); + assert_eq!(info.rejected.len(), 1); + assert_eq!(info.rejected[0].error.response_code, Some(550)); + assert_eq!(info.response_code, 250); + }) + .expect("spawn"); + drive(&mut ex, &mut client); + drive(&mut ex, &mut server); + } +} +#[test] +fn warmed_async_send_allocates_only_owned_result() { + let mut ex = LocalExecutor::::new(LoopConfig::default()).expect("executor"); + let h = ex.handle(); + let listener = Listener::bind(&h, "127.0.0.1:0".parse().expect("address")).expect("listen"); + let address = listener.local_addr().expect("address"); + let mut server = ex + .spawn_local(async move { + let mut s = listener.accept().await.expect("accept"); + write_all(&mut s, b"220 test\r\n").await.expect("hello"); + assert!(line(&mut s).await.starts_with(b"EHLO")); + write_all(&mut s, b"250-test\r\n250 PIPELINING\r\n") + .await + .expect("hello"); + for _ in 0..101 { + assert!(line(&mut s).await.starts_with(b"MAIL FROM:")); + assert_eq!(line(&mut s).await, b"RCPT TO:\r\n"); + write_all(&mut s, b"250 sender\r\n250 recipient\r\n") + .await + .expect("envelope"); + assert_eq!(line(&mut s).await, b"DATA\r\n"); + write_all(&mut s, b"354 go\r\n").await.expect("DATA"); + assert_eq!(line(&mut s).await, b"payload\r\n"); + assert_eq!(line(&mut s).await, b".\r\n"); + write_all(&mut s, b"250 queued\r\n").await.expect("sent"); + } + 101 + }) + .expect("server"); + let mut client=ex.spawn_local(async move {count::prove_counter().await;let at=h.now()+Duration::from_secs(30);let mut c=Transport::connect(&h,&ConnectOptions{address,protocol:turnloop_smtp::Config{tls:Tls::None,..Default::default()},tls:None},at).await.expect("connect");for i in 0..101 {let envelope=Envelope{from:"a@example.test".into(),to:vec!["ok@example.test".into()]};let id="id@example.test".into();let (result,n)=count::measure(c.send(envelope,id,b"payload\r\n",at)).await;let info=result.expect("send");assert_eq!(info.accepted,["ok@example.test"]);assert_eq!(info.response_code,250);if i>0 {assert_eq!(n,3,"only owned accepted vector, recipient and response; zero adapter allocations");}}}).expect("client"); + drive(&mut ex, &mut client); + assert_eq!(drive(&mut ex, &mut server), 101); +} + +#[test] +fn idle_transport_parks_and_cancelled_send_closes_socket() { + let mut ex = LocalExecutor::::new(LoopConfig::default()).expect("executor"); + let h = ex.handle(); + let listener = Listener::bind(&h, "127.0.0.1:0".parse().expect("address")).expect("listen"); + let address = listener.local_addr().expect("address"); + let mut server = ex + .spawn_local(async move { + let mut s = listener.accept().await.expect("accept"); + write_all(&mut s, b"220 test\r\n").await.expect("hello"); + assert!(line(&mut s).await.starts_with(b"EHLO")); + write_all(&mut s, b"250-test\r\n250 PIPELINING\r\n") + .await + .expect("hello"); + assert_eq!(line(&mut s).await, b"MAIL FROM:\r\n"); + assert_eq!(line(&mut s).await, b"RCPT TO:\r\n"); + let mut byte = [0]; + assert_eq!(read(&mut s, &mut byte).await.expect("cancel EOF"), 0); + 2 + }) + .expect("server"); + let idle = std::rc::Rc::new(std::cell::Cell::new(false)); + let client_idle = idle.clone(); + let mut client = ex + .spawn_local(async move { + let at = h.now() + Duration::from_secs(5); + let mut c = Transport::connect( + &h, + &ConnectOptions { + address, + protocol: turnloop_smtp::Config { + tls: Tls::None, + ..Default::default() + }, + tls: None, + }, + at, + ) + .await + .expect("connect"); + client_idle.set(true); + h.sleep(Duration::from_millis(60)) + .await + .expect("idle timer"); + let result = deadline( + &h, + h.now() + Duration::from_millis(20), + c.send( + Envelope { + from: "a@example.test".into(), + to: vec!["ok@example.test".into()], + }, + "message-id".into(), + b"payload", + at, + ), + ) + .await; + assert_eq!( + result.expect_err("drop pending send").kind(), + std::io::ErrorKind::TimedOut + ); + assert!(!c.is_reusable()); + }) + .expect("client"); + let end = ex.handle().now() + Duration::from_secs(5); + while !idle.get() { + assert!(ex.handle().now() < end); + ex.turn(Timeout::Until(end)).expect("turn"); + } + let mut waited = false; + for _ in 0..8 { + let before = ex.handle().now(); + let info = ex.turn(Timeout::Until(end)).expect("idle turn"); + if ex.handle().now().duration_since(before) >= Duration::from_millis(10) { + assert_eq!(info.os_waits, 1); + waited = true; + break; + } + } + assert!(waited, "idle SMTP transport spun instead of parking"); + drive(&mut ex, &mut client); + assert_eq!(drive(&mut ex, &mut server), 2); +} diff --git a/protocols/turnloop-tls/Cargo.toml b/protocols/turnloop-tls/Cargo.toml index a2922c0..7c0856f 100644 --- a/protocols/turnloop-tls/Cargo.toml +++ b/protocols/turnloop-tls/Cargo.toml @@ -18,6 +18,7 @@ turnloop = ["dep:turnloop-io"] turnloop-io = { workspace = true, optional = true } rustls.workspace = true +ring.workspace = true webpki-roots.workspace = true [dev-dependencies] @@ -34,7 +35,7 @@ workspace = true role = "protocol" service-group = "http" integration-tests = ["tls", "asynchronous", "async_allocations"] -wasi-tests = ["asynchronous", "async_allocations", "portable"] +wasi-tests = ["asynchronous", "async_allocations", "portable", "channel_binding"] [[test]] name = "portable" diff --git a/protocols/turnloop-tls/README.md b/protocols/turnloop-tls/README.md index e49a37c..3bc3200 100644 --- a/protocols/turnloop-tls/README.md +++ b/protocols/turnloop-tls/README.md @@ -24,6 +24,14 @@ and compose with HTTP/WebSocket adapters. `close` flushes close_notify; dropping aborts the transport. Refresh host wall time with `set_unix_seconds` when needed. The host continues calling `LocalExecutor::turn` throughout the connection. +After a successful handshake, `peer_certificates()` borrows the peer chain, +leaf first, without copying. `tls_server_end_point(leaf.as_ref())` derives the +RFC 5929 certificate binding with ring and no heap allocation. It reads the outer +certificate signature algorithm, supports RSA/ECDSA SHA-256/384/512 and RSA-PSS, +and maps MD5/SHA-1 to SHA-256. Unsupported algorithms such as Ed25519 return +`None`. This helper does not verify certificates: verification follows the TLS +configuration, including any explicitly insecure host option. + The adapter retains ciphertext/plaintext storage and uses rustls's unbuffered state machine. rustls 0.23's plaintext record representation still allocates; transport scratch allocation freedom is distinct from upstream crypto storage. diff --git a/protocols/turnloop-tls/src/asynchronous.rs b/protocols/turnloop-tls/src/asynchronous.rs index b23d148..55ac029 100644 --- a/protocols/turnloop-tls/src/asynchronous.rs +++ b/protocols/turnloop-tls/src/asynchronous.rs @@ -10,6 +10,105 @@ use std::{ }; use turnloop_io::{AsyncRead, AsyncWrite, Backend, ExecutorHandle, Instant, Stream}; +/// Certificate verification and wall time supplied by the embedding host. +#[derive(Clone)] +pub struct ClientTls { + pub config: ClientConfig, + pub server_name: rustls::pki_types::ServerName<'static>, + pub unix_seconds: u64, +} +/// A stream that can cross a protocol's explicit STARTTLS boundary once. +/// TLS state is allocated at connection setup, never per operation. +pub enum Transport { + Plain(S), + Tls(Box>), + Closed, +} +impl Transport { + pub async fn upgrade( + &mut self, + tls: &ClientTls, + executor: &ExecutorHandle, + at: Instant, + ) -> io::Result<()> { + let Self::Plain(stream) = std::mem::replace(self, Self::Closed) else { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "TLS already negotiated", + )); + }; + *self = Self::Tls(Box::new( + TlsStream::connect( + stream, + &tls.config, + tls.server_name.clone(), + executor, + at, + tls.unix_seconds, + ) + .await?, + )); + Ok(()) + } + pub fn is_tls(&self) -> bool { + matches!(self, Self::Tls(_)) + } + /// Handshaken peer chain, leaf first; absent on plaintext/closed transports. + pub fn peer_certificates(&self) -> Option<&[rustls::pki_types::CertificateDer<'static>]> { + match self { + Self::Tls(s) => s.peer_certificates(), + Self::Plain(_) | Self::Closed => None, + } + } + pub fn get_ref(&self) -> Option<&S> { + match self { + Self::Plain(s) => Some(s), + Self::Tls(s) => Some(s.get_ref()), + Self::Closed => None, + } + } +} +impl AsyncRead for Transport { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + bytes: &mut [u8], + ) -> Poll> { + match self.get_mut() { + Self::Plain(s) => Pin::new(s).poll_read(cx, bytes), + Self::Tls(s) => Pin::new(&mut **s).poll_read(cx, bytes), + Self::Closed => Poll::Ready(Err(io::ErrorKind::NotConnected.into())), + } + } +} +impl AsyncWrite for Transport { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + bytes: &[u8], + ) -> Poll> { + match self.get_mut() { + Self::Plain(s) => Pin::new(s).poll_write(cx, bytes), + Self::Tls(s) => Pin::new(&mut **s).poll_write(cx, bytes), + Self::Closed => Poll::Ready(Err(io::ErrorKind::NotConnected.into())), + } + } + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.get_mut() { + Self::Plain(s) => Pin::new(s).poll_flush(cx), + Self::Tls(s) => Pin::new(&mut **s).poll_flush(cx), + Self::Closed => Poll::Ready(Err(io::ErrorKind::NotConnected.into())), + } + } + fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.get_mut() { + Self::Plain(s) => Pin::new(s).poll_close(cx), + Self::Tls(s) => Pin::new(&mut **s).poll_close(cx), + Self::Closed => Poll::Ready(Ok(())), + } + } +} + trait Endpoint { type Data; fn process<'c, 'i>( @@ -231,6 +330,14 @@ impl TlsStream { Session::Server(s) => s.alpn_protocol(), } } + /// Peer chain from the completed handshake, leaf first. Verification follows + /// the configured verifier, including any explicitly insecure host options. + pub fn peer_certificates(&self) -> Option<&[rustls::pki_types::CertificateDer<'static>]> { + match &self.session { + Session::Client(c) => c.peer_certificates(), + Session::Server(s) => s.peer_certificates(), + } + } /// Refresh wall time supplied by the embedding host. pub fn set_unix_seconds(&mut self, seconds: u64) { self.unix_seconds = seconds; diff --git a/protocols/turnloop-tls/src/channel_binding.rs b/protocols/turnloop-tls/src/channel_binding.rs new file mode 100644 index 0000000..5ca6c04 --- /dev/null +++ b/protocols/turnloop-tls/src/channel_binding.rs @@ -0,0 +1,167 @@ +//! RFC 5929 certificate binding. This reads only the outer certificate envelope +//! and signature AlgorithmIdentifier; certificate verification belongs to rustls. +use ring::digest::{self, Algorithm, Digest, SHA256, SHA384, SHA512}; + +/// Hash a verified server leaf's DER for RFC 5929 §4.1 tls-server-end-point. +/// RSA/ECDSA SHA-256/384/512 and RSA-PSS parameters are supported. MD5/SHA-1 +/// signatures use SHA-256. Unknown algorithms (including Ed25519), malformed DER +/// and undefined bindings return `None`. This helper does not verify certificates +/// and performs no allocation; call it after successful TLS authentication. +pub fn tls_server_end_point(certificate: &[u8]) -> Option { + Some(digest::digest(signature_hash(certificate)?, certificate)) +} + +const RSA: &[u8] = b"\x2a\x86\x48\x86\xf7\x0d\x01\x01"; +const ECDSA: &[u8] = b"\x2a\x86\x48\xce\x3d\x04\x03"; +const SHA2: &[u8] = b"\x60\x86\x48\x01\x65\x03\x04\x02"; +const SHA1: &[u8] = b"\x2b\x0e\x03\x02\x1a"; + +/// Definite, minimally encoded DER lengths only. Each sub-reader is bounded to +/// its parent value; no offsets, recursion or heap storage escape that slice. +struct Der<'a>(&'a [u8]); +impl<'a> Der<'a> { + fn byte(&mut self) -> Option { + let (&first, rest) = self.0.split_first()?; + self.0 = rest; + Some(first) + } + fn value(&mut self, tag: u8) -> Option<&'a [u8]> { + if self.byte()? != tag { + return None; + } + let first = self.byte()?; + let len = if first < 128 { + first as usize + } else { + let count = (first & 127) as usize; + if count == 0 || count > size_of::() || self.0.first() == Some(&0) { + return None; + } + let mut len = 0usize; + for _ in 0..count { + len = len.checked_mul(256)?.checked_add(self.byte()? as usize)?; + } + if len < 128 { + return None; + } + len + }; + let (value, rest) = self.0.split_at_checked(len)?; + self.0 = rest; + Some(value) + } + fn end(self) -> Option<()> { + self.0.is_empty().then_some(()) + } + fn null_or_absent(self) -> Option<()> { + matches!(self.0, [] | [5, 0]).then_some(()) + } + fn integer(&mut self) -> Option { + let value = self.value(2)?; + let first = *value.first()?; + if first & 128 != 0 || (value.len() > 1 && first == 0 && value[1] & 128 == 0) { + return None; + } + value + .iter() + .try_fold(0usize, |n, b| n.checked_mul(256)?.checked_add(*b as usize)) + } +} + +fn signature_hash(certificate: &[u8]) -> Option<&'static Algorithm> { + let mut der = Der(certificate); + let mut cert = Der(der.value(0x30)?); + der.end()?; + if cert.value(0x30)?.is_empty() { + // TBSCertificate is opaque to this reader. + return None; + } + let mut alg = Der(cert.value(0x30)?); + let oid = alg.value(6)?; + // Certificate signatures are octet-aligned BIT STRINGs, not empty strings. + let signature = cert.value(3)?; + if signature.len() < 2 || signature[0] != 0 { + return None; + } + cert.end()?; + if let Some(suffix) = oid.strip_prefix(RSA) { + if suffix == [10] { + // id-RSASSA-PSS + let params = alg.value(0x30)?; + alg.end()?; + return pss_hash(params); + } + alg.null_or_absent()?; + match suffix { + [4 | 5 | 11] => Some(&SHA256), // MD5, SHA-1, SHA-256 + [12] => Some(&SHA384), + [13] => Some(&SHA512), + _ => None, + } + } else if let Some(suffix) = oid.strip_prefix(ECDSA) { + alg.end()?; // ECDSA parameters MUST be absent. + match suffix { + [2] => Some(&SHA256), + [3] => Some(&SHA384), + [4] => Some(&SHA512), + _ => None, + } + } else { + None + } +} + +fn hash_identifier(bytes: &[u8]) -> Option<&'static Algorithm> { + let mut der = Der(bytes); + let mut alg = Der(der.value(0x30)?); + der.end()?; + let oid = alg.value(6)?; + alg.null_or_absent()?; + if oid == SHA1 { + return Some(&SHA256); + } + match oid.strip_prefix(SHA2)? { + [1] => Some(&SHA256), + [2] => Some(&SHA384), + [3] => Some(&SHA512), + _ => None, + } +} + +fn pss_hash(parameters: &[u8]) -> Option<&'static Algorithm> { + // RFC 4055: absent hashAlgorithm defaults to SHA-1 (RFC 5929 -> SHA-256). + // MGF1's hash is validated separately; it does not select the binding hash. + let mut hash = &SHA256; + let mut params = Der(parameters); + let mut previous = 0; + while let Some(&tag) = params.0.first() { + if !(0xa0..=0xa3).contains(&tag) || tag <= previous { + return None; + } + previous = tag; + let field = params.value(tag)?; + match tag { + 0xa0 => hash = hash_identifier(field)?, + 0xa1 => { + let mut der = Der(field); + let mut mgf = Der(der.value(0x30)?); + der.end()?; + if mgf.value(6)?.strip_prefix(RSA)? != [8] { + // id-mgf1 + return None; + } + hash_identifier(mgf.0)?; + } + 0xa2 | 0xa3 => { + let mut der = Der(field); + let integer = der.integer()?; + der.end()?; + if tag == 0xa3 && integer != 1 { + return None; + } + } + _ => return None, + } + } + Some(hash) +} diff --git a/protocols/turnloop-tls/src/lib.rs b/protocols/turnloop-tls/src/lib.rs index 267d0f1..bd0f365 100644 --- a/protocols/turnloop-tls/src/lib.rs +++ b/protocols/turnloop-tls/src/lib.rs @@ -146,6 +146,9 @@ impl ServerConfig { } } +mod channel_binding; +pub use channel_binding::tls_server_end_point; + macro_rules! endpoint { ($name:ident, $connection:ty, $data:ty) => { pub struct $name { @@ -173,6 +176,13 @@ macro_rules! endpoint { pub fn alpn_protocol(&self) -> Option<&[u8]> { self.inner.alpn_protocol() } + /// Peer chain, leaf first. Verification follows the configured verifier; + /// consume it only after a successful handshake. + pub fn peer_certificates( + &self, + ) -> Option<&[rustls::pki_types::CertificateDer<'static>]> { + self.inner.peer_certificates() + } pub fn is_handshaking(&self) -> bool { self.inner.is_handshaking() } diff --git a/protocols/turnloop-tls/tests/async_allocations.rs b/protocols/turnloop-tls/tests/async_allocations.rs index 25627a4..06e311d 100644 --- a/protocols/turnloop-tls/tests/async_allocations.rs +++ b/protocols/turnloop-tls/tests/async_allocations.rs @@ -134,6 +134,25 @@ mod native { ) .await .expect("handshake"); + let expected_leaf = cert.cert.der(); + let expected_digest = turnloop_tls::tls_server_end_point(expected_leaf) + .expect("ECDSA SHA-256 certificate binding"); + let mut bindings = 0; + assert_eq!( + measure(|| { + for _ in 0..1000 { + let chain = tls.peer_certificates().expect("verified peer chain"); + assert_eq!(chain, std::slice::from_ref(expected_leaf)); + let digest = turnloop_tls::tls_server_end_point(chain[0].as_ref()) + .expect("binding"); + assert_eq!(digest.as_ref(), expected_digest.as_ref()); + bindings += 1; + } + }), + 0, + "peer chain access and binding must allocate zero" + ); + assert_eq!(bindings, 1000); for round in 0..110 { if round == 10 { COUNT.store(0, Ordering::Relaxed); diff --git a/protocols/turnloop-tls/tests/certificates/README.md b/protocols/turnloop-tls/tests/certificates/README.md new file mode 100644 index 0000000..fa8a51e --- /dev/null +++ b/protocols/turnloop-tls/tests/certificates/README.md @@ -0,0 +1,10 @@ +# RFC 5929 test certificates + +Generated locally with `python3 generate.py` and OpenSSL 3.6.3. All fourteen DER +files are real self-signed X.509 certificates; no private keys are retained. +The generator records independent Python hashlib binding digests in `digests.txt`. +Tests include the certificates and expectations and need no OpenSSL at runtime. +RSA covers MD5/SHA-1/SHA-256/384/512, ECDSA covers SHA-256/384/512, and RSA-PSS +covers default SHA-1 plus SHA-256/384/512 and a differing MGF1 digest. Ed25519 +exercises undefined channel binding. Weak signatures are parser fixtures only; +they are never trusted for production or handshake acceptance tests. diff --git a/protocols/turnloop-tls/tests/certificates/digests.txt b/protocols/turnloop-tls/tests/certificates/digests.txt new file mode 100644 index 0000000..6cac069 --- /dev/null +++ b/protocols/turnloop-tls/tests/certificates/digests.txt @@ -0,0 +1,14 @@ +rsa-md5 a4aca4a9dfe15cb214f3dad0e6f7a543e892236317ef00a72d944eb74de9852b +rsa-sha1 9972db6895027d8f720b5051336dce0f1cb976e82a890deb1dd51354bdb40461 +rsa-sha256 86d839ce5d95e0b2a1f31c08002a92d72101d95fc10eda21a8fd0a539782996c +rsa-sha384 da26a8505b6c1629160baa27f1b4e01a4e35013267ddcb42b59f046c813cd08747750013b5cb90d2fac16748160bc8df +rsa-sha512 a5d88cd71930d55289386bf2d4d4d7192bbe108f0dc379624b0b6c12a9e114aec3fc3f96bc1824a145b1c52ac9168b81b23ed6e0c045e18722ffb1080eebd799 +ecdsa-sha256 17f039f4fddfcfc293b274b34c96fdf3007968f109397f13fbbe8e08400de48c +ecdsa-sha384 4a88b8318d62434472f7fad96c51837b04a1cc22d2b595ba7b2a4ef62e98e0006d68854720b1949126131191a0916061 +ecdsa-sha512 0e97505f31b5a7803847af622448f4e5e08545b4277a766c755dcae0c260339bb6778af4c2f298057683f2506205905e32290282c828f991aeef0dc3beb39e5e +pss-sha1 701c51a4205d2205b9f4818511cd5d6e2362583526125e5bb7260029a1fb7e1a +pss-sha256 1a4d72c633e8cc8a658212a74406f8a24508a863b7a078eb95ae804e62849247 +pss-sha384 4d542b6f50b0c37264be1b80069126c4f767c856da7d9c2b306bccaad5d355860afbee3300162efd05c1683a0f56aa9a +pss-sha512 b3221c604362c3488c041bcd20b0a04ace202af5749643271ce0042543e69f36b2c386b1c244ecbdce1b851761a6239610ac9e4fc6c48d9e67d39358dbfa0ec0 +pss-sha384-mgf256 6ef5bfa0bec62c9cb92de9f18c9bd56bebd05dfb0807dfb9cd90a41a5aef1e13bd144ab14e8b5f48a2f52cc898156911 +ed25519 - diff --git a/protocols/turnloop-tls/tests/certificates/ecdsa-sha256.der b/protocols/turnloop-tls/tests/certificates/ecdsa-sha256.der new file mode 100644 index 0000000..e4c7821 Binary files /dev/null and b/protocols/turnloop-tls/tests/certificates/ecdsa-sha256.der differ diff --git a/protocols/turnloop-tls/tests/certificates/ecdsa-sha384.der b/protocols/turnloop-tls/tests/certificates/ecdsa-sha384.der new file mode 100644 index 0000000..ac7d645 Binary files /dev/null and b/protocols/turnloop-tls/tests/certificates/ecdsa-sha384.der differ diff --git a/protocols/turnloop-tls/tests/certificates/ecdsa-sha512.der b/protocols/turnloop-tls/tests/certificates/ecdsa-sha512.der new file mode 100644 index 0000000..6f214b3 Binary files /dev/null and b/protocols/turnloop-tls/tests/certificates/ecdsa-sha512.der differ diff --git a/protocols/turnloop-tls/tests/certificates/ed25519.der b/protocols/turnloop-tls/tests/certificates/ed25519.der new file mode 100644 index 0000000..bdaa567 Binary files /dev/null and b/protocols/turnloop-tls/tests/certificates/ed25519.der differ diff --git a/protocols/turnloop-tls/tests/certificates/generate.py b/protocols/turnloop-tls/tests/certificates/generate.py new file mode 100644 index 0000000..2c7397e --- /dev/null +++ b/protocols/turnloop-tls/tests/certificates/generate.py @@ -0,0 +1,34 @@ +"""Regenerate public certificate fixtures with OpenSSL; private keys are temporary.""" +import hashlib +import pathlib +import subprocess +import tempfile + +ROOT = pathlib.Path(__file__).resolve().parent + + +def openssl(*args): + subprocess.run(['openssl', *map(str, args)], check=True, capture_output=True) + + +with tempfile.TemporaryDirectory() as work: + work = pathlib.Path(work) + rsa = work / 'rsa.pem' + ec = work / 'ec.pem' + ed = work / 'ed.pem' + openssl('genpkey', '-algorithm', 'RSA', '-pkeyopt', 'rsa_keygen_bits:2048', '-out', rsa) + openssl('genpkey', '-algorithm', 'EC', '-pkeyopt', 'ec_paramgen_curve:P-256', '-out', ec) + openssl('genpkey', '-algorithm', 'ED25519', '-out', ed) + cases = [(f'rsa-{h}', rsa, h, []) for h in ['md5', 'sha1', 'sha256', 'sha384', 'sha512']] + cases += [(f'ecdsa-{h}', ec, h, []) for h in ['sha256', 'sha384', 'sha512']] + cases += [(f'pss-{h}', rsa, h, ['-sigopt', 'rsa_padding_mode:pss', '-sigopt', 'rsa_pss_saltlen:digest']) for h in ['sha1', 'sha256', 'sha384', 'sha512']] + cases += [('pss-sha384-mgf256', rsa, 'sha384', ['-sigopt', 'rsa_padding_mode:pss', '-sigopt', 'rsa_mgf1_md:sha256'])] + cases += [('ed25519', ed, None, [])] + lines = [] + for name, key, hash_name, options in cases: + path = ROOT / f'{name}.der' + openssl('req', '-new', '-x509', '-key', key, '-subj', '/CN=localhost', '-days', '36500', '-set_serial', '1', '-outform', 'DER', '-out', path, *([f'-{hash_name}'] if hash_name else []), *options) + binding_hash = 'sha256' if hash_name in ('md5', 'sha1') else hash_name + expected = hashlib.new(binding_hash, path.read_bytes()).hexdigest() if binding_hash else '-' + lines.append(f'{name} {expected}\n') + (ROOT / 'digests.txt').write_text(''.join(lines)) diff --git a/protocols/turnloop-tls/tests/certificates/pss-sha1.der b/protocols/turnloop-tls/tests/certificates/pss-sha1.der new file mode 100644 index 0000000..9d3c570 Binary files /dev/null and b/protocols/turnloop-tls/tests/certificates/pss-sha1.der differ diff --git a/protocols/turnloop-tls/tests/certificates/pss-sha256.der b/protocols/turnloop-tls/tests/certificates/pss-sha256.der new file mode 100644 index 0000000..bb94217 Binary files /dev/null and b/protocols/turnloop-tls/tests/certificates/pss-sha256.der differ diff --git a/protocols/turnloop-tls/tests/certificates/pss-sha384-mgf256.der b/protocols/turnloop-tls/tests/certificates/pss-sha384-mgf256.der new file mode 100644 index 0000000..1469cdf Binary files /dev/null and b/protocols/turnloop-tls/tests/certificates/pss-sha384-mgf256.der differ diff --git a/protocols/turnloop-tls/tests/certificates/pss-sha384.der b/protocols/turnloop-tls/tests/certificates/pss-sha384.der new file mode 100644 index 0000000..b539bed Binary files /dev/null and b/protocols/turnloop-tls/tests/certificates/pss-sha384.der differ diff --git a/protocols/turnloop-tls/tests/certificates/pss-sha512.der b/protocols/turnloop-tls/tests/certificates/pss-sha512.der new file mode 100644 index 0000000..b76f863 Binary files /dev/null and b/protocols/turnloop-tls/tests/certificates/pss-sha512.der differ diff --git a/protocols/turnloop-tls/tests/certificates/rsa-md5.der b/protocols/turnloop-tls/tests/certificates/rsa-md5.der new file mode 100644 index 0000000..f38464a Binary files /dev/null and b/protocols/turnloop-tls/tests/certificates/rsa-md5.der differ diff --git a/protocols/turnloop-tls/tests/certificates/rsa-sha1.der b/protocols/turnloop-tls/tests/certificates/rsa-sha1.der new file mode 100644 index 0000000..618f300 Binary files /dev/null and b/protocols/turnloop-tls/tests/certificates/rsa-sha1.der differ diff --git a/protocols/turnloop-tls/tests/certificates/rsa-sha256.der b/protocols/turnloop-tls/tests/certificates/rsa-sha256.der new file mode 100644 index 0000000..24c14ef Binary files /dev/null and b/protocols/turnloop-tls/tests/certificates/rsa-sha256.der differ diff --git a/protocols/turnloop-tls/tests/certificates/rsa-sha384.der b/protocols/turnloop-tls/tests/certificates/rsa-sha384.der new file mode 100644 index 0000000..7133d51 Binary files /dev/null and b/protocols/turnloop-tls/tests/certificates/rsa-sha384.der differ diff --git a/protocols/turnloop-tls/tests/certificates/rsa-sha512.der b/protocols/turnloop-tls/tests/certificates/rsa-sha512.der new file mode 100644 index 0000000..01ee617 Binary files /dev/null and b/protocols/turnloop-tls/tests/certificates/rsa-sha512.der differ diff --git a/protocols/turnloop-tls/tests/channel_binding.rs b/protocols/turnloop-tls/tests/channel_binding.rs new file mode 100644 index 0000000..7a8b9c8 --- /dev/null +++ b/protocols/turnloop-tls/tests/channel_binding.rs @@ -0,0 +1,113 @@ +use turnloop_tls::tls_server_end_point; + +const CERTIFICATES: &[&[u8]] = &[ + include_bytes!("certificates/rsa-md5.der"), + include_bytes!("certificates/rsa-sha1.der"), + include_bytes!("certificates/rsa-sha256.der"), + include_bytes!("certificates/rsa-sha384.der"), + include_bytes!("certificates/rsa-sha512.der"), + include_bytes!("certificates/ecdsa-sha256.der"), + include_bytes!("certificates/ecdsa-sha384.der"), + include_bytes!("certificates/ecdsa-sha512.der"), + include_bytes!("certificates/pss-sha1.der"), + include_bytes!("certificates/pss-sha256.der"), + include_bytes!("certificates/pss-sha384.der"), + include_bytes!("certificates/pss-sha512.der"), + include_bytes!("certificates/pss-sha384-mgf256.der"), + include_bytes!("certificates/ed25519.der"), +]; + +#[test] +fn real_signature_algorithms_match_independent_certificate_digests() { + let expected: Vec<_> = include_str!("certificates/digests.txt").lines().collect(); + assert_eq!(expected.len(), 14); + assert_eq!(CERTIFICATES.len(), expected.len()); + let mut hashes = 0; + for (certificate, expected) in CERTIFICATES.iter().zip(expected) { + let (name, hex) = expected.split_once(' ').expect("name and digest"); + let digest = tls_server_end_point(certificate); + if name == "ed25519" { + assert!(digest.is_none()); + } else { + let digest = digest.expect(name); + let actual: String = digest.as_ref().iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(actual, hex, "{name}"); + hashes += 1; + } + } + assert_eq!(hashes, 13); +} + +#[test] +fn truncated_and_malformed_certificates_never_produce_binding() { + let mut truncated = 0; + for certificate in &CERTIFICATES[..13] { + assert!(tls_server_end_point(certificate).is_some()); + for n in 0..certificate.len() { + assert!( + tls_server_end_point(&certificate[..n]).is_none(), + "prefix {n}" + ); + truncated += 1; + } + let mut trailing = certificate.to_vec(); + trailing.push(0); + assert!(tls_server_end_point(&trailing).is_none()); + let mut tag = certificate.to_vec(); + tag[0] = 0x31; + assert!(tls_server_end_point(&tag).is_none()); + } + assert!(truncated > 5000); + for bad in [ + &[0x30, 0x80][..], // indefinite length + &[0x30, 0x81, 0x7f], // nonminimal length + &[0x30, 0x82, 0, 0x80], // leading zero + &[0x30, 0x89, 1, 0, 0, 0, 0, 0, 0, 0, 0], // overflow + &[0x30, 0xff], + ] { + assert!(tls_server_end_point(bad).is_none()); + } +} + +fn tlv(tag: u8, value: &[u8]) -> Vec { + assert!(value.len() < 128); + [vec![tag, value.len() as u8], value.to_vec()].concat() +} +fn envelope(algorithm: &[u8]) -> Vec { + tlv( + 0x30, + &[tlv(0x30, &[2, 1, 1]), tlv(0x30, algorithm), tlv(3, &[0, 1])].concat(), + ) +} +#[test] +fn malformed_signature_identifiers_and_pss_parameters_are_rejected() { + let pss_oid = tlv(6, b"\x2a\x86\x48\x86\xf7\x0d\x01\x01\x0a"); + let sha256 = tlv(0x30, &tlv(6, b"\x60\x86\x48\x01\x65\x03\x04\x02\x01")); + let hash = tlv(0xa0, &sha256); + let pss = |params: &[u8]| envelope(&[pss_oid.clone(), tlv(0x30, params)].concat()); + assert!(tls_server_end_point(&pss(&[])).is_some()); // SHA-1 default + assert!(tls_server_end_point(&pss(&hash)).is_some()); + for params in [ + vec![0xa0, 0x80], + tlv(0xa0, &[0x30, 0x03, 0x06, 0x01, 0xff]), // unknown hash + tlv(0xa0, &[sha256.clone(), vec![0]].concat()), // trailing hash bytes + [hash.clone(), hash.clone()].concat(), // duplicate + [tlv(0xa2, &[2, 1, 20]), hash].concat(), // out of order + tlv(0xa1, &sha256), // not MGF1 + tlv(0xa2, &[2, 1, 0xff]), // negative salt length + tlv(0xa2, &[2, 2, 0, 20]), // nonminimal integer + tlv(0xa2, &[2, 0]), // empty integer + tlv(0xa3, &[2, 1, 2]), // unsupported trailer + tlv(0xa4, &[]), // unknown parameter + ] { + assert!(tls_server_end_point(&pss(¶ms)).is_none(), "{params:x?}"); + } + assert!(tls_server_end_point(&envelope(&pss_oid)).is_none()); // missing PSS parameters + let rsa = tlv(6, b"\x2a\x86\x48\x86\xf7\x0d\x01\x01\x0b"); + assert!(tls_server_end_point(&envelope(&rsa)).is_some()); + for params in [vec![5, 1, 0], vec![5, 0, 5, 0], vec![2, 1, 1]] { + assert!(tls_server_end_point(&envelope(&[rsa.clone(), params].concat())).is_none()); + } + let ec = tlv(6, b"\x2a\x86\x48\xce\x3d\x04\x03\x02"); + assert!(tls_server_end_point(&envelope(&[ec, vec![5, 0]].concat())).is_none()); +} diff --git a/scripts/ci/run-tests.py b/scripts/ci/run-tests.py index 2dcd4be..05eac80 100755 --- a/scripts/ci/run-tests.py +++ b/scripts/ci/run-tests.py @@ -130,6 +130,8 @@ def main(): parser.add_argument('suite', choices=['native', 'wasi', 'web', 'node', 'loom', 'miri', 'protocol', 'protocol-wasi', 'interop']) parser.add_argument('--manifest-path', default='Cargo.toml') parser.add_argument('--target') + parser.add_argument('--real-servers', action='store_true', help='Require WASI real-server adapter suites using the unified fixture environment') + parser.add_argument('--package', action='append', dest='packages', help='Select protocol members for a local fixture run; CI omits this filter') parser.add_argument('--mode', help='One applicable native CI matrix mode; omitted runs all applicable modes') parser.add_argument('--browser', choices=['chrome', 'firefox']) args = parser.parse_args() @@ -138,6 +140,12 @@ def main(): root = Path(data['workspace_root']) base = cargo(pin) + ['test', '--locked', '--manifest-path', str(Path(args.manifest_path).resolve())] env = os.environ.copy() + if args.packages: + known = {p['name'] for p in members(data)} + if set(args.packages) - known: + fail('Unknown selected protocol package') + if args.real_servers and (args.suite != 'protocol-wasi' or args.target != 'wasm32-wasip2'): + fail('--real-servers applies to protocol-wasi on WASI 0.2') if args.target: base += ['--target', args.target] if args.mode and args.suite not in ('native', 'interop'): @@ -156,10 +164,17 @@ def main(): env['CARGO_TARGET_' + args.target.upper().replace('-', '_') + '_RUNNER'] = str(ROOT / 'scripts/ci/wasmtime-runner.sh') if args.suite == 'protocol-wasi': selected = [(p, settings(p).get('wasi-tests', [])) for p in members(data) - if role(p) in ('protocol', 'codec', 'adapter') and settings(p).get('wasi-tests')] + if role(p) in ('protocol', 'codec', 'adapter') and settings(p).get('wasi-tests') + and (not args.packages or p['name'] in args.packages)] if not selected: fail('No wasi-tests metadata: protocol runtime coverage is required') for package, targets in selected: + if args.real_servers: + env['TURNLOOP_TEST_REQUIRED'] = '1' + for setup in settings(package).get('wasi-setup-tests', []): + checked_tests(cargo(PIN) + ['test', '--locked', '-p', package['name'], '--features', 'turnloop', '--test', setup, + '--', '--include-ignored', '--test-threads=1', '--nocapture'], cwd=root, env=env) + targets = targets + settings(package).get('wasi-integration-tests', []) available = {t['name'] for t in package['targets'] if 'test' in t['kind']} for target in targets: if target not in available: @@ -169,8 +184,13 @@ def main(): target_features = ['--features', ','.join(required)] if required else [] if args.target == 'wasm32-wasip3' and (required or role(package) == 'adapter'): target_features = ['--all-features'] - checked_tests(base + ['-p', package['name'], '--test', target] + target_features + - ['--', '--test-threads=1'], cwd=root, env=env) + # The pinned p3 compiler can trap before main when lowering + # arguments through a debug custom allocator. Keep the actual + # allocation/semantic assertions in release, as the core gate + # does (docs/lanes/wasm2-wasm3.md); never accept zero tests. + profile = ['--release'] if args.target == 'wasm32-wasip3' and target in settings(package).get('wasi-p3-release-tests', []) else [] + checked_tests(base + ['-p', package['name'], '--test', target] + target_features + profile + + ['--', '--test-threads=1'] + (['--include-ignored', '--nocapture'] if target in settings(package).get('wasi-integration-tests', []) else []), cwd=root, env=env) else: for package in select(data, 'core'): if settings(package).get('wasi-lib-tests'): @@ -253,7 +273,7 @@ def main(): fail('Core must mark pure-Rust tests with package.metadata.turnloop-ci.miri-filters') else: env['TURNLOOP_TEST_REQUIRED'] = '1' - packages = select(data, 'protocol') + packages = [p for p in select(data, 'protocol') if not args.packages or p['name'] in args.packages] if args.suite == 'interop': packages = [p for p in packages if settings(p).get('service-group') == 'http'] if not packages: diff --git a/scripts/ci/test_protocol_runner.py b/scripts/ci/test_protocol_runner.py index fd6da8a..1ec1a2b 100644 --- a/scripts/ci/test_protocol_runner.py +++ b/scripts/ci/test_protocol_runner.py @@ -83,6 +83,62 @@ def execute(command, **kwargs): runner.protocol_tests([p], [], ROOT, None) self.assertEqual(len(called), 2) + def test_wasi_real_servers_runs_native_setup_and_requires_actual_execution(self): + runner = module('run-tests') + p = package('mongo') + p['manifest_path'] = str(ROOT / 'protocols/mongo/Cargo.toml') + p['metadata'] = {'turnloop-ci': {'role': 'protocol', 'wasi-tests': ['socket'], + 'wasi-setup-tests': ['bootstrap'], 'wasi-integration-tests': ['real']}} + p['targets'] = [{'name': name, 'kind': ['test'], 'required-features': ['turnloop']} + for name in ('socket', 'bootstrap', 'real')] + data = {'workspace_root': str(ROOT), 'workspace_members': ['mongo'], 'packages': [p]} + argv = ['run-tests.py', 'protocol-wasi', '--target', 'wasm32-wasip2', '--real-servers'] + for count in (2, 0): + with self.subTest(passed=count), tempfile.TemporaryDirectory() as folder: + log = Path(folder) / 'calls.txt' + script = Path(folder) / 'cargo.py' + script.write_text("import pathlib,sys,os\n" + "assert os.environ['TURNLOOP_TEST_REQUIRED'] == '1'\n" + "with pathlib.Path(sys.argv[1]).open('a') as f: f.write(' '.join(sys.argv[2:]) + '\\n')\n" + f"print('test result: ok. {count} passed; 0 failed;')\n") + with patch.object(sys, 'argv', argv), patch.object(runner, 'metadata', return_value=data), \ + patch.object(runner, 'cargo', return_value=[sys.executable, str(script), str(log)]), \ + redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()): + if count: + runner.main() + else: + with self.assertRaises(RuntimeError): + runner.main() + calls = log.read_text().splitlines() + self.assertEqual(len(calls), 3 if count else 1) + self.assertIn('--test bootstrap', calls[0]) + self.assertNotIn('--target', calls[0]) + if count: + self.assertIn('--test socket', calls[1]) + self.assertNotIn('--include-ignored', calls[1]) + self.assertIn('--target wasm32-wasip2', calls[2]) + self.assertIn('--test real', calls[2]) + self.assertIn('--include-ignored', calls[2]) + self.assertIn('--features turnloop', calls[2]) + + def test_p3_allocator_profile_keeps_the_same_suite_and_required_features(self): + runner = module('run-tests') + p = package('db') + p['metadata'] = {'turnloop-ci': {'role': 'protocol', 'wasi-tests': ['asynchronous'], + 'wasi-p3-release-tests': ['asynchronous']}} + p['targets'] = [{'name': 'asynchronous', 'kind': ['test'], 'required-features': ['turnloop']}] + data = {'workspace_root': '/workspace', 'workspace_members': ['db'], 'packages': [p]} + for target in ('wasm32-wasip2', 'wasm32-wasip3'): + with self.subTest(target=target), patch.object(sys, 'argv', ['run-tests.py', 'protocol-wasi', '--target', target]), \ + patch.object(runner, 'metadata', return_value=data), patch.object(runner, 'checked_tests') as checked: + runner.main() + checked.assert_called_once() + command = checked.call_args.args[0] + self.assertEqual(command[command.index('--test') + 1], 'asynchronous') + self.assertEqual('--release' in command, target == 'wasm32-wasip3') + self.assertIn('--all-features' if target.endswith('p3') else '--features', command) + self.assertEqual(command[-2:], ['--', '--test-threads=1']) + if __name__ == '__main__': unittest.main() diff --git a/scripts/ci/wasmtime-runner.sh b/scripts/ci/wasmtime-runner.sh index bf3cbe5..59cc767 100755 --- a/scripts/ci/wasmtime-runner.sh +++ b/scripts/ci/wasmtime-runner.sh @@ -1,4 +1,12 @@ #!/usr/bin/env bash set -euo pipefail script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -exec "$script_dir/../../.tools/bin/wasmtime" run -S inherit-network=y -W timeout=120s "$@" +fixture_args=() +# Pass only the fixture contract, never the host's complete environment. +while IFS= read -r name; do + case "$name" in TURNLOOP_TEST_*) fixture_args+=(--env "$name");; esac +done < <(compgen -e) +# Fixture certificates/dumps use absolute paths supplied by test-servers.py. +root=$(cd -- "$script_dir/../.." && pwd) +if [[ -d "$root/.tools" ]]; then fixture_args+=(--dir "$root/.tools"); fi +exec "$root/.tools/bin/wasmtime" run -S inherit-network=y -S allow-ip-name-lookup=y -W timeout=120s "${fixture_args[@]}" "$@" diff --git a/scripts/test-servers.py b/scripts/test-servers.py index 18449e7..24b6591 100755 --- a/scripts/test-servers.py +++ b/scripts/test-servers.py @@ -362,7 +362,10 @@ def redis_start(): # Old nodes.conf contains stale node endpoints and must not be reused. node_file = directory / 'nodes.conf' node_file.unlink(missing_ok=True) - lines += ['cluster-enabled yes', 'cluster-config-file nodes.conf', + # Replication can write an RDB even when periodic saves are off. + # This private fixture starts a new cluster, never reuses its data. + (directory / 'dump.rdb').unlink(missing_ok=True) + lines += ['enable-debug-command yes', 'cluster-enabled yes', 'cluster-config-file nodes.conf', 'cluster-node-timeout 1000', 'cluster-announce-ip 127.0.0.1', f'cluster-announce-port {value}'] else: @@ -370,7 +373,7 @@ def redis_start(): f'sentinel auth-pass turnloop {TURNLOOP_TEST_REDIS_PASSWORD}'] config.write_text('\n'.join(lines) + '\n') with startup_logs(f'Redis {label}', directory / 'server.log'): - process = private_process([REDIS_SERVER, config] + (['--sentinel'] if label == 'sentinel' else []), directory / 'server.log') + process = private_process([str(Path(shutil.which(REDIS_SERVER) or REDIS_SERVER).resolve()), config] + (['--sentinel'] if label == 'sentinel' else []), directory / 'server.log') REDIS_CHILDREN[process.pid] = process records.append({'pid': process.pid, 'port': value, 'config': str(config)}) REDIS_STATE.write_text(json.dumps(records))