Compare commits

..

36 Commits

Author SHA1 Message Date
Bogdan-Ștefan Neacşu f4bd48263d Apply cargo fmt 2026-06-08 15:32:57 +03:00
rachyandco 5c40052d39 Bugfix: set connection fd callback on LP registration socket
The LP registration client opened its TCP connection to the gateway
  control port with a plain TcpStream::connect, without invoking the
  connection_fd_callback used by the websocket registration path. On
  Linux the callback sets SO_MARK (fwmark) so the daemon's own firewall
  allows the connection during the connecting state; without it the
  unmarked SYN matched no allow rule and was rejected locally with
  ECONNREFUSED. The daemon blamed the gateway, blacklisted it, and
  cycled through every gateway the same way, making it impossible to
  connect when LP registration is enabled.

  Plumb the existing connection_fd_callback from the builder config
  through RegistrationClientConfig into the LP client via an optional
  dialer, which creates the socket, applies the callback before
  connect(), and only then dials. The fd callback must run before
  connect so the SYN itself carries the mark.

  The exit gateway needs no dialer: its registration is forwarded
  through the entry gateway's nested session and never opens its own
  TCP connection.
2026-06-05 23:36:52 +02:00
Jędrzej Stuczyński 7b858dfd69 chore: LP registration adjustments (#6845)
* remove mixnet fallback for LP registration

* change LP registration timeouts and introduce exchange timeout

* remove fallback client construction and disable mixnet via LP registration
2026-06-02 16:28:57 +01:00
Jack Wampler a4bd547023 Handle Rate Limit Challenge Response (#6825)
rotate urls on HTTP response error indicating API rate limiting
2026-05-26 14:58:45 -06:00
Andy Duplain db03ec31b1 Merge pull request #6812 from nymtech/cherry-pick/nym-583-corrupt-db-windows
NYM-583: Avoid corrupted database on Windows.
2026-05-21 14:44:39 +01:00
Andy Duplain 9b285735b8 NYM-583: Avoid corrupted database on Windows.
NYM-583: Avoid corrupted database on Windows.
2026-05-21 14:23:20 +01:00
Simon Wicky 691280797a back to v8 on non sdk client (#6771) 2026-05-13 18:24:50 +02:00
benedettadavico f84de25302 update changelog 2026-05-06 07:16:42 +02:00
benedettadavico e554f1e0ad bump versions 2026-04-28 15:02:40 +02:00
benedetta davico 62a4a2ed70 Merge pull request #6710 from nymtech/bdq/versioning-fix 2026-04-28 10:01:52 +02:00
benedetta davico caad74c73d Merge pull request #6713 from nymtech/bdq/nym-binaries-ci
update CI runners
2026-04-27 15:39:44 +02:00
benedettadavico 917993d8fb clean 2026-04-27 12:17:31 +02:00
benedettadavico 1451db39e6 warn 2026-04-27 11:27:41 +02:00
benedettadavico f13a2a6c06 change to warn level 2026-04-27 10:45:42 +02:00
benedetta davico ce39fb6675 Update publish-nym-binaries.yml 2026-04-27 10:20:10 +02:00
benedettadavico 02a926b74a addressing comments 2026-04-27 10:10:08 +02:00
benedetta davico 54ba710ea0 Change CI platform from ubuntu-22.04 to arc-ubuntu-22.04 2026-04-27 09:33:57 +02:00
benedettadavico 2653d12e55 fix ipr msg, and unit tests 2026-04-24 16:07:49 +02:00
benedettadavico f94d6d51cf adding debugging traces 2026-04-24 14:11:19 +02:00
Andrej Mihajlov a0116f9aec Merge pull request #6708 from nymtech/am/lazy-init-dns
Only init SHARED_CLIENT if requested
2026-04-23 18:36:57 +02:00
Tommy Verrall aaa8ee9d53 Revert "Merge remote-tracking branch 'origin/develop' into chore/eslint-9-flat-config-migration"
This reverts commit ab0f6af4b9, reversing
changes made to cca19f36c2.
2026-04-23 15:42:27 +01:00
Tommy Verrall ab0f6af4b9 Merge remote-tracking branch 'origin/develop' into chore/eslint-9-flat-config-migration 2026-04-23 15:40:01 +01:00
Tommy Verrall 7669d0933f Migrate ESLint config to flat config for ESLint 9
Dependabot bumped eslint to ^9 across the lint-scoped TS packages but did
not migrate the legacy .eslintrc.* configs, breaking CI lint on develop.

Behavior preserved: yarn lint passes locally with the same effective rule
coverage as the pre-bump setup. Pre-existing warnings in nym-wallet and
mui-theme are unchanged. Orphan .eslintrc files in sdk/typescript outside
the lerna lint scope are left untouched.
2026-04-23 15:39:11 +01:00
Andrej Mihajlov 50433fe265 Only init SHARED_CLIENT if requested 2026-04-23 16:29:02 +02:00
benedettadavico 42aade29eb more v9 fixes 2026-04-23 13:28:17 +02:00
benedettadavico 9f26759b8d v9 bugfix 2026-04-23 13:28:17 +02:00
benedettadavico 9e642c6354 v9 bugfix 2026-04-23 13:28:16 +02:00
mfahampshire cca19f36c2 Remove unused header (#6699) 2026-04-22 09:35:59 +00:00
Merve 17894880e0 Changelog urda (#6698)
* test identity

* changelog update
2026-04-22 08:41:12 +00:00
benedetta davico ef6fc82c39 Merge pull request #6696 from nymtech/release/2026.8-urda
final merge todevelop
2026-04-21 13:14:23 +02:00
benedettadavico 0c83ae2408 duplicate description 2026-04-21 12:06:23 +02:00
benedetta davico 92490731e7 Merge pull request #6691 from nymtech/merge/release/2026.8-urda
Merge release/2026.8 urda
2026-04-20 14:28:34 +02:00
benedettadavico 0ce93e366e Merge branch 'release/2026.8-urda' into develop 2026-04-20 14:10:23 +02:00
benedettadavico 0d031875f6 merge conflicts 2026-04-20 14:07:38 +02:00
benedettadavico e6103e4c43 update changelog 2026-04-20 13:58:11 +02:00
benedettadavico e32c042c8d version bump 2026-04-17 11:03:03 +02:00
54 changed files with 978 additions and 2206 deletions
@@ -36,7 +36,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
platform: [ubuntu-22.04] platform: [arc-ubuntu-22.04]
runs-on: ${{ matrix.platform }} runs-on: ${{ matrix.platform }}
env: env:
-24
View File
@@ -54,30 +54,6 @@ jobs:
- name: "Build" - name: "Build"
run: make sdk-wasm-build run: make sdk-wasm-build
- name: "Build mix-fetch WASM (debug)"
run: |
make -C wasm/mix-fetch/go-mix-conn build-debug-dev
make -C wasm/mix-fetch build-rust-debug
- name: "Build mix-fetch internal-dev harness"
working-directory: wasm/mix-fetch/internal-dev
run: npm install && npm run build
- name: "Install Playwright browsers"
working-directory: wasm/mix-fetch/tests
run: npm install && npx playwright install --with-deps # --with-deps assumes Ubuntu/Debian, see note in wasm/mix-fetch/tests/README.md
- name: "Smoke-test mix-fetch internal-dev (headless)"
working-directory: wasm/mix-fetch/tests
run: npm run test:smoke
- name: Upload Playwright traces on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: mix-fetch-playwright-traces
path: wasm/mix-fetch/tests/test-results/
- name: "Test" - name: "Test"
run: make sdk-wasm-test run: make sdk-wasm-test
@@ -1,61 +0,0 @@
name: nightly-mix-fetch-stress
on:
schedule:
- cron: '0 3 * * *'
workflow_dispatch:
jobs:
stress:
runs-on: arc-linux-latest
env:
CARGO_TERM_COLOR: always
RUSTUP_PERMIT_COPY_RENAME: 1
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
with:
node-version: 20
- uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: ${{ vars.REQUIRED_RUSTC_VERSION }}
target: wasm32-unknown-unknown
override: true
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "1.24.6"
- name: Install wasm-pack
run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
- name: Install wasm-bindgen-cli
run: cargo install wasm-bindgen-cli
- name: "Build mix-fetch WASM (debug)"
run: |
make -C wasm/mix-fetch/go-mix-conn build-debug-dev
make -C wasm/mix-fetch build-rust-debug
- name: "Build internal-dev harness"
working-directory: wasm/mix-fetch/internal-dev
run: npm install && npm run build
- name: "Install Playwright browsers"
working-directory: wasm/mix-fetch/tests
run: npm install && npx playwright install --with-deps
- name: "Stress-test mix-fetch through mainnet"
working-directory: wasm/mix-fetch/tests
run: npm run test:stress
- name: Upload Playwright traces on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: mix-fetch-stress-traces
path: wasm/mix-fetch/tests/test-results/
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
- os: ubuntu-22.04 - os: arc-ubuntu-22.04
target: x86_64-unknown-linux-gnu target: x86_64-unknown-linux-gnu
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
+28
View File
@@ -4,6 +4,34 @@ Post 1.0.0 release, the changelog format is based on [Keep a Changelog](https://
## [Unreleased] ## [Unreleased]
## [2026.9-venaco] (2026-05-06)
- Fix for v9 IPR ([#6710])
- Only init SHARED_CLIENT if requested ([#6708])
- Fixes to crates and CI ([#6686])
- Return ipv6 addresses as well ([#6684])
- Fix invalid ticket spend ([#6683])
- Block non-public IPR/NR checks ([#6670])
[#6710]: https://github.com/nymtech/nym/pull/6710
[#6708]: https://github.com/nymtech/nym/pull/6708
[#6686]: https://github.com/nymtech/nym/pull/6686
[#6684]: https://github.com/nymtech/nym/pull/6684
[#6683]: https://github.com/nymtech/nym/pull/6683
[#6670]: https://github.com/nymtech/nym/pull/6670
## [2026.8-urda] (2026-04-20)
- Include all gateways in the returned list ([#6649])
- Optimize GW probe in NS agent ([#6636])
- Max/sdk docrs ([#6566])
- Max/sdk stream wrapper ([#6320])
[#6649]: https://github.com/nymtech/nym/pull/6649
[#6636]: https://github.com/nymtech/nym/pull/6636
[#6566]: https://github.com/nymtech/nym/pull/6566
[#6320]: https://github.com/nymtech/nym/pull/6320
## [2026.7-tola] (2026-04-07) ## [2026.7-tola] (2026-04-07)
- Simon/ecash contract serde fix ([#6634]) - Simon/ecash contract serde fix ([#6634])
Generated
+10 -9
View File
@@ -5275,7 +5275,7 @@ dependencies = [
[[package]] [[package]]
name = "mix-fetch-wasm" name = "mix-fetch-wasm"
version = "1.4.4" version = "1.4.3"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"futures", "futures",
@@ -5635,7 +5635,7 @@ dependencies = [
[[package]] [[package]]
name = "nym-api" name = "nym-api"
version = "1.1.77" version = "1.1.79"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -5880,7 +5880,7 @@ dependencies = [
[[package]] [[package]]
name = "nym-cli" name = "nym-cli"
version = "1.1.74" version = "1.1.76"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64 0.22.1", "base64 0.22.1",
@@ -5963,7 +5963,7 @@ dependencies = [
[[package]] [[package]]
name = "nym-client" name = "nym-client"
version = "1.1.74" version = "1.1.76"
dependencies = [ dependencies = [
"bs58", "bs58",
"clap", "clap",
@@ -7028,6 +7028,7 @@ dependencies = [
"bytes", "bytes",
"futures", "futures",
"nym-ip-packet-requests", "nym-ip-packet-requests",
"nym-lp",
"nym-sdk", "nym-sdk",
"thiserror 2.0.12", "thiserror 2.0.12",
"tokio", "tokio",
@@ -7372,7 +7373,7 @@ dependencies = [
[[package]] [[package]]
name = "nym-network-requester" name = "nym-network-requester"
version = "1.1.75" version = "1.1.77"
dependencies = [ dependencies = [
"addr", "addr",
"anyhow", "anyhow",
@@ -7422,7 +7423,7 @@ dependencies = [
[[package]] [[package]]
name = "nym-node" name = "nym-node"
version = "1.29.0" version = "1.31.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
@@ -7974,7 +7975,7 @@ dependencies = [
[[package]] [[package]]
name = "nym-socks5-client" name = "nym-socks5-client"
version = "1.1.74" version = "1.1.76"
dependencies = [ dependencies = [
"bs58", "bs58",
"clap", "clap",
@@ -8242,7 +8243,7 @@ dependencies = [
"tempfile", "tempfile",
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-test",
"windows 0.61.3", "windows 0.61.3",
] ]
@@ -8772,7 +8773,7 @@ dependencies = [
[[package]] [[package]]
name = "nymvisor" name = "nymvisor"
version = "0.1.39" version = "0.1.41"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
+1 -1
View File
@@ -1,7 +1,7 @@
[package] [package]
name = "nym-client" name = "nym-client"
description = "Implementation of the Nym Client" description = "Implementation of the Nym Client"
version = "1.1.74" version = "1.1.76"
authors = ["Dave Hrycyszyn <futurechimp@users.noreply.github.com>", "Jędrzej Stuczyński <andrew@nymtech.net>"] authors = ["Dave Hrycyszyn <futurechimp@users.noreply.github.com>", "Jędrzej Stuczyński <andrew@nymtech.net>"]
edition = "2021" edition = "2021"
license.workspace = true license.workspace = true
+1 -1
View File
@@ -1,7 +1,7 @@
[package] [package]
name = "nym-socks5-client" name = "nym-socks5-client"
description = "A SOCKS5 localhost proxy that converts incoming messages to Sphinx and sends them to a Nym address" description = "A SOCKS5 localhost proxy that converts incoming messages to Sphinx and sends them to a Nym address"
version = "1.1.74" version = "1.1.76"
authors = ["Dave Hrycyszyn <futurechimp@users.noreply.github.com>"] authors = ["Dave Hrycyszyn <futurechimp@users.noreply.github.com>"]
edition = "2021" edition = "2021"
license.workspace = true license.workspace = true
+10
View File
@@ -25,6 +25,8 @@ pub trait BandwidthTicketProvider: Send + Sync {
) -> Result<PreparedCredential, BandwidthControllerError>; ) -> Result<PreparedCredential, BandwidthControllerError>;
async fn get_upgrade_mode_token(&self) -> Result<Option<String>, BandwidthControllerError>; async fn get_upgrade_mode_token(&self) -> Result<Option<String>, BandwidthControllerError>;
async fn close(&self) {}
} }
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
@@ -56,6 +58,10 @@ where
.map_err(|_| BandwidthControllerError::MalformedUpgradeModeToken)?; .map_err(|_| BandwidthControllerError::MalformedUpgradeModeToken)?;
Ok(Some(token)) Ok(Some(token))
} }
async fn close(&self) {
self.storage.close().await;
}
} }
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
@@ -75,4 +81,8 @@ impl<T: BandwidthTicketProvider + ?Sized + Send> BandwidthTicketProvider for Box
async fn get_upgrade_mode_token(&self) -> Result<Option<String>, BandwidthControllerError> { async fn get_upgrade_mode_token(&self) -> Result<Option<String>, BandwidthControllerError> {
(**self).get_upgrade_mode_token().await (**self).get_upgrade_mode_token().await
} }
async fn close(&self) {
(**self).close().await;
}
} }
@@ -1023,6 +1023,16 @@ where
let encryption_keys = init_res.client_keys.encryption_keypair(); let encryption_keys = init_res.client_keys.encryption_keypair();
let identity_keys = init_res.client_keys.identity_keypair(); let identity_keys = init_res.client_keys.identity_keypair();
let credential_store_for_close = credential_store.clone();
let close_credential_token = shutdown_tracker.clone_shutdown_token();
shutdown_tracker.try_spawn_named(
async move {
close_credential_token.cancelled().await;
credential_store_for_close.close().await;
},
"CredentialStorage::close_on_shutdown",
);
// the components are started in very specific order. Unless you know what you are doing, // the components are started in very specific order. Unless you know what you are doing,
// do not change that. // do not change that.
let bandwidth_controller = self let bandwidth_controller = self
@@ -11,11 +11,17 @@ use nym_bandwidth_controller::BandwidthController;
use nym_client_core_gateways_storage::OnDiskGatewaysDetails; use nym_client_core_gateways_storage::OnDiskGatewaysDetails;
use nym_credential_storage::storage::Storage as CredentialStorage; use nym_credential_storage::storage::Storage as CredentialStorage;
use nym_validator_client::{QueryHttpRpcNyxdClient, nyxd}; use nym_validator_client::{QueryHttpRpcNyxdClient, nyxd};
use std::{io, path::Path}; use std::{io, path::Path, time::Duration};
use time::OffsetDateTime; use time::OffsetDateTime;
use tracing::{error, info, trace}; use tracing::{error, info, trace};
use url::Url; use url::Url;
/// Maximum rename retry attempts when the database file is temporarily locked.
const ARCHIVE_MAX_RETRY_ATTEMPTS: u8 = 15;
/// Delay between archive rename retry attempts.
const ARCHIVE_RETRY_DELAY: Duration = Duration::from_millis(200);
async fn setup_fresh_backend<P: AsRef<Path>>( async fn setup_fresh_backend<P: AsRef<Path>>(
db_path: P, db_path: P,
surb_config: &config::ReplySurbs, surb_config: &config::ReplySurbs,
@@ -74,13 +80,58 @@ async fn archive_corrupted_database<P: AsRef<Path>>(db_path: P) -> io::Result<()
}; };
let renamed = db_path.with_extension(new_extension); let renamed = db_path.with_extension(new_extension);
tokio::fs::rename(db_path, &renamed).await.inspect_err(|_| { // On Windows, sqlx may release its OS file handles asynchronously after
error!( // pool.close() returns, briefly keeping the file locked
"Failed to rename corrupt database file: {} to {}", // (ERROR_SHARING_VIOLATION, os error 32). Retry with a short delay to
db_path.display(), // give the OS time to flush the remaining handles.
renamed.display() for attempt in 0..ARCHIVE_MAX_RETRY_ATTEMPTS {
); match tokio::fs::rename(db_path, &renamed).await {
}) Ok(()) => return Ok(()),
Err(e) if is_file_locked_error(&e) && (attempt + 1) < ARCHIVE_MAX_RETRY_ATTEMPTS => {
trace!(
"Database file is temporarily locked, retrying archive \
(attempt {}/{}): {e}",
attempt + 1,
ARCHIVE_MAX_RETRY_ATTEMPTS
);
tokio::time::sleep(ARCHIVE_RETRY_DELAY).await;
}
Err(e) => {
error!(
"Failed to rename corrupt database file: {} to {}",
db_path.display(),
renamed.display()
);
return Err(e);
}
}
}
// Reached only when every attempt was blocked by a file lock.
error!(
"Failed to rename corrupt database file after {} attempts: {} to {}",
ARCHIVE_MAX_RETRY_ATTEMPTS,
db_path.display(),
renamed.display()
);
Err(io::Error::other(
"corrupt database archive blocked by persistent file lock",
))
}
/// Returns `true` when the IO error indicates a temporary file lock held by another handle
/// within the same process. Only meaningful on Windows; always `false` elsewhere.
fn is_file_locked_error(e: &io::Error) -> bool {
#[cfg(windows)]
{
// ERROR_SHARING_VIOLATION = 32, ERROR_LOCK_VIOLATION = 33
matches!(e.raw_os_error(), Some(32) | Some(33))
}
#[cfg(not(windows))]
{
let _ = e;
false
}
} }
pub async fn setup_fs_reply_surb_backend<P: AsRef<Path>>( pub async fn setup_fs_reply_surb_backend<P: AsRef<Path>>(
@@ -337,6 +337,8 @@ impl ReplyStorageBackend for Backend {
} }
async fn stop_storage_session(self) -> Result<(), Self::StorageError> { async fn stop_storage_session(self) -> Result<(), Self::StorageError> {
self.stop_client_use().await let result = self.stop_client_use().await;
self.shutdown().await;
result
} }
} }
+5 -3
View File
@@ -48,6 +48,7 @@ where
debug!("Started PersistentReplyStorage"); debug!("Started PersistentReplyStorage");
if let Err(err) = self.backend.start_storage_session().await { if let Err(err) = self.backend.start_storage_session().await {
error!("failed to start the storage session - {err}"); error!("failed to start the storage session - {err}");
self.backend.stop_storage_session().await.ok();
return; return;
} }
@@ -55,10 +56,11 @@ where
info!("PersistentReplyStorage is flushing all reply-related data to underlying storage"); info!("PersistentReplyStorage is flushing all reply-related data to underlying storage");
if let Err(err) = self.backend.flush_surb_storage(&mem_state).await { if let Err(err) = self.backend.flush_surb_storage(&mem_state).await {
error!("failed to flush our reply-related data to the persistent storage: {err}") error!("failed to flush our reply-related data to the persistent storage: {err}");
} else { self.backend.stop_storage_session().await.ok();
info!("Data flush is complete") return;
} }
info!("Data flush is complete");
if let Err(err) = self.backend.stop_storage_session().await { if let Err(err) = self.backend.stop_storage_session().await {
error!("failed to properly stop the storage session - {err}. We might not be able to smoothly restore it") error!("failed to properly stop the storage session - {err}. We might not be able to smoothly restore it")
+54 -10
View File
@@ -1152,7 +1152,10 @@ impl ApiClientCore for Client {
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
let response: Result<Response, HttpClientError> = { let response: Result<Response, HttpClientError> = {
let client = self.reqwest_client.as_ref().unwrap_or(&*SHARED_CLIENT); let client = self
.reqwest_client
.as_ref()
.unwrap_or_else(|| &*SHARED_CLIENT);
Ok( Ok(
wasmtimer::tokio::timeout(self.request_timeout, client.execute(req)) wasmtimer::tokio::timeout(self.request_timeout, client.execute(req))
.await .await
@@ -1162,12 +1165,24 @@ impl ApiClientCore for Client {
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
let response = { let response = {
let client = self.reqwest_client.as_ref().unwrap_or(&*SHARED_CLIENT); let client = self
.reqwest_client
.as_ref()
.unwrap_or_else(|| &*SHARED_CLIENT);
client.execute(req).await client.execute(req).await
}; };
match response { match response {
Ok(resp) => return Ok(resp), Ok(resp) => {
// Check if the response includes a rate limit error from the vercel API
if is_http_rate_limit_err(&resp) {
warn!("encountered vercel rate limit error for {}", url.as_str());
// if we have multiple urls, update to the next
self.maybe_rotate_hosts(Some(url.clone()));
}
return Ok(resp);
}
Err(err) => { Err(err) => {
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
let is_network_err = err.is_timeout(); let is_network_err = err.is_timeout();
@@ -1220,17 +1235,39 @@ impl ApiClientCore for Client {
} }
} }
const VERCEL_CHALLENGE_HEADER: &str = "x-vercel-mitigated";
const VERCEL_CHALLENGE_VALUE: &[u8] = b"challenge";
/// Check for Rate Limit challenge response from the vercel API
pub(crate) fn is_http_rate_limit_err(resp: &Response) -> bool {
let status = resp.status() == StatusCode::FORBIDDEN;
let header = resp
.headers()
.get(VERCEL_CHALLENGE_HEADER)
.is_some_and(|v| v.as_bytes() == VERCEL_CHALLENGE_VALUE);
let content_type = resp
.headers()
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<Mime>().ok())
.is_some_and(|mime_type| {
mime_type.type_() == mime::TEXT && mime_type.subtype() == mime::HTML
});
status && header && content_type
}
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
const MAX_ERR_SOURCE_ITERATIONS: usize = 4; const MAX_ERR_SOURCE_ITERATIONS: usize = 4;
/// This functions attempts to check the error returned by reqwest to see if /// This functions attempts to check the error returned by reqwest to see if rotating host
/// rotating host informtion (for clients with mutliple hosts defined) could be /// information (for clients with multiple hosts defined) could be helpful. This looks for
/// helpful. This looks for situations where the error could plausibly be caused /// situations where the error could plausibly be caused by a network adversary, or where rotating
/// by a network adversary, or where rotating to an equival hostname might help. /// to an equivalent hostname might help.
/// ///
/// For example --> NetworkUnreachable will not be helped by rotating domains, /// For example --> NetworkUnreachable will not be helped by rotating domains, but ConnectionReset
/// but ConnectionReset might be caused by a network adversary blocking by SNI /// might be caused by a network adversary blocking by SNI which could possibly benefit from
/// which could possibly benefit from rotating domains. /// rotating domains.
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub(crate) fn might_be_network_interference(err: &reqwest::Error) -> bool { pub(crate) fn might_be_network_interference(err: &reqwest::Error) -> bool {
if err.is_timeout() { if err.is_timeout() {
@@ -1691,6 +1728,13 @@ where
decode_raw_response(&headers, full) decode_raw_response(&headers, full)
} else if res.status() == StatusCode::NOT_FOUND { } else if res.status() == StatusCode::NOT_FOUND {
Err(HttpClientError::NotFound { url: Box::new(url) }) Err(HttpClientError::NotFound { url: Box::new(url) })
} else if is_http_rate_limit_err(&res) {
Err(HttpClientError::EndpointFailure {
url: Box::new(url),
status,
headers: Box::new(headers),
error: String::from("received vercel rate limit challenge response"),
})
} else { } else {
let Ok(plaintext) = res.text().await else { let Ok(plaintext) = res.text().await else {
return Err(HttpClientError::RequestFailure { return Err(HttpClientError::RequestFailure {
+8
View File
@@ -12,6 +12,14 @@ pub mod v7;
pub mod v8; pub mod v8;
pub mod v9; pub mod v9;
/// Highest IPR protocol version that is allowed to be sent as a **non-stream** mixnet payload
/// (i.e. not wrapped in `LpFrameKind::SphinxStream`).
pub const MAX_NON_STREAM_VERSION: u8 = v8::VERSION;
/// First IPR protocol version that **requires** the SphinxStream (LP) transport for non-stream
/// mixnet sends, matching the node-side enforcement in `ip-packet-router`.
pub const SPHINX_STREAM_VERSION_THRESHOLD: u8 = v9::VERSION;
// version 3: initial version // version 3: initial version
// version 4: IPv6 support // version 4: IPv6 support
// version 5: Add severity level to info response // version 5: Add severity level to info response
@@ -57,6 +57,34 @@ This page displays a full list of all the changes during our release cycle from
<VarInfo /> <VarInfo />
## `v2026.8-urda`
- [Release Binaries](https://github.com/nymtech/nym/releases/tag/nym-binaries-v2026.8-urda)
- [`nym-node`](nodes/nym-node.mdx) version `1.30.0`
```sh
nym-node
Binary Name: nym-node
Build Timestamp: 2026-04-21T10:11:28.164080002Z
Build Version: 1.30.0
Commit SHA: 0c83ae2408ea9efcc1abae613711c6e4e16148ad
Commit Date: 2026-04-21T12:06:23.000000000+02:00
Commit Branch: HEAD
rustc Version: 1.91.1
rustc Channel: stable
cargo Profile: release
```
### Features
- [Include all gateways in the returned list](https://github.com/nymtech/nym/pull/6649): Ensures gateway APIs return the full gateway set instead of filtered results, improving consistency across services.
- [Max/sdk stream wrapper](https://github.com/nymtech/nym/pull/6320): Introduces a Rust SDK stream abstraction enabling IPR-based mixnet communication and client-side streaming support.
- [Max/sdk docrs](https://github.com/nymtech/nym/pull/6566): Updates SDK documentation to reflect the new stream-based architecture and IPR communication model.
### Refactors & Maintenance
- [Optimize GW probe in NS agent](https://github.com/nymtech/nym/pull/6636): Refactors gateway probe integration from subprocess execution to library usage, removing CLI dependency and improving typed communication between agent and probe.
## `v2026.7-tola` ## `v2026.7-tola`
- [Release Binaries](https://github.com/nymtech/nym/releases/tag/nym-binaries-v2026.7-tola) - [Release Binaries](https://github.com/nymtech/nym/releases/tag/nym-binaries-v2026.7-tola)
@@ -20,10 +20,10 @@ This documentation page provides a guide on how to set up and run a [NYM NODE](.
```sh ```sh
Binary Name: nym-node Binary Name: nym-node
Build Timestamp: 2026-04-08T10:31:49.141185557Z Build Timestamp: 2026-04-21T10:11:28.164080002Z
Build Version: 1.29.0 Build Version: 1.30.0
Commit SHA: 97068b2aace6626e3dff6864a26e7b1ff35f5725 Commit SHA: 0c83ae2408ea9efcc1abae613711c6e4e16148ad
Commit Date: 2026-04-07T15:51:44.000000000+02:00 Commit Date: 2026-04-21T12:06:23.000000000+02:00
Commit Branch: HEAD Commit Branch: HEAD
rustc Version: 1.91.1 rustc Version: 1.91.1
rustc Channel: stable rustc Channel: stable
+1 -1
View File
@@ -3,7 +3,7 @@
[package] [package]
name = "nym-api" name = "nym-api"
version = "1.1.77" version = "1.1.79"
authors.workspace = true authors.workspace = true
edition = "2021" edition = "2021"
license = "GPL-3.0" license = "GPL-3.0"
+1
View File
@@ -26,3 +26,4 @@ tracing.workspace = true
nym-sdk = { workspace = true } nym-sdk = { workspace = true }
nym-ip-packet-requests = { workspace = true } nym-ip-packet-requests = { workspace = true }
nym-lp = { workspace = true }
+1 -1
View File
@@ -11,4 +11,4 @@ pub use error::Error;
pub use listener::{IprListener, MixnetMessageOutcome}; pub use listener::{IprListener, MixnetMessageOutcome};
// Re-export the currently used version // Re-export the currently used version
pub use nym_ip_packet_requests::v9 as current; pub use nym_ip_packet_requests::v8 as current;
+1 -1
View File
@@ -3,7 +3,7 @@
[package] [package]
name = "nym-node" name = "nym-node"
version = "1.29.0" version = "1.31.0"
authors.workspace = true authors.workspace = true
edition.workspace = true edition.workspace = true
license = "GPL-3.0" license = "GPL-3.0"
@@ -97,6 +97,8 @@ impl BuilderConfig {
exit: self.exit_node.clone(), exit: self.exit_node.clone(),
mode: self.mode, mode: self.mode,
lp_registration_config: self.lp_registration_config, lp_registration_config: self.lp_registration_config,
#[cfg(unix)]
connection_fd_callback: self.connection_fd_callback.clone(),
} }
} }
@@ -143,7 +143,6 @@ impl RegistrationClientBuilder {
config, config,
bandwidth_controller, bandwidth_controller,
cancel_token: self.config.cancel_token.clone(), cancel_token: self.config.cancel_token.clone(),
fallback_client_builder: Some(self),
}) })
} }
} }
+40 -23
View File
@@ -18,13 +18,12 @@ use rand09::{CryptoRng, RngCore, SeedableRng};
use std::sync::Arc; use std::sync::Arc;
use tokio::net::TcpStream; use tokio::net::TcpStream;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use tracing::warn;
pub struct LpBasedRegistrationClient { pub struct LpBasedRegistrationClient {
pub(crate) config: RegistrationClientConfig, pub(crate) config: RegistrationClientConfig,
pub(crate) bandwidth_controller: Box<dyn BandwidthTicketProvider>, pub(crate) bandwidth_controller: Box<dyn BandwidthTicketProvider>,
pub(crate) cancel_token: CancellationToken, pub(crate) cancel_token: CancellationToken,
// While we allow a fallback, we need to be able to build it
pub(crate) fallback_client_builder: Option<RegistrationClientBuilder>,
} }
impl LpBasedRegistrationClient { impl LpBasedRegistrationClient {
@@ -81,6 +80,32 @@ impl LpBasedRegistrationClient {
self.config.lp_registration_config, self.config.lp_registration_config,
); );
// Open the entry connection through a socket that has the connection
// fd callback applied before connecting (sets SO_MARK on Linux), so
// the connection is allowed through the VPN firewall during the
// connecting state.
#[cfg(unix)]
{
let fd_callback = self.config.connection_fd_callback.clone();
entry_client.set_dialer(Arc::new(move |addr| {
let fd_callback = fd_callback.clone();
Box::pin(async move {
let socket = if addr.is_ipv4() {
tokio::net::TcpSocket::new_v4()
} else {
tokio::net::TcpSocket::new_v6()
}
.map_err(|err| {
nym_lp::transport::LpTransportError::connection_failure(err.to_string())
})?;
fd_callback(std::os::fd::AsRawFd::as_raw_fd(&socket));
socket.connect(addr).await.map_err(|err| {
nym_lp::transport::LpTransportError::connection_failure(err.to_string())
})
})
}));
}
// Perform handshake with entry gateway (outer session now established) // Perform handshake with entry gateway (outer session now established)
entry_client.perform_handshake().await.map_err(|source| { entry_client.perform_handshake().await.map_err(|source| {
RegistrationClientError::EntryGatewayRegisterLp { RegistrationClientError::EntryGatewayRegisterLp {
@@ -162,15 +187,11 @@ impl LpBasedRegistrationClient {
self.register_wg_with_rng(&mut rng).await self.register_wg_with_rng(&mut rng).await
} }
pub(crate) async fn register(mut self) -> Result<RegistrationResult, RegistrationClientError> { async fn register_inner(mut self) -> Result<RegistrationResult, RegistrationClientError> {
let fallback = self.fallback_client_builder.take();
match &self.config.mode { match &self.config.mode {
RegistrationMode::Mixnet => { RegistrationMode::Mixnet => {
if let Some(fallback) = fallback { // mixnet registration is not supported for LP
register_with_fallback(fallback).await Err(RegistrationClientError::UnsupportedMode)
} else {
Err(RegistrationClientError::UnsupportedMode)
}
} }
RegistrationMode::Wireguard => { RegistrationMode::Wireguard => {
let lp_registration_result = self let lp_registration_result = self
@@ -182,15 +203,9 @@ impl LpBasedRegistrationClient {
// Everything went fine // Everything went fine
Some(Ok(res)) => Ok(res), Some(Ok(res)) => Ok(res),
// LP reg failed, try fallback if we have one
Some(Err(e)) => { Some(Err(e)) => {
tracing::error!("LP registration failed : {e}"); tracing::error!("LP registration failed : {e}");
if let Some(fallback) = fallback { Err(e)
tracing::info!("Registering with fallback");
register_with_fallback(fallback).await
} else {
Err(e)
}
} }
// Cancelled registration // Cancelled registration
@@ -199,12 +214,14 @@ impl LpBasedRegistrationClient {
} }
} }
} }
}
async fn register_with_fallback( pub(crate) async fn register(mut self) -> Result<RegistrationResult, RegistrationClientError> {
client_builder: RegistrationClientBuilder, let timeout = self.config.lp_registration_config.exchange_timeout;
) -> Result<RegistrationResult, RegistrationClientError> { tokio::time::timeout(timeout, self.register_inner())
// This is forcefully building a mixnet based client .await
let fallback_client = client_builder.build_mixnet().await?; .unwrap_or_else(|timeout| {
fallback_client.register().await warn!("timed out while attempting to complete LP registration");
Err(RegistrationClientError::Timeout(timeout))
})
}
} }
+5
View File
@@ -19,4 +19,9 @@ pub struct RegistrationClientConfig {
pub(crate) exit: NymNodeWithKeys, pub(crate) exit: NymNodeWithKeys,
pub(crate) mode: RegistrationMode, pub(crate) mode: RegistrationMode,
pub(crate) lp_registration_config: LpRegistrationConfig, pub(crate) lp_registration_config: LpRegistrationConfig,
/// Callback invoked with the raw fd of sockets opened for registration,
/// before connecting. Used to set `SO_MARK` on Linux so the connection is
/// allowed through the VPN firewall during the connecting state.
#[cfg(unix)]
pub(crate) connection_fd_callback: std::sync::Arc<dyn Fn(std::os::fd::RawFd) + Send + Sync>,
} }
+53 -16
View File
@@ -33,6 +33,19 @@ use std::time::Duration;
use tokio::net::TcpStream; use tokio::net::TcpStream;
use tracing::{debug, warn}; use tracing::{debug, warn};
/// Custom dialer used to open the connection to the gateway.
///
/// Allows the caller to configure the socket before the connection is
/// initiated, e.g. set `SO_MARK` on Linux so the connection is allowed
/// through the VPN firewall during the connecting state.
pub type LpDialer<S> = Arc<
dyn Fn(
SocketAddr,
) -> futures::future::BoxFuture<'static, std::result::Result<S, LpTransportError>>
+ Send
+ Sync,
>;
/// LP (Lewes Protocol) registration client for direct gateway connections. /// LP (Lewes Protocol) registration client for direct gateway connections.
/// ///
/// This client uses a persistent TCP connection model where a single TCP /// This client uses a persistent TCP connection model where a single TCP
@@ -70,6 +83,11 @@ pub struct LpRegistrationClient<S = TcpStream> {
/// Persistent TCP stream for the connection. /// Persistent TCP stream for the connection.
/// Opened on first use, closed after registration. /// Opened on first use, closed after registration.
stream: Option<S>, stream: Option<S>,
/// Optional custom dialer used to open the connection, allowing socket
/// configuration (e.g. `SO_MARK`) before the connection is initiated.
/// Falls back to `S::connect` when unset.
dialer: Option<LpDialer<S>>,
} }
impl<S> LpRegistrationClient<S> impl<S> LpRegistrationClient<S>
@@ -115,9 +133,18 @@ where
transport_session: None, transport_session: None,
config, config,
stream: None, stream: None,
dialer: None,
} }
} }
/// Sets a custom dialer used to open the connection to the gateway.
///
/// Allows socket configuration (e.g. setting `SO_MARK` on Linux so the
/// connection is allowed through the VPN firewall) before connecting.
pub fn set_dialer(&mut self, dialer: LpDialer<S>) {
self.dialer = Some(dialer);
}
/// Attempt to use this `LpRegistrationClient` as transport for `NestedSession` /// Attempt to use this `LpRegistrationClient` as transport for `NestedSession`
pub fn as_nested_connection(&mut self, exit_address: SocketAddr) -> NestedConnection<'_, S> { pub fn as_nested_connection(&mut self, exit_address: SocketAddr) -> NestedConnection<'_, S> {
NestedConnection { NestedConnection {
@@ -209,22 +236,32 @@ where
self.gateway_lp_address self.gateway_lp_address
); );
let mut stream = tokio::time::timeout( let connect_result = match &self.dialer {
self.config.connect_timeout, Some(dialer) => {
S::connect(self.gateway_lp_address), tokio::time::timeout(self.config.connect_timeout, dialer(self.gateway_lp_address))
) .await
.await }
.map_err(|_| LpClientError::TcpConnection { None => {
address: self.gateway_lp_address.to_string(), tokio::time::timeout(
source: LpTransportError::ConnectionFailure(format!( self.config.connect_timeout,
"Connection timeout after {:?}", S::connect(self.gateway_lp_address),
self.config.connect_timeout )
)), .await
})? }
.map_err(|source| LpClientError::TcpConnection { };
address: self.gateway_lp_address.to_string(),
source, let mut stream = connect_result
})?; .map_err(|_| LpClientError::TcpConnection {
address: self.gateway_lp_address.to_string(),
source: LpTransportError::ConnectionFailure(format!(
"Connection timeout after {:?}",
self.config.connect_timeout
)),
})?
.map_err(|source| LpClientError::TcpConnection {
address: self.gateway_lp_address.to_string(),
source,
})?;
// Set TCP_NODELAY for low latency // Set TCP_NODELAY for low latency
stream stream
+27 -24
View File
@@ -28,40 +28,43 @@ use std::time::Duration;
/// - Optimize for latency over throughput (small messages) /// - Optimize for latency over throughput (small messages)
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct LpRegistrationConfig { pub struct LpRegistrationConfig {
/// TCP connection timeout (nym-102). /// TCP connection timeout.
/// ///
/// Maximum time to wait for TCP connection establishment. /// Maximum time to wait for TCP connection establishment.
/// Default: 10 seconds. /// Default: 5 seconds.
pub connect_timeout: Duration, pub connect_timeout: Duration,
/// Noise protocol handshake timeout (nym-102). /// KKT/PSQ protocol handshake timeout.
/// ///
/// Maximum time to wait for Noise handshake completion (all round-trips). /// Maximum time to wait for KKT/PSQ handshake completion with the entry (all round-trips).
/// Default: 15 seconds. /// Default: 8 seconds.
pub handshake_timeout: Duration, pub handshake_timeout: Duration,
/// Registration request/response timeout (nym-102). /// Registration request/response timeout.
/// ///
/// Maximum time to wait for registration request send + response receive. /// Maximum time to wait for registration request send + response receive.
/// Includes credential verification on gateway side. /// Includes credential verification on gateway side.
/// Default: 30 seconds. /// Default: 8 seconds.
pub registration_timeout: Duration, pub registration_timeout: Duration,
/// Maximum time for the whole exchange (handshake + registration).
/// Default: 20 seconds.
pub exchange_timeout: Duration,
/// Forward packet send/receive timeout. /// Forward packet send/receive timeout.
/// ///
/// Maximum time to wait for forward packet send + response receive via entry gateway. /// Maximum time to wait for forward packet to get sent via entry gateway.
/// Covers the entire round-trip through entry to exit gateway and back. /// Default: 3 seconds.
/// Default: 30 seconds.
pub forward_timeout: Duration, pub forward_timeout: Duration,
/// Enable TCP_NODELAY (disable Nagle's algorithm) (nym-104). /// Enable TCP_NODELAY (disable Nagle's algorithm).
/// ///
/// When true, disables Nagle's algorithm for lower latency. /// When true, disables Nagle's algorithm for lower latency.
/// Recommended for registration messages which are small and latency-sensitive. /// Recommended for registration messages which are small and latency-sensitive.
/// Default: true. /// Default: true.
pub tcp_nodelay: bool, pub tcp_nodelay: bool,
/// TCP keepalive duration (nym-104). /// TCP keepalive duration.
/// ///
/// When Some, enables TCP keepalive with specified interval. /// When Some, enables TCP keepalive with specified interval.
/// Since LP is registration-only with short-lived connections, keepalive is not needed. /// Since LP is registration-only with short-lived connections, keepalive is not needed.
@@ -72,15 +75,14 @@ pub struct LpRegistrationConfig {
impl Default for LpRegistrationConfig { impl Default for LpRegistrationConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
// nym-102: Sane timeout defaults for real network conditions connect_timeout: Duration::from_secs(5),
connect_timeout: Duration::from_secs(10), handshake_timeout: Duration::from_secs(8),
handshake_timeout: Duration::from_secs(15), registration_timeout: Duration::from_secs(8),
registration_timeout: Duration::from_secs(30), exchange_timeout: Duration::from_secs(20),
forward_timeout: Duration::from_secs(30), forward_timeout: Duration::from_secs(3),
// nym-104: Optimized for registration-only protocol tcp_nodelay: true,
tcp_nodelay: true, // Lower latency for small messages tcp_keepalive: None,
tcp_keepalive: None, // Not needed for ephemeral connections
} }
} }
} }
@@ -93,10 +95,11 @@ mod tests {
fn test_default_config() { fn test_default_config() {
let config = LpRegistrationConfig::default(); let config = LpRegistrationConfig::default();
assert_eq!(config.connect_timeout, Duration::from_secs(10)); assert_eq!(config.connect_timeout, Duration::from_secs(5));
assert_eq!(config.handshake_timeout, Duration::from_secs(15)); assert_eq!(config.handshake_timeout, Duration::from_secs(8));
assert_eq!(config.registration_timeout, Duration::from_secs(30)); assert_eq!(config.registration_timeout, Duration::from_secs(8));
assert_eq!(config.forward_timeout, Duration::from_secs(30)); assert_eq!(config.forward_timeout, Duration::from_secs(3));
assert_eq!(config.exchange_timeout, Duration::from_secs(20));
assert!(config.tcp_nodelay); assert!(config.tcp_nodelay);
assert_eq!(config.tcp_keepalive, None); assert_eq!(config.tcp_keepalive, None);
} }
+1 -1
View File
@@ -41,4 +41,4 @@ windows = { version = "0.61", features = [
[dev-dependencies] [dev-dependencies]
tempfile.workspace = true tempfile.workspace = true
tracing-subscriber.workspace = true tracing-test.workspace = true
+96 -27
View File
@@ -3,8 +3,9 @@
use std::{ use std::{
io, io,
ops::{Deref, DerefMut}, ops::Deref,
path::{Path, PathBuf}, path::{Path, PathBuf},
sync::Arc,
time::Duration, time::Duration,
}; };
@@ -26,10 +27,8 @@ const CHECK_FILES_CLOSED_MAX_ATTEMPTS: u8 = 20;
/// Delay between file checks /// Delay between file checks
const CHECK_FILES_CLOSED_RETRY_DELAY: Duration = Duration::from_millis(100); const CHECK_FILES_CLOSED_RETRY_DELAY: Duration = Duration::from_millis(100);
/// `sqlx::SqlitePool` wrapper providing a workaround for the [known bug](https://github.com/launchbadge/sqlx/issues/3217). #[derive(Debug)]
/// In principle after requesting to close the sqlite pool, the wrapper monitors open file descriptor and polls periodically until all database files are closed. struct SqlitePoolGuardInner {
#[derive(Debug, Clone)]
pub struct SqlitePoolGuard {
/// Path to sqlite database file. /// Path to sqlite database file.
database_path: PathBuf, database_path: PathBuf,
@@ -37,6 +36,18 @@ pub struct SqlitePoolGuard {
connection_pool: sqlx::SqlitePool, connection_pool: sqlx::SqlitePool,
} }
/// `sqlx::SqlitePool` wrapper providing a workaround for the [known bug](https://github.com/launchbadge/sqlx/issues/3217).
/// In principle after requesting to close the sqlite pool, the wrapper monitors open file descriptor and polls periodically until all database files are closed.
///
/// This type is cheaply [`Clone`]-able: all clones share the same underlying pool and the same
/// reference count. The `Drop` impl only emits a warning when the **last** reference is dropped
/// without an explicit [`close`](Self::close) call, so it is safe to clone this guard temporarily
/// (e.g. to pass into a spawned task) without triggering spurious warnings.
#[derive(Debug, Clone)]
pub struct SqlitePoolGuard {
inner: Arc<SqlitePoolGuardInner>,
}
impl SqlitePoolGuard { impl SqlitePoolGuard {
/// Create new instance providing path to database and connection pool /// Create new instance providing path to database and connection pool
pub fn new(connection_pool: sqlx::SqlitePool) -> Self { pub fn new(connection_pool: sqlx::SqlitePool) -> Self {
@@ -46,46 +57,70 @@ impl SqlitePoolGuard {
.to_path_buf(); .to_path_buf();
Self { Self {
database_path, inner: Arc::new(SqlitePoolGuardInner {
connection_pool, database_path,
connection_pool,
}),
} }
} }
/// Returns database path /// Returns database path
pub fn database_path(&self) -> &Path { pub fn database_path(&self) -> &Path {
&self.database_path &self.inner.database_path
} }
/// Close udnerlying sqlite pool and wait for files to be closed before returning. /// Close the underlying sqlite pool and wait for OS file handles to be released.
///
/// **Callers must invoke this method before dropping the guard.** The `Drop` impl does
/// not perform async cleanup; it only logs a warning when the pool was not closed
/// beforehand.
pub async fn close(&self) { pub async fn close(&self) {
// Avoid waiting for db files once the pool is marked closed to ensure that we don't wait on some other sqlite pool to close the database. // Avoid waiting for db files once the pool is marked closed to ensure that we don't wait on some other sqlite pool to close the database.
if !self.connection_pool.is_closed() { if !self.inner.connection_pool.is_closed() {
tracing::info!("Closing sqlite pool: {}", self.database_path.display()); tracing::info!(
"Closing sqlite pool: {}",
self.inner.database_path.display()
);
self.close_pool_inner().await.ok(); self.close_pool_inner().await.ok();
} }
} }
async fn close_pool_inner(&self) -> std::io::Result<()> { async fn close_pool_inner(&self) -> std::io::Result<()> {
self.connection_pool.close().await; self.inner.connection_pool.close().await;
self.wait_for_db_files_close().await.inspect_err(|e| { if let Err(e) = self.wait_for_db_files_close().await {
tracing::error!("Failed to wait for file to close: {e}"); if e.kind() == std::io::ErrorKind::TimedOut {
}) tracing::warn!(
"Timed out waiting for OS file handles for sqlite database to be released; \
another connection to the same file may still be open. Path = {}",
self.inner.database_path.display()
);
} else {
tracing::warn!(
"Failed to wait for sqlite database file handles to be released: Path = {}. Error = {}",
self.inner.database_path.display(),
e
);
}
}
Ok(())
} }
/// Returns all database files, including shm and wal files. /// Returns all database files, including shm and wal files.
fn all_database_files(&self) -> Vec<PathBuf> { fn all_database_files(&self) -> Vec<PathBuf> {
let mut database_files = vec![]; let mut database_files = vec![];
let canonical_path = self let canonical_path = self
.inner
.database_path .database_path
.canonicalize() .canonicalize()
.inspect_err(|e| { .inspect_err(|e| {
tracing::error!( tracing::error!(
"Failed to canonicalize path: {}. Cause: {e}", "Failed to canonicalize path: {}. Cause: {e}",
self.database_path.display() self.inner.database_path.display()
); );
}) })
.unwrap_or(self.database_path.clone()); .unwrap_or(self.inner.database_path.clone());
if let Some(ext) = canonical_path.extension() { if let Some(ext) = canonical_path.extension() {
for added_ext in ["-shm", "-wal"] { for added_ext in ["-shm", "-wal"] {
@@ -120,34 +155,38 @@ impl SqlitePoolGuard {
} }
} }
impl Drop for SqlitePoolGuard {
fn drop(&mut self) {
if Arc::strong_count(&self.inner) == 1 && !self.inner.connection_pool.is_closed() {
tracing::warn!(
"SqlitePoolGuard dropped without explicit close(); path={}",
self.inner.database_path.display()
);
}
}
}
impl Deref for SqlitePoolGuard { impl Deref for SqlitePoolGuard {
type Target = sqlx::SqlitePool; type Target = sqlx::SqlitePool;
fn deref(&self) -> &Self::Target { fn deref(&self) -> &Self::Target {
&self.connection_pool &self.inner.connection_pool
} }
} }
impl DerefMut for SqlitePoolGuard {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.connection_pool
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use sqlx::{ use sqlx::{
ConnectOptions, Executor, ConnectOptions, Executor,
sqlite::{SqliteAutoVacuum, SqliteSynchronous}, sqlite::{SqliteAutoVacuum, SqliteSynchronous},
}; };
use tracing_test::traced_test;
use super::*; use super::*;
#[traced_test]
#[tokio::test] #[tokio::test]
async fn test_wait_close() { async fn test_wait_close() {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::TRACE)
.init();
let temp_dir = tempfile::tempdir().unwrap(); let temp_dir = tempfile::tempdir().unwrap();
let database_path = temp_dir.path().join("storage.db"); let database_path = temp_dir.path().join("storage.db");
@@ -177,4 +216,34 @@ mod tests {
assert!(guard.close_pool_inner().await.is_ok()); assert!(guard.close_pool_inner().await.is_ok());
tokio::fs::remove_file(database_path).await.unwrap(); tokio::fs::remove_file(database_path).await.unwrap();
} }
#[traced_test]
#[tokio::test]
async fn test_clone_drop_no_warning() {
// Cloning the guard and dropping the clone should not warn because the original is still alive.
let temp_dir = tempfile::tempdir().unwrap();
let database_path = temp_dir.path().join("storage2.db");
let opts = sqlx::sqlite::SqliteConnectOptions::new()
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
.synchronous(SqliteSynchronous::Normal)
.auto_vacuum(SqliteAutoVacuum::Incremental)
.filename(database_path.clone())
.create_if_missing(true)
.disable_statement_logging();
let connection_pool = sqlx::SqlitePool::connect_with(opts).await.unwrap();
let guard = SqlitePoolGuard::new(connection_pool);
{
let _clone = guard.clone();
assert_eq!(Arc::strong_count(&guard.inner), 2);
}
assert_eq!(Arc::strong_count(&guard.inner), 1);
assert!(!logs_contain(
"SqlitePoolGuard dropped without explicit close"
));
guard.close().await;
tokio::fs::remove_file(database_path).await.unwrap();
}
} }
@@ -1,6 +1,6 @@
{ {
"name": "@nymproject/mix-fetch-example-parcel", "name": "@nymproject/mix-fetch-example-parcel",
"version": "1.0.7", "version": "1.0.6",
"license": "Apache-2.0", "license": "Apache-2.0",
"scripts": { "scripts": {
"build": "parcel build --no-cache --no-content-hash", "build": "parcel build --no-cache --no-content-hash",
@@ -8,9 +8,9 @@
"start": "parcel --no-cache" "start": "parcel --no-cache"
}, },
"dependencies": { "dependencies": {
"@nymproject/mix-fetch": ">=1.4.4-rc.0 || ^1", "@nymproject/mix-fetch": ">=1.4.2-rc.0 || ^1",
"parcel": "^2.9.3" "parcel": "^2.9.3"
}, },
"private": false, "private": false,
"source": "src/index.html" "source": "src/index.html"
} }
@@ -1,6 +1,6 @@
{ {
"name": "@nymproject/mix-fetch-node", "name": "@nymproject/mix-fetch-node",
"version": "1.4.4", "version": "1.4.3",
"description": "This package is a drop-in replacement for `fetch` in NodeJS to send HTTP requests over the Nym Mixnet.", "description": "This package is a drop-in replacement for `fetch` in NodeJS to send HTTP requests over the Nym Mixnet.",
"license": "Apache-2.0", "license": "Apache-2.0",
"author": "Nym Technologies SA", "author": "Nym Technologies SA",
@@ -28,7 +28,7 @@
"tsc": "tsc --noEmit true" "tsc": "tsc --noEmit true"
}, },
"dependencies": { "dependencies": {
"@nymproject/mix-fetch-wasm-node": ">=1.4.4-rc.0 || ^1", "@nymproject/mix-fetch-wasm-node": ">=1.4.2-rc.0 || ^1",
"comlink": "^4.3.1", "comlink": "^4.3.1",
"fake-indexeddb": "^5.0.0", "fake-indexeddb": "^5.0.0",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
@@ -68,4 +68,4 @@
}, },
"private": false, "private": false,
"types": "./dist/cjs/index.d.ts" "types": "./dist/cjs/index.d.ts"
} }
@@ -1,6 +1,6 @@
{ {
"name": "@nymproject/mix-fetch-tester-webpack", "name": "@nymproject/mix-fetch-tester-webpack",
"version": "1.0.7", "version": "1.0.6",
"license": "Apache-2.0", "license": "Apache-2.0",
"scripts": { "scripts": {
"build": "webpack build --progress --config webpack.prod.js", "build": "webpack build --progress --config webpack.prod.js",
@@ -8,7 +8,7 @@
"start": "webpack serve --progress --port 3000" "start": "webpack serve --progress --port 3000"
}, },
"dependencies": { "dependencies": {
"@nymproject/mix-fetch": ">=1.4.3-rc.0 || ^1" "@nymproject/mix-fetch": ">=1.4.2-rc.0 || ^1"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.22.10", "@babel/core": "^7.22.10",
@@ -1,6 +1,6 @@
{ {
"name": "@nymproject/mix-fetch-tester-parcel", "name": "@nymproject/mix-fetch-tester-parcel",
"version": "1.0.7", "version": "1.0.6",
"license": "Apache-2.0", "license": "Apache-2.0",
"scripts": { "scripts": {
"build": "npx parcel build --no-cache --no-content-hash", "build": "npx parcel build --no-cache --no-content-hash",
@@ -8,7 +8,7 @@
"start": "npx parcel --no-cache" "start": "npx parcel --no-cache"
}, },
"dependencies": { "dependencies": {
"@nymproject/mix-fetch": ">=1.4.3-rc.0 || ^1" "@nymproject/mix-fetch": ">=1.4.2-rc.0 || ^1"
}, },
"private": false, "private": false,
"source": "../src/index.html" "source": "../src/index.html"
@@ -1,6 +1,6 @@
{ {
"name": "@nymproject/mix-fetch", "name": "@nymproject/mix-fetch",
"version": "1.4.4", "version": "1.4.3",
"description": "This package is a drop-in replacement for `fetch` to send HTTP requests over the Nym Mixnet.", "description": "This package is a drop-in replacement for `fetch` to send HTTP requests over the Nym Mixnet.",
"license": "Apache-2.0", "license": "Apache-2.0",
"author": "Nym Technologies SA", "author": "Nym Technologies SA",
@@ -34,7 +34,7 @@
"tsc": "tsc --noEmit true" "tsc": "tsc --noEmit true"
}, },
"dependencies": { "dependencies": {
"@nymproject/mix-fetch-wasm": ">=1.4.4-rc.0 || ^1", "@nymproject/mix-fetch-wasm": ">=1.4.2-rc.0 || ^1",
"comlink": "^4.3.1" "comlink": "^4.3.1"
}, },
"devDependencies": { "devDependencies": {
@@ -82,4 +82,4 @@
"private": false, "private": false,
"type": "module", "type": "module",
"types": "./dist/esm/index.d.ts" "types": "./dist/esm/index.d.ts"
} }
@@ -0,0 +1,5 @@
{
"name": "@nymproject/nym-client-wasm",
"version": "1.0.0",
"sideEffects": false
}
@@ -23,6 +23,7 @@ use crate::{
}; };
use futures::StreamExt; use futures::StreamExt;
use nym_ip_packet_requests::codec::MultiIpPacketCodec; use nym_ip_packet_requests::codec::MultiIpPacketCodec;
use nym_ip_packet_requests::{MAX_NON_STREAM_VERSION, SPHINX_STREAM_VERSION_THRESHOLD};
use nym_lp::packet::frame::{LpFrameHeader, LpFrameKind, SphinxStreamFrameAttributes}; use nym_lp::packet::frame::{LpFrameHeader, LpFrameKind, SphinxStreamFrameAttributes};
use nym_sdk::mixnet::MixnetMessageSender; use nym_sdk::mixnet::MixnetMessageSender;
use nym_sphinx::receiver::ReconstructedMessage; use nym_sphinx::receiver::ReconstructedMessage;
@@ -559,8 +560,9 @@ impl MixnetListener {
/// ///
/// # Version / transport enforcement /// # Version / transport enforcement
/// ///
/// - LP Stream frames (`stream_id` is `Some`) **must** carry v9+ payloads. /// - LP Stream frames (`stream_id` is `Some`) **must** carry payloads with version
/// - Non-stream messages (`stream_id` is `None`) **must** be v8 or lower. /// `>= SPHINX_STREAM_VERSION_THRESHOLD` (see `nym_ip_packet_requests`).
/// - Non-stream messages (`stream_id` is `None`) **must** be `<= MAX_NON_STREAM_VERSION`.
/// ///
/// Messages that violate these rules are dropped. /// Messages that violate these rules are dropped.
async fn on_ipr_message( async fn on_ipr_message(
@@ -578,16 +580,22 @@ impl MixnetListener {
}?; }?;
// Enforce version/transport consistency: // Enforce version/transport consistency:
// - LP Stream frames must carry v9+ payloads // - LP Stream frames must carry payloads at/above the SphinxStream threshold
// - Non-stream messages must be v8 or lower // - Non-stream messages must be at/below the max non-stream version
let version_num = request.version().into_u8(); let version_num = request.version().into_u8();
if stream_id.is_some() && version_num < 9 { if stream_id.is_some() && version_num < SPHINX_STREAM_VERSION_THRESHOLD {
log::warn!("LP Stream frame contains v{version_num} payload, expected v9+; dropping",); log::warn!(
"LP Stream frame contains v{version_num} payload, expected v{expected}+; dropping",
expected = SPHINX_STREAM_VERSION_THRESHOLD
);
return Ok(vec![]); return Ok(vec![]);
} }
if stream_id.is_none() && version_num >= 9 { if stream_id.is_none() && version_num > MAX_NON_STREAM_VERSION {
log::warn!("Non-stream message claims v{version_num}, expected v8 or lower; dropping",); log::warn!(
"Non-stream message claims v{version_num}, expected v{expected} or lower; dropping",
expected = MAX_NON_STREAM_VERSION
);
return Ok(vec![]); return Ok(vec![]);
} }
@@ -3,7 +3,7 @@
[package] [package]
name = "nym-network-requester" name = "nym-network-requester"
version = "1.1.75" version = "1.1.77"
authors.workspace = true authors.workspace = true
edition.workspace = true edition.workspace = true
license = "GPL-3.0" license = "GPL-3.0"
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "nym-cli" name = "nym-cli"
version = "1.1.74" version = "1.1.76"
authors.workspace = true authors.workspace = true
edition = "2021" edition = "2021"
license.workspace = true license.workspace = true
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "nymvisor" name = "nymvisor"
version = "0.1.39" version = "0.1.41"
authors.workspace = true authors.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
+1 -1
View File
@@ -1,7 +1,7 @@
[package] [package]
name = "mix-fetch-wasm" name = "mix-fetch-wasm"
version = "1.4.3"
authors = ["Jedrzej Stuczynski <andrew@nymtech.net>"] authors = ["Jedrzej Stuczynski <andrew@nymtech.net>"]
version = "1.4.4"
edition = "2021" edition = "2021"
license = "Apache-2.0" license = "Apache-2.0"
repository = "https://github.com/nymtech/nym" repository = "https://github.com/nymtech/nym"
@@ -92,8 +92,7 @@ func (ar *CurrentActiveRequests) InjectData(id types.RequestId, data []byte) {
defer ar.Unlock() defer ar.Unlock()
_, exists := ar.Requests[id] _, exists := ar.Requests[id]
if !exists { if !exists {
log.Error("attempted to inject data for connection %d that no longer exists — likely already cleaned up", id) panic("attempted to write to connection that doesn't exist")
return
} }
ar.Requests[id].injector.ServerData <- data ar.Requests[id].injector.ServerData <- data
} }
@@ -116,8 +115,7 @@ func (ar *CurrentActiveRequests) SendError(id types.RequestId, err error) {
defer ar.Unlock() defer ar.Unlock()
_, exists := ar.Requests[id] _, exists := ar.Requests[id]
if !exists { if !exists {
log.Error("attempted to inject error for connection %d that no longer exists — likely already cleaned up", id) panic("attempted to inject error data to connection that doesn't exist")
return
} }
ar.Requests[id].injector.RemoteError <- err ar.Requests[id].injector.RemoteError <- err
} }
+75 -117
View File
@@ -3,142 +3,100 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nym MixFetch Dev</title> <title>Nym MixFetch Demo</title>
<script src="bootstrap.js"></script> <script src="bootstrap.js"></script>
</head> </head>
<body> <body>
<h1>MixFetch Dev</h1> <h1>Mix Fetch Demo</h1>
<fieldset id="startup-controls"> <fieldset id="startup-controls">
<legend>Connection</legend> <legend>MixFetch Configuration</legend>
<div> <p>
<label><input type="radio" name="gateway-mode" value="default" checked /> You can either use the default Gateway/NR combo run by us, or have
Default Gateway (q2A2cbooyC16YJzvdYaSMH9X3cSiieZNtfBr8cE8Fi1)</label> MixFetch choose a random one on startup.
</p>
<div style="margin-bottom: 10px">
<label>
<input type="radio" name="gateway-mode" value="default" checked />
Default Gateway (q2A2cbooyC16YJzvdYaSMH9X3cSiieZNtfBr8cE8Fi1)
</label>
</div> </div>
<div> <div style="margin-bottom: 10px">
<label><input type="radio" name="gateway-mode" value="random" /> <label>
Random Gateway</label> <input type="radio" name="gateway-mode" value="random" />
</div> Random Gateway
<details style="margin-top: 8px"> </label>
<summary style="cursor: pointer; font-size: 0.9em; color: #555">Advanced Options</summary>
<div style="margin-top: 6px; padding: 8px; background: #f9f9f9; font-size: 0.9em">
<div>
<label><input type="checkbox" id="opt-force-tls" checked /> Force TLS</label>
</div>
<div style="margin-top: 4px">
<label>Client ID: <input type="text" id="opt-client-id" size="20" /></label>
<span style="font-size: 0.85em; color: #888">(randomised on load to avoid ID reuse)</span>
</div>
<div style="margin-top: 4px">
<label><input type="checkbox" id="opt-disable-poisson" checked /> Disable Poisson packet distribution</label>
</div>
<div style="margin-top: 4px">
<label><input type="checkbox" id="opt-disable-cover" checked /> Disable cover traffic</label>
</div>
<div style="margin-top: 4px">
<label>Request timeout (ms): <input type="number" id="opt-request-timeout" value="60000" min="1000" max="300000" style="width: 80px" /></label>
</div>
</div>
</details>
<div style="margin-top: 8px">
<button id="start-mixfetch">Start MixFetch</button>
<span id="mixfetch-status" style="margin-left: 10px; color: gray">Not started</span>
</div> </div>
<button id="start-mixfetch">Start MixFetch</button>
<span id="mixfetch-status" style="margin-left: 10px; color: gray"
>Not started</span
>
</fieldset> </fieldset>
<hr />
<fieldset id="fetch-controls" disabled> <fieldset id="fetch-controls" disabled>
<legend>Quick Fetch</legend> <legend>Fetch Controls</legend>
<div> <div>
<input type="text" size="55" id="fetch_payload_1" value="https://api.ipify.org?format=json" /> <label>Target Host 1: </label>
<button id="fetch-button-1">GET</button> <input
type="text"
size="60"
id="fetch_payload_1"
value="https://api.ipify.org?format=json"
/>
<button id="fetch-button-1">Fetch 1</button>
</div> </div>
<div style="margin-top: 4px"> <div>
<input type="text" size="55" id="fetch_payload_2" value="https://api6.ipify.org?format=json" /> <label>Target Host 2: </label>
<button id="fetch-button-2">GET</button> <input
type="text"
size="60"
id="fetch_payload_2"
value="https://api6.ipify.org?format=json"
/>
<button id="fetch-button-2">Fetch 2</button>
</div>
<p>
Note: if you're hammering these endpoints and you start to get timeouts
(or you've been reloading the page a lot) then change the target hosts
to e.g. http://ipv4.icanhazip.com and https://ipv6.icanhazip.com/
</p>
<div>
<button id="fetch-10-concurrent">
Send 10 Concurrent Requests (posts/1-10)
</button>
<p>
This does what is says on the tin - sends 10 fetch requests to
https://jsonplaceholder.typicode.com/posts/ 1 through 10
</p>
</div> </div>
<pre id="fetch-log" style="background: #f5f5f5; padding: 8px; margin-top: 8px; max-height: 150px; overflow-y: auto; font-size: 0.85em; white-space: pre-wrap; display: none"></pre>
<hr /> <hr />
<h3>Stress Test</h3>
<div> <div>
<label>Requests:</label> <label>POST URL: </label>
<input type="number" id="stress-test-count" value="20" min="1" max="200" style="width: 60px" /> <input
<label>Mode:</label> type="text"
<select id="stress-test-mode"> size="60"
<option value="uniform">Uniform (same URL, incremented ID)</option> id="post_url"
<option value="mixed" selected>Mixed sizes</option> value="https://jsonplaceholder.typicode.com/posts"
<option value="drip">Slow drip (timeout boundary)</option> />
</select>
</div> </div>
<div>
<div id="stress-uniform-opts" style="display: none; margin-top: 8px; padding: 8px; background: #f9f9f9"> <label>POST Body (JSON): </label>
<div> <textarea id="post_body" rows="3" cols="60">
<label>Base URL:</label> {"title": "Test Post", "body": "Hello from MixFetch!", "userId": 1}</textarea
<input type="text" size="50" id="stress-test-url" value="https://jsonplaceholder.typicode.com/posts/" /> >
</div>
<p style="margin: 4px 0 0; font-size: 0.85em; color: #666">
Request ID (1..N) appended to the URL. All requests are the same size.
</p>
</div> </div>
<div>
<div id="stress-mixed-opts" style="margin-top: 8px; padding: 8px; background: #f9f9f9"> <button id="post-button">Send POST Request</button>
<p style="margin: 0 0 4px; font-size: 0.85em; color: #666">
Each request randomly assigned a size via <code>httpbin.org/bytes/{n}</code>.
Tests how the mixnet handles asymmetric payload sizes concurrently.
</p>
<table style="font-size: 0.9em; border-collapse: collapse">
<tr><td style="padding: 1px 10px 1px 0"><b>tiny</b></td><td>128 B</td></tr>
<tr><td style="padding: 1px 10px 1px 0"><b>small</b></td><td>1 KB</td></tr>
<tr><td style="padding: 1px 10px 1px 0"><b>medium</b></td><td>10 KB</td></tr>
<tr><td style="padding: 1px 10px 1px 0"><b>large</b></td><td>100 KB</td></tr>
<tr><td style="padding: 1px 10px 1px 0"><b>xlarge</b></td><td>1 MB</td></tr>
</table>
</div> </div>
<p>Do a POST and get it echoed back</p>
<div id="stress-drip-opts" style="display: none; margin-top: 8px; padding: 8px; background: #f9f9f9">
<p style="margin: 0 0 4px; font-size: 0.85em; color: #666">
Uses <code>httpbin.org/drip</code> to slowly drip bytes over a set duration,
keeping connections alive for a controlled time. Profiles are computed from
the Go timeout value below, so they always straddle the configured boundary
&mdash; some finish well before the timeout, others are mid-transfer when it fires.
</p>
<table style="font-size: 0.9em; border-collapse: collapse">
<tr>
<td style="padding: 1px 10px 1px 0"><b>safe</b></td>
<td>~50% of timeout</td>
<td style="padding-left: 10px; color: #666">well under the limit</td>
</tr>
<tr>
<td style="padding: 1px 10px 1px 0"><b>boundary</b></td>
<td>~92% of timeout</td>
<td style="padding-left: 10px; color: #666">server-side OK, but mixnet latency pushes it over</td>
</tr>
<tr>
<td style="padding: 1px 10px 1px 0"><b>over</b></td>
<td>~108% of timeout</td>
<td style="padding-left: 10px; color: #666">exceeds the timeout on its own</td>
</tr>
<tr>
<td style="padding: 1px 10px 1px 0"><b>slow-start</b></td>
<td>~17% delay + ~83% drip</td>
<td style="padding-left: 10px; color: #666">late first byte, then long transfer</td>
</tr>
</table>
</div>
<div style="margin-top: 8px">
<label>Go timeout (ms):</label>
<input type="number" id="stress-go-timeout" value="60000" min="500" max="120000" style="width: 80px" />
</div>
<div style="margin-top: 8px">
<button id="stress-test-button">Run Stress Test</button>
<span id="stress-test-status" style="margin-left: 10px"></span>
</div>
<div id="stress-tracker" style="margin-top: 8px; font-family: monospace; font-size: 0.85em; display: none"></div>
</fieldset> </fieldset>
<hr />
<p>
<span id="output"></span>
</p>
</body> </body>
</html> </html>
+159 -197
View File
@@ -12,8 +12,6 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
// ─── Worker client ──────────────────────────────────────────────────────────
class WebWorkerClient { class WebWorkerClient {
worker = null; worker = null;
@@ -21,254 +19,218 @@ class WebWorkerClient {
this.worker = new Worker('./worker.js'); this.worker = new Worker('./worker.js');
this.worker.onmessage = (ev) => { this.worker.onmessage = (ev) => {
if (!ev.data || !ev.data.kind) return; if (ev.data && ev.data.kind) {
switch (ev.data.kind) { switch (ev.data.kind) {
case 'DisplayString': case 'DisplayString':
appendFetchLog(ev.data.args.rawString); const { rawString } = ev.data.args;
console.log('[mixfetch response]', ev.data.args.rawString); displayReceivedRawString(rawString)
break; break;
case 'Log': { case 'Log':
const { message, level } = ev.data.args; const { message, level } = ev.data.args;
const fn = level === 'error' ? console.error displayLog(message, level);
: level === 'warn' ? console.warn break;
: console.log; case 'MixFetchReady':
fn(`[worker/${level}]`, message); onMixFetchReady();
break; break;
case 'MixFetchError':
const { error } = ev.data.args;
onMixFetchError(error);
break;
} }
case 'MixFetchReady':
onMixFetchReady();
break;
case 'MixFetchError':
onMixFetchError(ev.data.args.error);
break;
case 'StressTestFetchResult':
onStressTestFetchResult(ev.data.args);
break;
} }
}; };
} }
startMixFetch = (preferredGateway, setupOpts) => { startMixFetch = (preferredGateway) => {
this.worker.postMessage({ kind: 'StartMixFetch', args: { preferredGateway, setupOpts } }); if (!this.worker) {
}; console.error('Could not send message because worker does not exist');
return;
}
this.worker.postMessage({
kind: 'StartMixFetch',
args: {
preferredGateway,
},
});
}
doFetch = (target) => { doFetch = (target) => {
this.worker.postMessage({ kind: 'FetchPayload', args: { target } }); if (!this.worker) {
}; console.error('Could not send message because worker does not exist');
return;
setGoTimeout = (timeoutMs) => {
this.worker.postMessage({ kind: 'SetGoTimeout', args: { timeoutMs } });
};
doStressTest = (requests) => {
for (const req of requests) {
this.worker.postMessage({
kind: 'StressTestFetch',
args: { id: req.id, url: req.url, label: req.label },
});
} }
};
this.worker.postMessage({
kind: 'FetchPayload',
args: {
target,
},
});
}
doPost = (url, body) => {
if (!this.worker) {
console.error('Could not send message because worker does not exist');
return;
}
this.worker.postMessage({
kind: 'PostPayload',
args: {
url,
body,
},
});
}
} }
// ─── Startup ────────────────────────────────────────────────────────────────
let client = null; let client = null;
const DEFAULT_GATEWAY = 'q2A2cbooyC16YJzvdYaSMH9X3cSiieZNtfBr8cE8Fi1';
const DEFAULT_GATEWAY = "q2A2cbooyC16YJzvdYaSMH9X3cSiieZNtfBr8cE8Fi1";
async function main() { async function main() {
client = new WebWorkerClient(); client = new WebWorkerClient();
// Randomise client ID on each load to avoid storage/state collisions const startButton = document.querySelector('#start-mixfetch');
document.getElementById('opt-client-id').value = startButton.onclick = function () {
'client-' + Math.random().toString(36).slice(2, 8);
document.querySelector('#start-mixfetch').onclick = () => {
const gatewayMode = document.querySelector('input[name="gateway-mode"]:checked').value; const gatewayMode = document.querySelector('input[name="gateway-mode"]:checked').value;
const preferredGateway = gatewayMode === 'default' ? DEFAULT_GATEWAY : undefined; const preferredGateway = gatewayMode === 'default' ? DEFAULT_GATEWAY : undefined;
const setupOpts = { startButton.disabled = true;
forceTls: document.getElementById('opt-force-tls').checked, document.querySelectorAll('input[name="gateway-mode"]').forEach(r => r.disabled = true);
clientId: document.getElementById('opt-client-id').value, updateStatus('Starting...', 'orange');
disablePoisson: document.getElementById('opt-disable-poisson').checked,
disableCover: document.getElementById('opt-disable-cover').checked,
requestTimeoutMs: parseInt(document.getElementById('opt-request-timeout').value, 10),
};
document.querySelector('#start-mixfetch').disabled = true; displayLog(`Starting MixFetch with ${gatewayMode} gateway${preferredGateway ? ` (${preferredGateway})` : ''}...`, 'info');
document.querySelectorAll('input[name="gateway-mode"]').forEach((r) => (r.disabled = true)); client.startMixFetch(preferredGateway);
updateStatus('mixfetch-status', 'Starting...'); }
// Sync the stress-test Go timeout to match the configured request timeout const fetchButton1 = document.querySelector('#fetch-button-1');
document.getElementById('stress-go-timeout').value = setupOpts.requestTimeoutMs; fetchButton1.onclick = function () {
doFetch(1);
}
console.log(`Starting MixFetch (${gatewayMode} gateway${preferredGateway ? `: ${preferredGateway}` : ''})...`); const fetchButton2 = document.querySelector('#fetch-button-2');
console.log('Setup options:', setupOpts); fetchButton2.onclick = function () {
client.startMixFetch(preferredGateway, setupOpts); doFetch(2);
}; }
document.querySelector('#fetch-button-1').onclick = () => doFetch(1); const fetch10Button = document.querySelector('#fetch-10-concurrent');
document.querySelector('#fetch-button-2').onclick = () => doFetch(2); fetch10Button.onclick = function () {
doFetch10Concurrent();
}
const stressModeSelect = document.getElementById('stress-test-mode'); const postButton = document.querySelector('#post-button');
stressModeSelect.onchange = function () { postButton.onclick = function () {
document.getElementById('stress-uniform-opts').style.display = this.value === 'uniform' ? 'block' : 'none'; doPost();
document.getElementById('stress-mixed-opts').style.display = this.value === 'mixed' ? 'block' : 'none'; }
document.getElementById('stress-drip-opts').style.display = this.value === 'drip' ? 'block' : 'none';
};
document.querySelector('#stress-test-button').onclick = () => {
const count = parseInt(document.getElementById('stress-test-count').value, 10);
const mode = document.getElementById('stress-test-mode').value;
const goTimeoutMs = parseInt(document.getElementById('stress-go-timeout').value, 10);
document.querySelector('#stress-test-button').disabled = true;
updateStatus('stress-test-status', 'Running...');
client.setGoTimeout(goTimeoutMs);
const requests = generateStressRequests(count, mode, goTimeoutMs);
stressTest = {
count,
startTime: performance.now(),
results: [],
};
console.log(`=== STRESS TEST: ${count} requests, ${mode} mode, timeout=${goTimeoutMs}ms ===`);
if (mode === 'mixed' || mode === 'drip') {
const breakdown = {};
for (const req of requests) breakdown[req.label] = (breakdown[req.label] || 0) + 1;
console.log('Profiles:', breakdown);
}
client.doStressTest(requests);
};
} }
// ─── UI helpers ───────────────────────────────────────────────────────────── function updateStatus(text, color) {
const status = document.getElementById('mixfetch-status');
function updateStatus(elementId, text) { status.textContent = text;
document.getElementById(elementId).textContent = text; status.style.color = color;
} }
function onMixFetchReady() { function onMixFetchReady() {
updateStatus('mixfetch-status', 'Ready'); updateStatus('Ready', 'green');
document.getElementById('fetch-controls').disabled = false; document.getElementById('fetch-controls').disabled = false;
console.log('MixFetch ready!'); displayLog('MixFetch is ready!', 'info');
} }
function onMixFetchError(error) { function onMixFetchError(error) {
updateStatus('mixfetch-status', 'Error: ' + error); updateStatus('Error: ' + error, 'red');
document.querySelector('#start-mixfetch').disabled = false; document.querySelector('#start-mixfetch').disabled = false;
document.querySelectorAll('input[name="gateway-mode"]').forEach((r) => (r.disabled = false)); document.querySelectorAll('input[name="gateway-mode"]').forEach(r => r.disabled = false);
console.error('MixFetch error:', error); displayLog('MixFetch error: ' + error, 'error');
} }
// ─── Quick fetch ────────────────────────────────────────────────────────────
function appendFetchLog(text) {
const log = document.getElementById('fetch-log');
log.style.display = 'block';
const ts = new Date().toISOString().substr(11, 12);
log.textContent += `${ts} ${text}\n`;
log.scrollTop = log.scrollHeight;
}
async function doFetch(id) { async function doFetch(id) {
const url = document.getElementById(`fetch_payload_${id}`).value; const payload = document.getElementById(`fetch_payload_${id}`).value;
appendFetchLog(`GET ${url}`); await client.doFetch(payload)
console.log(`GET ${url}`);
await client.doFetch(url); displaySend(`[${id}] clicked the button and the payload is: ${payload}...`);
} }
// ─── Stress test ──────────────────────────────────────────────────────────── async function doFetch10Concurrent() {
const baseUrl = 'https://jsonplaceholder.typicode.com/posts/';
displaySend('Starting 10 concurrent requests to posts/1-10...');
const STRESS_PROFILES = [
{ label: 'tiny', bytes: 128 },
{ label: 'small', bytes: 1024 },
{ label: 'medium', bytes: 10240 },
{ label: 'large', bytes: 102400 },
{ label: 'xlarge', bytes: 1048576 },
];
function buildDripProfiles(timeoutSec) {
return [
{ label: 'safe', dripDuration: Math.round(timeoutSec * 0.50), dripDelay: 0, dripBytes: 100 },
{ label: 'boundary', dripDuration: Math.round(timeoutSec * 0.92), dripDelay: 0, dripBytes: 100 },
{ label: 'over', dripDuration: Math.round(timeoutSec * 1.08), dripDelay: 0, dripBytes: 100 },
{ label: 'slow-start', dripDuration: Math.round(timeoutSec * 0.83), dripDelay: Math.round(timeoutSec * 0.17), dripBytes: 100 },
];
}
function generateStressRequests(count, mode, timeoutMs) {
const requests = []; const requests = [];
if (mode === 'uniform') { for (let i = 1; i <= 10; i++) {
const baseUrl = document.getElementById('stress-test-url').value; const url = `${baseUrl}${i}`;
for (let i = 1; i <= count; i++) { displaySend(`[${i}] Sending request to ${url}`);
requests.push({ id: i, url: `${baseUrl}${i}`, label: 'uniform', bytes: null }); requests.push(client.doFetch(url));
}
} else if (mode === 'drip') {
const dripProfiles = buildDripProfiles(timeoutMs / 1000);
for (let i = 1; i <= count; i++) {
const p = dripProfiles[Math.floor(Math.random() * dripProfiles.length)];
requests.push({
id: i,
url: `https://httpbin.org/drip?duration=${p.dripDuration}&numbytes=${p.dripBytes}&delay=${p.dripDelay}&code=200`,
label: p.label,
bytes: p.dripBytes,
});
}
} else {
for (let i = 1; i <= count; i++) {
const p = STRESS_PROFILES[Math.floor(Math.random() * STRESS_PROFILES.length)];
requests.push({
id: i,
url: `https://httpbin.org/bytes/${p.bytes}`,
label: p.label,
bytes: p.bytes,
});
}
} }
return requests;
await Promise.all(requests);
displaySend('All 10 concurrent requests dispatched!');
} }
let stressTest = null; async function doPost() {
const url = document.getElementById('post_url').value;
const body = document.getElementById('post_body').value;
function onStressTestFetchResult(result) { displaySend(`[POST] Sending POST request to ${url}`);
if (!stressTest) return; displaySend(`[POST] Body: ${body}`);
stressTest.results.push(result); await client.doPost(url, body);
}
const progress = `${stressTest.results.length}/${stressTest.count}`; /**
const tag = `#${result.id} ${result.label}`; * Display log messages from MixFetch. Colors based on level.
*
* @param {string} message
* @param {string} level - 'info', 'error', 'warn', or 'debug'
*/
function displayLog(message, level) {
let timestamp = new Date().toISOString().substr(11, 12);
if (result.ok) { const colors = {
console.log(`[${tag}] ${result.status} OK ${result.elapsed}s ${result.textLength}B (${progress})`); info: 'gray',
} else { error: 'red',
console.error(`[${tag}] FAIL ${result.elapsed}s ${result.error} (${progress})`); warn: 'orange',
} debug: 'purple',
};
updateStatus('stress-test-status', progress); let logDiv = document.createElement('div');
let paragraph = document.createElement('p');
paragraph.setAttribute('style', `color: ${colors[level] || 'gray'}`);
let paragraphContent = document.createTextNode(timestamp + ' [' + level.toUpperCase() + '] ' + message);
paragraph.appendChild(paragraphContent);
if (stressTest.results.length === stressTest.count) { logDiv.appendChild(paragraph);
const totalElapsed = ((performance.now() - stressTest.startTime) / 1000).toFixed(2); document.getElementById('output').appendChild(logDiv);
const succeeded = stressTest.results.filter((r) => r.ok).length; }
const failed = stressTest.results.filter((r) => !r.ok).length;
console.log(`=== COMPLETE: ${totalElapsed}s | OK ${succeeded}/${stressTest.count} | Failed ${failed}/${stressTest.count} ===`); /**
* Display messages that have been sent up the websocket. Colours them blue.
*
* @param {string} message
*/
function displaySend(message) {
let timestamp = new Date().toISOString().substr(11, 12);
if (failed > 0) { let sendDiv = document.createElement('div');
const failures = stressTest.results.filter((r) => !r.ok); let paragraph = document.createElement('p');
for (const f of failures) { paragraph.setAttribute('style', 'color: blue');
console.log(` FAIL #${f.id} ${f.label} (${f.elapsed}s): ${f.error}`); let paragraphContent = document.createTextNode(timestamp + ' sent >>> ' + message);
} paragraph.appendChild(paragraphContent);
}
updateStatus('stress-test-status', sendDiv.appendChild(paragraph);
`Done: ${succeeded}/${stressTest.count} OK, ${failed} failed (${totalElapsed}s)` document.getElementById('output').appendChild(sendDiv);
); }
document.querySelector('#stress-test-button').disabled = false;
stressTest = null; function displayReceivedRawString(raw) {
} let timestamp = new Date().toISOString().substr(11, 12);
let receivedDiv = document.createElement('div');
let paragraph = document.createElement('p');
paragraph.setAttribute('style', 'color: green');
let paragraphContent = document.createTextNode(timestamp + ' received >>> ' + raw);
paragraph.appendChild(paragraphContent);
receivedDiv.appendChild(paragraph);
document.getElementById('output').appendChild(receivedDiv);
} }
main(); main();
+121 -120
View File
@@ -1,11 +1,11 @@
{ {
"name": "mix-fetch-internal-dev", "name": "create-wasm-app",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mix-fetch-internal-dev", "name": "create-wasm-app",
"version": "0.1.0", "version": "0.1.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@@ -26,7 +26,7 @@
"../go-mix-conn/build": {}, "../go-mix-conn/build": {},
"../pkg": { "../pkg": {
"name": "@nymproject/mix-fetch-wasm", "name": "@nymproject/mix-fetch-wasm",
"version": "1.4.4", "version": "1.4.2",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@discoveryjs/json-ext": { "node_modules/@discoveryjs/json-ext": {
@@ -141,14 +141,14 @@
} }
}, },
"node_modules/@jsonjoy.com/fs-core": { "node_modules/@jsonjoy.com/fs-core": {
"version": "4.57.1", "version": "4.56.10",
"resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.56.10.tgz",
"integrity": "sha512-YrEi/ZPmgc+GfdO0esBF04qv8boK9Dg9WpRQw/+vM8Qt3nnVIJWIa8HwZ/LXVZ0DB11XUROM8El/7yYTJX+WtA==", "integrity": "sha512-PyAEA/3cnHhsGcdY+AmIU+ZPqTuZkDhCXQ2wkXypdLitSpd6d5Ivxhnq4wa2ETRWFVJGabYynBWxIijOswSmOw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@jsonjoy.com/fs-node-builtins": "4.57.1", "@jsonjoy.com/fs-node-builtins": "4.56.10",
"@jsonjoy.com/fs-node-utils": "4.57.1", "@jsonjoy.com/fs-node-utils": "4.56.10",
"thingies": "^2.5.0" "thingies": "^2.5.0"
}, },
"engines": { "engines": {
@@ -163,15 +163,15 @@
} }
}, },
"node_modules/@jsonjoy.com/fs-fsa": { "node_modules/@jsonjoy.com/fs-fsa": {
"version": "4.57.1", "version": "4.56.10",
"resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.56.10.tgz",
"integrity": "sha512-ooEPvSW/HQDivPDPZMibHGKZf/QS4WRir1czGZmXmp3MsQqLECZEpN0JobrD8iV9BzsuwdIv+PxtWX9WpPLsIA==", "integrity": "sha512-/FVK63ysNzTPOnCCcPoPHt77TOmachdMS422txM4KhxddLdbW1fIbFMYH0AM0ow/YchCyS5gqEjKLNyv71j/5Q==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@jsonjoy.com/fs-core": "4.57.1", "@jsonjoy.com/fs-core": "4.56.10",
"@jsonjoy.com/fs-node-builtins": "4.57.1", "@jsonjoy.com/fs-node-builtins": "4.56.10",
"@jsonjoy.com/fs-node-utils": "4.57.1", "@jsonjoy.com/fs-node-utils": "4.56.10",
"thingies": "^2.5.0" "thingies": "^2.5.0"
}, },
"engines": { "engines": {
@@ -186,17 +186,17 @@
} }
}, },
"node_modules/@jsonjoy.com/fs-node": { "node_modules/@jsonjoy.com/fs-node": {
"version": "4.57.1", "version": "4.56.10",
"resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.56.10.tgz",
"integrity": "sha512-3YaKhP8gXEKN+2O49GLNfNb5l2gbnCFHyAaybbA2JkkbQP3dpdef7WcUaHAulg/c5Dg4VncHsA3NWAUSZMR5KQ==", "integrity": "sha512-7R4Gv3tkUdW3dXfXiOkqxkElxKNVdd8BDOWC0/dbERd0pXpPY+s2s1Mino+aTvkGrFPiY+mmVxA7zhskm4Ue4Q==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@jsonjoy.com/fs-core": "4.57.1", "@jsonjoy.com/fs-core": "4.56.10",
"@jsonjoy.com/fs-node-builtins": "4.57.1", "@jsonjoy.com/fs-node-builtins": "4.56.10",
"@jsonjoy.com/fs-node-utils": "4.57.1", "@jsonjoy.com/fs-node-utils": "4.56.10",
"@jsonjoy.com/fs-print": "4.57.1", "@jsonjoy.com/fs-print": "4.56.10",
"@jsonjoy.com/fs-snapshot": "4.57.1", "@jsonjoy.com/fs-snapshot": "4.56.10",
"glob-to-regex.js": "^1.0.0", "glob-to-regex.js": "^1.0.0",
"thingies": "^2.5.0" "thingies": "^2.5.0"
}, },
@@ -212,9 +212,9 @@
} }
}, },
"node_modules/@jsonjoy.com/fs-node-builtins": { "node_modules/@jsonjoy.com/fs-node-builtins": {
"version": "4.57.1", "version": "4.56.10",
"resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.56.10.tgz",
"integrity": "sha512-XHkFKQ5GSH3uxm8c3ZYXVrexGdscpWKIcMWKFQpMpMJc8gA3AwOMBJXJlgpdJqmrhPyQXxaY9nbkNeYpacC0Og==", "integrity": "sha512-uUnKz8R0YJyKq5jXpZtkGV9U0pJDt8hmYcLRrPjROheIfjMXsz82kXMgAA/qNg0wrZ1Kv+hrg7azqEZx6XZCVw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
@@ -229,15 +229,15 @@
} }
}, },
"node_modules/@jsonjoy.com/fs-node-to-fsa": { "node_modules/@jsonjoy.com/fs-node-to-fsa": {
"version": "4.57.1", "version": "4.56.10",
"resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.56.10.tgz",
"integrity": "sha512-pqGHyWWzNck4jRfaGV39hkqpY5QjRUQ/nRbNT7FYbBa0xf4bDG+TE1Gt2KWZrSkrkZZDE3qZUjYMbjwSliX6pg==", "integrity": "sha512-oH+O6Y4lhn9NyG6aEoFwIBNKZeYy66toP5LJcDOMBgL99BKQMUf/zWJspdRhMdn/3hbzQsZ8EHHsuekbFLGUWw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@jsonjoy.com/fs-fsa": "4.57.1", "@jsonjoy.com/fs-fsa": "4.56.10",
"@jsonjoy.com/fs-node-builtins": "4.57.1", "@jsonjoy.com/fs-node-builtins": "4.56.10",
"@jsonjoy.com/fs-node-utils": "4.57.1" "@jsonjoy.com/fs-node-utils": "4.56.10"
}, },
"engines": { "engines": {
"node": ">=10.0" "node": ">=10.0"
@@ -251,13 +251,13 @@
} }
}, },
"node_modules/@jsonjoy.com/fs-node-utils": { "node_modules/@jsonjoy.com/fs-node-utils": {
"version": "4.57.1", "version": "4.56.10",
"resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.56.10.tgz",
"integrity": "sha512-vp+7ZzIB8v43G+GLXTS4oDUSQmhAsRz532QmmWBbdYA20s465JvwhkSFvX9cVTqRRAQg+vZ7zWDaIEh0lFe2gw==", "integrity": "sha512-8EuPBgVI2aDPwFdaNQeNpHsyqPi3rr+85tMNG/lHvQLiVjzoZsvxA//Xd8aB567LUhy4QS03ptT+unkD/DIsNg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@jsonjoy.com/fs-node-builtins": "4.57.1" "@jsonjoy.com/fs-node-builtins": "4.56.10"
}, },
"engines": { "engines": {
"node": ">=10.0" "node": ">=10.0"
@@ -271,13 +271,13 @@
} }
}, },
"node_modules/@jsonjoy.com/fs-print": { "node_modules/@jsonjoy.com/fs-print": {
"version": "4.57.1", "version": "4.56.10",
"resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.56.10.tgz",
"integrity": "sha512-Ynct7ZJmfk6qoXDOKfpovNA36ITUx8rChLmRQtW08J73VOiuNsU8PB6d/Xs7fxJC2ohWR3a5AqyjmLojfrw5yw==", "integrity": "sha512-JW4fp5mAYepzFsSGrQ48ep8FXxpg4niFWHdF78wDrFGof7F3tKDJln72QFDEn/27M1yHd4v7sKHHVPh78aWcEw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@jsonjoy.com/fs-node-utils": "4.57.1", "@jsonjoy.com/fs-node-utils": "4.56.10",
"tree-dump": "^1.1.0" "tree-dump": "^1.1.0"
}, },
"engines": { "engines": {
@@ -292,14 +292,14 @@
} }
}, },
"node_modules/@jsonjoy.com/fs-snapshot": { "node_modules/@jsonjoy.com/fs-snapshot": {
"version": "4.57.1", "version": "4.56.10",
"resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.56.10.tgz",
"integrity": "sha512-/oG8xBNFMbDXTq9J7vepSA1kerS5vpgd3p5QZSPd+nX59uwodGJftI51gDYyHRpP57P3WCQf7LHtBYPqwUg2Bg==", "integrity": "sha512-DkR6l5fj7+qj0+fVKm/OOXMGfDFCGXLfyHkORH3DF8hxkpDgIHbhf/DwncBMs2igu/ST7OEkexn1gIqoU6Y+9g==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@jsonjoy.com/buffers": "^17.65.0", "@jsonjoy.com/buffers": "^17.65.0",
"@jsonjoy.com/fs-node-utils": "4.57.1", "@jsonjoy.com/fs-node-utils": "4.56.10",
"@jsonjoy.com/json-pack": "^17.65.0", "@jsonjoy.com/json-pack": "^17.65.0",
"@jsonjoy.com/util": "^17.65.0" "@jsonjoy.com/util": "^17.65.0"
}, },
@@ -869,9 +869,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "25.5.0", "version": "25.3.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz",
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -879,9 +879,9 @@
} }
}, },
"node_modules/@types/qs": { "node_modules/@types/qs": {
"version": "6.15.0", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -1332,9 +1332,9 @@
} }
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.10.13", "version": "2.10.0",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.13.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
"integrity": "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==", "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
@@ -1414,9 +1414,9 @@
} }
}, },
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.28.2", "version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
"integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -1434,11 +1434,11 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.10.12", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001782", "caniuse-lite": "^1.0.30001759",
"electron-to-chromium": "^1.5.328", "electron-to-chromium": "^1.5.263",
"node-releases": "^2.0.36", "node-releases": "^2.0.27",
"update-browserslist-db": "^1.2.3" "update-browserslist-db": "^1.2.0"
}, },
"bin": { "bin": {
"browserslist": "cli.js" "browserslist": "cli.js"
@@ -1522,9 +1522,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001784", "version": "1.0.30001774",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz",
"integrity": "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==", "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -1878,9 +1878,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.330", "version": "1.5.302",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.330.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
"integrity": "sha512-jFNydB5kFtYUobh4IkWUnXeyDbjf/r9gcUEXe1xcrcUxIGfTdzPXA+ld6zBRbwvgIGVzDll/LTIiDztEtckSnA==", "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@@ -1895,9 +1895,9 @@
} }
}, },
"node_modules/enhanced-resolve": { "node_modules/enhanced-resolve": {
"version": "5.20.1", "version": "5.19.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
"integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2766,9 +2766,9 @@
} }
}, },
"node_modules/is-network-error": { "node_modules/is-network-error": {
"version": "1.3.1", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.1.tgz", "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.0.tgz",
"integrity": "sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw==", "integrity": "sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -2894,9 +2894,9 @@
} }
}, },
"node_modules/launch-editor": { "node_modules/launch-editor": {
"version": "2.13.2", "version": "2.13.0",
"resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.13.2.tgz", "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.13.0.tgz",
"integrity": "sha512-4VVDnbOpLXy/s8rdRCSXb+zfMeFR0WlJWpET1iA9CQdlZDfwyLjUuGQzXU4VeOoey6AicSAluWan7Etga6Kcmg==", "integrity": "sha512-u+9asUHMJ99lA15VRMXw5XKfySFR9dGXwgsgS14YTbUq3GITP58mIM32At90P5fZ+MUId5Yw+IwI/yKub7jnCQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2952,20 +2952,20 @@
} }
}, },
"node_modules/memfs": { "node_modules/memfs": {
"version": "4.57.1", "version": "4.56.10",
"resolved": "https://registry.npmjs.org/memfs/-/memfs-4.57.1.tgz", "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.56.10.tgz",
"integrity": "sha512-WvzrWPwMQT+PtbX2Et64R4qXKK0fj/8pO85MrUCzymX3twwCiJCdvntW3HdhG1teLJcHDDLIKx5+c3HckWYZtQ==", "integrity": "sha512-eLvzyrwqLHnLYalJP7YZ3wBe79MXktMdfQbvMrVD80K+NhrIukCVBvgP30zTJYEEDh9hZ/ep9z0KOdD7FSHo7w==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@jsonjoy.com/fs-core": "4.57.1", "@jsonjoy.com/fs-core": "4.56.10",
"@jsonjoy.com/fs-fsa": "4.57.1", "@jsonjoy.com/fs-fsa": "4.56.10",
"@jsonjoy.com/fs-node": "4.57.1", "@jsonjoy.com/fs-node": "4.56.10",
"@jsonjoy.com/fs-node-builtins": "4.57.1", "@jsonjoy.com/fs-node-builtins": "4.56.10",
"@jsonjoy.com/fs-node-to-fsa": "4.57.1", "@jsonjoy.com/fs-node-to-fsa": "4.56.10",
"@jsonjoy.com/fs-node-utils": "4.57.1", "@jsonjoy.com/fs-node-utils": "4.56.10",
"@jsonjoy.com/fs-print": "4.57.1", "@jsonjoy.com/fs-print": "4.56.10",
"@jsonjoy.com/fs-snapshot": "4.57.1", "@jsonjoy.com/fs-snapshot": "4.56.10",
"@jsonjoy.com/json-pack": "^1.11.0", "@jsonjoy.com/json-pack": "^1.11.0",
"@jsonjoy.com/util": "^1.9.0", "@jsonjoy.com/util": "^1.9.0",
"glob-to-regex.js": "^1.0.1", "glob-to-regex.js": "^1.0.1",
@@ -3114,9 +3114,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.36", "version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
"integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -3287,9 +3287,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/path-to-regexp": { "node_modules/path-to-regexp": {
"version": "0.1.13", "version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -3311,9 +3311,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "2.3.2", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -3337,9 +3337,9 @@
} }
}, },
"node_modules/pkijs": { "node_modules/pkijs": {
"version": "3.4.0", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz", "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.3.3.tgz",
"integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==", "integrity": "sha512-+KD8hJtqQMYoTuL1bbGOqxb4z+nZkTAwVdNtWwe8Tc2xNbEmdJYIYoc6Qt0uF55e6YW6KuTHw1DjQ18gMhzepw==",
"dev": true, "dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
@@ -4142,9 +4142,9 @@
} }
}, },
"node_modules/tapable": { "node_modules/tapable": {
"version": "2.3.2", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
"integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -4156,9 +4156,9 @@
} }
}, },
"node_modules/terser": { "node_modules/terser": {
"version": "5.46.1", "version": "5.46.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz",
"integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"dependencies": { "dependencies": {
@@ -4175,15 +4175,16 @@
} }
}, },
"node_modules/terser-webpack-plugin": { "node_modules/terser-webpack-plugin": {
"version": "5.4.0", "version": "5.3.16",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz", "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz",
"integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==", "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/trace-mapping": "^0.3.25", "@jridgewell/trace-mapping": "^0.3.25",
"jest-worker": "^27.4.5", "jest-worker": "^27.4.5",
"schema-utils": "^4.3.0", "schema-utils": "^4.3.0",
"serialize-javascript": "^6.0.2",
"terser": "^5.31.1" "terser": "^5.31.1"
}, },
"engines": { "engines": {
@@ -4209,9 +4210,9 @@
} }
}, },
"node_modules/thingies": { "node_modules/thingies": {
"version": "2.6.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/thingies/-/thingies-2.6.0.tgz", "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.5.0.tgz",
"integrity": "sha512-rMHRjmlFLM1R96UYPvpmnc3LYtdFrT33JIB7L9hetGue1qAPfn1N2LJeEjxUSidu1Iku+haLZXDuEXUHNGO/lg==", "integrity": "sha512-s+2Bwztg6PhWUD7XMfeYm5qliDdSiZm7M7n8KjTkIsm3l/2lgVRc2/Gx/v+ZX8lT4FMA+i8aQvhcWylldc+ZNw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -4423,9 +4424,9 @@
} }
}, },
"node_modules/webpack": { "node_modules/webpack": {
"version": "5.105.4", "version": "5.105.2",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.2.tgz",
"integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", "integrity": "sha512-dRXm0a2qcHPUBEzVk8uph0xWSjV/xZxenQQbLwnwP7caQCYpqG1qddwlyEkIDkYn0K8tvmcrZ+bOrzoQ3HxCDw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -4435,11 +4436,11 @@
"@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/ast": "^1.14.1",
"@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1",
"@webassemblyjs/wasm-parser": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1",
"acorn": "^8.16.0", "acorn": "^8.15.0",
"acorn-import-phases": "^1.0.3", "acorn-import-phases": "^1.0.3",
"browserslist": "^4.28.1", "browserslist": "^4.28.1",
"chrome-trace-event": "^1.0.2", "chrome-trace-event": "^1.0.2",
"enhanced-resolve": "^5.20.0", "enhanced-resolve": "^5.19.0",
"es-module-lexer": "^2.0.0", "es-module-lexer": "^2.0.0",
"eslint-scope": "5.1.1", "eslint-scope": "5.1.1",
"events": "^3.2.0", "events": "^3.2.0",
@@ -4451,9 +4452,9 @@
"neo-async": "^2.6.2", "neo-async": "^2.6.2",
"schema-utils": "^4.3.3", "schema-utils": "^4.3.3",
"tapable": "^2.3.0", "tapable": "^2.3.0",
"terser-webpack-plugin": "^5.3.17", "terser-webpack-plugin": "^5.3.16",
"watchpack": "^2.5.1", "watchpack": "^2.5.1",
"webpack-sources": "^3.3.4" "webpack-sources": "^3.3.3"
}, },
"bin": { "bin": {
"webpack": "bin/webpack.js" "webpack": "bin/webpack.js"
@@ -4716,9 +4717,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/ws": { "node_modules/ws": {
"version": "8.20.0", "version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "mix-fetch-internal-dev", "name": "create-wasm-app",
"version": "0.1.0", "version": "0.1.0",
"description": "Internal dev/stress-test harness for mix-fetch WASM", "description": "create an app to fetch data through the mixnet",
"main": "index.js", "main": "index.js",
"bin": { "bin": {
"create-wasm-app": ".bin/create-wasm-app.js" "create-wasm-app": ".bin/create-wasm-app.js"
@@ -13,7 +13,7 @@
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/nymtech/nym.git" "url": "git+https://github.com/rustwasm/create-wasm-app.git"
}, },
"keywords": [ "keywords": [
"webassembly", "webassembly",
+123 -86
View File
@@ -12,8 +12,8 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
const RUST_WASM_URL = "mix_fetch_wasm_bg.wasm"; const RUST_WASM_URL = "mix_fetch_wasm_bg.wasm"
const GO_WASM_URL = "go_conn.wasm"; const GO_WASM_URL = "go_conn.wasm"
importScripts('mix_fetch_wasm.js'); importScripts('mix_fetch_wasm.js');
importScripts('wasm_exec.js'); importScripts('wasm_exec.js');
@@ -36,9 +36,11 @@ const {
disconnectMixFetch, disconnectMixFetch,
setupMixFetchWithConfig, setupMixFetchWithConfig,
mix_fetch_initialised, mix_fetch_initialised,
finish_mixnet_connection, finish_mixnet_connection
} = wasm_bindgen; } = wasm_bindgen;
let client = null;
let tester = null;
const go = new Go(); // Defined in wasm_exec.js const go = new Go(); // Defined in wasm_exec.js
var goWasm; var goWasm;
let mixFetchReady = false; let mixFetchReady = false;
@@ -62,20 +64,83 @@ function sendError(error) {
} }
async function logFetchResult(res) { async function logFetchResult(res) {
let text = await res.text(); console.log(res)
console.log(`${res.status} ${res.statusText} (${text.length} bytes)`); let text = await res.text()
console.log(text); console.log("HEADERS: ", ...res.headers)
console.log("STATUS: ", res.status)
console.log("STATUS TEXT: ", res.statusText)
console.log("OK: ", res.ok)
console.log("TYPE: ", res.type)
console.log("URL: ", res.url)
console.log("BODYUSED: ", res.bodyUsed)
console.log("REDIRECTED: ", res.redirected)
console.log("TEXT: ", text)
self.postMessage({ self.postMessage({
kind: 'DisplayString', kind: 'DisplayString',
args: { rawString: text }, args: {
rawString: text,
},
}); });
} }
// For custom MixFetchConfig (specific network requester, debug overrides), async function wasm_bindgenSetup() {
// see setupMixFetchWithConfig() and the MixFetchConfig / MixFetchConfigOpts types. const preferredGateway = "6qQYb4ArXANU6HJDxzH4PFCUqYb39Dae2Gem2KpxescM";
const validator = 'https://qa-nym-api.qa.nymte.ch/api';
async function nativeSetup(preferredGateway, setupOpts = {}) { // local
const mixFetchNetworkRequesterAddress = "2o47bhnXWna6VEyt4mXMGQQAbXfpKmX7BkjkxUz8uQVi.6uQGnCqSczpXwh86NdbsCoDDXuqZQM9Uwko8GE7uC9g8@6qQYb4ArXANU6HJDxzH4PFCUqYb39Dae2Gem2KpxescM";
// const mixFetchNetworkRequesterAddress= "GqiGWmKRCbGQFSqH88BzLKijvZgipnqhmbNFsmkZw84t.4L8sXFuAUyUYyHZYgMdM3AtiusKnYUft6Pd8e41rrCHA@6qQYb4ArXANU6HJDxzH4PFCUqYb39Dae2Gem2KpxescM";
// STEP 1. construct config
// those are just some examples, there are obviously more permutations;
// note, the extra optional argument is of the following type:
// /*
// export interface MixFetchConfigOpts {
// id?: string;
// nymApi?: string;
// nyxd?: string;
// debug?: DebugWasm;
// }
// */
//
// const debug = no_cover_debug()
//
// #1
// const config = new MixFetchConfig(mixFetchNetworkRequesterAddress, { id: 'my-awesome-mix-fetch-client', nymApi: validator, debug: debug} );
// #2
// const config = new MixFetchConfig(mixFetchNetworkRequesterAddress, { nymApi: validator, debug: debug} );
// #3
// const config = new MixFetchConfig(mixFetchNetworkRequesterAddress, { id: 'my-awesome-mix-fetch-client' } );
//
// #4
const differentDebug = default_debug()
const updatedTraffic = differentDebug.traffic;
updatedTraffic.use_extended_packet_size = true
updatedTraffic.average_packet_delay_ms = 666;
differentDebug.traffic = updatedTraffic;
const config = new MixFetchConfig(mixFetchNetworkRequesterAddress, {debug: differentDebug});
//
// // STEP 2. setup the client
// // note, the extra optional argument is of the following type:
// /*
// export interface MixFetchOptsSimple {
// preferredGateway?: string;
// storagePassphrase?: string;
// }
// */
// #1
await setupMixFetchWithConfig(config)
//
// #2
// await setupMixFetchWithConfig(config, { storagePassphrase: "foomp" })
//
// #3
// await setupMixFetchWithConfig(config, { storagePassphrase: "foomp", preferredGateway })
}
async function nativeSetup(preferredGateway) {
sendLog('Setting up MixFetch...'); sendLog('Setting up MixFetch...');
if (preferredGateway) { if (preferredGateway) {
sendLog(`Using preferred gateway: ${preferredGateway}`); sendLog(`Using preferred gateway: ${preferredGateway}`);
@@ -83,25 +148,17 @@ async function nativeSetup(preferredGateway, setupOpts = {}) {
sendLog('Using random gateway selection'); sendLog('Using random gateway selection');
} }
const {
forceTls = true,
clientId = 'client-' + Math.random().toString(36).slice(2, 8),
disablePoisson = true,
disableCover = true,
requestTimeoutMs = 60000,
} = setupOpts;
const noCoverTrafficOverride = { const noCoverTrafficOverride = {
traffic: { disableMainPoissonPacketDistribution: disablePoisson }, traffic: {disableMainPoissonPacketDistribution: true},
coverTraffic: { disableLoopCoverTrafficStream: disableCover }, coverTraffic: {disableLoopCoverTrafficStream: true},
}; }
const mixFetchOverride = { const mixFetchOverride = {
requestTimeoutMs, requestTimeoutMs: 60000
}; }
const opts = { const opts = {
forceTls, forceTls: true,
clientId, clientId: "my-client",
clientOverride: noCoverTrafficOverride, clientOverride: noCoverTrafficOverride,
mixFetchOverride, mixFetchOverride,
}; };
@@ -110,19 +167,16 @@ async function nativeSetup(preferredGateway, setupOpts = {}) {
opts.preferredGateway = preferredGateway; opts.preferredGateway = preferredGateway;
} }
sendLog(
`Setup config: forceTls=${forceTls}, clientId=${clientId}, disablePoisson=${disablePoisson}, disableCover=${disableCover}, timeout=${requestTimeoutMs}ms`
);
sendLog('Calling setupMixFetch...'); sendLog('Calling setupMixFetch...');
await setupMixFetch(opts); await setupMixFetch(opts);
sendLog('setupMixFetch completed'); sendLog('setupMixFetch completed');
} }
async function startMixFetch(preferredGateway, setupOpts) { async function startMixFetch(preferredGateway) {
sendLog('Instantiating MixFetch...'); sendLog('Instantiating MixFetch...');
try { try {
await nativeSetup(preferredGateway, setupOpts); await nativeSetup(preferredGateway);
mixFetchReady = true; mixFetchReady = true;
sendLog('MixFetch client running!'); sendLog('MixFetch client running!');
sendReady(); sendReady();
@@ -139,7 +193,7 @@ async function handleFetchPayload(target) {
} }
const url = target; const url = target;
const args = { mode: "unsafe-ignore-cors" }; const args = {mode: "unsafe-ignore-cors"};
try { try {
sendLog(`Fetching: ${url}`); sendLog(`Fetching: ${url}`);
@@ -152,50 +206,40 @@ async function handleFetchPayload(target) {
} }
} }
async function handleStressTestFetch(id, url, label) { async function handlePostPayload(url, body) {
if (!mixFetchReady) { if (!mixFetchReady) {
sendLog('MixFetch not ready yet', 'error'); sendLog('MixFetch not ready yet', 'error');
return; return;
} }
const tag = `[stress #${id} ${label}]`; const args = {
const start = performance.now(); method: 'POST',
const args = { mode: "unsafe-ignore-cors" }; mode: "unsafe-ignore-cors",
headers: {
'Content-Type': 'application/json',
},
body: body,
};
try { try {
sendLog(`${tag} Fetching: ${url}`); sendLog(`POST request to: ${url}`);
const res = await mixFetch(url, args); sendLog(`POST body: ${body}`);
const text = await res.text(); const mixFetchRes = await mixFetch(url, args);
const elapsed = ((performance.now() - start) / 1000).toFixed(2); sendLog('POST completed');
sendLog(`${tag} ${res.status} OK in ${elapsed}s (${text.length} bytes)`); await logFetchResult(mixFetchRes);
self.postMessage({
kind: 'StressTestFetchResult',
args: {
id,
label,
ok: true,
status: res.status,
elapsed,
textLength: text.length,
},
});
} catch (e) { } catch (e) {
const elapsed = ((performance.now() - start) / 1000).toFixed(2); sendLog('POST request failure: ' + e, 'error');
sendLog(`${tag} FAILED in ${elapsed}s: ${e}`, 'error'); console.error("mix fetch POST request failure: ", e);
self.postMessage({
kind: 'StressTestFetchResult',
args: { id, label, ok: false, elapsed, error: String(e) },
});
} }
} }
function setupMessageHandler() { function setupMessageHandler() {
self.onmessage = async (event) => { self.onmessage = async event => {
if (event.data && event.data.kind) { if (event.data && event.data.kind) {
switch (event.data.kind) { switch (event.data.kind) {
case 'StartMixFetch': { case 'StartMixFetch': {
const { preferredGateway, setupOpts } = event.data.args; const { preferredGateway } = event.data.args;
await startMixFetch(preferredGateway, setupOpts); await startMixFetch(preferredGateway);
break; break;
} }
case 'FetchPayload': { case 'FetchPayload': {
@@ -203,17 +247,9 @@ function setupMessageHandler() {
await handleFetchPayload(target); await handleFetchPayload(target);
break; break;
} }
case 'SetGoTimeout': { case 'PostPayload': {
const { timeoutMs } = event.data.args; const { url, body } = event.data.args;
sendLog(`Setting Go-side request timeout to ${timeoutMs}ms`); await handlePostPayload(url, body);
self.__go_rs_bridge__.goWasmSetMixFetchRequestTimeout(timeoutMs);
break;
}
case 'StressTestFetch': {
const { id, url, label } = event.data.args;
// NOT awaited — each request runs independently,
// just like separate callers in a real app
handleStressTestFetch(id, url, label);
break; break;
} }
} }
@@ -221,30 +257,31 @@ function setupMessageHandler() {
}; };
} }
// TODO: look into https://www.aaron-powell.com/posts/2019-02-08-golang-wasm-5-compiling-with-webpack/ // TODO: look into https://www.aaron-powell.com/posts/2019-02-08-golang-wasm-5-compiling-with-webpack/
async function loadGoWasm() { async function loadGoWasm() {
const resp = await fetch(GO_WASM_URL); const resp = await fetch(GO_WASM_URL);
if ('instantiateStreaming' in WebAssembly) { if ('instantiateStreaming' in WebAssembly) {
const wasmObj = await WebAssembly.instantiateStreaming(resp, go.importObject); const wasmObj = await WebAssembly.instantiateStreaming(resp, go.importObject)
goWasm = wasmObj.instance; goWasm = wasmObj.instance
go.run(goWasm); go.run(goWasm)
} else { } else {
const bytes = await resp.arrayBuffer(); const bytes = await resp.arrayBuffer()
const wasmObj = await WebAssembly.instantiate(bytes, go.importObject); const wasmObj = await WebAssembly.instantiate(bytes, go.importObject)
goWasm = wasmObj.instance; goWasm = wasmObj.instance
go.run(goWasm); go.run(goWasm)
} }
} }
function setupRsGoBridge() { function setupRsGoBridge() {
// (note: reason for intermediate `__rs_go_bridge__` object is to decrease global scope bloat // (note: reason for intermediate `__rs_go_bridge__` object is to decrease global scope bloat
// and to discourage users from trying to call those methods directly) // and to discourage users from trying to call those methods directly)
self.__rs_go_bridge__ = {}; self.__rs_go_bridge__ = {}
self.__rs_go_bridge__.send_client_data = send_client_data; self.__rs_go_bridge__.send_client_data = send_client_data
self.__rs_go_bridge__.start_new_mixnet_connection = start_new_mixnet_connection; self.__rs_go_bridge__.start_new_mixnet_connection = start_new_mixnet_connection
self.__rs_go_bridge__.mix_fetch_initialised = mix_fetch_initialised; self.__rs_go_bridge__.mix_fetch_initialised = mix_fetch_initialised
self.__rs_go_bridge__.finish_mixnet_connection = finish_mixnet_connection; self.__rs_go_bridge__.finish_mixnet_connection = finish_mixnet_connection
} }
async function main() { async function main() {
@@ -252,7 +289,7 @@ async function main() {
// load rust WASM package // load rust WASM package
sendLog('Loading Rust WASM...'); sendLog('Loading Rust WASM...');
await wasm_bindgen({ module_or_path: RUST_WASM_URL }); await wasm_bindgen(RUST_WASM_URL);
sendLog('Loaded Rust WASM'); sendLog('Loaded Rust WASM');
// load go WASM package // load go WASM package
@@ -265,7 +302,7 @@ async function main() {
setupRsGoBridge(); setupRsGoBridge();
goWasmSetLogging("trace"); goWasmSetLogging("trace")
// Set up message handler (MixFetch will be started on demand) // Set up message handler (MixFetch will be started on demand)
setupMessageHandler(); setupMessageHandler();
@@ -274,4 +311,4 @@ async function main() {
} }
// Let's get started! // Let's get started!
main(); main();
+6 -10
View File
@@ -6,7 +6,7 @@ use crate::go_bridge::{goWasmCloseRemoteSocket, goWasmInjectConnError, goWasmInj
use crate::RequestId; use crate::RequestId;
use nym_ordered_buffer::OrderedMessageBuffer; use nym_ordered_buffer::OrderedMessageBuffer;
use nym_socks5_requests::SocketData; use nym_socks5_requests::SocketData;
use nym_wasm_utils::{console_error, console_log, console_warn}; use nym_wasm_utils::{console_error, console_log};
use rand::{thread_rng, RngCore}; use rand::{thread_rng, RngCore};
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
@@ -69,8 +69,7 @@ impl ActiveRequests {
let mut guard = self.inner.lock().await; let mut guard = self.inner.lock().await;
let old = guard.remove(&id); let old = guard.remove(&id);
if old.is_none() { if old.is_none() {
console_error!("attempted to reject request {id}, but it no longer exists — likely already cleaned up by Go timeout"); console_error!("attempted to reject request {id}, but it seems to have never existed?")
return;
} }
goWasmInjectConnError(id.to_string(), err.to_string()) goWasmInjectConnError(id.to_string(), err.to_string())
@@ -80,15 +79,12 @@ impl ActiveRequests {
let id = data.header.connection_id; let id = data.header.connection_id;
let mut guard = self.inner.lock().await; let mut guard = self.inner.lock().await;
let Some(req) = guard.get_mut(&id) else { let Some(req) = guard.get_mut(&id) else {
// if there's no data and the socket is closed, we're all good because our local // if there's no data and the socket is closed, we're all good because our local must have already closed
// must have already closed - this is likely just a retransmitted fragment that
// arrived after the original
if !data.data.is_empty() || !data.header.local_socket_closed { if !data.data.is_empty() || !data.header.local_socket_closed {
console_warn!( console_error!("attempted to send data for request {id}, however it no longer exists. Has it been aborted?");
"received data for request {id} which is no longer active \
(likely a retransmitted packet for an already-completed request)"
);
} }
// TODO: if it doesn't exist here, make sure to clear Go's memory too
return; return;
}; };
-1
View File
@@ -1 +0,0 @@
test-results/
-81
View File
@@ -1,81 +0,0 @@
# mix-fetch Playwright Tests
Automated browser tests for the mix-fetch internal-dev harness. Tests run against the webpack-built `internal-dev/dist/` served locally.
## Prerequisites
WASM build artifacts must exist (Go first, then Rust). For local dev, use the debug targets:
```bash
# Builds to go-mix-conn/build/
make -C wasm/mix-fetch/go-mix-conn build-debug-dev
# Builds to pkg/ (needs Go bindings)
make -C wasm/mix-fetch build-rust-debug
```
CI uses the same debug targets.
Build the internal-dev webpack bundle:
```bash
cd wasm/mix-fetch/internal-dev && npm install && npm run build
```
Install Playwright and browsers:
```bash
cd wasm/mix-fetch/tests && npm install && npx playwright install --with-deps
```
## Running
```bash
# Smoke tests (all browsers, WASM load + MixFetch init)
npm run test:smoke
# Stress tests (all browsers, stresstest on mainnet)
npm run test:stress
# Single browser
npx playwright test --project=smoke-chromium
npx playwright test --project=stress-firefox
```
## Test tiers
### Smoke (`smoke.spec.mjs`)
Verifies the internal-dev harness loads in a headless browser: Rust WASM + Go WASM initialise, the worker signals readiness, MixFetch connects to a random Entry Gateway, and no console errors are emitted. Runs on Chromium, Firefox, and WebKit.
- **CI workflow**: `ci-sdk-wasm.yml`
- **Trigger**: every PR that touches `wasm/**`, `clients/client-core/**`, `common/**`, or the workflow itself
- **Timeout**: 1 minute
### Stress (`stress.spec.mjs`)
Connects to mainnet via a random Entry Gateway, fires 10 concurrent mixed-size fetches through the mixnet, and asserts >= 80% succeed. Runs on all three browsers.
- **CI workflow**: `nightly-mix-fetch-stress.yml`
- **Trigger**: daily at 03:00 UTC via cron, also available via `workflow_dispatch` for manual runs
- **Timeout**: 2 minutes per browser, 2 retries
## Arch/Manjaro note
Playwright's WebKit is built for Ubuntu 24.04 and links against specific soname versions that don't match Arch's (e.g. `libicu*.so.74` vs `.so.78`, `libxml2.so.2` vs `.so.16`). `playwright install --with-deps` also fails because it uses `apt-get`.
Chromium and Firefox work without any workarounds. To skip WebKit locally:
```bash
npx playwright test --project=smoke-chromium --project=smoke-firefox
npx playwright test --project=stress-chromium --project=stress-firefox
```
All three browsers work on CI (Ubuntu runners with `--with-deps`).
> **TODO**: investigate getting WebKit running on Arch/Manjaro (soname symlinks or alternative Playwright WebKit build).
## TODO
- [ ] Add Playwright CI for `wasm/client/` (nym-client-wasm) via the chat-app examples after WASM cleanup
- [ ] Add Playwright CI for other SDK examples once stale dependencies are resolved
- [ ] Consider WebKit system deps in CI runner setup (currently relies on `playwright install --with-deps` on Ubuntu)
File diff suppressed because it is too large Load Diff
-13
View File
@@ -1,13 +0,0 @@
{
"name": "mix-fetch-playwright-tests",
"private": true,
"scripts": {
"test": "playwright test",
"test:smoke": "playwright test --project=smoke-chromium --project=smoke-firefox --project=smoke-webkit",
"test:stress": "playwright test --project=stress-chromium --project=stress-firefox --project=stress-webkit"
},
"devDependencies": {
"@playwright/test": "^1.52.0",
"serve": "^14.2.4"
}
}
@@ -1,53 +0,0 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
timeout: 60_000,
retries: 1,
use: {
trace: "on-first-retry",
},
webServer: {
command: "npx serve ../internal-dev/dist -l 8001 --no-clipboard",
port: 8001,
reuseExistingServer: true,
},
projects: [
{
name: "smoke-chromium",
testMatch: "smoke.spec.mjs",
use: { browserName: "chromium" },
},
{
name: "smoke-firefox",
testMatch: "smoke.spec.mjs",
use: { browserName: "firefox" },
},
{
name: "smoke-webkit",
testMatch: "smoke.spec.mjs",
use: { browserName: "webkit" },
},
{
name: "stress-chromium",
testMatch: "stress.spec.mjs",
timeout: 120_000,
retries: 2,
use: { browserName: "chromium" },
},
{
name: "stress-firefox",
testMatch: "stress.spec.mjs",
timeout: 120_000,
retries: 2,
use: { browserName: "firefox" },
},
{
name: "stress-webkit",
testMatch: "stress.spec.mjs",
timeout: 120_000,
retries: 2,
use: { browserName: "webkit" },
},
],
});
-71
View File
@@ -1,71 +0,0 @@
// Smoke test: verify the internal-dev harness loads both WASM runtimes
// and successfully initialises a MixFetch connection to mainnet.
import { test, expect } from "@playwright/test";
function waitForConsole(page, predicate, timeoutMs = 60_000) {
return new Promise((resolve, reject) => {
const timer = setTimeout(
() =>
reject(
new Error(`Timed out waiting for console message (${timeoutMs}ms)`)
),
timeoutMs
);
page.on("console", function handler(msg) {
if (predicate(msg.text())) {
clearTimeout(timer);
page.removeListener("console", handler);
resolve(msg.text());
}
});
});
}
test("internal-dev harness loads and MixFetch initialises", async ({
page,
}) => {
const errors = [];
// Forward worker lifecycle + errors to test output
page.on("console", (msg) => {
const text = msg.text();
if (msg.type() === "error") {
if (!text.includes("favicon.ico")) {
errors.push(text);
}
console.log(`[ERROR] ${text}`);
} else if (
text.includes("Worker") ||
text.includes("MixFetch") ||
text.includes("Setting up") ||
text.includes("gateway")
) {
console.log(text);
}
});
page.on("pageerror", (err) => {
errors.push(`pageerror: ${err.message}`);
});
const workerReady = waitForConsole(
page,
(text) => text.includes("Worker ready"),
30_000
);
await page.goto("http://localhost:8001");
await workerReady;
// Init MixFetch with a random gateway
const mixFetchReady = waitForConsole(
page,
(text) => text.includes("MixFetch ready!"),
120_000
);
await page.check('input[name="gateway-mode"][value="random"]');
await page.click("#start-mixfetch");
await mixFetchReady;
expect(errors).toEqual([]);
});
@@ -1,87 +0,0 @@
// Stress test: connect to mainnet via a random Entry Gateway and run concurrent fetches.
// Pass criteria: >= 80% of requests succeed.
import { test, expect } from "@playwright/test";
const STRESS_COUNT = 10;
const MIN_SUCCESS_RATE = 0.8;
function waitForConsole(page, predicate, timeoutMs = 60_000) {
return new Promise((resolve, reject) => {
const timer = setTimeout(
() =>
reject(
new Error(`Timed out waiting for console message (${timeoutMs}ms)`)
),
timeoutMs
);
page.on("console", function handler(msg) {
if (predicate(msg.text())) {
clearTimeout(timer);
page.removeListener("console", handler);
resolve(msg.text());
}
});
});
}
test("stress test: mixed-size fetches through mainnet", async ({ page }) => {
// Forward warnings, errors, and worker lifecycle messages to test output
page.on("console", (msg) => {
const text = msg.text();
if (msg.type() === "warning" || msg.type() === "error") {
console.log(`[${msg.type().toUpperCase()}] ${text}`);
} else if (
text.includes("Worker") ||
text.includes("MixFetch") ||
text.includes("stress") ||
text.includes("COMPLETE") ||
text.includes("FAIL")
) {
console.log(text);
}
});
const workerReady = waitForConsole(
page,
(text) => text.includes("Worker ready"),
30_000
);
await page.goto("http://localhost:8001");
await workerReady;
const mixFetchReady = waitForConsole(
page,
(text) => text.includes("MixFetch ready!"),
120_000
);
await page.check('input[name="gateway-mode"][value="random"]');
await page.click("#start-mixfetch");
await mixFetchReady;
const stressComplete = waitForConsole(
page,
(text) => text.includes("=== COMPLETE:"),
90_000
);
await page.fill("#stress-test-count", String(STRESS_COUNT));
await page.selectOption("#stress-test-mode", "mixed");
await page.click("#stress-test-button");
const completionMsg = await stressComplete;
const match = completionMsg.match(/OK (\d+)\/(\d+)/);
expect(
match,
`Could not parse completion message: ${completionMsg}`
).toBeTruthy();
const [, succeeded, total] = match;
const successRate = parseInt(succeeded, 10) / parseInt(total, 10);
console.log(
`Stress test result: ${succeeded}/${total} succeeded (${(
successRate * 100
).toFixed(0)}%)`
);
expect(successRate).toBeGreaterThanOrEqual(MIN_SUCCESS_RATE);
});