diff --git a/.gitignore b/.gitignore index 5e9a98400..462c66c54 100644 --- a/.gitignore +++ b/.gitignore @@ -69,5 +69,18 @@ node_modules # Integration test artifacts test-results/ **/*lcov.info + +# Pre-compiled plugin executor (generated at build time) +plugins/lib/pool-executor.js + +# Build executor cache files +plugins/lib/.build-cache-hash +plugins/lib/pool-executor.js.sha256 + +# TypeScript compiled output in plugins (we use ts-node for runtime) +plugins/**/*.js +plugins/**/*.js.map +plugins/**/*.d.ts + profraw/ coverage/ diff --git a/Cargo.lock b/Cargo.lock index a7d040935..584661297 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1476,6 +1476,18 @@ dependencies = [ "serde_json", ] +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-compression" version = "0.4.36" @@ -2834,6 +2846,19 @@ dependencies = [ "winnow 0.6.26", ] +[[package]] +name = "crossbeam" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -2862,6 +2887,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -5525,6 +5559,7 @@ dependencies = [ "apalis", "apalis-cron", "apalis-redis", + "async-channel", "async-trait", "aws-config", "aws-sdk-kms", @@ -5537,6 +5572,7 @@ dependencies = [ "chrono", "clap", "color-eyre", + "crossbeam", "ctor", "dashmap 6.1.0", "dotenvy", diff --git a/Cargo.toml b/Cargo.toml index 8eb7375de..990356098 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,6 +65,8 @@ hmac = { version = "0.12" } sha2 = { version = "0.10" } sha3 = { version = "0.10" } dashmap = { version = "6.1" } +crossbeam = { version = "0.8.4" } +async-channel = "2.5" actix-governor = "0.8" solana-sdk = { version = "3" } solana-client = { version = "3" } diff --git a/build.rs b/build.rs new file mode 100644 index 000000000..efb0b1321 --- /dev/null +++ b/build.rs @@ -0,0 +1,87 @@ +use std::path::Path; +use std::process::Command; + +fn main() { + let plugins_dir = Path::new("plugins"); + if !plugins_dir.exists() { + println!("cargo:warning=plugins directory not found, skipping plugin setup"); + return; + } + + let node_modules = plugins_dir.join("node_modules"); + let pool_executor_ts = plugins_dir.join("lib/pool-executor.ts"); + let pool_executor_js = plugins_dir.join("lib/pool-executor.js"); + + // Tell Cargo when to rerun this script + println!("cargo:rerun-if-changed=plugins/lib/pool-executor.ts"); + println!("cargo:rerun-if-changed=plugins/package.json"); + + // Check if pnpm is available + let pnpm_check = Command::new("pnpm").arg("--version").output(); + if pnpm_check.is_err() { + println!("cargo:warning=pnpm not found in PATH, skipping plugin setup"); + println!("cargo:warning=Run 'pnpm install && pnpm run build' in plugins/ manually"); + return; + } + + // Only run pnpm install if node_modules is missing + if !node_modules.exists() { + println!("Installing plugin dependencies..."); + let output = Command::new("pnpm") + .arg("install") + .arg("--ignore-scripts") // Skip postinstall, we'll build explicitly below + .current_dir(plugins_dir) + .output(); + + match output { + Ok(output) if output.status.success() => { + println!("✓ pnpm install completed"); + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + println!("cargo:warning=pnpm install failed: {stderr}"); + return; + } + Err(e) => { + println!("cargo:warning=Failed to execute pnpm install: {e}"); + return; + } + } + } + + // Build pool-executor if source is newer than output (or output missing) + let needs_build = if !pool_executor_js.exists() { + true + } else { + // Compare modification times + match ( + pool_executor_ts.metadata().and_then(|m| m.modified()), + pool_executor_js.metadata().and_then(|m| m.modified()), + ) { + (Ok(src_time), Ok(out_time)) => src_time > out_time, + _ => true, // Rebuild if we can't determine + } + }; + + if needs_build { + println!("Building executor..."); + let output = Command::new("pnpm") + .arg("run") + .arg("build") + .current_dir(plugins_dir) + .output(); + + match output { + Ok(output) if output.status.success() => { + println!("✓ executor built successfully"); + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + println!("cargo:warning=executor build failed: {stderr}"); + } + Err(e) => { + println!("cargo:warning=Failed to build executor: {e}"); + } + } + } +} diff --git a/docs/plugins/index.mdx b/docs/plugins/index.mdx index b57071a37..84f3acaed 100644 --- a/docs/plugins/index.mdx +++ b/docs/plugins/index.mdx @@ -716,3 +716,208 @@ The relayer will automatically detect which pattern your plugin uses: - If neither → shows clear error message This ensures a smooth transition period where both patterns work simultaneously. + +## Performance Tuning + +For production deployments handling high concurrency, the plugin system offers extensive tuning options via environment variables. + +### Execution Modes + +The plugin system supports two execution modes: + +| Mode | Environment Variable | Description | +|------|---------------------|-------------| +| **Pool mode** (default) | `PLUGIN_USE_POOL=true` | Uses persistent worker pool. Best performance. | +| **ts-node mode** | `PLUGIN_USE_POOL=false` | Spawns a new process per request. Simpler but slower. | + + +Pool mode is enabled by default since v1.4. It significantly improves performance by reusing worker processes and maintaining persistent connections. Use `PLUGIN_USE_POOL=false` only for debugging or if you encounter issues. + + +### Environment Variables Reference + +#### Simple Scaling (Recommended) + + +**Most users only need one variable!** Set `PLUGIN_MAX_CONCURRENCY` and everything else is auto-calculated. + + +```bash +# This is all you need for 3000 concurrent users: +export PLUGIN_MAX_CONCURRENCY=3000 +``` + +The system automatically derives on **both Rust and Node.js sides**: + +**Rust side (connection pool & queue):** +- `PLUGIN_POOL_MAX_CONNECTIONS` = 3000 (1:1 ratio) +- `PLUGIN_SOCKET_MAX_CONCURRENT_CONNECTIONS` = 4500 (1.5x for headroom) +- `PLUGIN_POOL_MAX_QUEUE_SIZE` = 6000 (2x for burst handling) +- `PLUGIN_POOL_QUEUE_SEND_TIMEOUT_MS` = auto-scaled based on workload per thread (500-1000ms) + +**Node.js side (worker pool):** +- `PLUGIN_POOL_MAX_THREADS` = memory-aware scaling (see below), capped at 32 +- `PLUGIN_POOL_CONCURRENT_TASKS` = (concurrency / maxThreads) × 1.2, capped at 250 +- `PLUGIN_WORKER_HEAP_MB` = 512 + (concurrent_tasks × 5), between 1024-2048MB + +#### Advanced Overrides (Power Users) + +You can override any auto-derived value when needed: + +```bash +# Set the primary scaling knob +export PLUGIN_MAX_CONCURRENCY=3000 + +# Override just the queue size (other values still auto-derived) +export PLUGIN_POOL_MAX_QUEUE_SIZE=10000 +``` + +#### All Available Variables + +| Variable | Default | Auto-Derived From | Description | +|----------|---------|-------------------|-------------| +| `PLUGIN_MAX_CONCURRENCY` | 2048 | - | **Primary scaling knob** - sets expected concurrent load | +| **Rust Side** |||| +| `PLUGIN_POOL_MAX_CONNECTIONS` | 2048 | `MAX_CONCURRENCY` | Max connections to pool server | +| `PLUGIN_SOCKET_MAX_CONCURRENT_CONNECTIONS` | 4096 | `MAX_CONCURRENCY × 1.5` | Max plugin connections to relayer | +| `PLUGIN_POOL_MAX_QUEUE_SIZE` | 4096 | `MAX_CONCURRENCY × 2` | Max queued requests | +| `PLUGIN_POOL_QUEUE_SEND_TIMEOUT_MS` | 500 | Workload-based (500-1000ms) | Wait time when queue is full | +| `PLUGIN_POOL_CONNECT_RETRIES` | 15 | - | Retry attempts when connecting | +| `PLUGIN_POOL_REQUEST_TIMEOUT_SECS` | 30 | - | Timeout for pool requests | +| `PLUGIN_SOCKET_IDLE_TIMEOUT_SECS` | 60 | - | Close idle connections after | +| `PLUGIN_SOCKET_READ_TIMEOUT_SECS` | 30 | - | Read timeout per message | +| `PLUGIN_POOL_WORKERS` | auto | CPU cores | Queue processing workers | +| `PLUGIN_POOL_SOCKET_BACKLOG` | 2048 | `MAX_CONCURRENCY` | Socket connection backlog | +| **Node.js Side** |||| +| `PLUGIN_POOL_MIN_THREADS` | auto | `max(2, cpuCount/2)` | Minimum worker threads | +| `PLUGIN_POOL_MAX_THREADS` | auto | Memory-aware (capped at 32) | Worker threads in pool | +| `PLUGIN_POOL_CONCURRENT_TASKS` | auto | `(concurrency/threads) × 1.2` | Tasks per worker (max 250) | +| `PLUGIN_WORKER_HEAP_MB` | auto | `512 + (tasks × 5)` | Worker heap size (1024-2048MB) | +| `PLUGIN_POOL_IDLE_TIMEOUT` | 60000 | - | Worker idle timeout (ms) | + +#### Memory-Aware Thread Scaling + +The plugin system automatically scales worker threads based on **both concurrency requirements AND available system memory**. This prevents out-of-memory issues on systems with limited RAM. + +**How it works:** +1. **Memory budget**: Uses 50% of system RAM for worker threads +2. **Per-worker allocation**: ~1GB heap budget per worker thread +3. **Concurrency scaling**: `concurrency / 200` threads (each thread handles ~200 concurrent requests via async I/O) +4. **Final calculation**: `min(memory_based, concurrency_based)`, capped at 32 threads + +**Examples:** + +| System RAM | Max Concurrency | Memory-Based | Concurrency-Based | Final Threads | +|------------|-----------------|--------------|-------------------|---------------| +| 16GB | 1000 | 8 | 5 | 5 | +| 16GB | 5000 | 8 | 25 | 8 | +| 32GB | 5000 | 16 | 25 | 16 | +| 64GB | 10000 | 32 | 50 | 32 | + + +This prevents the previous issue where high concurrency (e.g., 5000 VUs) would spawn too many threads, causing excessive memory pressure and GC pauses. + + +#### Queue Timeout Auto-Scaling + +The queue send timeout (`PLUGIN_POOL_QUEUE_SEND_TIMEOUT_MS`) automatically scales based on workload per thread: + +| Workload per Thread | Timeout | +|---------------------|---------| +| > 100 items/thread | 1000ms (heavy load) | +| 50-100 items/thread | 750ms (medium load) | +| < 50 items/thread | 500ms (light load) | + +This ensures requests have sufficient time to queue during traffic spikes while maintaining responsiveness under normal load. + +#### Timeout Alignment + + +Timeouts must be aligned! If your plugin takes up to 120s, set these accordingly. + + +```bash +# In config.json: "timeout": 120 + +# Environment should match: +export PLUGIN_POOL_REQUEST_TIMEOUT_SECS=120 +export PLUGIN_SOCKET_IDLE_TIMEOUT_SECS=180 # 1.5x plugin timeout +``` + +#### Health & Recovery + +Controls automatic health monitoring and recovery. + +| Variable | Default | Description | +|----------|---------|-------------| +| `PLUGIN_POOL_HEALTH_CHECK_INTERVAL_SECS` | 5 | Minimum seconds between health checks | +| `PLUGIN_TRACE_TIMEOUT_MS` | 100 | Timeout for collecting execution traces | + +### Load Profile Examples + +#### Low Load (< 100 concurrent requests) + +```bash +# Default settings are sufficient - pool mode is enabled automatically +``` + +#### Medium Load (100-1000 concurrent requests) + +```bash +export PLUGIN_MAX_CONCURRENCY=1000 +``` + +#### High Load (1000-5000 concurrent requests) + +```bash +export PLUGIN_MAX_CONCURRENCY=3000 +export PLUGIN_POOL_REQUEST_TIMEOUT_SECS=60 +``` + +#### Extreme Load (5000+ concurrent requests) + +```bash +export PLUGIN_MAX_CONCURRENCY=8000 +export PLUGIN_POOL_REQUEST_TIMEOUT_SECS=120 +export PLUGIN_POOL_CONNECT_RETRIES=20 +``` + +### Troubleshooting Common Errors + +| Error | Cause | Solution | +|-------|-------|----------| +| `Plugin execution queue is full` | More requests than queue can hold | Increase `PLUGIN_POOL_MAX_QUEUE_SIZE` and `PLUGIN_POOL_QUEUE_SEND_TIMEOUT_MS` | +| `Connection limit reached` | Too many concurrent plugin connections | Increase `PLUGIN_SOCKET_MAX_CONCURRENT_CONNECTIONS` | +| `Failed to connect to pool after N attempts` | Pool server overwhelmed | Increase `PLUGIN_POOL_CONNECT_RETRIES` and `PLUGIN_POOL_MAX_CONNECTIONS` | +| `ScriptTimeout(N)` | Plugin execution exceeded timeout | Increase `timeout` in plugin config (config.json) | +| `All connection permits exhausted` | Connection pool at capacity | Increase `PLUGIN_POOL_MAX_CONNECTIONS` | +| `FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory` | Worker heap too small | Increase `PLUGIN_WORKER_HEAP_MB` or reduce `PLUGIN_POOL_CONCURRENT_TASKS` | +| `Pool server crashed and restarting` | Memory pressure or GC issues | Check logs for heap usage; reduce `PLUGIN_MAX_CONCURRENCY` or increase system RAM | + + +When increasing limits significantly, monitor your system's memory and CPU usage. Each connection consumes resources. + + +## Architecture & Internals + +For developers who want to understand or modify the plugin system internals, see the [Architecture Guide](https://github.com/OpenZeppelin/openzeppelin-relayer/blob/main/plugins/ARCHITECTURE.md). + +The architecture documentation covers: + +- **System Architecture**: High-level diagram showing Rust ↔ Node.js communication +- **Module Overview**: Purpose of each Rust and TypeScript module +- **Communication Protocols**: JSON-line protocol for pool and shared socket communication +- **Request Flow**: Step-by-step execution path for plugin requests +- **Health & Recovery**: Circuit breaker states, dead server detection, memory pressure handling +- **Module Dependencies**: How the codebase modules relate to each other + +### Key Source Files + +| Component | Location | Description | +|-----------|----------|-------------| +| **Rust Plugin Runner** | `src/services/plugins/runner.rs` | Entry point for plugin execution | +| **Pool Executor** | `src/services/plugins/pool_executor.rs` | Manages Node.js process and connections | +| **Configuration** | `src/services/plugins/config.rs` | Auto-derivation logic for all env vars | +| **Pool Server** | `plugins/lib/pool-server.ts` | Node.js server accepting plugin requests | +| **Executor** | `plugins/lib/pool-executor.ts` | Plugin execution | +| **Plugin SDK** | `plugins/lib/plugin.ts` | PluginContext and API for plugins | diff --git a/examples/basic-example-plugin/test-plugin/package.json b/examples/basic-example-plugin/test-plugin/package.json index f27c9f4a9..24f3acad8 100644 --- a/examples/basic-example-plugin/test-plugin/package.json +++ b/examples/basic-example-plugin/test-plugin/package.json @@ -13,7 +13,7 @@ "author": "", "license": "", "dependencies": { - "@openzeppelin/relayer-sdk": "^1.6.0", + "@openzeppelin/relayer-sdk": "^1.8.0", "@types/node": "^24.0.3", "ethers": "^6.14.3", "uuid": "^11.1.0" diff --git a/examples/basic-example-plugin/test-plugin/pnpm-lock.yaml b/examples/channels-plugin-example/channel/pnpm-lock.yaml similarity index 72% rename from examples/basic-example-plugin/test-plugin/pnpm-lock.yaml rename to examples/channels-plugin-example/channel/pnpm-lock.yaml index 81f3e65a1..1e028f30d 100644 --- a/examples/basic-example-plugin/test-plugin/pnpm-lock.yaml +++ b/examples/channels-plugin-example/channel/pnpm-lock.yaml @@ -6,22 +6,19 @@ settings: importers: .: dependencies: + '@openzeppelin/relayer-plugin-channels': + specifier: ^0.6.0 + version: 0.6.0 '@openzeppelin/relayer-sdk': - specifier: ^1.6.0 - version: 1.6.0 - '@types/node': - specifier: ^24.0.3 - version: 24.2.0 - ethers: - specifier: ^6.14.3 - version: 6.15.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) - uuid: - specifier: ^11.1.0 - version: 11.1.0 + specifier: ^1.9.0 + version: 1.9.0 devDependencies: '@types/jest': specifier: ^29.5.12 version: 29.5.14 + '@types/node': + specifier: ^24.0.3 + version: 24.10.9 '@types/uuid': specifier: ^10.0.0 version: 10.0.0 @@ -30,62 +27,64 @@ importers: version: 9.1.7 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@24.2.0) + version: 29.7.0(@types/node@24.10.9)(ts-node@10.9.2(@types/node@24.10.9)(typescript@5.9.3)) ts-jest: specifier: ^29.1.2 - version: 29.4.1(@babel/core@7.28.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.2.0))(typescript@5.9.2) + version: 29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.10.9)(ts-node@10.9.2(@types/node@24.10.9)(typescript@5.9.3)))(typescript@5.9.3) + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@24.10.9)(typescript@5.9.3) typescript: specifier: ^5.3.3 - version: 5.9.2 + version: 5.9.3 packages: - '@adraffy/ens-normalize@1.10.1': - resolution: {integrity: sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==} - '@ampproject/remapping@2.3.0': - resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} - engines: {node: '>=6.0.0'} - '@babel/code-frame@7.27.1': - resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + '@actions/exec@1.1.1': + resolution: {integrity: sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==} + '@actions/io@1.1.3': + resolution: {integrity: sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==} + '@babel/code-frame@7.28.6': + resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.28.0': - resolution: {integrity: sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==} + '@babel/compat-data@7.28.6': + resolution: {integrity: sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==} engines: {node: '>=6.9.0'} - '@babel/core@7.28.0': - resolution: {integrity: sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==} + '@babel/core@7.28.6': + resolution: {integrity: sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==} engines: {node: '>=6.9.0'} - '@babel/generator@7.28.0': - resolution: {integrity: sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==} + '@babel/generator@7.28.6': + resolution: {integrity: sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==} engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.27.2': - resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} engines: {node: '>=6.9.0'} '@babel/helper-globals@7.28.0': resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.27.1': - resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.27.3': - resolution: {integrity: sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==} + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-plugin-utils@7.27.1': - resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} engines: {node: '>=6.9.0'} '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.27.1': - resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.28.2': - resolution: {integrity: sha512-/V9771t+EgXz62aCcyofnQhGM8DQACbRhvzKFsXKC9QM+5MadF8ZmIm0crDMaz3+o0h0zXfJnd4EhbYbxsrcFw==} + '@babel/helpers@7.28.6': + resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} engines: {node: '>=6.9.0'} - '@babel/parser@7.28.0': - resolution: {integrity: sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==} + '@babel/parser@7.28.6': + resolution: {integrity: sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==} engines: {node: '>=6.0.0'} hasBin: true '@babel/plugin-syntax-async-generators@7.8.4': @@ -105,8 +104,8 @@ packages: engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-import-attributes@7.27.1': - resolution: {integrity: sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==} + '@babel/plugin-syntax-import-attributes@7.28.6': + resolution: {integrity: sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 @@ -118,8 +117,8 @@ packages: resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-jsx@7.27.1': - resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==} + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 @@ -157,22 +156,25 @@ packages: engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-typescript@7.27.1': - resolution: {integrity: sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==} + '@babel/plugin-syntax-typescript@7.28.6': + resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/template@7.27.2': - resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.28.0': - resolution: {integrity: sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==} + '@babel/traverse@7.28.6': + resolution: {integrity: sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==} engines: {node: '>=6.9.0'} - '@babel/types@7.28.2': - resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==} + '@babel/types@7.28.6': + resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==} engines: {node: '>=6.9.0'} '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} '@istanbuljs/load-nyc-config@1.1.0': resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} engines: {node: '>=8'} @@ -231,22 +233,30 @@ packages: '@jest/types@29.6.3': resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - '@jridgewell/gen-mapping@0.3.12': - resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==} + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} - '@jridgewell/sourcemap-codec@1.5.4': - resolution: {integrity: sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==} - '@jridgewell/trace-mapping@0.3.29': - resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==} - '@noble/curves@1.2.0': - resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==} - '@noble/hashes@1.3.2': - resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==} - engines: {node: '>= 16'} - '@openzeppelin/relayer-sdk@1.6.0': - resolution: {integrity: sha512-UjL4TLz4tvCY1ssE3wf47FU+/2vE/bJ5HoXyufUsidL5bRXwf6ziwWZWUa0YsPiGEQNdVtsuoUR9qqHjxYqlXg==} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@noble/curves@1.9.7': + resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} + engines: {node: ^14.21.3 || >=16} + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@openzeppelin/relayer-plugin-channels@0.6.0': + resolution: {integrity: sha512-vqNi6TXuu7LNbc1NoGfUD9vh+egBMB/vhwf03idzWhKPdNLVUzGE+PV7fzQNjHkpbTROs+d7vnBEdA8CJxo5gQ==} + engines: {node: '>=22.18.0', npm: use pnpm, pnpm: '>=9', yarn: use pnpm} + '@openzeppelin/relayer-sdk@1.9.0': + resolution: {integrity: sha512-G3Zhg0imaT9Ej1cE9Cq5d4uQvW+DinD2GvRuiPWo3+O9KI8ivBnGJkjEGgK9Ja3/QsUNIfx8Gk5Mdw2sPXjVWA==} engines: {node: '>=22.14.0', npm: use pnpm, pnpm: '>=9', yarn: use pnpm} '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} @@ -254,6 +264,22 @@ packages: resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@stellar/js-xdr@3.1.2': + resolution: {integrity: sha512-VVolPL5goVEIsvuGqDc5uiKxV03lzfWdvYg1KikvwheDmTBO68CKDji3bAZ/kppZrx5iTA8z3Ld5yuytcvhvOQ==} + '@stellar/stellar-base@14.0.4': + resolution: {integrity: sha512-UbNW6zbdOBXJwLAV2mMak0bIC9nw3IZVlQXkv2w2dk1jgCbJjy3oRVC943zeGE5JAm0Z9PHxrIjmkpGhayY7kw==} + engines: {node: '>=20.0.0'} + '@stellar/stellar-sdk@14.4.3': + resolution: {integrity: sha512-QfaScSNd4Ku0GGfaZjR8679+M5gLHG+09OLLqV3Bv1VaDKXjHmhf8ikalz2jlx3oFnmlEpEgnqXIdf4kdD2x/w==} + engines: {node: '>=20.0.0'} + '@tsconfig/node10@1.0.12': + resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} + '@tsconfig/node12@1.0.11': + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + '@tsconfig/node14@1.0.3': + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + '@tsconfig/node16@1.0.4': + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} '@types/babel__generator@7.27.0': @@ -272,20 +298,23 @@ packages: resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} '@types/jest@29.5.14': resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} - '@types/node@22.7.5': - resolution: {integrity: sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==} - '@types/node@24.2.0': - resolution: {integrity: sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==} + '@types/node@24.10.9': + resolution: {integrity: sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==} '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} '@types/uuid@10.0.0': resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} - '@types/yargs@17.0.33': - resolution: {integrity: sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==} - aes-js@4.0.0-beta.5: - resolution: {integrity: sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==} + '@types/yargs@17.0.35': + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + acorn-walk@8.3.4: + resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} + engines: {node: '>=0.4.0'} + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} @@ -301,12 +330,17 @@ packages: anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - axios@1.11.0: - resolution: {integrity: sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==} + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + axios@1.13.2: + resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} babel-jest@29.7.0: resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -329,13 +363,23 @@ packages: '@babel/core': ^7.0.0 balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base32.js@0.1.0: + resolution: {integrity: sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==} + engines: {node: '>=0.12.0'} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.9.14: + resolution: {integrity: sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==} + hasBin: true + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.25.1: - resolution: {integrity: sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==} + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true bs-logger@0.2.6: @@ -345,12 +389,17 @@ packages: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - bufferutil@4.0.9: - resolution: {integrity: sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==} - engines: {node: '>=6.14.2'} + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + call-bind@1.0.8: + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + engines: {node: '>= 0.4'} + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -360,8 +409,8 @@ packages: camelcase@6.3.0: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} - caniuse-lite@1.0.30001731: - resolution: {integrity: sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==} + caniuse-lite@1.0.30001764: + resolution: {integrity: sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==} chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -379,8 +428,8 @@ packages: co@4.6.0: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} - collect-v8-coverage@1.0.2: - resolution: {integrity: sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==} + collect-v8-coverage@1.0.3: + resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -397,19 +446,21 @@ packages: resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true + create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - debug@4.4.1: - resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} peerDependencies: supports-color: '*' peerDependenciesMeta: supports-color: optional: true - dedent@1.6.0: - resolution: {integrity: sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==} + dedent@1.7.1: + resolution: {integrity: sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==} peerDependencies: babel-plugin-macros: ^3.1.0 peerDependenciesMeta: @@ -418,6 +469,9 @@ packages: deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -427,18 +481,21 @@ packages: diff-sequences@29.6.3: resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + diff@4.0.2: + resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} + engines: {node: '>=0.3.1'} dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - electron-to-chromium@1.5.194: - resolution: {integrity: sha512-SdnWJwSUot04UR51I2oPD8kuP2VI37/CADR1OHsFOUzZIvfWJBO6q11k5P/uKNyTT3cdOsnyjkrZ+DDShqYqJA==} + electron-to-chromium@1.5.267: + resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} emittery@0.13.1: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} engines: {node: '>=12'} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - error-ex@1.3.2: - resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -461,9 +518,9 @@ packages: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} hasBin: true - ethers@6.15.0: - resolution: {integrity: sha512-Kf/3ZW54L4UT0pZtsY/rf+EkBU7Qi5nnhonjUb8yTXcxH3cdcWrV2cRyk0Xk/4jK6OoHhxxZHriyhje20If2hQ==} - engines: {node: '>=14.0.0'} + eventsource@2.0.2: + resolution: {integrity: sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==} + engines: {node: '>=12.0.0'} execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -477,6 +534,8 @@ packages: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + feaxios@0.0.23: + resolution: {integrity: sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==} fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -491,8 +550,11 @@ packages: peerDependenciesMeta: debug: optional: true - form-data@4.0.4: - resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -536,6 +598,8 @@ packages: has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -554,6 +618,8 @@ packages: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} hasBin: true + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} import-local@3.2.0: resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} engines: {node: '>=8'} @@ -568,6 +634,9 @@ packages: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} is-core-module@2.16.1: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} @@ -580,9 +649,17 @@ packages: is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-retry-allowed@3.0.0: + resolution: {integrity: sha512-9xH0xvoggby+u0uGF7cZXdrutWiBiaFG8ZT4YFPXL8NzkyAwX3AKGLeFQLvzDpM430+nDFBZ1LHkie/8ocL06A==} + engines: {node: '>=12'} is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} istanbul-lib-coverage@3.2.2: @@ -600,8 +677,8 @@ packages: istanbul-lib-source-maps@4.0.1: resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} engines: {node: '>=10'} - istanbul-reports@3.1.7: - resolution: {integrity: sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==} + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} jest-changed-files@29.7.0: resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} @@ -708,8 +785,8 @@ packages: optional: true js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@3.14.1: - resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} @@ -770,13 +847,10 @@ packages: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - node-gyp-build@4.8.4: - resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} - hasBin: true node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - node-releases@2.0.19: - resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -825,6 +899,9 @@ packages: pkg-dir@4.2.0: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} pretty-format@29.7.0: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -835,6 +912,8 @@ packages: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} require-directory@2.1.1: @@ -849,17 +928,26 @@ packages: resolve.exports@2.0.3: resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} engines: {node: '>=10'} - resolve@1.22.10: - resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} engines: {node: '>= 0.4'} hasBin: true + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.7.2: - resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} engines: {node: '>=10'} hasBin: true + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + sha.js@2.4.12: + resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==} + engines: {node: '>= 0.10'} + hasBin: true shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -915,11 +1003,16 @@ packages: engines: {node: '>=8'} tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + to-buffer@1.2.2: + resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} + engines: {node: '>= 0.4'} to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} - ts-jest@29.4.1: - resolution: {integrity: sha512-SaeUtjfpg9Uqu8IbeDKtdaS0g8lS6FT6OzM3ezrDfErPJPHNDo/Ey+VFGP1bQIDfagYDLyRpd7O15XpG1Es2Uw==} + toml@3.0.0: + resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} + ts-jest@29.4.6: + resolution: {integrity: sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==} engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: @@ -944,8 +1037,19 @@ packages: optional: true jest-util: optional: true - tslib@2.7.0: - resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} + ts-node@10.9.2: + resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true type-detect@4.0.8: resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} engines: {node: '>=4'} @@ -955,34 +1059,36 @@ packages: type-fest@4.41.0: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} - typescript@5.9.2: - resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true uglify-js@3.19.3: resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} engines: {node: '>=0.8.0'} hasBin: true - undici-types@6.19.8: - resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} - undici-types@7.10.0: - resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==} - update-browserslist-db@1.1.3: - resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' - utf-8-validate@5.0.10: - resolution: {integrity: sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==} - engines: {node: '>=6.14.2'} - uuid@11.1.0: - resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} - hasBin: true + urijs@1.19.11: + resolution: {integrity: sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==} + v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} v8-to-istanbul@9.3.0: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + which-typed-array@1.1.20: + resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} + engines: {node: '>= 0.4'} which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -997,17 +1103,6 @@ packages: write-file-atomic@4.0.2: resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} - ws@8.17.1: - resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -1019,200 +1114,205 @@ packages: yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} snapshots: - '@adraffy/ens-normalize@1.10.1': {} - '@ampproject/remapping@2.3.0': + '@actions/exec@1.1.1': dependencies: - '@jridgewell/gen-mapping': 0.3.12 - '@jridgewell/trace-mapping': 0.3.29 - '@babel/code-frame@7.27.1': + '@actions/io': 1.1.3 + '@actions/io@1.1.3': {} + '@babel/code-frame@7.28.6': dependencies: - '@babel/helper-validator-identifier': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.28.0': {} - '@babel/core@7.28.0': - dependencies: - '@ampproject/remapping': 2.3.0 - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.0 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-module-transforms': 7.27.3(@babel/core@7.28.0) - '@babel/helpers': 7.28.2 - '@babel/parser': 7.28.0 - '@babel/template': 7.27.2 - '@babel/traverse': 7.28.0 - '@babel/types': 7.28.2 + '@babel/compat-data@7.28.6': {} + '@babel/core@7.28.6': + dependencies: + '@babel/code-frame': 7.28.6 + '@babel/generator': 7.28.6 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.28.6) + '@babel/helpers': 7.28.6 + '@babel/parser': 7.28.6 + '@babel/template': 7.28.6 + '@babel/traverse': 7.28.6 + '@babel/types': 7.28.6 + '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.1 + debug: 4.4.3 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/generator@7.28.0': + '@babel/generator@7.28.6': dependencies: - '@babel/parser': 7.28.0 - '@babel/types': 7.28.2 - '@jridgewell/gen-mapping': 0.3.12 - '@jridgewell/trace-mapping': 0.3.29 + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - '@babel/helper-compilation-targets@7.27.2': + '@babel/helper-compilation-targets@7.28.6': dependencies: - '@babel/compat-data': 7.28.0 + '@babel/compat-data': 7.28.6 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.25.1 + browserslist: 4.28.1 lru-cache: 5.1.1 semver: 6.3.1 '@babel/helper-globals@7.28.0': {} - '@babel/helper-module-imports@7.27.1': + '@babel/helper-module-imports@7.28.6': dependencies: - '@babel/traverse': 7.28.0 - '@babel/types': 7.28.2 + '@babel/traverse': 7.28.6 + '@babel/types': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.27.3(@babel/core@7.28.0)': + '@babel/helper-module-transforms@7.28.6(@babel/core@7.28.6)': dependencies: - '@babel/core': 7.28.0 - '@babel/helper-module-imports': 7.27.1 - '@babel/helper-validator-identifier': 7.27.1 - '@babel/traverse': 7.28.0 + '@babel/core': 7.28.6 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/helper-plugin-utils@7.27.1': {} + '@babel/helper-plugin-utils@7.28.6': {} '@babel/helper-string-parser@7.27.1': {} - '@babel/helper-validator-identifier@7.27.1': {} + '@babel/helper-validator-identifier@7.28.5': {} '@babel/helper-validator-option@7.27.1': {} - '@babel/helpers@7.28.2': + '@babel/helpers@7.28.6': dependencies: - '@babel/template': 7.27.2 - '@babel/types': 7.28.2 - '@babel/parser@7.28.0': + '@babel/template': 7.28.6 + '@babel/types': 7.28.6 + '@babel/parser@7.28.6': dependencies: - '@babel/types': 7.28.2 - '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.28.0)': + '@babel/types': 7.28.6 + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.28.6)': dependencies: - '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.28.0)': + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.28.6)': dependencies: - '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.28.0)': + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.28.6)': dependencies: - '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.28.0)': + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.28.6)': dependencies: - '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.28.0)': + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.28.6)': dependencies: - '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.0)': + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.6)': dependencies: - '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.28.0)': + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.28.6)': dependencies: - '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.0)': + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.28.6)': dependencies: - '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.28.0)': + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.28.6)': dependencies: - '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.28.0)': + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.28.6)': dependencies: - '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.28.0)': + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.28.6)': dependencies: - '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.28.0)': + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.28.6)': dependencies: - '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.28.0)': + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.28.6)': dependencies: - '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.28.0)': + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.28.6)': dependencies: - '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.28.0)': + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.28.6)': dependencies: - '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.28.0)': + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.28.6)': dependencies: - '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.0)': + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.28.6)': dependencies: - '@babel/core': 7.28.0 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/template@7.27.2': + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/template@7.28.6': dependencies: - '@babel/code-frame': 7.27.1 - '@babel/parser': 7.28.0 - '@babel/types': 7.28.2 - '@babel/traverse@7.28.0': + '@babel/code-frame': 7.28.6 + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 + '@babel/traverse@7.28.6': dependencies: - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.0 + '@babel/code-frame': 7.28.6 + '@babel/generator': 7.28.6 '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.28.0 - '@babel/template': 7.27.2 - '@babel/types': 7.28.2 - debug: 4.4.1 + '@babel/parser': 7.28.6 + '@babel/template': 7.28.6 + '@babel/types': 7.28.6 + debug: 4.4.3 transitivePeerDependencies: - supports-color - '@babel/types@7.28.2': + '@babel/types@7.28.6': dependencies: '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 '@bcoe/v8-coverage@0.2.3': {} + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 '@istanbuljs/load-nyc-config@1.1.0': dependencies: camelcase: 5.3.1 find-up: 4.1.0 get-package-type: 0.1.0 - js-yaml: 3.14.1 + js-yaml: 3.14.2 resolve-from: 5.0.0 '@istanbuljs/schema@0.1.3': {} '@jest/console@29.7.0': dependencies: '@jest/types': 29.6.3 - '@types/node': 24.2.0 + '@types/node': 24.10.9 chalk: 4.1.2 jest-message-util: 29.7.0 jest-util: 29.7.0 slash: 3.0.0 - '@jest/core@29.7.0': + '@jest/core@29.7.0(ts-node@10.9.2(@types/node@24.10.9)(typescript@5.9.3))': dependencies: '@jest/console': 29.7.0 '@jest/reporters': 29.7.0 '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 24.2.0 + '@types/node': 24.10.9 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@24.2.0) + jest-config: 29.7.0(@types/node@24.10.9)(ts-node@10.9.2(@types/node@24.10.9)(typescript@5.9.3)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -1236,7 +1336,7 @@ snapshots: dependencies: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 24.2.0 + '@types/node': 24.10.9 jest-mock: 29.7.0 '@jest/expect-utils@29.7.0': dependencies: @@ -1251,7 +1351,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@sinonjs/fake-timers': 10.3.0 - '@types/node': 24.2.0 + '@types/node': 24.10.9 jest-message-util: 29.7.0 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -1270,10 +1370,10 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@jridgewell/trace-mapping': 0.3.29 - '@types/node': 24.2.0 + '@jridgewell/trace-mapping': 0.3.31 + '@types/node': 24.10.9 chalk: 4.1.2 - collect-v8-coverage: 1.0.2 + collect-v8-coverage: 1.0.3 exit: 0.1.2 glob: 7.2.3 graceful-fs: 4.2.11 @@ -1281,7 +1381,7 @@ snapshots: istanbul-lib-instrument: 6.0.3 istanbul-lib-report: 3.0.1 istanbul-lib-source-maps: 4.0.1 - istanbul-reports: 3.1.7 + istanbul-reports: 3.2.0 jest-message-util: 29.7.0 jest-util: 29.7.0 jest-worker: 29.7.0 @@ -1296,7 +1396,7 @@ snapshots: '@sinclair/typebox': 0.27.8 '@jest/source-map@29.6.3': dependencies: - '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/trace-mapping': 0.3.31 callsites: 3.1.0 graceful-fs: 4.2.11 '@jest/test-result@29.7.0': @@ -1304,7 +1404,7 @@ snapshots: '@jest/console': 29.7.0 '@jest/types': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 - collect-v8-coverage: 1.0.2 + collect-v8-coverage: 1.0.3 '@jest/test-sequencer@29.7.0': dependencies: '@jest/test-result': 29.7.0 @@ -1313,9 +1413,9 @@ snapshots: slash: 3.0.0 '@jest/transform@29.7.0': dependencies: - '@babel/core': 7.28.0 + '@babel/core': 7.28.6 '@jest/types': 29.6.3 - '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/trace-mapping': 0.3.31 babel-plugin-istanbul: 6.1.1 chalk: 4.1.2 convert-source-map: 2.0.0 @@ -1335,26 +1435,42 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 24.2.0 - '@types/yargs': 17.0.33 + '@types/node': 24.10.9 + '@types/yargs': 17.0.35 chalk: 4.1.2 - '@jridgewell/gen-mapping@0.3.12': + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/remapping@2.3.5': dependencies: - '@jridgewell/sourcemap-codec': 1.5.4 - '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/resolve-uri@3.1.2': {} - '@jridgewell/sourcemap-codec@1.5.4': {} - '@jridgewell/trace-mapping@0.3.29': + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.4 - '@noble/curves@1.2.0': + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@noble/curves@1.9.7': + dependencies: + '@noble/hashes': 1.8.0 + '@noble/hashes@1.8.0': {} + '@openzeppelin/relayer-plugin-channels@0.6.0': dependencies: - '@noble/hashes': 1.3.2 - '@noble/hashes@1.3.2': {} - '@openzeppelin/relayer-sdk@1.6.0': + '@actions/exec': 1.1.1 + '@openzeppelin/relayer-sdk': 1.9.0 + '@stellar/stellar-sdk': 14.4.3 + axios: 1.13.2 + transitivePeerDependencies: + - debug + '@openzeppelin/relayer-sdk@1.9.0': dependencies: - axios: 1.11.0 + axios: 1.13.2 transitivePeerDependencies: - debug '@sinclair/typebox@0.27.8': {} @@ -1364,26 +1480,51 @@ snapshots: '@sinonjs/fake-timers@10.3.0': dependencies: '@sinonjs/commons': 3.0.1 + '@stellar/js-xdr@3.1.2': {} + '@stellar/stellar-base@14.0.4': + dependencies: + '@noble/curves': 1.9.7 + '@stellar/js-xdr': 3.1.2 + base32.js: 0.1.0 + bignumber.js: 9.3.1 + buffer: 6.0.3 + sha.js: 2.4.12 + '@stellar/stellar-sdk@14.4.3': + dependencies: + '@stellar/stellar-base': 14.0.4 + axios: 1.13.2 + bignumber.js: 9.3.1 + eventsource: 2.0.2 + feaxios: 0.0.23 + randombytes: 2.1.0 + toml: 3.0.0 + urijs: 1.19.11 + transitivePeerDependencies: + - debug + '@tsconfig/node10@1.0.12': {} + '@tsconfig/node12@1.0.11': {} + '@tsconfig/node14@1.0.3': {} + '@tsconfig/node16@1.0.4': {} '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.28.0 - '@babel/types': 7.28.2 + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.28.2 + '@babel/types': 7.28.6 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.28.0 - '@babel/types': 7.28.2 + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.28.2 + '@babel/types': 7.28.6 '@types/graceful-fs@4.1.9': dependencies: - '@types/node': 24.2.0 + '@types/node': 24.10.9 '@types/istanbul-lib-coverage@2.0.6': {} '@types/istanbul-lib-report@3.0.3': dependencies: @@ -1395,19 +1536,19 @@ snapshots: dependencies: expect: 29.7.0 pretty-format: 29.7.0 - '@types/node@22.7.5': - dependencies: - undici-types: 6.19.8 - '@types/node@24.2.0': + '@types/node@24.10.9': dependencies: - undici-types: 7.10.0 + undici-types: 7.16.0 '@types/stack-utils@2.0.3': {} '@types/uuid@10.0.0': {} '@types/yargs-parser@21.0.3': {} - '@types/yargs@17.0.33': + '@types/yargs@17.0.35': dependencies: '@types/yargs-parser': 21.0.3 - aes-js@4.0.0-beta.5: {} + acorn-walk@8.3.4: + dependencies: + acorn: 8.15.0 + acorn@8.15.0: {} ansi-escapes@4.3.2: dependencies: type-fest: 0.21.3 @@ -1420,24 +1561,28 @@ snapshots: dependencies: normalize-path: 3.0.0 picomatch: 2.3.1 + arg@4.1.3: {} argparse@1.0.10: dependencies: sprintf-js: 1.0.3 asynckit@0.4.0: {} - axios@1.11.0: + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + axios@1.13.2: dependencies: follow-redirects: 1.15.11 - form-data: 4.0.4 + form-data: 4.0.5 proxy-from-env: 1.1.0 transitivePeerDependencies: - debug - babel-jest@29.7.0(@babel/core@7.28.0): + babel-jest@29.7.0(@babel/core@7.28.6): dependencies: - '@babel/core': 7.28.0 + '@babel/core': 7.28.6 '@jest/transform': 29.7.0 '@types/babel__core': 7.20.5 babel-plugin-istanbul: 6.1.1 - babel-preset-jest: 29.6.3(@babel/core@7.28.0) + babel-preset-jest: 29.6.3(@babel/core@7.28.6) chalk: 4.1.2 graceful-fs: 4.2.11 slash: 3.0.0 @@ -1445,7 +1590,7 @@ snapshots: - supports-color babel-plugin-istanbul@6.1.1: dependencies: - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@istanbuljs/load-nyc-config': 1.1.0 '@istanbuljs/schema': 0.1.3 istanbul-lib-instrument: 5.2.1 @@ -1454,34 +1599,38 @@ snapshots: - supports-color babel-plugin-jest-hoist@29.6.3: dependencies: - '@babel/template': 7.27.2 - '@babel/types': 7.28.2 + '@babel/template': 7.28.6 + '@babel/types': 7.28.6 '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.28.0 - babel-preset-current-node-syntax@1.2.0(@babel/core@7.28.0): - dependencies: - '@babel/core': 7.28.0 - '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.28.0) - '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.28.0) - '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.28.0) - '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.28.0) - '@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.28.0) - '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.28.0) - '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.28.0) - '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.28.0) - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.28.0) - '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.28.0) - '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.28.0) - '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.28.0) - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.28.0) - '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.28.0) - '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.28.0) - babel-preset-jest@29.6.3(@babel/core@7.28.0): - dependencies: - '@babel/core': 7.28.0 + babel-preset-current-node-syntax@1.2.0(@babel/core@7.28.6): + dependencies: + '@babel/core': 7.28.6 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.28.6) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.28.6) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.28.6) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.28.6) + '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.28.6) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.28.6) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.28.6) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.28.6) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.28.6) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.28.6) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.28.6) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.28.6) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.28.6) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.28.6) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.28.6) + babel-preset-jest@29.6.3(@babel/core@7.28.6): + dependencies: + '@babel/core': 7.28.6 babel-plugin-jest-hoist: 29.6.3 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.0) + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.6) balanced-match@1.0.2: {} + base32.js@0.1.0: {} + base64-js@1.5.1: {} + baseline-browser-mapping@2.9.14: {} + bignumber.js@9.3.1: {} brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -1489,12 +1638,13 @@ snapshots: braces@3.0.3: dependencies: fill-range: 7.1.1 - browserslist@4.25.1: + browserslist@4.28.1: dependencies: - caniuse-lite: 1.0.30001731 - electron-to-chromium: 1.5.194 - node-releases: 2.0.19 - update-browserslist-db: 1.1.3(browserslist@4.25.1) + baseline-browser-mapping: 2.9.14 + caniuse-lite: 1.0.30001764 + electron-to-chromium: 1.5.267 + node-releases: 2.0.27 + update-browserslist-db: 1.2.3(browserslist@4.28.1) bs-logger@0.2.6: dependencies: fast-json-stable-stringify: 2.1.0 @@ -1502,18 +1652,28 @@ snapshots: dependencies: node-int64: 0.4.0 buffer-from@1.1.2: {} - bufferutil@4.0.9: + buffer@6.0.3: dependencies: - node-gyp-build: 4.8.4 - optional: true + base64-js: 1.5.1 + ieee754: 1.2.1 call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 function-bind: 1.1.2 + call-bind@1.0.8: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 callsites@3.1.0: {} camelcase@5.3.1: {} camelcase@6.3.0: {} - caniuse-lite@1.0.30001731: {} + caniuse-lite@1.0.30001764: {} chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -1527,7 +1687,7 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 co@4.6.0: {} - collect-v8-coverage@1.0.2: {} + collect-v8-coverage@1.0.3: {} color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -1537,13 +1697,13 @@ snapshots: delayed-stream: 1.0.0 concat-map@0.0.1: {} convert-source-map@2.0.0: {} - create-jest@29.7.0(@types/node@24.2.0): + create-jest@29.7.0(@types/node@24.10.9)(ts-node@10.9.2(@types/node@24.10.9)(typescript@5.9.3)): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@24.2.0) + jest-config: 29.7.0(@types/node@24.10.9)(ts-node@10.9.2(@types/node@24.10.9)(typescript@5.9.3)) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -1551,28 +1711,35 @@ snapshots: - babel-plugin-macros - supports-color - ts-node + create-require@1.1.1: {} cross-spawn@7.0.6: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 - debug@4.4.1: + debug@4.4.3: dependencies: ms: 2.1.3 - dedent@1.6.0: {} + dedent@1.7.1: {} deepmerge@4.3.1: {} + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 delayed-stream@1.0.0: {} detect-newline@3.1.0: {} diff-sequences@29.6.3: {} + diff@4.0.2: {} dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 es-errors: 1.3.0 gopd: 1.2.0 - electron-to-chromium@1.5.194: {} + electron-to-chromium@1.5.267: {} emittery@0.13.1: {} emoji-regex@8.0.0: {} - error-ex@1.3.2: + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 es-define-property@1.0.1: {} @@ -1589,18 +1756,7 @@ snapshots: escalade@3.2.0: {} escape-string-regexp@2.0.0: {} esprima@4.0.1: {} - ethers@6.15.0(bufferutil@4.0.9)(utf-8-validate@5.0.10): - dependencies: - '@adraffy/ens-normalize': 1.10.1 - '@noble/curves': 1.2.0 - '@noble/hashes': 1.3.2 - '@types/node': 22.7.5 - aes-js: 4.0.0-beta.5 - tslib: 2.7.0 - ws: 8.17.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) - transitivePeerDependencies: - - bufferutil - - utf-8-validate + eventsource@2.0.2: {} execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -1624,6 +1780,9 @@ snapshots: fb-watchman@2.0.2: dependencies: bser: 2.1.1 + feaxios@0.0.23: + dependencies: + is-retry-allowed: 3.0.0 fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -1632,7 +1791,10 @@ snapshots: locate-path: 5.0.0 path-exists: 4.0.0 follow-redirects@1.15.11: {} - form-data@4.0.4: + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + form-data@4.0.5: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 @@ -1682,6 +1844,9 @@ snapshots: optionalDependencies: uglify-js: 3.19.3 has-flag@4.0.0: {} + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 has-symbols@1.1.0: {} has-tostringtag@1.0.2: dependencies: @@ -1692,6 +1857,7 @@ snapshots: html-escaper@2.0.2: {} human-signals@2.1.0: {} husky@9.1.7: {} + ieee754@1.2.1: {} import-local@3.2.0: dependencies: pkg-dir: 4.2.0 @@ -1703,19 +1869,25 @@ snapshots: wrappy: 1.0.2 inherits@2.0.4: {} is-arrayish@0.2.1: {} + is-callable@1.2.7: {} is-core-module@2.16.1: dependencies: hasown: 2.0.2 is-fullwidth-code-point@3.0.0: {} is-generator-fn@2.1.0: {} is-number@7.0.0: {} + is-retry-allowed@3.0.0: {} is-stream@2.0.1: {} + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.20 + isarray@2.0.5: {} isexe@2.0.0: {} istanbul-lib-coverage@3.2.2: {} istanbul-lib-instrument@5.2.1: dependencies: - '@babel/core': 7.28.0 - '@babel/parser': 7.28.0 + '@babel/core': 7.28.6 + '@babel/parser': 7.28.6 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 semver: 6.3.1 @@ -1723,11 +1895,11 @@ snapshots: - supports-color istanbul-lib-instrument@6.0.3: dependencies: - '@babel/core': 7.28.0 - '@babel/parser': 7.28.0 + '@babel/core': 7.28.6 + '@babel/parser': 7.28.6 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 - semver: 7.7.2 + semver: 7.7.3 transitivePeerDependencies: - supports-color istanbul-lib-report@3.0.1: @@ -1737,12 +1909,12 @@ snapshots: supports-color: 7.2.0 istanbul-lib-source-maps@4.0.1: dependencies: - debug: 4.4.1 + debug: 4.4.3 istanbul-lib-coverage: 3.2.2 source-map: 0.6.1 transitivePeerDependencies: - supports-color - istanbul-reports@3.1.7: + istanbul-reports@3.2.0: dependencies: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 @@ -1757,10 +1929,10 @@ snapshots: '@jest/expect': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 24.2.0 + '@types/node': 24.10.9 chalk: 4.1.2 co: 4.6.0 - dedent: 1.6.0 + dedent: 1.7.1 is-generator-fn: 2.1.0 jest-each: 29.7.0 jest-matcher-utils: 29.7.0 @@ -1776,16 +1948,16 @@ snapshots: transitivePeerDependencies: - babel-plugin-macros - supports-color - jest-cli@29.7.0(@types/node@24.2.0): + jest-cli@29.7.0(@types/node@24.10.9)(ts-node@10.9.2(@types/node@24.10.9)(typescript@5.9.3)): dependencies: - '@jest/core': 29.7.0 + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@24.10.9)(typescript@5.9.3)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@24.2.0) + create-jest: 29.7.0(@types/node@24.10.9)(ts-node@10.9.2(@types/node@24.10.9)(typescript@5.9.3)) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@24.2.0) + jest-config: 29.7.0(@types/node@24.10.9)(ts-node@10.9.2(@types/node@24.10.9)(typescript@5.9.3)) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -1794,12 +1966,12 @@ snapshots: - babel-plugin-macros - supports-color - ts-node - jest-config@29.7.0(@types/node@24.2.0): + jest-config@29.7.0(@types/node@24.10.9)(ts-node@10.9.2(@types/node@24.10.9)(typescript@5.9.3)): dependencies: - '@babel/core': 7.28.0 + '@babel/core': 7.28.6 '@jest/test-sequencer': 29.7.0 '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.28.0) + babel-jest: 29.7.0(@babel/core@7.28.6) chalk: 4.1.2 ci-info: 3.9.0 deepmerge: 4.3.1 @@ -1819,7 +1991,8 @@ snapshots: slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: - '@types/node': 24.2.0 + '@types/node': 24.10.9 + ts-node: 10.9.2(@types/node@24.10.9)(typescript@5.9.3) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -1844,7 +2017,7 @@ snapshots: '@jest/environment': 29.7.0 '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 24.2.0 + '@types/node': 24.10.9 jest-mock: 29.7.0 jest-util: 29.7.0 jest-get-type@29.6.3: {} @@ -1852,7 +2025,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.9 - '@types/node': 24.2.0 + '@types/node': 24.10.9 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -1875,7 +2048,7 @@ snapshots: pretty-format: 29.7.0 jest-message-util@29.7.0: dependencies: - '@babel/code-frame': 7.27.1 + '@babel/code-frame': 7.28.6 '@jest/types': 29.6.3 '@types/stack-utils': 2.0.3 chalk: 4.1.2 @@ -1887,7 +2060,7 @@ snapshots: jest-mock@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 24.2.0 + '@types/node': 24.10.9 jest-util: 29.7.0 jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): optionalDependencies: @@ -1907,7 +2080,7 @@ snapshots: jest-pnp-resolver: 1.2.3(jest-resolve@29.7.0) jest-util: 29.7.0 jest-validate: 29.7.0 - resolve: 1.22.10 + resolve: 1.22.11 resolve.exports: 2.0.3 slash: 3.0.0 jest-runner@29.7.0: @@ -1917,7 +2090,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 24.2.0 + '@types/node': 24.10.9 chalk: 4.1.2 emittery: 0.13.1 graceful-fs: 4.2.11 @@ -1944,10 +2117,10 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 24.2.0 + '@types/node': 24.10.9 chalk: 4.1.2 cjs-module-lexer: 1.4.3 - collect-v8-coverage: 1.0.2 + collect-v8-coverage: 1.0.3 glob: 7.2.3 graceful-fs: 4.2.11 jest-haste-map: 29.7.0 @@ -1963,15 +2136,15 @@ snapshots: - supports-color jest-snapshot@29.7.0: dependencies: - '@babel/core': 7.28.0 - '@babel/generator': 7.28.0 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.0) - '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.0) - '@babel/types': 7.28.2 + '@babel/core': 7.28.6 + '@babel/generator': 7.28.6 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.28.6) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.28.6) + '@babel/types': 7.28.6 '@jest/expect-utils': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.0) + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.6) chalk: 4.1.2 expect: 29.7.0 graceful-fs: 4.2.11 @@ -1982,13 +2155,13 @@ snapshots: jest-util: 29.7.0 natural-compare: 1.4.0 pretty-format: 29.7.0 - semver: 7.7.2 + semver: 7.7.3 transitivePeerDependencies: - supports-color jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 24.2.0 + '@types/node': 24.10.9 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -2005,7 +2178,7 @@ snapshots: dependencies: '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 24.2.0 + '@types/node': 24.10.9 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 @@ -2013,23 +2186,23 @@ snapshots: string-length: 4.0.2 jest-worker@29.7.0: dependencies: - '@types/node': 24.2.0 + '@types/node': 24.10.9 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 - jest@29.7.0(@types/node@24.2.0): + jest@29.7.0(@types/node@24.10.9)(ts-node@10.9.2(@types/node@24.10.9)(typescript@5.9.3)): dependencies: - '@jest/core': 29.7.0 + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@24.10.9)(typescript@5.9.3)) '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@24.2.0) + jest-cli: 29.7.0(@types/node@24.10.9)(ts-node@10.9.2(@types/node@24.10.9)(typescript@5.9.3)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros - supports-color - ts-node js-tokens@4.0.0: {} - js-yaml@3.14.1: + js-yaml@3.14.2: dependencies: argparse: 1.0.10 esprima: 4.0.1 @@ -2048,7 +2221,7 @@ snapshots: yallist: 3.1.1 make-dir@4.0.0: dependencies: - semver: 7.7.2 + semver: 7.7.3 make-error@1.3.6: {} makeerror@1.0.12: dependencies: @@ -2071,10 +2244,8 @@ snapshots: ms@2.1.3: {} natural-compare@1.4.0: {} neo-async@2.6.2: {} - node-gyp-build@4.8.4: - optional: true node-int64@0.4.0: {} - node-releases@2.0.19: {} + node-releases@2.0.27: {} normalize-path@3.0.0: {} npm-run-path@4.0.1: dependencies: @@ -2097,8 +2268,8 @@ snapshots: p-try@2.2.0: {} parse-json@5.2.0: dependencies: - '@babel/code-frame': 7.27.1 - error-ex: 1.3.2 + '@babel/code-frame': 7.28.6 + error-ex: 1.3.4 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 path-exists@4.0.0: {} @@ -2111,6 +2282,7 @@ snapshots: pkg-dir@4.2.0: dependencies: find-up: 4.1.0 + possible-typed-array-names@1.1.0: {} pretty-format@29.7.0: dependencies: '@jest/schemas': 29.6.3 @@ -2122,6 +2294,9 @@ snapshots: sisteransi: 1.0.5 proxy-from-env@1.1.0: {} pure-rand@6.1.0: {} + randombytes@2.1.0: + dependencies: + safe-buffer: 5.2.1 react-is@18.3.1: {} require-directory@2.1.1: {} resolve-cwd@3.0.0: @@ -2129,13 +2304,27 @@ snapshots: resolve-from: 5.0.0 resolve-from@5.0.0: {} resolve.exports@2.0.3: {} - resolve@1.22.10: + resolve@1.22.11: dependencies: is-core-module: 2.16.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + safe-buffer@5.2.1: {} semver@6.3.1: {} - semver@7.7.2: {} + semver@7.7.3: {} + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + sha.js@2.4.12: + dependencies: + inherits: 2.0.4 + safe-buffer: 5.2.1 + to-buffer: 1.2.2 shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -2180,55 +2369,87 @@ snapshots: glob: 7.2.3 minimatch: 3.1.2 tmpl@1.0.5: {} + to-buffer@1.2.2: + dependencies: + isarray: 2.0.5 + safe-buffer: 5.2.1 + typed-array-buffer: 1.0.3 to-regex-range@5.0.1: dependencies: is-number: 7.0.0 - ? ts-jest@29.4.1(@babel/core@7.28.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.2.0))(typescript@5.9.2) + toml@3.0.0: {} + ? ts-jest@29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.10.9)(ts-node@10.9.2(@types/node@24.10.9)(typescript@5.9.3)))(typescript@5.9.3) : dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 handlebars: 4.7.8 - jest: 29.7.0(@types/node@24.2.0) + jest: 29.7.0(@types/node@24.10.9)(ts-node@10.9.2(@types/node@24.10.9)(typescript@5.9.3)) json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 - semver: 7.7.2 + semver: 7.7.3 type-fest: 4.41.0 - typescript: 5.9.2 + typescript: 5.9.3 yargs-parser: 21.1.1 optionalDependencies: - '@babel/core': 7.28.0 + '@babel/core': 7.28.6 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.28.0) + babel-jest: 29.7.0(@babel/core@7.28.6) jest-util: 29.7.0 - tslib@2.7.0: {} + ts-node@10.9.2(@types/node@24.10.9)(typescript@5.9.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.12 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 24.10.9 + acorn: 8.15.0 + acorn-walk: 8.3.4 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 5.9.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 type-detect@4.0.8: {} type-fest@0.21.3: {} type-fest@4.41.0: {} - typescript@5.9.2: {} + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + typescript@5.9.3: {} uglify-js@3.19.3: optional: true - undici-types@6.19.8: {} - undici-types@7.10.0: {} - update-browserslist-db@1.1.3(browserslist@4.25.1): + undici-types@7.16.0: {} + update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: - browserslist: 4.25.1 + browserslist: 4.28.1 escalade: 3.2.0 picocolors: 1.1.1 - utf-8-validate@5.0.10: - dependencies: - node-gyp-build: 4.8.4 - optional: true - uuid@11.1.0: {} + urijs@1.19.11: {} + v8-compile-cache-lib@3.0.1: {} v8-to-istanbul@9.3.0: dependencies: - '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/trace-mapping': 0.3.31 '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 walker@1.0.8: dependencies: makeerror: 1.0.12 + which-typed-array@1.1.20: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 which@2.0.2: dependencies: isexe: 2.0.0 @@ -2243,10 +2464,6 @@ snapshots: dependencies: imurmurhash: 0.1.4 signal-exit: 3.0.7 - ws@8.17.1(bufferutil@4.0.9)(utf-8-validate@5.0.10): - optionalDependencies: - bufferutil: 4.0.9 - utf-8-validate: 5.0.10 y18n@5.0.8: {} yallist@3.1.1: {} yargs-parser@21.1.1: {} @@ -2259,4 +2476,5 @@ snapshots: string-width: 4.2.3 y18n: 5.0.8 yargs-parser: 21.1.1 + yn@3.1.1: {} yocto-queue@0.1.0: {} diff --git a/examples/channels-plugin-example/config/config.json b/examples/channels-plugin-example/config/config.json index 1b6df0b37..d87f87cee 100644 --- a/examples/channels-plugin-example/config/config.json +++ b/examples/channels-plugin-example/config/config.json @@ -8,7 +8,8 @@ "network_type": "stellar", "signer_id": "channels-fund-signer", "policies": { - "concurrent_transactions": true + "concurrent_transactions": true, + "fee_payment_strategy": "relayer" } }, { @@ -17,7 +18,10 @@ "network": "testnet", "paused": false, "network_type": "stellar", - "signer_id": "channel-001-signer" + "signer_id": "channel-001-signer", + "policies": { + "fee_payment_strategy": "relayer" + } }, { "id": "channel-002", @@ -25,7 +29,10 @@ "network": "testnet", "paused": false, "network_type": "stellar", - "signer_id": "channel-002-signer" + "signer_id": "channel-002-signer", + "policies": { + "fee_payment_strategy": "relayer" + } } ], "notifications": [], diff --git a/openapi.json b/openapi.json index 393e09e18..3cbee8653 100644 --- a/openapi.json +++ b/openapi.json @@ -956,6 +956,269 @@ ] } }, + "/api/v1/plugins/{plugin_id}": { + "get": { + "tags": [ + "Plugins" + ], + "summary": "Get plugin by ID", + "operationId": "getPlugin", + "parameters": [ + { + "name": "plugin_id", + "in": "path", + "description": "The unique identifier of the plugin", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Plugin retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_PluginModel" + }, + "example": { + "data": { + "allow_get_invocation": false, + "config": { + "featureFlag": true + }, + "emit_logs": false, + "emit_traces": false, + "forward_logs": false, + "id": "my-plugin", + "path": "plugins/my-plugin.ts", + "raw_response": false, + "timeout": 30 + }, + "error": null, + "success": true + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_String" + }, + "example": { + "data": null, + "error": "Unauthorized", + "success": false + } + } + } + }, + "404": { + "description": "Plugin not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_String" + }, + "example": { + "data": null, + "error": "Plugin with id my-plugin not found", + "success": false + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_String" + }, + "example": { + "data": null, + "error": "Too Many Requests", + "success": false + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_String" + }, + "example": { + "data": null, + "error": "Internal Server Error", + "success": false + } + } + } + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "patch": { + "tags": [ + "Plugins" + ], + "summary": "Update plugin configuration", + "description": "Updates mutable plugin fields such as timeout, emit_logs, emit_traces,\nraw_response, allow_get_invocation, config, and forward_logs.\nThe plugin id and path cannot be changed after creation.\n\nAll fields are optional - only the provided fields will be updated.\nTo clear the `config` field, pass `\"config\": null`.", + "operationId": "updatePlugin", + "parameters": [ + { + "name": "plugin_id", + "in": "path", + "description": "The unique identifier of the plugin", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Plugin configuration update. All fields are optional.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdatePluginRequest" + }, + "example": { + "config": { + "apiKey": "xyz123", + "featureFlag": true + }, + "emit_logs": true, + "forward_logs": true, + "timeout": 60 + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Plugin updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_PluginModel" + }, + "example": { + "data": { + "allow_get_invocation": false, + "config": { + "apiKey": "xyz123", + "featureFlag": true + }, + "emit_logs": true, + "emit_traces": false, + "forward_logs": true, + "id": "my-plugin", + "path": "plugins/my-plugin.ts", + "raw_response": false, + "timeout": 60 + }, + "error": null, + "success": true + } + } + } + }, + "400": { + "description": "Bad Request (invalid timeout or other validation error)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_String" + }, + "example": { + "data": null, + "error": "Timeout must be greater than 0", + "success": false + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_String" + }, + "example": { + "data": null, + "error": "Unauthorized", + "success": false + } + } + } + }, + "404": { + "description": "Plugin not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_String" + }, + "example": { + "data": null, + "error": "Plugin with id my-plugin not found", + "success": false + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_String" + }, + "example": { + "data": null, + "error": "Too Many Requests", + "success": false + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_String" + }, + "example": { + "data": null, + "error": "Internal Server Error", + "success": false + } + } + } + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, "/api/v1/plugins/{plugin_id}/call": { "get": { "tags": [ @@ -4631,6 +4894,81 @@ } } }, + "ApiResponse_PluginModel": { + "type": "object", + "required": [ + "success" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "id", + "path", + "timeout" + ], + "properties": { + "allow_get_invocation": { + "type": "boolean", + "description": "Whether to allow GET requests to invoke plugin logic" + }, + "config": { + "type": [ + "object", + "null" + ], + "description": "User-defined configuration accessible to the plugin (must be a JSON object)", + "additionalProperties": {}, + "propertyNames": { + "type": "string" + } + }, + "emit_logs": { + "type": "boolean", + "description": "Whether to include logs in the HTTP response" + }, + "emit_traces": { + "type": "boolean", + "description": "Whether to include traces in the HTTP response" + }, + "forward_logs": { + "type": "boolean", + "description": "Whether to forward plugin logs into the relayer's tracing output" + }, + "id": { + "type": "string", + "description": "Plugin ID" + }, + "path": { + "type": "string", + "description": "Plugin path" + }, + "raw_response": { + "type": "boolean", + "description": "Whether to return raw plugin response without ApiResponse wrapper" + }, + "timeout": { + "type": "integer", + "format": "int64", + "description": "Plugin timeout", + "minimum": 0 + } + } + }, + "error": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/PluginMetadata" + }, + "pagination": { + "$ref": "#/components/schemas/PaginationMeta" + }, + "success": { + "type": "boolean" + } + } + }, "ApiResponse_RelayerResponse": { "type": "object", "required": [ @@ -9407,6 +9745,68 @@ }, "additionalProperties": false }, + "UpdatePluginRequest": { + "type": "object", + "description": "Request model for updating an existing plugin.\nAll fields are optional to allow partial updates.\nNote: `id` and `path` are not updateable after creation.", + "properties": { + "allow_get_invocation": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to allow GET requests to invoke plugin logic" + }, + "config": { + "type": [ + "object", + "null" + ], + "description": "User-defined configuration accessible to the plugin (must be a JSON object)\nUse `null` to clear the config", + "additionalProperties": {}, + "propertyNames": { + "type": "string" + } + }, + "emit_logs": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to include logs in the HTTP response" + }, + "emit_traces": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to include traces in the HTTP response" + }, + "forward_logs": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to forward plugin logs into the relayer's tracing output" + }, + "raw_response": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to return raw plugin response without ApiResponse wrapper" + }, + "timeout": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Plugin timeout in seconds", + "minimum": 0 + } + }, + "additionalProperties": false + }, "UpdateRelayerRequest": { "type": "object", "properties": { diff --git a/plugins/ARCHITECTURE.md b/plugins/ARCHITECTURE.md new file mode 100644 index 000000000..326e6b6cf --- /dev/null +++ b/plugins/ARCHITECTURE.md @@ -0,0 +1,289 @@ +# Plugin System Architecture + +Developer guide for understanding and modifying the plugin execution system. + +## High-Level Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Rust Relayer │ +│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │ +│ │ HTTP API │───▶│ PluginRunner │───▶│ PoolManager │ │ +│ │ /plugins/* │ │ │ │ - CircuitBreaker │ │ +│ └─────────────┘ └──────────────┘ │ - ConnectionPool │ │ +│ │ - RequestQueue │ │ +│ └───────────┬──────────────┘ │ +│ │ │ +│ Unix Socket (JSON-line protocol) │ +│ │ │ +└──────────────────────────────────────────────────────┼───────────────────┘ + │ +┌──────────────────────────────────────────────────────┼───────────────────┐ +│ Node.js Pool Server │ │ +│ ┌───────────▼──────────────┐ │ +│ ┌──────────────────────┐ │ pool-server.ts │ │ +│ │ Piscina Worker Pool │◀──────────────│ - Request Router │ │ +│ │ ┌─────────────────┐ │ │ - Memory Pressure Mon │ │ +│ │ │ Worker Thread 1 │ │ │ - Cache Management │ │ +│ │ │ (sandbox-exec) │ │ └──────────────────────────┘ │ +│ │ ├─────────────────┤ │ │ +│ │ │ Worker Thread 2 │ │ ┌──────────────────────────┐ │ +│ │ │ (sandbox-exec) │ │ │ SharedSocketService │ │ +│ │ ├─────────────────┤ │◀─────────────▶│ (Rust ↔ Plugin comms) │ │ +│ │ │ Worker Thread N │ │ └──────────────────────────┘ │ +│ │ └─────────────────┘ │ │ +│ └──────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +## Component Overview + +### Rust Side (`src/services/plugins/`) + +| Module | Purpose | +|--------|---------| +| `config.rs` | Centralized configuration with auto-derivation from `PLUGIN_MAX_CONCURRENCY` | +| `runner.rs` | Entry point - routes requests to pool executor, handles precompilation | +| `pool_executor.rs` | Manages Node.js process lifecycle, connection pooling, circuit breaker | +| `health.rs` | Circuit breaker, health status, dead server detection | +| `protocol.rs` | JSON-line message types (PoolRequest, PoolResponse) | +| `connection.rs` | Lock-free connection pool with semaphore-based concurrency | +| `shared_socket.rs` | Per-request Unix socket for plugin ↔ Rust API communication | +| `relayer_api.rs` | Plugin API implementation (sendTransaction, signMessage, etc.) | + +### Node.js Side (`plugins/lib/`) + +| Module | Purpose | +|--------|---------| +| `pool-server.ts` | Main server - accepts connections, routes to workers, manages memory | +| `worker-pool.ts` | Piscina wrapper with dynamic scaling and cache management | +| `pool-executor.ts` | Plugin execution | +| `compiler.ts` | TypeScript/JavaScript compilation with esbuild | +| `plugin.ts` | Plugin SDK (PluginContext, PluginAPI) | +| `kv.ts` | Redis-backed key-value store for plugins | + +## Communication Protocol + +### Pool Protocol (Rust ↔ pool-server.ts) + +Unix socket at `/tmp/relayer-plugin-pool-{uuid}.sock` using newline-delimited JSON. + +**Request Types:** +```typescript +// Execute a plugin +{ type: "execute", taskId, pluginId, compiledCode?, pluginPath?, params, socketPath, timeout? } + +// Precompile TypeScript +{ type: "precompile", taskId, pluginId, pluginPath?, sourceCode? } + +// Cache compiled code +{ type: "cache", taskId, pluginId, compiledCode } + +// Invalidate cache +{ type: "invalidate", taskId, pluginId } + +// Health check +{ type: "health", taskId } + +// Graceful shutdown +{ type: "shutdown", taskId } +``` + +**Response:** +```typescript +{ taskId, success: boolean, result?, error?: { message, code?, status?, details? }, logs? } +``` + +### Shared Socket Protocol (Plugin ↔ Rust) + +Per-request socket at `/tmp/relayer-shared-{uuid}.sock` for plugin API calls. + +```typescript +// Plugin sends: +{ type: "sendTransaction", requestId, relayerId, transaction } +{ type: "getRelayerBalance", requestId, relayerId } +// ... other API methods + +// Rust responds: +{ requestId, success: boolean, data?, error? } +``` + +## Request Flow + +``` +1. HTTP POST /api/v1/plugins/{id}/call + │ +2. PluginRunner.run_plugin() + │ + ├─ Check if compiled code cached + │ └─ If not: precompile via pool + │ +3. PoolManager.execute_plugin() + │ + ├─ Circuit breaker check (reject if open) + │ + ├─ Try acquire connection permit (fast path) + │ └─ If unavailable: queue request (slow path) + │ +4. Send Execute request to pool-server + │ +5. pool-server routes to Piscina worker + │ +6. pool-executor.ts runs plugin in VM + │ + ├─ Plugin calls api.useRelayer().sendTransaction() + │ └─ Communicates via shared socket to Rust + │ +7. Response flows back through chain + │ +8. HTTP 200 with { success, data, metadata } +``` + +## Environment Variables + +> **Source of truth**: `src/constants/plugins.rs` defines all default values. +> Auto-derivation logic is in `src/services/plugins/config.rs`. + +### Primary Scaling Knob + +| Variable | Default | Description | +|----------|---------|-------------| +| `PLUGIN_MAX_CONCURRENCY` | 2048 | **Main knob** - drives auto-derivation of all other values | + +### Auto-Derived (override only if needed) + +| Variable | Default Formula | Description | +|----------|-----------------|-------------| +| `PLUGIN_POOL_MAX_CONNECTIONS` | = max_concurrency | Rust connection pool size | +| `PLUGIN_POOL_MAX_QUEUE_SIZE` | = max_concurrency * 2 | Request queue capacity | +| `PLUGIN_SOCKET_MAX_CONCURRENT_CONNECTIONS` | = max_concurrency * 1.5 | Shared socket connections | +| `PLUGIN_POOL_MIN_THREADS` | max(2, cpu_count/2) | Node.js minimum workers | +| `PLUGIN_POOL_MAX_THREADS` | memory-aware, max 32 | Node.js maximum workers | +| `PLUGIN_POOL_CONCURRENT_TASKS` | (concurrency/threads)*1.2, max 250 | Tasks per worker | +| `PLUGIN_WORKER_HEAP_MB` | 512 + (concurrent_tasks * 5) | Per-worker heap size | + +### Fine-Tuning + +| Variable | Default | Description | +|----------|---------|-------------| +| `PLUGIN_POOL_WORKERS` | 0 (auto) | Rust queue worker threads | +| `PLUGIN_POOL_CONNECT_RETRIES` | 15 | Connection retry attempts | +| `PLUGIN_POOL_REQUEST_TIMEOUT_SECS` | 30 | Per-request timeout | +| `PLUGIN_POOL_QUEUE_SEND_TIMEOUT_MS` | 500 | Queue wait timeout (auto-scales to 1000) | +| `PLUGIN_POOL_IDLE_TIMEOUT` | 60000 | Worker idle timeout (ms) | +| `PLUGIN_POOL_SOCKET_BACKLOG` | max(concurrency, 2048) | Socket backlog size | +| `PLUGIN_POOL_HEALTH_CHECK_INTERVAL_SECS` | 5 | Health check frequency | +| `PLUGIN_SOCKET_IDLE_TIMEOUT_SECS` | 60 | Shared socket idle timeout | +| `PLUGIN_SOCKET_READ_TIMEOUT_SECS` | 30 | Shared socket read timeout | + +## Health & Recovery + +### Circuit Breaker States + +``` +CLOSED ──(>50% failure rate)──▶ OPEN + ▲ │ + │ (5s backoff) + │ ▼ + └──(10 consecutive OK)──── HALF_OPEN +``` + +- **Closed**: All requests allowed +- **Open**: Most requests rejected, pool server restart triggered +- **Half-Open**: 10% of requests allowed to probe recovery + +### Dead Server Detection + +Errors that trigger automatic restart: +- `eof while parsing` - Connection closed mid-message +- `broken pipe` - Write to closed socket +- `connection refused` - Server not listening +- `connection reset` - Server forcefully closed +- `socket file missing` - Unix socket deleted + +### Memory Pressure Handling + +pool-server.ts monitors heap usage and triggers: +1. **75% heap**: Force GC, evict old cache entries +2. **85% heap**: Aggressive cache eviction, warning logs +3. **95% heap**: Emergency measures, reject new requests + +## Performance Tuning + +### Low Concurrency (<100 plugins/sec) +```bash +export PLUGIN_MAX_CONCURRENCY=100 +# Everything auto-derives appropriately +``` + +### Medium Concurrency (100-1000 plugins/sec) +```bash +export PLUGIN_MAX_CONCURRENCY=500 +# Consider increasing if seeing queue full errors: +# export PLUGIN_POOL_MAX_QUEUE_SIZE=2000 +``` + +### High Concurrency (1000+ plugins/sec) +```bash +export PLUGIN_MAX_CONCURRENCY=5000 +# On 32GB+ systems, can increase threads: +# export PLUGIN_POOL_MAX_THREADS=16 +# export PLUGIN_POOL_CONCURRENT_TASKS=200 +``` + +### Debugging + +Enable debug logging: +```bash +RUST_LOG=openzeppelin_relayer::services::plugins=debug cargo run +``` + +Check health endpoint: +```bash +curl http://localhost:8080/api/v1/health/plugins +``` + +## Module Dependency Graph + +``` +config.rs + │ + ▼ +health.rs ◀─────────────────────┐ + │ │ + ▼ │ +protocol.rs │ + │ │ + ▼ │ +connection.rs │ + │ │ + ▼ │ +pool_executor.rs ───────────────┘ + │ + ▼ +runner.rs ◀──── shared_socket.rs ◀──── relayer_api.rs +``` + +## Testing + +```bash +# Rust unit tests +cargo test --package openzeppelin-relayer plugins + +# TypeScript tests +cd plugins && pnpm test + +# Integration test with load +# (requires k6: https://k6.io) +k6 run tests/load/plugins.js +``` + +## Common Issues + +| Symptom | Likely Cause | Solution | +|---------|--------------|----------| +| "Queue full" errors | Max concurrency too low | Increase `PLUGIN_MAX_CONCURRENCY` | +| OOM in Node.js | Too many threads/tasks | Reduce `PLUGIN_POOL_MAX_THREADS` | +| Slow response times | GC pressure | Check health endpoint, reduce concurrent tasks | +| "Circuit breaker open" | Pool server crashed | Check logs, will auto-recover | +| Connection refused | Pool server not started | Check `ensure_started()` is called | diff --git a/plugins/examples/example-rpc.ts b/plugins/examples/example-rpc.ts index dcc69ae29..79ac49199 100644 --- a/plugins/examples/example-rpc.ts +++ b/plugins/examples/example-rpc.ts @@ -22,7 +22,7 @@ export async function handler(context: PluginContext): Promise { + const content = await fs.promises.readFile(filePath); + return crypto.createHash('sha256').update(content).digest('hex'); +} + +/** + * Safe file deletion - handles EBUSY, ENOENT gracefully + */ +async function safeUnlink(filePath: string): Promise { + try { + await fs.promises.unlink(filePath); + return true; + } catch (err) { + const error = err as NodeJS.ErrnoException; + // Ignore common non-critical errors + if (error.code === 'ENOENT') return false; // File doesn't exist + if (error.code === 'EBUSY') { + console.warn(` ⚠ File busy, skipping cleanup: ${filePath}`); + return false; + } + if (error.code === 'EPERM') { + console.warn(` ⚠ Permission denied, skipping cleanup: ${filePath}`); + return false; + } + // Re-throw unexpected errors + throw err; + } +} + +/** + * Clean up partial output files on failure + */ +async function cleanup(tempPath?: string): Promise { + console.log('Cleaning up...'); + if (tempPath) await safeUnlink(tempPath); + await safeUnlink(OUTPUT_PATH); + await safeUnlink(HASH_PATH); +} + +/** + * Validate input file exists and is readable + */ +async function validateInput(): Promise { + try { + await fs.promises.access(INPUT_PATH, fs.constants.R_OK); + } catch (err) { + const error = err as NodeJS.ErrnoException; + if (error.code === 'ENOENT') { + throw new Error(`Input file not found: ${INPUT_PATH}`); + } + throw new Error(`Input file is not readable: ${INPUT_PATH}`); + } + + const stats = await fs.promises.stat(INPUT_PATH); + if (!stats.isFile()) { + throw new Error(`Input path is not a file: ${INPUT_PATH}`); + } + + if (stats.size === 0) { + throw new Error(`Input file is empty: ${INPUT_PATH}`); + } +} + +/** + * Check if build can be skipped (input unchanged) + */ +async function checkBuildCache(inputHash: string, force: boolean): Promise { + if (force) { + console.log(' → Force flag set, skipping cache check'); + return false; + } + + // Check if output exists + try { + await fs.promises.access(OUTPUT_PATH); + } catch { + console.log(' → Output file missing, rebuild required'); + return false; + } + + // Check if hash file exists and compare + try { + const cachedHash = (await fs.promises.readFile(CACHE_HASH_PATH, 'utf-8')).trim(); + if (cachedHash === inputHash) { + return true; // Cache hit - no rebuild needed + } + console.log(' → Input changed, rebuild required'); + } catch { + console.log(' → Cache hash missing or read failed, rebuild required'); + } + + return false; +} + +/** + * Verify output file has valid JavaScript syntax (without executing) + */ +async function verifySyntax(filePath: string): Promise { + const content = await fs.promises.readFile(filePath, 'utf-8'); + + try { + // Use vm.Script to parse without executing + // This catches syntax errors without side effects + new vm.Script(content, { filename: filePath }); + } catch (err) { + const error = err as Error; + throw new Error(`Output has invalid JavaScript syntax: ${error.message}`); + } +} + +/** + * Verify output file is valid + */ +async function verifyOutput(filePath: string): Promise { + try { + await fs.promises.access(filePath); + } catch { + throw new Error(`Output file was not created: ${filePath}`); + } + + const stats = await fs.promises.stat(filePath); + if (stats.size === 0) { + throw new Error(`Output file is empty: ${filePath}`); + } + + // Syntax check without execution (no side effects) + await verifySyntax(filePath); +} + +/** + * Atomic file write: write to temp, then rename + */ +async function atomicWriteFile(targetPath: string, content: string): Promise { + const tempPath = path.join( + os.tmpdir(), + `build-executor-${crypto.randomUUID()}.tmp` + ); + + await fs.promises.writeFile(tempPath, content, 'utf-8'); + await fs.promises.rename(tempPath, targetPath); + + return tempPath; +} + +/** + * Write hash to .sha256 file for runtime verification + */ +async function writeHashFile(hash: string): Promise { + const content = `${hash} pool-executor.js\n`; + await atomicWriteFile(HASH_PATH, content); +} + +/** + * Save input hash for build caching + */ +async function saveBuildCache(inputHash: string): Promise { + await atomicWriteFile(CACHE_HASH_PATH, inputHash); +} + +/** + * Generate build metadata banner + */ +function generateBanner(inputHash: string): string { + const now = new Date().toISOString(); + const nodeVersion = process.version; + return `/** + * Auto-generated by build-executor.ts + * Build time: ${now} + * Node version: ${nodeVersion} + * Source: pool-executor.ts + * Source hash: ${inputHash.substring(0, 16)} + * DO NOT EDIT - Regenerate with: npx ts-node build-executor.ts + */`; +} + +async function build(): Promise { + const forceRebuild = process.argv.includes('--force'); + let tempOutputPath: string | undefined; + + console.log('=== Building pool-executor ==='); + console.log(`Input: ${INPUT_PATH}`); + console.log(`Output: ${OUTPUT_PATH}`); + + // Step 1: Validate dependencies + console.log('\n[1/7] Checking dependencies...'); + console.log(' ✓ esbuild available'); + + // Step 2: Validate input + console.log('\n[2/7] Validating input file...'); + await validateInput(); + const inputHash = await calculateHash(INPUT_PATH); + console.log(` ✓ Input valid (SHA256: ${inputHash.substring(0, 16)}...)`); + + // Step 3: Check build cache + console.log('\n[3/7] Checking build cache...'); + if (await checkBuildCache(inputHash, forceRebuild)) { + console.log(' ✓ Build cache valid - skipping rebuild'); + console.log('\n=== Build skipped (up to date) ==='); + return; + } + + // Step 4: Build to temp file (atomic write preparation) + console.log('\n[4/7] Compiling TypeScript...'); + tempOutputPath = path.join(os.tmpdir(), `pool-executor-${crypto.randomUUID()}.js`); + + const result = await esbuild.build({ + entryPoints: [INPUT_PATH], + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + sourcemap: false, + write: true, + outfile: tempOutputPath, + loader: { '.ts': 'ts' }, + external: ['node:*'], + banner: { + js: generateBanner(inputHash), + }, + }); + + // Handle errors + if (result.errors.length > 0) { + console.error('\n❌ Build errors:'); + for (const error of result.errors) { + console.error(` - ${error.text}`); + if (error.location) { + console.error(` at ${error.location.file}:${error.location.line}:${error.location.column}`); + } + } + await cleanup(tempOutputPath); + process.exit(1); + } + + // Handle warnings + if (result.warnings.length > 0) { + console.log('\n⚠️ Build warnings:'); + for (const warning of result.warnings) { + console.log(` - ${warning.text}`); + if (warning.location) { + console.log(` at ${warning.location.file}:${warning.location.line}:${warning.location.column}`); + } + } + } + + console.log(' ✓ Compilation successful'); + + // Step 5: Verify output (syntax check, no execution) + console.log('\n[5/7] Verifying output syntax...'); + await verifyOutput(tempOutputPath); + console.log(' ✓ Output has valid JavaScript syntax'); + + // Step 6: Atomic move to final location + console.log('\n[6/7] Finalizing output...'); + await fs.promises.rename(tempOutputPath, OUTPUT_PATH); + tempOutputPath = undefined; // Clear so cleanup doesn't try to delete + console.log(' ✓ Output written atomically'); + + // Step 7: Write hash files and cache + console.log('\n[7/7] Writing integrity hash...'); + const outputHash = await calculateHash(OUTPUT_PATH); + await writeHashFile(outputHash); + await saveBuildCache(inputHash); + console.log(` ✓ SHA256: ${outputHash}`); + console.log(` ✓ Hash saved to: ${HASH_PATH}`); + + // Print summary + const stats = await fs.promises.stat(OUTPUT_PATH); + console.log('\n=== Build complete! ==='); + console.log('─'.repeat(50)); + console.log(` Output: ${OUTPUT_PATH}`); + console.log(` Hash file: ${HASH_PATH}`); + console.log(` Size: ${(stats.size / 1024).toFixed(1)} KB`); + console.log(` Input hash: ${inputHash.substring(0, 16)}...`); + console.log(` Output hash: ${outputHash.substring(0, 16)}...`); + console.log('─'.repeat(50)); +} + +// Run build with proper error handling +build().catch(async (err) => { + const error = err as Error; + console.error(`\n❌ Build failed: ${error.message}`); + if (error.stack) { + console.error('\nStack trace:'); + console.error(error.stack.split('\n').slice(1, 5).join('\n')); + } + await cleanup(); + process.exit(1); +}); diff --git a/plugins/lib/compiler.ts b/plugins/lib/compiler.ts new file mode 100644 index 000000000..570cadab8 --- /dev/null +++ b/plugins/lib/compiler.ts @@ -0,0 +1,572 @@ +/** + * Plugin Compiler Module + * + * Uses esbuild for fast TypeScript → JavaScript compilation with bundling. + * Dependencies are bundled into the output for self-contained plugins. + * + * Security: + * - Input size limits to prevent memory exhaustion + * - Path traversal protection + * - Compilation timeout + * - Sanitized error messages + */ + +import * as esbuild from 'esbuild'; +import type { Message } from 'esbuild'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +/** Maximum source file size (5MB) */ +const MAX_SOURCE_SIZE = 5 * 1024 * 1024; + +/** Maximum compiled code size (10MB) */ +const MAX_COMPILED_SIZE = 10 * 1024 * 1024; + +/** Default compilation timeout (30 seconds) */ +const DEFAULT_COMPILE_TIMEOUT_MS = 30000; + +/** Maximum concurrent compilations in batch mode */ +const MAX_CONCURRENT_COMPILATIONS = 10; + +/** Default base directory for plugins */ +const DEFAULT_PLUGIN_BASE_DIR = path.resolve(process.cwd(), 'plugins'); + +/** + * Result of compiling a plugin + */ +export interface CompilationResult { + /** The compiled JavaScript code */ + code: string; + /** Source map (optional, for debugging) */ + sourceMap?: string; + /** Compilation warnings */ + warnings: string[]; +} + +/** + * Options for plugin compilation + */ +export interface CompilerOptions { + /** Target ECMAScript version */ + target?: 'es2020' | 'es2021' | 'es2022' | 'esnext'; + /** Whether to generate source maps ('inline' or 'external') */ + sourcemap?: boolean | 'inline' | 'external'; + /** Whether to minify the output */ + minify?: boolean; + /** Compilation timeout in milliseconds (default: 30000) */ + timeout?: number; + /** Base directory for security checks (plugins must be within this directory) */ + baseDir?: string; + /** Directory to resolve node_modules from (default: process.cwd()) */ + resolveDir?: string; +} + +/** + * Error codes for compilation failures + */ +export type CompilationErrorCode = + | 'FILE_NOT_FOUND' + | 'FILE_NOT_READABLE' + | 'SIZE_LIMIT' + | 'COMPILE_ERROR' + | 'TIMEOUT' + | 'PATH_TRAVERSAL' + | 'UNKNOWN'; + +/** + * Error information from batch compilation + */ +export interface CompilationError { + /** Path to the plugin that failed */ + pluginPath: string; + /** Error message (sanitized) */ + message: string; + /** Error code for categorization */ + code: CompilationErrorCode; +} + +/** + * Result of batch compilation + */ +export interface BatchCompilationResult { + /** Successfully compiled plugins */ + results: Map; + /** Compilation errors */ + errors: CompilationError[]; +} + +const DEFAULT_OPTIONS: Required> & { + timeout: number; +} = { + target: 'es2022', + sourcemap: false, + minify: false, + timeout: DEFAULT_COMPILE_TIMEOUT_MS, +}; + +/** + * Valid error codes for type checking + */ +const VALID_ERROR_CODES: readonly CompilationErrorCode[] = [ + 'FILE_NOT_FOUND', + 'FILE_NOT_READABLE', + 'SIZE_LIMIT', + 'COMPILE_ERROR', + 'TIMEOUT', + 'PATH_TRAVERSAL', + 'UNKNOWN', +]; + +/** + * Normalize an error code to a valid CompilationErrorCode + */ +function normalizeErrorCode(code: unknown): CompilationErrorCode { + if (typeof code === 'string' && VALID_ERROR_CODES.includes(code as CompilationErrorCode)) { + return code as CompilationErrorCode; + } + return 'UNKNOWN'; +} + +/** + * Sanitize file path for safe error messages. + * Replaces absolute paths with relative alternatives for cleaner, portable error messages. + * + * @param filePath - The file path to sanitize + * @returns Sanitized path safe for error messages + */ +function sanitizePath(filePath: string): string { + // Replace current working directory with '.' for relative paths + const cwd = process.cwd(); + if (filePath.startsWith(cwd)) { + return '.' + filePath.slice(cwd.length); + } + + // If path is outside CWD, return as-is (already absolute or relative) + return filePath; +} + +/** + * Validate that a path doesn't contain traversal attacks. + * + * @param pluginPath - Path to validate + * @param basePath - Optional base path that the plugin must be within + * @throws Error if path contains traversal sequences + */ +function validatePathSecurity(pluginPath: string, basePath?: string): void { + // Normalize the path first + const normalized = path.normalize(pluginPath); + + // Check if the NORMALIZED path still contains '..' (actual traversal) + const parts = normalized.split(path.sep); + if (parts.includes('..')) { + throw Object.assign(new Error('Path traversal not allowed'), { + code: 'PATH_TRAVERSAL', + }); + } + + // If base path specified, ensure plugin is within it + if (basePath) { + const absolutePlugin = path.isAbsolute(normalized) + ? normalized + : path.resolve(basePath, normalized); + const absoluteBase = path.resolve(basePath); + + // Ensure plugin path starts with base path (with separator to avoid partial matches) + if (!absolutePlugin.startsWith(absoluteBase + path.sep) && absolutePlugin !== absoluteBase) { + throw Object.assign(new Error('Plugin path must be within allowed directory'), { + code: 'PATH_TRAVERSAL', + }); + } + } +} + +/** + * Validate source code input. + * + * @param sourceCode - Source code to validate + * @param sourcePath - Path for error messages + * @throws Error if validation fails + */ +function validateSourceCode(sourceCode: string, sourcePath: string): void { + if (typeof sourceCode !== 'string') { + throw Object.assign(new Error(`Invalid source code type for ${sanitizePath(sourcePath)}`), { + code: 'COMPILE_ERROR', + }); + } + + if (sourceCode.length === 0) { + throw Object.assign(new Error(`Source code is empty for ${sanitizePath(sourcePath)}`), { + code: 'COMPILE_ERROR', + }); + } + + if (sourceCode.length > MAX_SOURCE_SIZE) { + throw Object.assign( + new Error( + `Source code exceeds size limit (${(sourceCode.length / 1024 / 1024).toFixed(1)}MB > ${MAX_SOURCE_SIZE / 1024 / 1024}MB)` + ), + { code: 'SIZE_LIMIT' } + ); + } +} + +/** + * Validate compiled code output. + * + * @param code - Compiled code to validate + * @throws Error if validation fails + */ +function validateCompiledCode(code: string): void { + if (code.length > MAX_COMPILED_SIZE) { + throw Object.assign( + new Error( + `Compiled code exceeds size limit (${(code.length / 1024 / 1024).toFixed(1)}MB > ${MAX_COMPILED_SIZE / 1024 / 1024}MB)` + ), + { code: 'SIZE_LIMIT' } + ); + } +} + +/** + * Check if a file exists and is readable. + * + * @param filePath - Path to check + * @throws Error with specific code if file is not accessible + */ +async function validateFileAccess(filePath: string): Promise { + try { + await fs.promises.access(filePath, fs.constants.R_OK); + } catch (err) { + const error = err as NodeJS.ErrnoException; + if (error.code === 'ENOENT') { + throw Object.assign(new Error(`File not found: ${sanitizePath(filePath)}`), { + code: 'FILE_NOT_FOUND', + }); + } + throw Object.assign(new Error(`File not readable: ${sanitizePath(filePath)}`), { + code: 'FILE_NOT_READABLE', + }); + } +} + +/** + * Run a promise with a timeout. + * + * @param promise - Promise to run + * @param timeoutMs - Timeout in milliseconds + * @param timeoutMessage - Error message on timeout + * @returns Promise result + */ +async function withTimeout( + promise: Promise, + timeoutMs: number, + timeoutMessage: string +): Promise { + let timeoutId: NodeJS.Timeout | undefined; + + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(Object.assign(new Error(timeoutMessage), { code: 'TIMEOUT' })); + }, timeoutMs); + }); + + try { + const result = await Promise.race([promise, timeoutPromise]); + clearTimeout(timeoutId!); + return result; + } catch (err) { + clearTimeout(timeoutId!); + throw err; + } +} + +/** + * Format an esbuild warning/error message + */ +function formatMessage(msg: Message, defaultPath: string): string { + const file = msg.location?.file + ? sanitizePath(msg.location.file) + : sanitizePath(defaultPath); + const line = msg.location?.line ?? 0; + const column = msg.location?.column ?? 0; + return `${file}:${line}:${column}: ${msg.text}`; +} + +/** + * Compiles a TypeScript plugin file to JavaScript using esbuild with bundling. + * All imports are resolved and bundled into the output for self-contained execution. + * + * @param pluginPath - Path to the TypeScript plugin file + * @param options - Compilation options + * @returns Compiled JavaScript code and metadata + * @throws Error if compilation fails + * + * @example + * ```typescript + * const result = await compilePlugin('plugins/my-plugin.ts'); + * console.log(result.code); // Compiled and bundled JavaScript + * ``` + */ +export async function compilePlugin( + pluginPath: string, + options: CompilerOptions = {} +): Promise { + const opts = { ...DEFAULT_OPTIONS, ...options }; + + // Security: validate path with base directory + const baseDir = opts.baseDir ?? DEFAULT_PLUGIN_BASE_DIR; + validatePathSecurity(pluginPath, baseDir); + + // Resolve to absolute path + const absolutePath = path.isAbsolute(pluginPath) + ? pluginPath + : path.resolve(process.cwd(), pluginPath); + + // Validate file exists and is readable + await validateFileAccess(absolutePath); + + // Read source with size check + const stats = await fs.promises.stat(absolutePath); + if (stats.size > MAX_SOURCE_SIZE) { + throw Object.assign( + new Error( + `Source file exceeds size limit (${(stats.size / 1024 / 1024).toFixed(1)}MB > ${MAX_SOURCE_SIZE / 1024 / 1024}MB)` + ), + { code: 'SIZE_LIMIT' } + ); + } + + const sourceCode = await fs.promises.readFile(absolutePath, 'utf-8'); + + // Use the file's directory as resolveDir for proper import resolution + const resolveDir = opts.resolveDir ?? path.dirname(absolutePath); + + return compilePluginSource(sourceCode, pluginPath, { ...opts, resolveDir }); +} + +/** + * Compiles TypeScript source code to JavaScript with bundling. + * This variant accepts source code directly (useful when code is already in memory). + * All imports are resolved and bundled into the output. + * + * @param sourceCode - TypeScript source code + * @param sourcePath - Original path (for error messages and source maps) + * @param options - Compilation options + * @returns Compiled JavaScript code and metadata + * @throws Error if compilation fails + * + * @example + * ```typescript + * const source = ` + * import { ethers } from 'ethers'; + * export async function handler({ api }) { + * return ethers.utils.formatEther('1000000000000000000'); + * } + * `; + * const result = await compilePluginSource(source, 'inline-plugin.ts'); + * // result.code contains bundled code with ethers included + * ``` + */ +export async function compilePluginSource( + sourceCode: string, + sourcePath: string, + options: CompilerOptions = {} +): Promise { + const opts = { ...DEFAULT_OPTIONS, ...options }; + + // Validate input + validateSourceCode(sourceCode, sourcePath); + + const doCompile = async (): Promise => { + try { + // Use esbuild.build() with stdin to bundle imports + const result = await esbuild.build({ + stdin: { + contents: sourceCode, + loader: 'ts', + sourcefile: sourcePath, + resolveDir: opts.resolveDir ?? process.cwd(), + }, + bundle: true, // CRITICAL: Bundle to resolve and inline all imports + platform: 'node', + target: opts.target, + format: 'cjs', // CommonJS for Function constructor compatibility (executed via new Function()) + sourcemap: opts.sourcemap === 'external' ? true : opts.sourcemap === 'inline' ? 'inline' : false, + minify: opts.minify, + write: false, // Keep in memory, don't write to disk + external: [ + // Don't bundle Node.js built-ins - they're available in the sandbox + 'node:*', + // The SDK is already available in the sandbox + '@openzeppelin/relayer-sdk', + ], + logLevel: 'silent', // We handle errors ourselves + }); + + // Validate we got output + if (!result.outputFiles || result.outputFiles.length === 0) { + throw Object.assign(new Error('esbuild produced no output'), { + code: 'COMPILE_ERROR', + }); + } + + const code = result.outputFiles[0].text; + + // Validate output size + validateCompiledCode(code); + + // Format warnings + const warnings = result.warnings.map((w) => formatMessage(w, sourcePath)); + + // Handle source map based on configuration + let sourceMap: string | undefined; + if (opts.sourcemap === 'external' && result.outputFiles.length > 1) { + sourceMap = result.outputFiles[1].text; + } + // For 'inline', the source map is embedded in the code + + return { + code, + sourceMap, + warnings, + }; + } catch (error) { + if (error instanceof Error) { + // Check if it's already one of our errors + if ((error as any).code && VALID_ERROR_CODES.includes((error as any).code)) { + throw error; + } + + // Sanitize error message - replace absolute paths with relative/safe versions + const sanitizedMessage = sanitizePath(error.message); + + throw Object.assign(new Error(`Compilation failed: ${sanitizedMessage}`), { + code: 'COMPILE_ERROR', + }); + } + throw error; + } + }; + + // Run with timeout + return withTimeout( + doCompile(), + opts.timeout ?? DEFAULT_COMPILE_TIMEOUT_MS, + `Compilation timed out after ${opts.timeout ?? DEFAULT_COMPILE_TIMEOUT_MS}ms` + ); +} + +/** + * Batch compile multiple plugins with concurrency limits. + * Returns both successful results and detailed error information. + * + * @param pluginPaths - Array of plugin file paths + * @param options - Compilation options + * @returns Map of successful results and array of errors + * + * @example + * ```typescript + * const { results, errors } = await compilePlugins(['a.ts', 'b.ts', 'c.ts']); + * + * // Handle successes + * for (const [path, result] of results) { + * console.log(`Compiled ${path}: ${result.code.length} bytes`); + * } + * + * // Handle errors + * for (const err of errors) { + * console.error(`${err.pluginPath}: [${err.code}] ${err.message}`); + * } + * ``` + */ +export async function compilePlugins( + pluginPaths: string[], + options: CompilerOptions = {} +): Promise { + const results = new Map(); + const errors: CompilationError[] = []; + + // Process in batches to limit concurrency + for (let i = 0; i < pluginPaths.length; i += MAX_CONCURRENT_COMPILATIONS) { + const batch = pluginPaths.slice(i, i + MAX_CONCURRENT_COMPILATIONS); + + const compilations = await Promise.allSettled( + batch.map(async (pluginPath) => { + const result = await compilePlugin(pluginPath, options); + return { pluginPath, result }; + }) + ); + + // Process results with defensive checks + for (let j = 0; j < batch.length; j++) { + const pluginPath = batch[j]; + const compilation = compilations[j]; + + if (!compilation) { + // Should never happen, but defensive programming + errors.push({ + pluginPath, + message: 'Compilation result missing', + code: 'UNKNOWN', + }); + continue; + } + + if (compilation.status === 'fulfilled') { + results.set(compilation.value.pluginPath, compilation.value.result); + } else { + const error = compilation.reason as Error & { code?: string }; + errors.push({ + pluginPath, + message: error.message || 'Unknown error', + code: normalizeErrorCode(error.code), + }); + } + } + } + + return { results, errors }; +} + +/** + * Creates a wrapped version of compiled code that exports the handler. + * + * @deprecated This function is no longer used internally. The compiled code is executed + * directly via `new Function()` constructor in pool-executor.ts. This function is kept + * for backward compatibility but may be removed in a future version. + * + * @param compiledCode - The compiled JavaScript code + * @returns Wrapped code as an IIFE + * @throws Error if input is invalid + * + * @example + * ```typescript + * const compiled = await compilePluginSource(source, 'plugin.ts'); + * const wrapped = wrapForVm(compiled.code); + * // Note: This is legacy code. Current execution uses new Function() directly. + * ``` + */ +export function wrapForVm(compiledCode: string): string { + // Validate input + if (typeof compiledCode !== 'string') { + throw new Error('wrapForVm: compiledCode must be a string'); + } + + if (compiledCode.length === 0) { + throw new Error('wrapForVm: compiledCode cannot be empty'); + } + + if (compiledCode.length > MAX_COMPILED_SIZE) { + throw new Error( + `wrapForVm: code exceeds size limit (${(compiledCode.length / 1024 / 1024).toFixed(1)}MB)` + ); + } + + // The compiled code uses CommonJS (module.exports / exports.handler) + // We wrap it in an IIFE for legacy vm.Script compatibility + return ` +(function(exports, require, module, __filename, __dirname) { +${compiledCode} +}); +`; +} diff --git a/plugins/lib/constants.ts b/plugins/lib/constants.ts new file mode 100644 index 000000000..4bb496a46 --- /dev/null +++ b/plugins/lib/constants.ts @@ -0,0 +1,35 @@ +/** + * Plugin Pool Constants + * + * IMPORTANT: These are fallback values only for standalone testing. + * In production, ALL configuration comes from Rust (src/services/plugins/config.rs) + * and is passed via environment variables when spawning the pool server. + * + * The single source of truth is: PLUGIN_MAX_CONCURRENCY + * Set it in .env and Rust derives everything else. + */ + +// ============================================================================= +// Fallbacks for standalone testing (when not spawned by Rust) +// ============================================================================= + +/** Fallback minimum worker threads if not passed from Rust */ +export const DEFAULT_POOL_MIN_THREADS = 2; + +/** Fallback max threads if not passed from Rust */ +export const DEFAULT_POOL_MAX_THREADS_FLOOR = 8; + +/** Fallback concurrent tasks if not passed from Rust */ +export const DEFAULT_POOL_CONCURRENT_TASKS_PER_WORKER = 20; + +/** Fallback idle timeout if not passed from Rust */ +export const DEFAULT_POOL_IDLE_TIMEOUT_MS = 60000; + +/** Socket backlog for high concurrency */ +export const DEFAULT_POOL_SOCKET_BACKLOG = 2048; + +/** Default execution timeout (ms) */ +export const DEFAULT_POOL_EXECUTION_TIMEOUT_MS = 30000; + +/** Default per-request timeout for socket communication (ms) */ +export const DEFAULT_SOCKET_REQUEST_TIMEOUT_MS = 30000; diff --git a/plugins/lib/executor.ts b/plugins/lib/executor.ts index 015c0c545..b3c1334eb 100644 --- a/plugins/lib/executor.ts +++ b/plugins/lib/executor.ts @@ -1,12 +1,21 @@ #!/usr/bin/env node /** - * Plugin executor script for executing user plugins + * Legacy Plugin Executor (ts-node mode) * - * This is the main entry point for executing specific plugins from the Rust environment. - * It serves as a bridge between the Rust relayer and TypeScript plugin ecosystem. + * **⚠️ Legacy/Fallback Implementation** + * This executor is used only when pool-based execution is disabled (`PLUGIN_USE_POOL=false`). + * The default execution mode uses the pool executor (`pool-executor.ts`), which is faster + * and more efficient. This ts-node executor spawns a new process per request, which is + * slower but simpler. * - * Called from: src/services/plugins/script_executor.rs + * **Default Execution Path**: Pool executor (`pool-executor.ts`) via `pool-server.ts` → + * `worker-pool.ts` → `pool-executor.ts` (Piscina workers) + * + * **Legacy Execution Path**: This executor (`executor.ts`) via `script_executor.rs` → + * ts-node → `executor.ts` (one-shot process per request) + * + * Called from: `src/services/plugins/script_executor.rs` (only when `PLUGIN_USE_POOL=false`) * The Rust code invokes this script via ts-node and passes parameters as command line arguments. * * This script: diff --git a/plugins/lib/kv.ts b/plugins/lib/kv.ts index 89dbb8059..6716bc71f 100644 --- a/plugins/lib/kv.ts +++ b/plugins/lib/kv.ts @@ -85,6 +85,12 @@ export interface PluginKVStore { fn: () => Promise, opts?: { ttlSec?: number; onBusy?: 'throw' | 'skip' } ): Promise; + + /** + * Disconnect from the backing store. + * Call this when the store is no longer needed to prevent connection leaks. + */ + disconnect(): Promise; } /** @@ -274,4 +280,20 @@ export class DefaultPluginKVStore implements PluginKVStore { await this.client.eval(this.UNLOCK_SCRIPT, 1, lockKey, token); } } + + /** + * Explicitly connect to Redis. + * Normally not needed since lazyConnect will connect on first command. + */ + async connect(): Promise { + await this.client.connect(); + } + + /** + * Disconnect from Redis. + * Call this when the store is no longer needed. + */ + async disconnect(): Promise { + await this.client.disconnect(); + } } diff --git a/plugins/lib/logger.ts b/plugins/lib/logger.ts index be1d7e20a..5880fe1c3 100644 --- a/plugins/lib/logger.ts +++ b/plugins/lib/logger.ts @@ -4,6 +4,29 @@ export interface LogEntry { message: string; } +/** + * Safely stringify a value, handling circular references and BigInt. + * Falls back to String() if JSON.stringify fails. + */ +function safeStringify(value: unknown): string { + try { + return JSON.stringify(value, (_, v) => { + // Handle BigInt which JSON.stringify can't serialize + if (typeof v === 'bigint') { + return v.toString() + 'n'; + } + return v; + }); + } catch { + // Handle circular references or other stringify failures + try { + return String(value); + } catch { + return '[Unstringifiable value]'; + } + } +} + export class LogInterceptor { private logs: LogEntry[] = []; private originalConsole: { @@ -34,13 +57,13 @@ export class LogInterceptor { const message = args.map(arg => typeof arg === 'string' ? arg : arg instanceof Error ? arg.message : - JSON.stringify(arg) + safeStringify(arg) ).join(' '); const logEntry: LogEntry = { level, message }; this.logs.push(logEntry); - this.originalConsole.log(JSON.stringify(logEntry)); + this.originalConsole.log(safeStringify(logEntry)); }; console.log = createLogger("log"); @@ -59,7 +82,7 @@ export class LogInterceptor { message: message, }; this.logs.push(logEntry); - this.originalConsole.log(JSON.stringify(logEntry)); + this.originalConsole.log(safeStringify(logEntry)); } /** diff --git a/plugins/lib/plugin.ts b/plugins/lib/plugin.ts index 88f6b4db5..a41ed928c 100644 --- a/plugins/lib/plugin.ts +++ b/plugins/lib/plugin.ts @@ -43,9 +43,24 @@ import { import { DefaultPluginKVStore } from './kv'; import { LogInterceptor } from './logger'; import type { PluginKVStore } from './kv'; +import { DEFAULT_SOCKET_REQUEST_TIMEOUT_MS } from './constants'; import net from 'node:net'; import { v4 as uuidv4 } from 'uuid'; +/** + * Custom error for socket closure - provides a recognizable error code + * for server-side connection termination (e.g., Rust's 60-second connection lifetime). + */ +class SocketClosedError extends Error { + code: string; + + constructor(message: string) { + super(message); + this.name = 'SocketClosedError'; + this.code = 'ESOCKETCLOSED'; + } +} + /** * Smart serialization for plugin return values * - Objects/Arrays: JSON.stringify (need serialization) @@ -386,7 +401,27 @@ export async function loadAndExecutePlugin( } /** - * The plugin API. + * Plugin API implementation for direct execution mode (ts-node). + * + * **⚠️ Legacy/Fallback Implementation** + * This implementation is used by `executor.ts` when Rust calls plugins via `ts-node` directly + * (see `src/services/plugins/script_executor.rs`). This is the legacy execution path, enabled + * only when `PLUGIN_USE_POOL=false`. The default execution mode uses the pool executor + * (`PluginAPIImpl` in `pool-executor.ts`), which is faster and more efficient. + * + * **Note**: New features should be added to the pool executor (`PluginAPIImpl`), not this + * implementation, as pool executor is the default and preferred path. + * + * **Why a separate implementation?** + * This implementation has different requirements than the worker pool implementation: + * + * - **Registration protocol**: Sends a `register` message with `execution_id` after connection + * (required by the direct execution protocol) + * - **One-shot lifecycle**: Process exits after plugin completes, so no socket pooling needed + * - **Immediate connection**: Connects in constructor since it's a single-use process + * - **Protocol support**: Handles both new protocol (`api_request`/`api_response`) and legacy format + * + * **See also**: `PluginAPIImpl` in `pool-executor.ts` for the worker pool implementation (default). * * @property useRelayer - Creates a relayer API for the given relayer ID. * @property sendTransaction - Sends a transaction to the relayer. @@ -398,6 +433,8 @@ export class DefaultPluginAPI implements PluginAPI { private _connectionPromise: Promise | null = null; private _connected: boolean = false; private _httpRequestId?: string; + private _registered: boolean = false; + private _registrationPromise: Promise | null = null; constructor(socketPath: string, httpRequestId?: string) { this.socket = net.createConnection(socketPath); @@ -405,15 +442,24 @@ export class DefaultPluginAPI implements PluginAPI { this._httpRequestId = httpRequestId; this._connectionPromise = new Promise((resolve, reject) => { - this.socket.on('connect', () => { + this.socket.on('connect', async () => { this._connected = true; + // Register with execution_id immediately after connection (new protocol) + await this._register(); resolve(); }); this.socket.on('error', (error) => { console.error('Socket ERROR:', error); + this._rejectAllPending(error); reject(error); }); + + this.socket.on('close', () => { + this._connected = false; + // Use SocketClosedError so callers can identify server-side closure + this._rejectAllPending(new SocketClosedError('Socket closed by server (connection lifetime exceeded)')); + }); }); this.socket.on('data', (data) => { @@ -422,15 +468,80 @@ export class DefaultPluginAPI implements PluginAPI { .split('\n') .filter(Boolean) .forEach((msg: string) => { - const parsed = JSON.parse(msg); - const { requestId, result, error } = parsed; - const resolver = this.pending.get(requestId); - if (resolver) { - error ? resolver.reject(error) : resolver.resolve(result); - this.pending.delete(requestId); + try { + const parsed = JSON.parse(msg); + + // Handle new protocol ApiResponse format + if (parsed.type === 'api_response') { + const { request_id, result, error } = parsed; + const resolver = this.pending.get(request_id); + if (resolver) { + error ? resolver.reject(new Error(error)) : resolver.resolve(result); + this.pending.delete(request_id); + } + } + // Fallback to legacy format for backward compatibility + else if (parsed.requestId) { + const { requestId, result, error } = parsed; + const resolver = this.pending.get(requestId); + if (resolver) { + error ? resolver.reject(error) : resolver.resolve(result); + this.pending.delete(requestId); + } + } + } catch { + // Ignore malformed messages + } + }); + }); + } + + /** + * Register execution_id with the server (new protocol requirement) + * This must be the first message sent after connection + */ + private async _register(): Promise { + if (this._registered || !this._httpRequestId) { + return; + } + + if (this._registrationPromise) { + return this._registrationPromise; + } + + this._registrationPromise = new Promise((resolve, reject) => { + const registerMsg = { + type: 'register', + execution_id: this._httpRequestId, + }; + const message = JSON.stringify(registerMsg) + '\n'; + + try { + this.socket.write(message, (err) => { + if (err) { + reject(err); + return; } + this._registered = true; + resolve(); }); + } catch (err) { + reject(err); + } }); + + return this._registrationPromise; + } + + /** + * Reject all pending requests with the given error. + * Called on socket error or close to prevent hanging promises. + */ + private _rejectAllPending(error: Error): void { + for (const [requestId, resolver] of this.pending.entries()) { + resolver.reject(error); + this.pending.delete(requestId); + } } /** @@ -506,32 +617,80 @@ export class DefaultPluginAPI implements PluginAPI { async _send(relayerId: string, method: string, payload: any): Promise { const requestId = uuidv4(); - const msg: any = { requestId, relayerId, method, payload }; - if (this._httpRequestId) { - msg.httpRequestId = this._httpRequestId; - } - const message = JSON.stringify(msg) + '\n'; + // Ensure connection and registration are complete if (!this._connected) { await this._connectionPromise; } - const result = this.socket.write(message, (error) => { - if (error) { - console.error('Error sending message:', error); - } - }); + // Ensure registration is sent (for new protocol) + // If httpRequestId is available, use new protocol; otherwise fall back to legacy + if (this._httpRequestId && !this._registered) { + await this._register(); + } - if (!result) { - throw new Error(`Failed to send message to relayer: ${message}`); + // Use new protocol format if registered, otherwise fall back to legacy format + let msg: any; + if (this._registered && this._httpRequestId) { + // New protocol: ApiRequest message + msg = { + type: 'api_request', + request_id: requestId, + relayer_id: relayerId, + method: method, + payload: payload, + }; + } else { + // Legacy protocol format (for backward compatibility when httpRequestId is missing) + msg = { requestId, relayerId, method, payload }; + if (this._httpRequestId) { + msg.httpRequestId = this._httpRequestId; + } } + const message = JSON.stringify(msg) + '\n'; return new Promise((resolve, reject) => { - this.pending.set(requestId, { resolve, reject }); + let timeoutId: NodeJS.Timeout | undefined; + + // Set up timeout to prevent hanging forever + timeoutId = setTimeout(() => { + this.pending.delete(requestId); + reject(new Error(`Socket request '${method}' timed out after ${DEFAULT_SOCKET_REQUEST_TIMEOUT_MS}ms`)); + }, DEFAULT_SOCKET_REQUEST_TIMEOUT_MS); + + // Wrap resolvers to clear timeout on completion + this.pending.set(requestId, { + resolve: (value) => { + if (timeoutId) clearTimeout(timeoutId); + resolve(value); + }, + reject: (reason) => { + if (timeoutId) clearTimeout(timeoutId); + reject(reason); + }, + }); + + this.socket.write(message, (error) => { + if (error) { + if (timeoutId) clearTimeout(timeoutId); + this.pending.delete(requestId); + reject(error); + } + }); }); } close() { + // Send shutdown message before closing (new protocol) + if (this._connected && this._registered) { + try { + const shutdownMsg = { type: 'shutdown' }; + const message = JSON.stringify(shutdownMsg) + '\n'; + this.socket.write(message); + } catch { + // Ignore errors when sending shutdown (socket might already be closed) + } + } this.socket.end(); } diff --git a/plugins/lib/pool-executor.ts b/plugins/lib/pool-executor.ts new file mode 100644 index 000000000..5a78d6efc --- /dev/null +++ b/plugins/lib/pool-executor.ts @@ -0,0 +1,914 @@ +/** + * Plugin Executor Module + * + * Piscina worker that executes compiled JavaScript plugins. + * Plugins run in worker threads for parallelism. + */ + +import * as v8 from 'node:v8'; +import * as net from 'node:net'; +import { v4 as uuidv4 } from 'uuid'; +import { DefaultPluginKVStore } from './kv'; +import type { PluginAPI, PluginContext, PluginHeaders, Relayer } from './plugin'; +import { + ApiResponseRelayerResponseData, + ApiResponseRelayerStatusData, + JsonRpcRequestNetworkRpcRequest, + NetworkTransactionRequest, + SignTransactionResponse, + SignTransactionRequest, + TransactionResponse, + TransactionStatus, + pluginError, +} from '@openzeppelin/relayer-sdk'; +import { DEFAULT_SOCKET_REQUEST_TIMEOUT_MS } from './constants'; + +/** + * Function Cache - Caches compiled plugin factory functions. + * Compiling with Function constructor takes 1-5ms. Caching eliminates this for repeated code. + * Memory-aware: monitors heap usage and proactively evicts under pressure. + */ +class FunctionCache { + private cache = new Map(); + private readonly maxSize = 100; // Max functions to cache per worker + private lastMemoryCheck = 0; + private readonly memoryCheckInterval = 5000; // Check every 5s + + get(code: string): Function | undefined { + // Periodic memory check on get operations + this.maybeCheckMemory(); + + const entry = this.cache.get(code); + if (entry) { + // Update access timestamp for LRU + entry.timestamp = Date.now(); + return entry.factory; + } + return undefined; + } + + set(code: string, factory: Function): void { + // Check memory before adding + this.maybeCheckMemory(); + + // Evict oldest entry if at capacity (FIFO) + if (this.cache.size >= this.maxSize) { + this.evictOldest(1); + } + this.cache.set(code, { factory, timestamp: Date.now() }); + } + + /** + * Periodic memory check - evict cache if heap is under pressure. + */ + private maybeCheckMemory(): void { + const now = Date.now(); + if (now - this.lastMemoryCheck < this.memoryCheckInterval) { + return; + } + this.lastMemoryCheck = now; + + const usage = process.memoryUsage(); + const heapStats = v8.getHeapStatistics(); + // Use heap_size_limit (the actual max heap) instead of heapTotal (current allocated) + // heapTotal grows dynamically and can be much smaller than the configured max, + // causing false positive pressure detection (e.g., 28MB/30MB = 93% when max is 26GB) + const heapUsedRatio = usage.heapUsed / heapStats.heap_size_limit; + + // At 85% heap usage, evict 50% of cache + if (heapUsedRatio >= 0.85 && this.cache.size > 0) { + const evictCount = Math.max(1, Math.ceil(this.cache.size * 0.5)); + console.warn( + `[worker-cache] HIGH memory pressure (${Math.round(heapUsedRatio * 100)}% of heap limit), ` + + `evicting ${evictCount} functions` + ); + this.evictOldest(evictCount); + } else if (heapUsedRatio >= 0.70 && this.cache.size > 0) { + // At 70% heap usage, evict 25% of cache + const evictCount = Math.max(1, Math.ceil(this.cache.size * 0.25)); + console.warn( + `[worker-cache] Memory pressure (${Math.round(heapUsedRatio * 100)}% of heap limit), ` + + `evicting ${evictCount} functions` + ); + this.evictOldest(evictCount); + } + } + + evictOldest(count: number): void { + // Sort by timestamp (oldest first) and evict + const entries = [...this.cache.entries()].sort( + (a, b) => a[1].timestamp - b[1].timestamp + ); + + let evicted = 0; + for (const [key] of entries) { + if (evicted >= count) break; + this.cache.delete(key); + evicted++; + } + if (evicted > 0) { + console.log(`[worker-cache] Evicted ${evicted} oldest functions`); + } + } + + clear(): void { + // Emergency cleanup - drop all cached functions + const size = this.cache.size; + this.cache.clear(); + if (size > 0) { + console.log(`[worker-cache] Emergency eviction: cleared ${size} cached functions`); + } + } + + size(): number { + return this.cache.size; + } +} + +/** + * Custom error for socket closure - enables retry logic to detect and handle + * server-side connection termination (e.g., Rust's 60-second connection lifetime). + */ +class SocketClosedError extends Error { + code: string; + + constructor(message: string) { + super(message); + this.name = 'SocketClosedError'; + this.code = 'ESOCKETCLOSED'; + } +} + +/** + * Pooled socket with creation timestamp for age-based eviction. + */ +interface PooledSocket { + socket: net.Socket; + createdAt: number; +} + +/** + * Result of acquiring a socket from the pool. + */ +interface AcquiredSocket { + socket: net.Socket; + createdAt: number; +} + +/** + * Socket Pool - Reuses socket connections across tasks in the same worker. + * Creating a socket takes 0.1-1ms. Pooling reduces this overhead. + * + * CRITICAL: The Rust server has a 60-second TOTAL CONNECTION LIFETIME (not idle). + * This means sockets created at T=0 will be closed at T=60, regardless of activity. + * We track per-socket creation time and discard sockets older than 50 seconds. + */ +class SocketPool { + private available: PooledSocket[] = []; + private readonly maxSize = 5; // Max sockets to cache per worker + // Rust server connection lifetime is 60s. Discard sockets older than 45s. + private readonly maxSocketAgeMs = 45_000; + + acquire(): AcquiredSocket | null { + const now = Date.now(); + + // Pop sockets until we find a valid one or pool is empty + while (this.available.length > 0) { + const pooled = this.available.pop()!; + const age = now - pooled.createdAt; + + // Discard sockets older than max age (Rust will close them soon anyway) + if (age > this.maxSocketAgeMs) { + pooled.socket.destroy(); + continue; + } + + // Check socket health + if (pooled.socket.writable && !pooled.socket.destroyed) { + return { socket: pooled.socket, createdAt: pooled.createdAt }; + } + + // Socket unhealthy, destroy and try next + pooled.socket.destroy(); + } + + // No valid socket found + return null; + } + + /** + * Release a socket back to the pool. + * @param socket The socket to release + * @param createdAt When the socket was originally created (for age tracking) + */ + release(socket: net.Socket, createdAt: number): void { + const now = Date.now(); + const age = now - createdAt; + + // Don't pool sockets that are already old or unhealthy + if ( + age > this.maxSocketAgeMs || + !socket.writable || + socket.destroyed || + this.available.length >= this.maxSize + ) { + socket.destroy(); + return; + } + + this.available.push({ socket, createdAt }); + } + + clear(): void { + // Clean up all pooled sockets + for (const pooled of this.available) { + pooled.socket.destroy(); + } + this.available = []; + } +} + +// Worker-level caches (thread-safe since Piscina workers are single-threaded) +const functionCache = new FunctionCache(); +const socketPool = new SocketPool(); + +/** + * Task payload received from the worker pool + */ +export interface ExecutorTask { + /** Unique task ID for correlation */ + taskId: string; + /** Plugin ID for KV namespacing */ + pluginId: string; + /** Pre-compiled JavaScript code */ + compiledCode: string; + /** Plugin parameters */ + params: any; + /** HTTP headers from incoming request */ + headers?: PluginHeaders; + /** Unix socket path for relayer communication */ + socketPath: string; + /** HTTP request ID for tracing */ + httpRequestId?: string; + /** Execution timeout in milliseconds */ + timeout: number; + /** Wildcard route path (e.g., "/verify" from "/api/v1/plugins/:id/*") */ + route?: string; + /** Plugin configuration object */ + config?: any; + /** HTTP method (GET, POST, etc.) */ + method?: string; + /** Query parameters */ + query?: any; +} + +/** + * Result returned from plugin execution + */ +export interface ExecutorResult { + /** Task ID for correlation */ + taskId: string; + /** Whether execution succeeded */ + success: boolean; + /** Return value (if success) */ + result?: any; + /** Error information (if failed) */ + error?: { + message: string; + code?: string; + status?: number; + details?: any; + }; + /** Captured console logs */ + logs: LogEntry[]; +} + +export interface LogEntry { + level: 'error' | 'warn' | 'info' | 'log' | 'debug' | 'result'; + message: string; +} + +/** + * Plugin API implementation for worker pool execution mode (Piscina workers). + * + * **✅ Default Implementation (Preferred)** + * This is the default and preferred plugin execution path. It's used when plugins run in + * Piscina worker threads (see `pool-server.ts` → `worker-pool.ts` → `pool-executor.ts`). + * Pool mode is enabled by default for better performance and is the recommended execution + * mode for production deployments. + * + * **Why a separate implementation?** + * This implementation has different requirements than the legacy ts-node execution: + * + * - **Socket pooling**: Reuses sockets across multiple plugin executions in the same worker thread + * (critical for performance in high-concurrency scenarios) + * - **No registration protocol**: Worker pool uses a different communication model that doesn't + * require registration messages (simpler and more efficient) + * - **Lazy connection**: Connects only when first API call is made (better for worker lifecycle) + * - **EPIPE retry logic**: Handles stale pooled sockets that were closed by the server but client + * doesn't know yet (common with 60-second connection lifetime) + * - **Handler cleanup**: Properly removes socket listeners before returning to pool to prevent + * listener accumulation (MaxListenersExceededWarning) + * + * **See also**: `DefaultPluginAPI` in `plugin.ts` for the legacy ts-node execution implementation + * (fallback mode, enabled only when `PLUGIN_USE_POOL=false`). + */ +class PluginAPIImpl implements PluginAPI { + private socket: net.Socket | null = null; + private pending: Map void; reject: (reason: any) => void }>; + private connectionPromise: Promise | null = null; + private connected: boolean = false; + private httpRequestId?: string; + private socketPath: string; + private readonly maxPendingRequests = 100; // Prevent memory leak from unbounded pending + private socketCreatedAt: number = 0; // Track socket creation time for pool age-based eviction + private socketBuffer: string = ''; // Accumulator for TCP stream data (handles partial messages) + + // Store handler references for proper cleanup (prevents listener accumulation) + private boundErrorHandler: ((error: Error) => void) | null = null; + private boundCloseHandler: (() => void) | null = null; + private boundDataHandler: ((data: Buffer) => void) | null = null; + + constructor(socketPath: string, httpRequestId?: string) { + this.socketPath = socketPath; + this.pending = new Map(); + this.httpRequestId = httpRequestId; + } + + /** + * Ensure socket is connected. + * @param forceNewSocket If true, skip the pool and create a fresh socket. + * Used after retryable errors to avoid getting another stale socket. + */ + private async ensureConnected(forceNewSocket: boolean = false): Promise { + if (this.connected) return; + + if (!this.connectionPromise) { + // Try to get socket from pool first (unless forced to create new) + const acquired = forceNewSocket ? null : socketPool.acquire(); + + if (acquired) { + this.socket = acquired.socket; + this.socketCreatedAt = acquired.createdAt; + this.connected = true; + this.connectionPromise = Promise.resolve(); + + // Set up error/close handlers for pooled socket to enable reconnection + this.setupSocketHandlers(this.socket); + } else { + // Create new socket if pool is empty + this.socket = net.createConnection(this.socketPath); + this.socketCreatedAt = Date.now(); + + // Set up tracked handlers (can be removed later) + this.setupSocketHandlers(this.socket); + + this.connectionPromise = new Promise((resolve, reject) => { + // 'connect' is one-time event, use once() so it auto-removes + this.socket!.once('connect', () => { + this.connected = true; + resolve(); + }); + + // Additional one-time error handler for connection phase only + this.socket!.once('error', reject); + }); + } + } + + await this.connectionPromise; + } + + /** + * Set up error/close/data handlers for a socket (pooled or new). + * This ensures sockets can trigger reconnection on failure. + * Stores references for proper cleanup to prevent listener accumulation. + */ + private setupSocketHandlers(socket: net.Socket): void { + // Create bound handlers so they can be removed later + this.boundErrorHandler = (error: Error) => this.handleSocketError(error); + this.boundCloseHandler = () => this.handleSocketClose(); + this.boundDataHandler = (data: Buffer) => this.handleSocketData(data); + + socket.on('error', this.boundErrorHandler); + socket.on('close', this.boundCloseHandler); + socket.on('data', this.boundDataHandler); + } + + /** + * Remove socket handlers before returning to pool. + * Prevents listener accumulation (MaxListenersExceededWarning). + * Also clears the socket buffer to prevent data leakage between executions. + */ + private removeSocketHandlers(socket: net.Socket): void { + if (this.boundErrorHandler) { + socket.removeListener('error', this.boundErrorHandler); + this.boundErrorHandler = null; + } + if (this.boundCloseHandler) { + socket.removeListener('close', this.boundCloseHandler); + this.boundCloseHandler = null; + } + if (this.boundDataHandler) { + socket.removeListener('data', this.boundDataHandler); + this.boundDataHandler = null; + } + // Clear buffer to prevent data leakage between pool reuses + this.socketBuffer = ''; + } + + /** + * Handle socket error - reject pending requests and reset connection state + */ + private handleSocketError(error: Error): void { + this.rejectAllPending(error); + // Reset connection state to force reconnection on next call + this.connected = false; + this.connectionPromise = null; + this.socket = null; + this.socketBuffer = ''; // Clear any partial data in buffer + } + + /** + * Handle socket close - reset connection state to enable reconnection. + * Uses SocketClosedError to enable automatic retry in sendWithRetry(). + */ + private handleSocketClose(): void { + this.connected = false; + // Use SocketClosedError so retry logic can detect and handle this + this.rejectAllPending(new SocketClosedError('Socket closed by server (connection lifetime exceeded)')); + // Reset connection state to force reconnection on next call + this.connectionPromise = null; + this.socket = null; + this.socketBuffer = ''; // Clear any partial data in buffer + } + + /** + * Handle incoming data from socket. + * Uses a persistent buffer to handle TCP stream fragmentation - data may arrive + * in chunks that don't align with message boundaries (newline-delimited JSON). + */ + private handleSocketData(data: Buffer): void { + this.socketBuffer += data.toString(); + + // Extract and process complete messages (newline-delimited) + let newlineIndex; + while ((newlineIndex = this.socketBuffer.indexOf('\n')) !== -1) { + const line = this.socketBuffer.slice(0, newlineIndex); + this.socketBuffer = this.socketBuffer.slice(newlineIndex + 1); + + if (!line) continue; + + try { + const parsed = JSON.parse(line); + const { requestId, result, error } = parsed; + const resolver = this.pending.get(requestId); + if (resolver) { + error ? resolver.reject(error) : resolver.resolve(result); + this.pending.delete(requestId); + } + } catch (e) { + // Log parse errors for debugging - this shouldn't happen with complete messages + console.error('[PluginAPIImpl] Failed to parse response line:', line.substring(0, 200), e); + } + } + } + + /** + * Reject all pending requests with the given error. + * Called on socket error or close to prevent hanging promises. + */ + private rejectAllPending(error: Error): void { + for (const [requestId, resolver] of this.pending.entries()) { + resolver.reject(error); + this.pending.delete(requestId); + } + } + + useRelayer(relayerId: string): Relayer { + return { + sendTransaction: async (payload: NetworkTransactionRequest) => { + const result = await this.send<{ id: string; relayer_id: string }>(relayerId, 'sendTransaction', payload); + return { + ...result, + wait: (options?: { interval?: number; timeout?: number }) => + this.transactionWait(result, options), + } as any; + }, + getTransaction: (payload: { transactionId: string }) => + this.send(relayerId, 'getTransaction', payload), + getRelayerStatus: () => + this.send(relayerId, 'getRelayerStatus', {}), + signTransaction: (payload: SignTransactionRequest) => + this.send(relayerId, 'signTransaction', payload), + getRelayer: () => + this.send(relayerId, 'getRelayer', {}), + rpc: (payload: JsonRpcRequestNetworkRpcRequest) => + this.send(relayerId, 'rpc', payload), + }; + } + + async transactionWait( + transaction: { id: string; relayer_id: string }, + options?: { interval?: number; timeout?: number } + ): Promise { + const interval = options?.interval ?? 5000; + const timeout = options?.timeout ?? 60000; + const relayer = this.useRelayer(transaction.relayer_id); + let shouldContinue = true; + + const poll = async (): Promise => { + let tx = await relayer.getTransaction({ transactionId: transaction.id }); + while ( + shouldContinue && + tx.status !== TransactionStatus.MINED && + tx.status !== TransactionStatus.CONFIRMED && + tx.status !== TransactionStatus.CANCELED && + tx.status !== TransactionStatus.EXPIRED && + tx.status !== TransactionStatus.FAILED + ) { + await new Promise((resolve) => setTimeout(resolve, interval)); + if (!shouldContinue) break; + tx = await relayer.getTransaction({ transactionId: transaction.id }); + } + return tx; + }; + + let timeoutId: NodeJS.Timeout | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + shouldContinue = false; + reject(pluginError(`Transaction ${transaction.id} timed out after ${timeout}ms`, { status: 504 })); + }, timeout); + }); + + return Promise.race([poll(), timeoutPromise]).finally(() => { + shouldContinue = false; + if (timeoutId) clearTimeout(timeoutId); + }); + } + + private async send(relayerId: string, method: string, payload: any): Promise { + return this.sendWithRetry(relayerId, method, payload, false, false); + } + + /** + * Send request with EPIPE retry logic. + * EPIPE occurs when the pooled socket was closed by the server but client doesn't know yet. + * On EPIPE, we destroy the stale socket and retry with a fresh connection. + * + * @param forceNewSocket If true, skip the socket pool and create a fresh connection. + * Used on retries to avoid getting another stale socket from pool. + */ + private async sendWithRetry( + relayerId: string, + method: string, + payload: any, + isRetry: boolean, + forceNewSocket: boolean + ): Promise { + const requestId = uuidv4(); + const msg: any = { requestId, relayerId, method, payload }; + if (this.httpRequestId) { + msg.httpRequestId = this.httpRequestId; + } + const message = JSON.stringify(msg) + '\n'; + + // Check pending request limit to prevent memory leaks + if (this.pending.size >= this.maxPendingRequests) { + throw new Error( + `Too many concurrent API requests (max ${this.maxPendingRequests}). ` + + `Await previous requests before making new ones.` + ); + } + + // Ensure we're connected before sending + // On retry, force new socket to avoid getting another stale socket from pool + await this.ensureConnected(forceNewSocket); + + try { + // Capture socket reference locally to guard against TOCTOU race condition. + // Socket can become null between ensureConnected() and write() if an error/close + // event fires asynchronously. This is especially likely after stress testing when + // pooled connections may be in a degraded state. + const socket = this.socket; + if (!socket) { + // Socket was nullified by error/close handler between ensureConnected and now. + // Treat as a stale connection error that can be retried. + const staleError = new Error('Socket became unavailable after connection') as NodeJS.ErrnoException; + staleError.code = 'ECONNRESET'; + throw staleError; + } + + return await new Promise((resolve, reject) => { + let timeoutId: NodeJS.Timeout | undefined; + + // Set up timeout to prevent hanging forever + timeoutId = setTimeout(() => { + this.pending.delete(requestId); + reject(new Error(`Socket request '${method}' timed out after ${DEFAULT_SOCKET_REQUEST_TIMEOUT_MS}ms`)); + }, DEFAULT_SOCKET_REQUEST_TIMEOUT_MS); + + // Wrap resolvers to clear timeout on completion + this.pending.set(requestId, { + resolve: (value) => { + if (timeoutId) clearTimeout(timeoutId); + resolve(value); + }, + reject: (reason) => { + if (timeoutId) clearTimeout(timeoutId); + reject(reason); + }, + }); + + socket.write(message, (error) => { + if (error) { + if (timeoutId) clearTimeout(timeoutId); + this.pending.delete(requestId); + reject(error); + } + }); + }); + } catch (error: any) { + // Handle connection errors - stale socket from pool or server-side closure + // EPIPE: write to closed socket (client doesn't know server closed) + // ECONNRESET: connection reset by peer (server forcefully closed) + // ESOCKETCLOSED: server closed connection (e.g., 60-second lifetime expired) + const isRetryableError = + error.code === 'EPIPE' || + error.code === 'ECONNRESET' || + error.code === 'ESOCKETCLOSED'; + + if (!isRetry && isRetryableError) { + // Destroy the stale socket (don't return to pool) + if (this.socket) { + this.removeSocketHandlers(this.socket); + this.socket.destroy(); + this.socket = null; + } + this.connected = false; + this.connectionPromise = null; + + // Retry with fresh connection, forcing new socket to bypass pool + return this.sendWithRetry(relayerId, method, payload, true, true); + } + throw error; + } + } + + close(): void { + // Return socket to pool if healthy, otherwise destroy + if (this.socket) { + // Remove handlers BEFORE returning to pool to prevent listener accumulation + this.removeSocketHandlers(this.socket); + socketPool.release(this.socket, this.socketCreatedAt); + this.socket = null; + } + this.connected = false; + } +} + +/** + * Safely stringify a value, handling circular references and BigInt. + * Falls back to String() if JSON.stringify fails. + */ +function safeStringify(value: unknown): string { + try { + return JSON.stringify(value, (_, v) => { + if (typeof v === 'bigint') { + return v.toString() + 'n'; + } + return v; + }); + } catch { + try { + return String(value); + } catch { + return '[Unstringifiable value]'; + } + } +} + +/** + * Creates a console-like object that captures logs with lazy stringification. + * Stringification is deferred until logs are accessed to reduce overhead. + */ +function createPluginConsole(logs: LogEntry[]): Console { + const log = (level: LogEntry['level']) => (...args: any[]) => { + // Store raw args, stringify lazily when accessed + const entry: any = { + level, + _args: args, // Store raw args + _stringified: false, + _message: '', + }; + + // Lazy getter for message + Object.defineProperty(entry, 'message', { + get() { + if (!this._stringified) { + this._message = this._args.map((arg: any) => + typeof arg === 'object' ? safeStringify(arg) : String(arg) + ).join(' '); + this._stringified = true; + // Clear raw args to free memory + delete this._args; + } + return this._message; + }, + enumerable: true, + configurable: true, + }); + + logs.push(entry); + }; + + return { + log: log('log'), + info: log('info'), + warn: log('warn'), + error: log('error'), + debug: log('debug'), + trace: log('debug'), + // Required console methods (no-ops for unused ones) + assert: () => {}, + clear: () => {}, + count: () => {}, + countReset: () => {}, + dir: () => {}, + dirxml: () => {}, + group: () => {}, + groupCollapsed: () => {}, + groupEnd: () => {}, + table: () => {}, + time: () => {}, + timeEnd: () => {}, + timeLog: () => {}, + timeStamp: () => {}, + profile: () => {}, + profileEnd: () => {}, + Console: console.Console, + } as Console; +} + +/** + * Executes a compiled plugin. + * This is the Piscina worker function. + * Uses function caching for performance. + */ +export default async function executePlugin(task: ExecutorTask): Promise { + const logs: LogEntry[] = []; + const api = new PluginAPIImpl(task.socketPath, task.httpRequestId); + const kv = new DefaultPluginKVStore(task.pluginId); + + try { + // Create console that captures logs + const pluginConsole = createPluginConsole(logs); + + // Create module-like objects for CommonJS compatibility + const moduleExports: any = {}; + const moduleObject = { exports: moduleExports }; + + // Try to get cached factory function, otherwise compile and cache + let factory = functionCache.get(task.compiledCode); + if (!factory) { + // eslint-disable-next-line no-new-func + factory = new Function( + 'exports', + 'require', + 'module', + '__filename', + '__dirname', + 'console', + task.compiledCode + ); + functionCache.set(task.compiledCode, factory); + } + + // Execute the factory to populate module.exports + // Pass our custom console to capture logs + factory(moduleExports, require, moduleObject, `plugin-${task.pluginId}.js`, '/plugins', pluginConsole); + + // Get the handler from exports + const handler = moduleObject.exports.handler || moduleExports.handler; + + if (!handler || typeof handler !== 'function') { + throw new Error('Plugin must export a handler function'); + } + + // Execute the handler with timeout protection + // This prevents hung async handlers from blocking workers indefinitely + let result: any; + + const handlerPromise = (async () => { + if (handler.length === 1) { + // Modern context-based handler + const pluginContext: PluginContext = { + api, + params: task.params, + kv, + headers: task.headers ?? {}, + route: task.route ?? '', + config: task.config, + method: task.method ?? 'POST', + query: task.query ?? {}, + }; + return await handler(pluginContext); + } else { + // Legacy 2-param handler (no KV/headers access) + return await handler(api, task.params); + } + })(); + + // Race handler against timeout to prevent worker starvation + let timeoutId: NodeJS.Timeout | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + const error = new Error(`Plugin handler timed out after ${task.timeout}ms`); + (error as any).code = 'ERR_HANDLER_TIMEOUT'; + reject(error); + }, task.timeout); + }); + + try { + result = await Promise.race([handlerPromise, timeoutPromise]); + } finally { + // Clear timeout if handler completed before timeout + if (timeoutId) clearTimeout(timeoutId); + } + + return { + taskId: task.taskId, + success: true, + result, + logs, + }; + } catch (error) { + const err = error as any; + + // Extract detailed error information + let errorCode = 'PLUGIN_ERROR'; + let errorMessage = String(error); + let errorDetails: any = undefined; + let errorStatus = 500; + + if (err && typeof err === 'object') { + errorMessage = err.message || String(error); + + // Determine error code from error type + if (err.name === 'SyntaxError') { + errorCode = 'SYNTAX_ERROR'; + } else if (err.name === 'TypeError') { + errorCode = 'TYPE_ERROR'; + } else if (err.name === 'ReferenceError') { + errorCode = 'REFERENCE_ERROR'; + } else if (err.code === 'ERR_SCRIPT_EXECUTION_TIMEOUT') { + errorCode = 'TIMEOUT'; + errorStatus = 504; + errorMessage = `Plugin module compilation timed out after ${task.timeout}ms`; + } else if (err.code === 'ERR_HANDLER_TIMEOUT') { + errorCode = 'TIMEOUT'; + errorStatus = 504; + // Message already set by the timeout error + } else if (err.code) { + errorCode = err.code; + } + + // Capture any additional details + if (err.details) { + errorDetails = err.details; + } + + // Use status if provided + if (typeof err.status === 'number') { + errorStatus = err.status; + } + } + + return { + taskId: task.taskId, + success: false, + error: { + message: errorMessage, + code: errorCode, + status: errorStatus, + details: errorDetails, + }, + logs, + }; + } finally { + // Close API socket (non-blocking, don't throw) + try { + api.close(); + } catch { + // Log but don't fail - cleanup is best-effort + } + + // Disconnect KV (async, don't throw) + try { + await kv.disconnect(); + } catch { + // Ignore disconnect errors - connection may not have been established + } + } +} diff --git a/plugins/lib/pool-server.ts b/plugins/lib/pool-server.ts new file mode 100644 index 000000000..98414d262 --- /dev/null +++ b/plugins/lib/pool-server.ts @@ -0,0 +1,1101 @@ +#!/usr/bin/env node + +/** + * Pool Server + * + * Long-running Node.js process that manages the Piscina worker pool. + * Communicates with Rust via Unix socket using JSON-line protocol. + * + * Protocol: + * - Each message is a single JSON line terminated by \n + * - Request: { type: "execute", taskId, pluginId, compiledCode?, pluginPath?, params, headers?, socketPath, httpRequestId?, timeout? } + * - Request: { type: "precompile", taskId, pluginId, pluginPath?, sourceCode? } + * - Request: { type: "cache", taskId, pluginId, compiledCode } + * - Request: { type: "invalidate", taskId, pluginId } + * - Request: { type: "stats", taskId } + * - Request: { type: "shutdown", taskId } + * - Response: { taskId, success, result?, error?, logs? } + * + * Configuration: + * + * All configuration is derived from PLUGIN_MAX_CONCURRENCY in Rust's config.rs + * and passed via environment variables when spawning this process. + * See: src/services/plugins/config.rs + * + * Environment Variables (passed from Rust): + * - PLUGIN_MAX_CONCURRENCY: Primary scaling knob + * - PLUGIN_POOL_MAX_THREADS: Worker threads (derived from concurrency) + * - PLUGIN_POOL_CONCURRENT_TASKS: Tasks per worker (derived from concurrency) + * - PLUGIN_POOL_IDLE_TIMEOUT: Idle timeout in ms + * - PLUGIN_POOL_DEBUG: Set to 'true' to enable debug logging + */ + +import * as net from 'node:net'; +import * as fs from 'node:fs'; +import * as v8 from 'node:v8'; +import { WorkerPoolManager, type PluginExecutionRequest } from './worker-pool'; +import { + DEFAULT_POOL_MIN_THREADS, + DEFAULT_POOL_MAX_THREADS_FLOOR, + DEFAULT_POOL_CONCURRENT_TASKS_PER_WORKER, + DEFAULT_POOL_IDLE_TIMEOUT_MS, + DEFAULT_POOL_SOCKET_BACKLOG, +} from './constants'; + +// Debug logging helper +const DEBUG = process.env.PLUGIN_POOL_DEBUG === 'true'; +function debug(...args: any[]): void { + if (DEBUG) { + console.error('[pool-server]', ...args); + } +} + +/** + * Memory Pressure Monitor - Pool Server Edition + * Monitors main process heap and logs warnings when approaching limits. + * Pool server manages workers, code cache, and socket communication. + * + * SELF-HEALING FEATURES: + * - Proactive GC triggering at 80% heap usage + * - Cache eviction at 85% heap usage + * - Emergency restart signal at 95% (tells Rust to restart) + * + * ASYNC SAFETY: + * - All callbacks are queued to event loop via setImmediate to avoid blocking the timer + * - Emergency shutdown is tracked to prevent duplicate invocations + */ +class PoolServerMemoryMonitor { + private checkInterval: NodeJS.Timeout | null = null; + private readonly warningThreshold = 0.75; // 75% of heap - start monitoring + private readonly gcThreshold = 0.80; // 80% of heap - force GC + private readonly criticalThreshold = 0.85; // 85% of heap - evict caches + private readonly emergencyThreshold = 0.92; // 92% of heap - signal restart + private lastWarningTime = 0; + private readonly warningCooldown = 10000; // 10 seconds between warnings (more frequent) + private consecutiveHighPressure = 0; + private onCacheEvictionRequest: (() => void) | null = null; + private onEmergencyRestart: (() => void) | null = null; + /** Tracks if emergency shutdown has already been triggered (prevents duplicate calls) */ + private emergencyTriggered = false; + + start(): void { + if (this.checkInterval) return; + + // Check memory pressure every 5 seconds (more frequent for faster response) + this.checkInterval = setInterval(() => { + this.checkMemoryPressure(); + }, 5000); + + // Don't prevent process exit + this.checkInterval.unref(); + + console.error('[pool-server] Memory monitoring started (thresholds: 75%/80%/85%/92%)'); + } + + stop(): void { + if (this.checkInterval) { + clearInterval(this.checkInterval); + this.checkInterval = null; + } + } + + /** + * Register callback for cache eviction requests. + */ + onCacheEviction(callback: () => void): void { + this.onCacheEvictionRequest = callback; + } + + /** + * Register callback for emergency restart signal. + */ + onEmergency(callback: () => void): void { + this.onEmergencyRestart = callback; + } + + private checkMemoryPressure(): void { + const usage = process.memoryUsage(); + const heapStats = v8.getHeapStatistics(); + const heapUsed = usage.heapUsed; + // Use heap_size_limit (V8's configured maximum heap) instead of heapTotal + // heapTotal is the currently allocated heap which grows dynamically, + // heap_size_limit is the actual maximum (e.g., from --max-old-space-size) + const heapLimit = heapStats.heap_size_limit; + const heapUsedRatio = heapUsed / heapLimit; + + // Track consecutive high pressure for emergency detection + if (heapUsedRatio >= this.criticalThreshold) { + this.consecutiveHighPressure++; + } else if (heapUsedRatio < this.warningThreshold) { + this.consecutiveHighPressure = 0; + } + + // Emergency: persistent critical pressure = GC can't keep up + if (heapUsedRatio >= this.emergencyThreshold || this.consecutiveHighPressure >= 6) { + this.handleEmergency(heapUsed, heapLimit, usage.external, usage.rss); + } else if (heapUsedRatio >= this.criticalThreshold) { + this.handleCriticalPressure(heapUsed, heapLimit, usage.external, usage.rss); + } else if (heapUsedRatio >= this.gcThreshold) { + this.handleGCPressure(heapUsed, heapLimit); + } else if (heapUsedRatio >= this.warningThreshold) { + this.handleWarningPressure(heapUsed, heapLimit, usage.external, usage.rss); + } + } + + private handleEmergency(heapUsed: number, heapLimit: number, external: number, rss: number): void { + // Prevent duplicate emergency shutdowns + if (this.emergencyTriggered) { + return; + } + this.emergencyTriggered = true; + + const heapUsedMB = Math.round(heapUsed / 1024 / 1024); + const heapLimitMB = Math.round(heapLimit / 1024 / 1024); + const percent = Math.round((heapUsed / heapLimit) * 100); + + console.error( + `[pool-server] EMERGENCY: Heap at ${heapUsedMB}MB / ${heapLimitMB}MB limit (${percent}%). ` + + `Consecutive high pressure: ${this.consecutiveHighPressure}. ` + + `GC cannot keep up - signaling Rust for graceful restart.` + ); + + // Try last-ditch GC + if (global.gc) { + try { + global.gc(); + } catch { + // Ignore + } + } + + // Queue emergency callback to event loop to avoid blocking the timer + // This ensures the setInterval can continue and the async shutdown + // is handled properly without blocking other operations + if (this.onEmergencyRestart) { + setImmediate(() => { + this.onEmergencyRestart!(); + }); + } + + // Reset counter after emergency signal + this.consecutiveHighPressure = 0; + } + + private handleCriticalPressure(heapUsed: number, heapLimit: number, external: number, rss: number): void { + const now = Date.now(); + if (now - this.lastWarningTime < this.warningCooldown) return; + this.lastWarningTime = now; + + const heapUsedMB = Math.round(heapUsed / 1024 / 1024); + const heapLimitMB = Math.round(heapLimit / 1024 / 1024); + const externalMB = Math.round(external / 1024 / 1024); + const rssMB = Math.round(rss / 1024 / 1024); + const percent = Math.round((heapUsed / heapLimit) * 100); + + console.error( + `[pool-server] CRITICAL: Heap at ${heapUsedMB}MB / ${heapLimitMB}MB limit (${percent}%). ` + + `External: ${externalMB}MB, RSS: ${rssMB}MB. ` + + `Requesting cache eviction and forcing GC.` + ); + + // Request cache eviction - queue to event loop to avoid blocking timer + if (this.onCacheEvictionRequest) { + setImmediate(() => { + this.onCacheEvictionRequest!(); + }); + } + + // Force GC + if (global.gc) { + try { + global.gc(); + console.error(`[pool-server] Forced GC triggered`); + } catch (err) { + console.error(`[pool-server] Failed to trigger GC:`, err); + } + } + } + + private handleGCPressure(heapUsed: number, heapLimit: number): void { + // Silently trigger GC at 80% threshold + if (global.gc) { + try { + global.gc(); + } catch { + // Ignore + } + } + } + + private handleWarningPressure(heapUsed: number, heapLimit: number, external: number, rss: number): void { + const now = Date.now(); + if (now - this.lastWarningTime < this.warningCooldown * 3) return; // Less frequent warnings + this.lastWarningTime = now; + + const heapUsedMB = Math.round(heapUsed / 1024 / 1024); + const heapLimitMB = Math.round(heapLimit / 1024 / 1024); + const percent = Math.round((heapUsed / heapLimit) * 100); + + console.error( + `[pool-server] WARNING: Heap at ${heapUsedMB}MB / ${heapLimitMB}MB limit (${percent}%).` + ); + } +} + +// Message types +interface BaseMessage { + type: string; + taskId: string; +} + +interface ExecuteMessage extends BaseMessage { + type: 'execute'; + pluginId: string; + compiledCode?: string; + pluginPath?: string; + params: any; + headers?: Record; + socketPath: string; + httpRequestId?: string; + timeout?: number; + route?: string; + config?: any; + method?: string; + query?: any; +} + +interface PrecompileMessage extends BaseMessage { + type: 'precompile'; + pluginId: string; + pluginPath?: string; + sourceCode?: string; +} + +interface CacheMessage extends BaseMessage { + type: 'cache'; + pluginId: string; + compiledCode: string; +} + +interface InvalidateMessage extends BaseMessage { + type: 'invalidate'; + pluginId: string; +} + +interface StatsMessage extends BaseMessage { + type: 'stats'; +} + +interface HealthMessage extends BaseMessage { + type: 'health'; +} + +interface ShutdownMessage extends BaseMessage { + type: 'shutdown'; +} + +type Message = + | ExecuteMessage + | PrecompileMessage + | CacheMessage + | InvalidateMessage + | StatsMessage + | HealthMessage + | ShutdownMessage; + +interface Response { + taskId: string; + success: boolean; + result?: any; + error?: { + message: string; + code?: string; + status?: number; + details?: any; + }; + logs?: Array<{ level: string; message: string }>; +} + +/** + * Pool Server - manages worker pool and handles requests from Rust + */ +class PoolServer { + private pool: WorkerPoolManager; + private server: net.Server | null = null; + private socketPath: string; + private running: boolean = false; + private shuttingDown: boolean = false; + private activeRequests: number = 0; + private readonly shutdownTimeoutMs: number = 30000; // 30 seconds max drain time + + constructor(socketPath: string) { + this.socketPath = socketPath; + + const cpuCount = require('os').cpus().length; + + // ========================================================================= + // Configuration - ALL values are passed from Rust (single source of truth) + // ========================================================================= + // Rust's config.rs derives all values from PLUGIN_MAX_CONCURRENCY and + // passes them via environment variables when spawning this process. + // + // Fallbacks are provided only for manual testing without Rust. + // + const maxConcurrency = parseInt(process.env.PLUGIN_MAX_CONCURRENCY || '2048', 10); + const minThreads = parseInt( + process.env.PLUGIN_POOL_MIN_THREADS || String(Math.max(DEFAULT_POOL_MIN_THREADS, Math.floor(cpuCount / 2))), + 10 + ); + const maxThreads = parseInt( + process.env.PLUGIN_POOL_MAX_THREADS || String(Math.max(cpuCount, DEFAULT_POOL_MAX_THREADS_FLOOR)), + 10 + ); + const concurrentTasksPerWorker = parseInt( + process.env.PLUGIN_POOL_CONCURRENT_TASKS || String(DEFAULT_POOL_CONCURRENT_TASKS_PER_WORKER), + 10 + ); + const idleTimeout = parseInt( + process.env.PLUGIN_POOL_IDLE_TIMEOUT || String(DEFAULT_POOL_IDLE_TIMEOUT_MS), + 10 + ); + + // Log effective configuration (received from Rust) + console.error(`[pool-server] Configuration (from Rust): maxConcurrency=${maxConcurrency}, minThreads=${minThreads}, maxThreads=${maxThreads}, concurrentTasksPerWorker=${concurrentTasksPerWorker}, idleTimeout=${idleTimeout}ms`); + + // WorkerPoolManager handles cache lifecycle with active eviction (runs every 5 mins) + // Cache is properly cleaned up on shutdown via pool.destroy() + this.pool = new WorkerPoolManager({ + minThreads, + maxThreads, + concurrentTasksPerWorker, + idleTimeout, + }); + } + + /** + * Start the pool server + */ + async start(): Promise { + debug('Initializing worker pool...'); + // Initialize the worker pool + await this.pool.initialize(); + debug('Worker pool initialized'); + + // Clean up any existing socket file + try { + fs.unlinkSync(this.socketPath); + debug('Removed existing socket file'); + } catch { + // Ignore if doesn't exist + } + + // Create Unix socket server with high connection backlog for bursts + this.server = net.createServer((socket) => { + debug('net.createServer callback - new connection'); + this.handleConnection(socket); + }); + + + // Log any server-level errors + this.server.on('error', (err) => { + console.error('[pool-server] Server error:', err); + }); + + this.server.on('connection', (socket) => { + debug('Server connection event from:', socket.remoteAddress); + }); + + // Backlog for pending connections (default 511, increase for high concurrency) + // Note: Rust exports PLUGIN_POOL_SOCKET_BACKLOG with derived headroom + const backlog = parseInt(process.env.PLUGIN_POOL_SOCKET_BACKLOG || String(DEFAULT_POOL_SOCKET_BACKLOG), 10); + + return new Promise((resolve, reject) => { + this.server!.on('error', (err) => { + console.error('[pool-server] Listen error:', err); + reject(err); + }); + + this.server!.listen(this.socketPath, backlog, () => { + this.running = true; + debug(`Listening on ${this.socketPath} (backlog=${backlog})`); + + // Verify the socket file exists + try { + const stats = fs.statSync(this.socketPath); + debug(`Socket file verified: mode=${stats.mode.toString(8)}, isSocket=${stats.isSocket()}`); + } catch (e: any) { + console.warn(`[pool-server] Socket file check failed:`, e.message); + } + + // Verify server state + const addr = this.server!.address(); + debug(`Server address:`, addr); + debug(`Server listening:`, this.server!.listening); + + resolve(); + }); + }); + } + + /** + * Handle a client connection + */ + private handleConnection(socket: net.Socket): void { + const clientId = Math.random().toString(36).substring(7); + debug(`[${clientId}] Client connected`); + + // Enable keep-alive to prevent connection drops + socket.setKeepAlive(true, 30000); // 30 second keep-alive probe + socket.setNoDelay(true); // Disable Nagle's algorithm for lower latency + + let buffer = ''; + let processingPromise: Promise | null = null; + const pendingLines: string[] = []; + + // Max buffer size to prevent OOM from malicious clients (10MB) + const MAX_BUFFER_SIZE = 10 * 1024 * 1024; + + const processQueue = async (): Promise => { + // Wait for any in-progress processing to complete (proper mutual exclusion) + if (processingPromise) { + await processingPromise; + } + + if (pendingLines.length === 0) return; + + processingPromise = (async () => { + while (pendingLines.length > 0) { + const line = pendingLines.shift()!; + if (!line.trim()) continue; + + try { + const message = JSON.parse(line) as Message; + debug('Processing message type:', message.type); + const response = await this.handleMessage(message); + debug('Sending response for task:', response.taskId); + // Check if socket is still writable before writing + if (socket.writable) { + socket.write(JSON.stringify(response) + '\n'); + } else { + debug('Socket no longer writable, discarding response'); + } + } catch (err) { + const error = err as Error; + debug('Error handling message:', error); + const response: Response = { + taskId: 'unknown', + success: false, + error: { + message: error.message || 'Failed to parse message', + code: 'PARSE_ERROR', + }, + }; + if (socket.writable) { + socket.write(JSON.stringify(response) + '\n'); + } + } + } + })(); + + await processingPromise; + processingPromise = null; + }; + + // Define handlers for proper cleanup on close + const dataHandler = (data: Buffer): void => { + buffer += data.toString(); + debug(`[${clientId}] Received data: ${data.length} bytes, buffer: ${buffer.length}`); + + // Buffer overflow protection - disconnect malicious clients + if (buffer.length > MAX_BUFFER_SIZE) { + console.error(`[pool-server] [${clientId}] Buffer overflow (${buffer.length} bytes), disconnecting`); + socket.destroy(); + return; + } + + debug(`[${clientId}] Buffer content: ${buffer.substring(0, 200)}...`); + + // Extract complete messages (newline-delimited) + let newlineIndex; + while ((newlineIndex = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, newlineIndex); + buffer = buffer.slice(newlineIndex + 1); + pendingLines.push(line); + debug(`[${clientId}] Queued message: ${line.substring(0, 100)}...`); + } + + // Process queue (async but don't await here) + processQueue().catch((err) => { + console.error(`[pool-server] [${clientId}] Error in processQueue:`, err); + // Send error response so client doesn't hang + const response: Response = { + taskId: 'unknown', + success: false, + error: { message: 'Internal queue processing error', code: 'QUEUE_ERROR' }, + }; + if (socket.writable) { + socket.write(JSON.stringify(response) + '\n'); + } + }); + }; + + const errorHandler = (err: Error): void => { + // Connection resets are normal during shutdown, don't log as errors + if ((err as any).code === 'ECONNRESET') { + debug(`[${clientId}] Connection reset`); + } else { + console.error(`[pool-server] [${clientId}] Socket error:`, err.message); + } + }; + + const closeHandler = (): void => { + // Explicit cleanup of listeners (good practice for long-running servers) + socket.removeListener('data', dataHandler); + socket.removeListener('error', errorHandler); + debug(`[${clientId}] Client disconnected, listeners cleaned up`); + }; + + socket.on('data', dataHandler); + socket.on('error', errorHandler); + socket.once('close', closeHandler); + } + + /** + * Handle a message from Rust + */ + private async handleMessage(message: Message): Promise { + const { taskId } = message; + + // Allow shutdown and health messages during shutdown, reject others + if (this.shuttingDown && message.type !== 'shutdown' && message.type !== 'health') { + return { + taskId, + success: false, + error: { + message: 'Server is shutting down, not accepting new requests', + code: 'SHUTTING_DOWN', + }, + }; + } + + // Track active requests for graceful shutdown draining + // Work requests get try/finally to ensure counter accuracy + const isWorkRequest = message.type === 'execute' || message.type === 'precompile'; + + if (isWorkRequest) { + this.activeRequests++; + try { + return await this.handleWorkRequest(message); + } finally { + this.activeRequests--; + } + } + + // Non-work requests don't need tracking + return this.handleNonWorkRequest(message); + } + + /** + * Handle work requests (execute, precompile) - tracked for graceful shutdown + */ + private async handleWorkRequest(message: ExecuteMessage | PrecompileMessage): Promise { + const { taskId } = message; + try { + switch (message.type) { + case 'execute': + return await this.handleExecute(message); + case 'precompile': + return await this.handlePrecompile(message); + default: { + // TypeScript exhaustiveness check + const _exhaustive: never = message; + return { + taskId, + success: false, + error: { message: 'Unknown work request type', code: 'UNKNOWN_TYPE' }, + }; + } + } + } catch (err) { + const error = err as Error; + return { + taskId, + success: false, + error: { + message: error.message || String(err), + code: 'HANDLER_ERROR', + }, + }; + } + } + + /** + * Handle non-work requests (cache, invalidate, stats, health, shutdown) + */ + private async handleNonWorkRequest(message: Message): Promise { + const { taskId } = message; + try { + switch (message.type) { + case 'cache': + return this.handleCache(message as CacheMessage); + case 'invalidate': + return this.handleInvalidate(message as InvalidateMessage); + case 'stats': + return this.handleStats(message as StatsMessage); + case 'health': + return this.handleHealth(message as HealthMessage); + case 'shutdown': + return await this.handleShutdown(message as ShutdownMessage); + default: + return { + taskId, + success: false, + error: { + message: `Unknown message type: ${(message as any).type}`, + code: 'UNKNOWN_MESSAGE_TYPE', + }, + }; + } + } catch (err) { + const error = err as Error; + return { + taskId, + success: false, + error: { + message: error.message || String(err), + code: 'HANDLER_ERROR', + }, + }; + } + } + + /** + * Execute a plugin + */ + private async handleExecute(message: ExecuteMessage): Promise { + debug('handleExecute called with:', JSON.stringify({ + pluginId: message.pluginId, + pluginPath: message.pluginPath, + hasCompiledCode: !!message.compiledCode, + socketPath: message.socketPath, + timeout: message.timeout, + })); + + const request: PluginExecutionRequest = { + pluginId: message.pluginId, + pluginPath: message.pluginPath, + compiledCode: message.compiledCode, + params: message.params, + headers: message.headers, + socketPath: message.socketPath, + httpRequestId: message.httpRequestId, + timeout: message.timeout, + route: message.route, + config: message.config, + method: message.method, + query: message.query, + }; + + try { + debug('Calling pool.runPlugin...'); + const result = await this.pool.runPlugin(request); + debug('runPlugin returned:', JSON.stringify({ + success: result.success, + hasResult: !!result.result, + hasError: !!result.error, + })); + + return { + taskId: message.taskId, + success: result.success, + result: result.result, + error: result.error, + logs: result.logs, + }; + } catch (err) { + debug('runPlugin threw error:', err); + const error = err as any; + + // Provide detailed error context + return { + taskId: message.taskId, + success: false, + error: { + message: error?.message || String(err), + code: error?.code || 'POOL_EXECUTION_ERROR', + status: typeof error?.status === 'number' ? error.status : 500, + details: { + pluginId: message.pluginId, + errorType: error?.name || 'Error', + }, + }, + logs: [], + }; + } + } + + /** + * Precompile a plugin + */ + private async handlePrecompile(message: PrecompileMessage): Promise { + let compilationResult; + + if (message.sourceCode) { + compilationResult = await this.pool.precompilePluginSource( + message.pluginId, + message.sourceCode + ); + } else if (message.pluginPath) { + compilationResult = await this.pool.precompilePlugin(message.pluginPath); + } else { + return { + taskId: message.taskId, + success: false, + error: { + message: 'Either sourceCode or pluginPath is required for precompilation', + code: 'MISSING_SOURCE', + }, + }; + } + + return { + taskId: message.taskId, + success: true, + result: { + code: compilationResult.code, + warnings: compilationResult.warnings, + }, + }; + } + + /** + * Cache compiled code + */ + private handleCache(message: CacheMessage): Response { + this.pool.cacheCompiledCode(message.pluginId, message.compiledCode); + return { + taskId: message.taskId, + success: true, + }; + } + + /** + * Invalidate cached plugin + */ + private handleInvalidate(message: InvalidateMessage): Response { + this.pool.invalidatePlugin(message.pluginId); + return { + taskId: message.taskId, + success: true, + }; + } + + /** + * Get pool statistics + */ + private handleStats(message: StatsMessage): Response { + const stats = this.pool.getStats(); + return { + taskId: message.taskId, + success: true, + result: stats, + }; + } + + /** + * Health check - returns basic health status + */ + private handleHealth(message: HealthMessage): Response { + const stats = this.pool.getStats(); + const memUsage = process.memoryUsage(); + + return { + taskId: message.taskId, + success: true, + result: { + status: 'healthy', + uptime: stats.uptime, + memory: { + heapUsed: memUsage.heapUsed, + heapTotal: memUsage.heapTotal, + rss: memUsage.rss, + }, + pool: stats.pool ? { + completed: stats.pool.completed, + queued: stats.pool.queued, + } : null, + execution: { + total: stats.execution.total, + successRate: stats.execution.successRate, + }, + }, + }; + } + + /** + * Shutdown the server gracefully + * 1. Stop accepting new requests + * 2. Wait for in-flight requests to complete (with timeout) + * 3. Clean up resources and exit + */ + private async handleShutdown(message: ShutdownMessage): Promise { + if (this.shuttingDown) { + return { + taskId: message.taskId, + success: true, + result: { status: 'already_shutting_down', activeRequests: this.activeRequests }, + }; + } + + console.error(`[pool-server] Graceful shutdown initiated, ${this.activeRequests} active requests`); + this.shuttingDown = true; + + // Wait for in-flight requests to drain, then shutdown + // This runs asynchronously - we send the response first + this.drainAndExit().catch((err) => { + console.error('[pool-server] Error during graceful shutdown:', err); + process.exit(1); + }); + + return { + taskId: message.taskId, + success: true, + result: { + status: 'draining', + activeRequests: this.activeRequests, + timeoutMs: this.shutdownTimeoutMs, + }, + }; + } + + /** + * Wait for active requests to complete, then exit + */ + private async drainAndExit(): Promise { + const startTime = Date.now(); + const checkInterval = 100; // Check every 100ms + + // Wait for requests to drain + while (this.activeRequests > 0) { + const elapsed = Date.now() - startTime; + if (elapsed >= this.shutdownTimeoutMs) { + console.error( + `[pool-server] Shutdown timeout (${this.shutdownTimeoutMs}ms) reached, ` + + `${this.activeRequests} requests still active - forcing shutdown` + ); + break; + } + + // Log progress every 5 seconds + if (elapsed > 0 && elapsed % 5000 < checkInterval) { + console.error( + `[pool-server] Draining: ${this.activeRequests} requests remaining ` + + `(${Math.round(elapsed / 1000)}s / ${this.shutdownTimeoutMs / 1000}s)` + ); + } + + await new Promise((resolve) => setTimeout(resolve, checkInterval)); + } + + const drainTime = Date.now() - startTime; + console.error(`[pool-server] Drain complete in ${drainTime}ms, proceeding with shutdown`); + + await this.stop(); + process.exit(0); + } + + /** + * Evict code cache under memory pressure. + * Called by memory monitor when heap usage is critical. + */ + evictCache(): void { + try { + this.pool.clearCache(); + console.error('[pool-server] Code cache evicted due to memory pressure'); + } catch (err) { + console.error('[pool-server] Failed to evict cache:', err); + } + } + + /** + * Emergency shutdown due to memory pressure. + * Uses shorter timeout but same drain logic for consistency. + * Exit code 1 indicates abnormal termination (not 137 which is SIGKILL). + */ + async emergencyShutdown(): Promise { + if (this.shuttingDown) { + // Already shutting down, just wait + return; + } + + console.error(`[pool-server] Emergency shutdown initiated, ${this.activeRequests} active requests`); + this.shuttingDown = true; + + // Shorter timeout for emergency (10 seconds vs normal 30) + const emergencyTimeoutMs = 10000; + const startTime = Date.now(); + const checkInterval = 100; + + // Wait for requests to drain with shorter timeout + while (this.activeRequests > 0) { + const elapsed = Date.now() - startTime; + if (elapsed >= emergencyTimeoutMs) { + console.error( + `[pool-server] Emergency shutdown timeout (${emergencyTimeoutMs}ms) reached, ` + + `${this.activeRequests} requests still active - forcing exit` + ); + break; + } + + if (elapsed > 0 && elapsed % 2000 < checkInterval) { + console.error( + `[pool-server] Emergency draining: ${this.activeRequests} requests remaining` + ); + } + + await new Promise((resolve) => setTimeout(resolve, checkInterval)); + } + + const drainTime = Date.now() - startTime; + console.error(`[pool-server] Emergency drain complete in ${drainTime}ms`); + + await this.stop(); + // Exit code 1 = abnormal termination (OOM-like condition) + // Note: 137 is SIGKILL which is incorrect for voluntary exit + process.exit(1); + } + + /** + * Stop the server + */ + async stop(): Promise { + this.running = false; + + if (this.server) { + await new Promise((resolve) => { + this.server!.close(() => resolve()); + }); + this.server = null; + } + + await this.pool.shutdown(); + + // Clean up socket file + try { + fs.unlinkSync(this.socketPath); + } catch { + // Ignore + } + + console.log('Pool server stopped'); + } +} + +// Main entry point +async function main(): Promise { + const socketPath = process.argv[2] || process.env.PLUGIN_POOL_SOCKET || '/tmp/plugin-pool.sock'; + + debug('Starting with socket path:', socketPath); + + // Log heap configuration from NODE_OPTIONS + const nodeOptions = process.env.NODE_OPTIONS || ''; + const maxOldSpaceMatch = nodeOptions.match(/--max-old-space-size=(\d+)/); + const configuredHeapMB = maxOldSpaceMatch ? parseInt(maxOldSpaceMatch[1], 10) : 'default'; + const currentHeapMB = Math.round(process.memoryUsage().heapTotal / 1024 / 1024); + + console.info( + `[pool-server] Heap configuration: ${configuredHeapMB}MB max ` + + `(current: ${currentHeapMB}MB, will grow as needed)` + ); + + const server = new PoolServer(socketPath); + + // Start memory monitoring for pool server process + const memoryMonitor = new PoolServerMemoryMonitor(); + + // Connect memory monitor to pool for cache eviction + memoryMonitor.onCacheEviction(() => { + console.error('[pool-server] Memory pressure - clearing code cache'); + server.evictCache(); + }); + + // Connect emergency handler - graceful shutdown to trigger Rust restart + memoryMonitor.onEmergency(() => { + console.error('[pool-server] Emergency memory pressure - initiating graceful shutdown for restart'); + // Reuse the same graceful shutdown logic as normal shutdown + // This ensures active requests are drained properly (with timeout) + // Rust will detect the exit and restart the process + server.emergencyShutdown().catch((err) => { + console.error('[pool-server] Error during emergency shutdown:', err); + process.exit(1); + }); + }); + + memoryMonitor.start(); + + // Handle uncaught exceptions to prevent silent crashes + process.on('uncaughtException', async (err) => { + console.error('[pool-server] Uncaught exception:', err); + try { + await server.stop(); + } catch (stopErr) { + console.error('[pool-server] Error during cleanup:', stopErr); + } + process.exit(1); + }); + + process.on('unhandledRejection', async (reason, promise) => { + console.error('[pool-server] Unhandled rejection at:', promise, 'reason:', reason); + try { + await server.stop(); + } catch (stopErr) { + console.error('[pool-server] Error during cleanup:', stopErr); + } + process.exit(1); + }); + + // Handle signals for graceful shutdown + process.on('SIGINT', async () => { + debug('Received SIGINT, shutting down...'); + await server.stop(); + process.exit(0); + }); + + process.on('SIGTERM', async () => { + debug('Received SIGTERM, shutting down...'); + await server.stop(); + process.exit(0); + }); + + try { + await server.start(); + debug('Server started successfully, listening on', socketPath); + // Write ready signal to stdout for Rust to detect + console.log('POOL_SERVER_READY'); + + // Keep the process alive forever - the server will handle connections + debug('Entering event loop, waiting for connections...'); + + // Periodic heartbeat to verify event loop is running (debug mode only) + if (process.env.PLUGIN_POOL_DEBUG) { + let heartbeatCount = 0; + setInterval(() => { + heartbeatCount++; + if (heartbeatCount <= 5 || heartbeatCount % 60 === 0) { + debug(`Heartbeat #${heartbeatCount} - event loop is running`); + } + }, 1000); + } + + // Keep process alive + await new Promise(() => {}); + } catch (err) { + console.error('[pool-server] Failed to start pool server:', err); + process.exit(1); + } +} + +main().catch((err) => { + console.error('[pool-server] Fatal error in main:', err); + process.exit(1); +}); diff --git a/plugins/lib/worker-pool.ts b/plugins/lib/worker-pool.ts new file mode 100644 index 000000000..eb6f04cd1 --- /dev/null +++ b/plugins/lib/worker-pool.ts @@ -0,0 +1,1046 @@ +/** + * Worker Pool Manager + * + * Manages a Piscina worker pool for executing compiled plugins. + * Provides a high-level interface for running plugins with proper lifecycle management. + * + * ## Performance Tuning + * + * ### Memory Configuration + * Each worker thread runs with an increased heap size (512MB via resourceLimits) to prevent + * OOM errors under high load. Node.js v8 defaults to ~96MB heap per worker, which is + * insufficient when handling many concurrent plugin contexts (e.g., 2500+ VUs). + * + * Symptoms of insufficient heap: + * - "FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory" + * - Workers crash during v8::internal::ContextDeserializer::Deserialize + * - Queue builds up (>50% capacity warnings) then all requests fail + * + * ### Thread Scaling + * - Worker threads scale dynamically based on PLUGIN_MAX_CONCURRENCY + * - Formula: max(cpuCount * 2, concurrency / 50, 8) with 64 thread cap + * - Each worker handles multiple concurrent tasks via Node.js async I/O + */ + +import Piscina from 'piscina'; +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as v8 from 'node:v8'; +import { v4 as uuidv4 } from 'uuid'; +import { compilePlugin, compilePluginSource, type CompilationResult } from './compiler'; +import type { ExecutorTask, ExecutorResult, LogEntry } from './pool-executor'; +import type { PluginHeaders } from './plugin'; +import { + DEFAULT_POOL_MIN_THREADS, + DEFAULT_POOL_IDLE_TIMEOUT_MS, + DEFAULT_POOL_EXECUTION_TIMEOUT_MS, + DEFAULT_POOL_MAX_THREADS_FLOOR, + DEFAULT_POOL_CONCURRENT_TASKS_PER_WORKER, +} from './constants'; + +/** + * Worker pool configuration options + */ +export interface WorkerPoolOptions { + /** Minimum number of worker threads to maintain */ + minThreads?: number; + /** Maximum number of worker threads */ + maxThreads?: number; + /** Number of concurrent tasks per worker */ + concurrentTasksPerWorker?: number; + /** Idle timeout before shutting down excess workers (ms) */ + idleTimeout?: number; + /** Task-level timeout to prevent stuck workers (ms). Defaults to execution timeout + 5s buffer. */ + taskTimeout?: number; +} + +/** + * Plugin execution request + */ +export interface PluginExecutionRequest { + /** Plugin ID */ + pluginId: string; + /** Path to plugin source file (for on-demand compilation) */ + pluginPath?: string; + /** Pre-compiled JavaScript code (if already compiled) */ + compiledCode?: string; + /** Plugin parameters */ + params: any; + /** HTTP headers from incoming request */ + headers?: PluginHeaders; + /** Unix socket path for relayer communication */ + socketPath: string; + /** HTTP request ID for tracing */ + httpRequestId?: string; + /** Execution timeout in milliseconds */ + timeout?: number; + /** Wildcard route path (e.g., "/verify" from "/api/v1/plugins/:id/*") */ + route?: string; + /** Plugin configuration object */ + config?: any; + /** HTTP method (GET, POST, etc.) */ + method?: string; + /** Query parameters */ + query?: any; +} + +/** + * Plugin execution result + */ +export interface PluginExecutionResult { + /** Whether execution succeeded */ + success: boolean; + /** Return value (if success) */ + result?: any; + /** Error information (if failed) */ + error?: { + message: string; + code?: string; + status?: number; + details?: any; + }; + /** Captured console logs */ + logs: LogEntry[]; +} + +const DEFAULT_TIMEOUT = DEFAULT_POOL_EXECUTION_TIMEOUT_MS; + +// Task timeout includes a 5s buffer over execution timeout for cleanup overhead +const DEFAULT_TASK_TIMEOUT = DEFAULT_TIMEOUT + 5000; + +const DEFAULT_OPTIONS: Required = { + minThreads: DEFAULT_POOL_MIN_THREADS, + maxThreads: Math.max(os.cpus().length, DEFAULT_POOL_MAX_THREADS_FLOOR), + concurrentTasksPerWorker: DEFAULT_POOL_CONCURRENT_TASKS_PER_WORKER, + idleTimeout: DEFAULT_POOL_IDLE_TIMEOUT_MS, + taskTimeout: DEFAULT_TASK_TIMEOUT, +}; + +/** + * Path to the pre-compiled sandbox executor. + * This file is generated at build time by running: npx ts-node build-executor.ts + */ +const PRECOMPILED_EXECUTOR_PATH = path.resolve(__dirname, 'pool-executor.js'); + +/** + * Metrics tracking for plugin execution + */ +interface PluginMetrics { + // Execution metrics + totalExecutions: number; + successfulExecutions: number; + failedExecutions: number; + + // Timing metrics (in ms) + totalExecutionTime: number; + minExecutionTime: number; + maxExecutionTime: number; + + // Cache metrics + cacheHits: number; + cacheMisses: number; + + // Compilation metrics + totalCompilations: number; + totalCompilationTime: number; + + // Per-plugin execution counts + pluginExecutions: Map; + + // Error tracking + errorsByType: Map; + + // Timestamps + startTime: number; + lastExecutionTime: number | null; +} + +/** + * Create initial metrics state + */ +function createInitialMetrics(): PluginMetrics { + return { + totalExecutions: 0, + successfulExecutions: 0, + failedExecutions: 0, + totalExecutionTime: 0, + minExecutionTime: Infinity, + maxExecutionTime: 0, + cacheHits: 0, + cacheMisses: 0, + totalCompilations: 0, + totalCompilationTime: 0, + pluginExecutions: new Map(), + errorsByType: new Map(), + startTime: Date.now(), + lastExecutionTime: null, + }; +} + +/** + * Increment a counter in a bounded Map. + * If the map exceeds maxSize, removes the oldest entry (FIFO eviction). + */ +function incrementBoundedMap( + map: Map, + key: string, + maxSize: number +): void { + map.set(key, (map.get(key) || 0) + 1); + + // Evict oldest entries if over limit + if (map.size > maxSize) { + const firstKey = map.keys().next().value; + if (firstKey !== undefined) { + map.delete(firstKey); + } + } +} + +/** + * In-memory cache for compiled plugin code + * Includes memory pressure awareness for proactive eviction + */ +class CompiledCodeCache { + private cache: Map = new Map(); + private maxAge: number; + private cleanupInterval!: NodeJS.Timeout; + private memoryCheckInterval!: NodeJS.Timeout; + private totalCacheSize: number = 0; + // Max cache size in bytes (100MB default - adjust under memory pressure) + private maxCacheSize: number = 100 * 1024 * 1024; + + constructor(maxAgeMs: number = 3600000) { + // 1 hour default + this.maxAge = maxAgeMs; + this.startCleanupTimer(); + this.startMemoryPressureMonitor(); + } + + /** + * Start periodic cleanup timer to evict expired entries. + * Runs every 5 minutes to prevent memory leaks. + */ + private startCleanupTimer(): void { + this.cleanupInterval = setInterval(() => { + this.evictExpired(); + }, 300000); // 5 minutes + // unref() allows process to exit even if timer is active + this.cleanupInterval.unref(); + } + + /** + * Start memory pressure monitoring - proactively evict cache under pressure. + * This helps prevent GC issues under high load. + */ + private startMemoryPressureMonitor(): void { + this.memoryCheckInterval = setInterval(() => { + this.checkMemoryPressure(); + }, 10000); // Every 10 seconds + this.memoryCheckInterval.unref(); + } + + /** + * Check memory pressure and evict cache entries proactively. + */ + private checkMemoryPressure(): void { + const usage = process.memoryUsage(); + const heapStats = v8.getHeapStatistics(); + // Use heap_size_limit (the actual max heap) instead of heapTotal (current allocated) + // heapTotal grows dynamically and can be much smaller than the configured max, + // causing false positive pressure detection (e.g., 28MB/30MB = 93% when max is 26GB) + const heapUsedRatio = usage.heapUsed / heapStats.heap_size_limit; + + // At 70% heap usage, start evicting oldest entries + if (heapUsedRatio >= 0.70) { + const evictCount = Math.ceil(this.cache.size * 0.25); // Evict 25% + if (evictCount > 0) { + console.warn( + `[cache] Memory pressure detected (${Math.round(heapUsedRatio * 100)}% of heap limit), ` + + `evicting ${evictCount} oldest entries` + ); + this.evictOldest(evictCount); + + // Force GC if available + if (global.gc) { + try { + global.gc(); + } catch { + // Ignore + } + } + } + } + + // At 85% heap usage, be more aggressive + if (heapUsedRatio >= 0.85) { + const evictCount = Math.ceil(this.cache.size * 0.5); // Evict 50% + if (evictCount > 0) { + console.error( + `[cache] HIGH memory pressure (${Math.round(heapUsedRatio * 100)}% of heap limit), ` + + `evicting ${evictCount} entries` + ); + this.evictOldest(evictCount); + + // Force GC + if (global.gc) { + try { + global.gc(); + } catch { + // Ignore + } + } + } + } + } + + /** + * Evict all expired entries from the cache. + */ + private evictExpired(): void { + const now = Date.now(); + let evictedCount = 0; + for (const [key, entry] of this.cache.entries()) { + if (now - entry.timestamp > this.maxAge) { + // Check delete return value to prevent size drift + if (this.cache.delete(key)) { + this.totalCacheSize -= entry.size; + evictedCount++; + } + } + } + if (evictedCount > 0) { + console.log(`[cache] Evicted ${evictedCount} expired entries`); + } + } + + /** + * Evict N oldest entries (LRU-style) - used under memory pressure. + */ + private evictOldest(count: number): void { + // Sort by timestamp (oldest first) + const entries = [...this.cache.entries()].sort( + (a, b) => a[1].timestamp - b[1].timestamp + ); + + let evicted = 0; + for (const [key, entry] of entries) { + if (evicted >= count) break; + // Check delete return value to prevent size drift + if (this.cache.delete(key)) { + this.totalCacheSize -= entry.size; + evicted++; + } + } + + if (evicted > 0) { + console.log(`[cache] Evicted ${evicted} oldest entries under memory pressure`); + } + } + + get(pluginPath: string): string | undefined { + const entry = this.cache.get(pluginPath); + if (!entry) return undefined; + + // Check if expired + if (Date.now() - entry.timestamp > this.maxAge) { + if (this.cache.delete(pluginPath)) { + this.totalCacheSize -= entry.size; + } + return undefined; + } + + // Update timestamp for LRU tracking + // Note: Timestamp update is racy under concurrent access but acceptable + // for LRU approximation - lost updates only cause slightly suboptimal eviction + entry.timestamp = Date.now(); + + return entry.code; + } + + set(pluginPath: string, code: string): void { + const size = Buffer.byteLength(code, 'utf8'); + + // Evict if adding this would exceed max cache size + while (this.totalCacheSize + size > this.maxCacheSize && this.cache.size > 0) { + this.evictOldest(1); + } + + const existing = this.cache.get(pluginPath); + if (existing) { + this.totalCacheSize -= existing.size; + } + + this.cache.set(pluginPath, { code, timestamp: Date.now(), size }); + this.totalCacheSize += size; + } + + delete(pluginPath: string): boolean { + const entry = this.cache.get(pluginPath); + if (entry) { + this.totalCacheSize -= entry.size; + } + return this.cache.delete(pluginPath); + } + + clear(): void { + this.cache.clear(); + this.totalCacheSize = 0; + } + + has(pluginPath: string): boolean { + return this.get(pluginPath) !== undefined; + } + + /** + * Get current cache stats. + */ + stats(): { entries: number; totalSizeBytes: number; maxSizeBytes: number } { + return { + entries: this.cache.size, + totalSizeBytes: this.totalCacheSize, + maxSizeBytes: this.maxCacheSize, + }; + } + + /** + * Destroy the cache and stop cleanup timer. + */ + destroy(): void { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + } + if (this.memoryCheckInterval) { + clearInterval(this.memoryCheckInterval); + } + this.clear(); + } +} + +/** + * Worker Pool Manager + * + * Manages plugin execution using a Piscina worker pool. + * Handles compilation caching, task routing, and pool lifecycle. + */ +/** Maximum entries in metrics Maps to prevent unbounded growth */ +const MAX_METRICS_ENTRIES = 1000; + +export class WorkerPoolManager { + private pool: Piscina | null = null; + private options: Required; + private compiledCache: CompiledCodeCache; + private initialized: boolean = false; + /** Promise for in-flight initialization to prevent race conditions */ + private initPromise: Promise | null = null; + private compiledWorkerPath: string | null = null; + /** Whether compiledWorkerPath is a temp file that should be cleaned up */ + private isTemporaryWorkerFile: boolean = false; + private metrics: PluginMetrics; + + // Event handlers stored as class properties for proper cleanup + private poolErrorHandler: ((err: Error) => void) | null = null; + private poolExitHandler: ((workerId: number, exitCode: number) => void) | null = null; + + constructor(options: WorkerPoolOptions = {}) { + this.options = { ...DEFAULT_OPTIONS, ...options }; + this.compiledCache = new CompiledCodeCache(); + this.metrics = createInitialMetrics(); + + // Register cleanup handlers for unclean shutdown (temp file leak prevention) + this.registerCleanupHandlers(); + } + + /** + * Cleanup handler for process exit - stored as property for removal + */ + private cleanupHandler = (): void => { + if (this.isTemporaryWorkerFile && this.compiledWorkerPath) { + try { + fs.unlinkSync(this.compiledWorkerPath); + } catch { + // Ignore - best effort cleanup + } + } + }; + + /** + * Register process handlers to clean up temp files on unexpected exit. + */ + private registerCleanupHandlers(): void { + // Handle various exit scenarios + process.once('beforeExit', this.cleanupHandler); + process.once('SIGINT', this.cleanupHandler); + process.once('SIGTERM', this.cleanupHandler); + } + + /** + * Remove process cleanup handlers (called during shutdown) + */ + private removeCleanupHandlers(): void { + process.off('beforeExit', this.cleanupHandler); + process.off('SIGINT', this.cleanupHandler); + process.off('SIGTERM', this.cleanupHandler); + } + + /** + * Initialize the worker pool. + * Call this before executing any plugins. + * + * Uses pre-compiled pool-executor.js if available, + * otherwise compiles it on-the-fly (slower first startup). + * + * Thread-safe: multiple concurrent calls will await the same initialization. + */ + async initialize(): Promise { + // Already initialized + if (this.initialized) return; + + // Another call is initializing - await it (prevents race condition) + if (this.initPromise) { + await this.initPromise; + return; + } + + // Start initialization and store promise for concurrent callers + this.initPromise = this.doInitialize(); + + try { + await this.initPromise; + } finally { + // Clear promise after completion (success or failure) + this.initPromise = null; + } + } + + /** + * Internal initialization logic. + */ + private async doInitialize(): Promise { + // Use pre-compiled pool-executor.js if it exists + if (fs.existsSync(PRECOMPILED_EXECUTOR_PATH)) { + this.compiledWorkerPath = PRECOMPILED_EXECUTOR_PATH; + this.isTemporaryWorkerFile = false; + } else { + // Fallback: compile on-the-fly (for fresh checkouts, dev mode, etc.) + console.warn( + `[pool] Pre-compiled sandbox executor not found at ${PRECOMPILED_EXECUTOR_PATH}. ` + + `Compiling on-the-fly. Run 'npm run build:executor' in plugins/ for faster startup.` + ); + this.compiledWorkerPath = await this.compileExecutorOnTheFly(); + this.isTemporaryWorkerFile = true; + } + + // Worker heap size from Rust config (based on concurrent tasks per worker) + // Formula: 512 + (concurrent_tasks * 8) MB - ensures enough heap for burst context creation + // Default to 2048MB if not passed (conservative for high load) + const workerHeapMb = parseInt(process.env.PLUGIN_WORKER_HEAP_MB || '2048', 10); + + console.error(`[worker-pool] Worker heap size: ${workerHeapMb}MB (per worker thread)`); + + this.pool = new Piscina({ + filename: this.compiledWorkerPath, + minThreads: this.options.minThreads, + maxThreads: this.options.maxThreads, + concurrentTasksPerWorker: this.options.concurrentTasksPerWorker, + idleTimeout: this.options.idleTimeout, + recordTiming: true, // Unless you need waitTime/runTime metrics + // Worker heap size scaled by concurrent tasks per worker + // Each vm.createContext() uses ~4-8MB, plus GC headroom + // Set by Rust via PLUGIN_WORKER_HEAP_MB env var + resourceLimits: { + maxOldGenerationSizeMb: workerHeapMb, + }, + }); + + // Track worker crashes for self-healing monitoring + let workerCrashCount = 0; + let lastCrashReset = Date.now(); + const CRASH_WINDOW_MS = 60000; // 1 minute window for crash rate monitoring + + // Store event handlers as class properties for proper cleanup on shutdown + this.poolErrorHandler = (err: Error) => { + console.error('[worker-pool] Worker error (will be replaced):', err.message); + // Track errors that indicate memory issues + const errMsg = err.message.toLowerCase(); + if (errMsg.includes('heap') || errMsg.includes('memory') || errMsg.includes('allocation')) { + console.error('[worker-pool] Memory-related worker error detected - consider increasing worker heap size'); + } + // Don't throw - Piscina handles recovery + }; + + this.poolExitHandler = (workerId: number, exitCode: number) => { + // Reset crash counter periodically + const now = Date.now(); + if (now - lastCrashReset > CRASH_WINDOW_MS) { + workerCrashCount = 0; + lastCrashReset = now; + } + + if (exitCode !== 0) { + workerCrashCount++; + console.error( + `[worker-pool] Worker ${workerId} exited with code ${exitCode} ` + + `(replaced automatically, crashes in window: ${workerCrashCount})` + ); + + // Detect crash storms (many workers dying = likely OOM or GC issue) + if (workerCrashCount >= 3) { + console.error( + `[worker-pool] WARNING: ${workerCrashCount} worker crashes in ${CRASH_WINDOW_MS}ms - ` + + `possible memory pressure. Consider reducing PLUGIN_MAX_CONCURRENCY or increasing heap.` + ); + } + + // Exit codes that indicate memory issues + if (exitCode === 134 || exitCode === 137 || exitCode === 9) { + console.error( + `[worker-pool] Worker ${workerId} killed with signal (code ${exitCode}) - ` + + `likely OOM. Clearing script cache as mitigation.` + ); + // Clear cache as mitigation + this.compiledCache.clear(); + } + } + }; + + // Listen to worker events for monitoring + this.pool.on('error', this.poolErrorHandler); + this.pool.on('workerExit', this.poolExitHandler); + + this.initialized = true; + } + + /** + * Compile the sandbox executor on-the-fly when pre-compiled version is not available. + * This is slower but ensures the pool can start in any environment. + */ + private async compileExecutorOnTheFly(): Promise { + const sandboxExecutorPath = path.resolve(__dirname, 'pool-executor.ts'); + + const esbuild = await import('esbuild'); + const result = await esbuild.build({ + entryPoints: [sandboxExecutorPath], + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + sourcemap: false, + write: false, + loader: { '.ts': 'ts' }, + external: ['node:*'], + }); + + // Write to temp file + const tempPath = path.join(os.tmpdir(), `pool-executor-${uuidv4()}.js`); + fs.writeFileSync(tempPath, result.outputFiles[0].text); + return tempPath; + } + + /** + * Pre-compile a plugin and cache the result. + * + * @param pluginPath - Path to the plugin source file + * @param pluginId - Optional plugin identifier for caching + * @returns Compilation result + */ + async precompilePlugin(pluginPath: string, pluginId?: string): Promise { + const startTime = Date.now(); + const result = await compilePlugin(pluginPath); + const compilationTime = Date.now() - startTime; + + this.metrics.totalCompilations++; + this.metrics.totalCompilationTime += compilationTime; + + // Cache by pluginId if provided, otherwise by path (single key to avoid duplication) + const cacheKey = pluginId || pluginPath; + this.compiledCache.set(cacheKey, result.code); + return result; + } + + /** + * Pre-compile a plugin from source code and cache the result. + * + * @param pluginId - Plugin identifier for caching + * @param sourceCode - TypeScript source code + * @returns Compilation result + */ + async precompilePluginSource( + pluginId: string, + sourceCode: string + ): Promise { + const startTime = Date.now(); + const result = await compilePluginSource(sourceCode, `${pluginId}.ts`); + const compilationTime = Date.now() - startTime; + + this.metrics.totalCompilations++; + this.metrics.totalCompilationTime += compilationTime; + + this.compiledCache.set(pluginId, result.code); + return result; + } + + /** + * Store pre-compiled code in the cache. + * Useful when compiled code comes from external storage (Redis). + * + * @param pluginId - Plugin identifier + * @param compiledCode - Pre-compiled JavaScript code + */ + cacheCompiledCode(pluginId: string, compiledCode: string): void { + this.compiledCache.set(pluginId, compiledCode); + } + + /** + * Get compiled code from cache. + * + * @param pluginId - Plugin identifier + * @returns Compiled code or undefined if not cached + */ + getCachedCode(pluginId: string): string | undefined { + return this.compiledCache.get(pluginId); + } + + /** + * Execute a plugin in a worker. + * + * @param request - Plugin execution request + * @returns Plugin execution result + */ + async runPlugin(request: PluginExecutionRequest): Promise { + if (!this.initialized || !this.pool) { + await this.initialize(); + } + + // Verify pool exists after init (defensive - initialize() should throw on failure) + if (!this.pool) { + return { + success: false, + error: { + message: 'Worker pool failed to initialize', + code: 'POOL_INIT_FAILED', + status: 500, + }, + logs: [], + }; + } + + const executionStartTime = Date.now(); + let cacheHit = false; + + // Get compiled code (from request, cache, or compile on-demand) + let compiledCode = request.compiledCode; + + if (!compiledCode && request.pluginPath) { + // Try cache first + compiledCode = this.compiledCache.get(request.pluginPath); + + if (compiledCode) { + cacheHit = true; + this.metrics.cacheHits++; + } else { + // Compile on-demand + this.metrics.cacheMisses++; + const compileStartTime = Date.now(); + const result = await compilePlugin(request.pluginPath); + this.metrics.totalCompilations++; + this.metrics.totalCompilationTime += Date.now() - compileStartTime; + compiledCode = result.code; + this.compiledCache.set(request.pluginPath, compiledCode); + } + } + + if (!compiledCode) { + // Try by plugin ID + compiledCode = this.compiledCache.get(request.pluginId); + if (compiledCode) { + cacheHit = true; + this.metrics.cacheHits++; + } else { + this.metrics.cacheMisses++; + } + } + + if (!compiledCode) { + this.metrics.totalExecutions++; + this.metrics.failedExecutions++; + const errorCode = 'NO_COMPILED_CODE'; + incrementBoundedMap(this.metrics.errorsByType, errorCode, MAX_METRICS_ENTRIES); + return { + success: false, + error: { + message: `No compiled code available for plugin ${request.pluginId}`, + code: errorCode, + status: 500, + }, + logs: [], + }; + } + + // Create task for the worker + const task: ExecutorTask = { + taskId: uuidv4(), + pluginId: request.pluginId, + compiledCode, + params: request.params, + headers: request.headers, + socketPath: request.socketPath, + httpRequestId: request.httpRequestId, + timeout: request.timeout ?? DEFAULT_TIMEOUT, + route: request.route, + config: request.config, + method: request.method, + query: request.query, + }; + + // Track per-plugin execution (bounded to prevent memory leak) + incrementBoundedMap(this.metrics.pluginExecutions, request.pluginId, MAX_METRICS_ENTRIES); + + // Use task timeout to prevent permanently stuck workers + // This is a safety net beyond the handler-level timeout in pool-executor + const taskTimeout = this.options.taskTimeout; + let timeoutId: NodeJS.Timeout | undefined; + + try { + const runPromise = this.pool!.run(task); + + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error(`Task timed out after ${taskTimeout}ms (worker may be stuck)`)); + }, taskTimeout); + }); + + const result: ExecutorResult = await Promise.race([runPromise, timeoutPromise]); + + // Update execution metrics + const executionTime = Date.now() - executionStartTime; + this.metrics.totalExecutions++; + this.metrics.lastExecutionTime = Date.now(); + this.metrics.totalExecutionTime += executionTime; + this.metrics.minExecutionTime = Math.min(this.metrics.minExecutionTime, executionTime); + this.metrics.maxExecutionTime = Math.max(this.metrics.maxExecutionTime, executionTime); + + if (result.success) { + this.metrics.successfulExecutions++; + } else { + this.metrics.failedExecutions++; + if (result.error?.code) { + incrementBoundedMap(this.metrics.errorsByType, result.error.code, MAX_METRICS_ENTRIES); + } + } + + return { + success: result.success, + result: result.result, + error: result.error, + logs: result.logs, + }; + } catch (error) { + const err = error as Error; + + // Update execution metrics for error case + const executionTime = Date.now() - executionStartTime; + this.metrics.totalExecutions++; + this.metrics.failedExecutions++; + this.metrics.lastExecutionTime = Date.now(); + this.metrics.totalExecutionTime += executionTime; + this.metrics.minExecutionTime = Math.min(this.metrics.minExecutionTime, executionTime); + this.metrics.maxExecutionTime = Math.max(this.metrics.maxExecutionTime, executionTime); + incrementBoundedMap(this.metrics.errorsByType, 'WORKER_ERROR', MAX_METRICS_ENTRIES); + + return { + success: false, + error: { + message: err.message || String(error), + code: 'WORKER_ERROR', + status: 500, + }, + logs: [], + }; + } finally { + // Clear timeout to prevent timer leak + if (timeoutId) { + clearTimeout(timeoutId); + } + } + } + + /** + * Clear the compiled code cache. + */ + clearCache(): void { + this.compiledCache.clear(); + } + + /** + * Invalidate a specific plugin from the cache. + * Removes entries by both pluginId and any associated path. + * + * @param pluginId - Plugin identifier to invalidate + * @param pluginPath - Optional path to also invalidate + */ + invalidatePlugin(pluginId: string, pluginPath?: string): void { + this.compiledCache.delete(pluginId); + if (pluginPath) { + this.compiledCache.delete(pluginPath); + } + } + + /** + * Get pool statistics including execution metrics. + */ + getStats(): { + pool: { + completed: number; + queued: number; + runTime: { average: number; max: number; min: number }; + waitTime: { average: number; max: number; min: number }; + } | null; + execution: { + total: number; + successful: number; + failed: number; + successRate: number; + avgExecutionTime: number; + minExecutionTime: number; + maxExecutionTime: number; + }; + cache: { + hits: number; + misses: number; + hitRate: number; + }; + compilation: { + total: number; + totalTime: number; + avgTime: number; + }; + plugins: Record; + errors: Record; + uptime: number; + } { + const poolStats = this.pool ? { + completed: this.pool.completed, + queued: this.pool.queueSize, + runTime: { + average: this.pool.runTime.average, + max: this.pool.runTime.max, + min: this.pool.runTime.min, + }, + waitTime: { + average: this.pool.waitTime.average, + max: this.pool.waitTime.max, + min: this.pool.waitTime.min, + }, + } : null; + + const totalCacheAccesses = this.metrics.cacheHits + this.metrics.cacheMisses; + + return { + pool: poolStats, + execution: { + total: this.metrics.totalExecutions, + successful: this.metrics.successfulExecutions, + failed: this.metrics.failedExecutions, + successRate: this.metrics.totalExecutions > 0 + ? this.metrics.successfulExecutions / this.metrics.totalExecutions + : 1, + avgExecutionTime: this.metrics.totalExecutions > 0 + ? this.metrics.totalExecutionTime / this.metrics.totalExecutions + : 0, + minExecutionTime: this.metrics.minExecutionTime === Infinity + ? 0 + : this.metrics.minExecutionTime, + maxExecutionTime: this.metrics.maxExecutionTime, + }, + cache: { + hits: this.metrics.cacheHits, + misses: this.metrics.cacheMisses, + hitRate: totalCacheAccesses > 0 + ? this.metrics.cacheHits / totalCacheAccesses + : 0, + }, + compilation: { + total: this.metrics.totalCompilations, + totalTime: this.metrics.totalCompilationTime, + avgTime: this.metrics.totalCompilations > 0 + ? this.metrics.totalCompilationTime / this.metrics.totalCompilations + : 0, + }, + plugins: Object.fromEntries(this.metrics.pluginExecutions), + errors: Object.fromEntries(this.metrics.errorsByType), + uptime: Date.now() - this.metrics.startTime, + }; + } + + /** + * Reset metrics to initial state. + * Useful for testing or periodic resets. + */ + resetMetrics(): void { + this.metrics = createInitialMetrics(); + } + + /** + * Shutdown the worker pool. + * Call this when the application is shutting down. + */ + async shutdown(): Promise { + if (this.pool) { + // Remove event listeners before destroying to prevent accumulation + if (this.poolErrorHandler) { + this.pool.off('error', this.poolErrorHandler); + this.poolErrorHandler = null; + } + if (this.poolExitHandler) { + this.pool.off('workerExit', this.poolExitHandler); + this.poolExitHandler = null; + } + + await this.pool.destroy(); + this.pool = null; + this.initialized = false; + } + + // Remove process cleanup handlers to prevent accumulation + this.removeCleanupHandlers(); + + // Clean up temporary compiled worker file to prevent disk space leak + if (this.isTemporaryWorkerFile && this.compiledWorkerPath) { + try { + fs.unlinkSync(this.compiledWorkerPath); + } catch { + // Ignore errors - file may already be deleted or inaccessible + } + } + + this.compiledWorkerPath = null; + this.isTemporaryWorkerFile = false; + // Properly destroy cache (clears timer + entries) + this.compiledCache.destroy(); + } +} + +// Singleton instance for convenience +let defaultPool: WorkerPoolManager | null = null; + +/** + * Get or create the default worker pool instance. + */ +export function getDefaultPool(options?: WorkerPoolOptions): WorkerPoolManager { + if (!defaultPool) { + defaultPool = new WorkerPoolManager(options); + } + return defaultPool; +} + +/** + * Shutdown the default worker pool. + */ +export async function shutdownDefaultPool(): Promise { + if (defaultPool) { + await defaultPool.shutdown(); + defaultPool = null; + } +} diff --git a/plugins/package.json b/plugins/package.json index 55944d813..e9f7e0816 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -7,16 +7,19 @@ "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", - "build": "tsc" + "build": "ts-node lib/build-executor.ts", + "postinstall": "pnpm run build" }, "keywords": [], "author": "", "license": "", "dependencies": { - "@openzeppelin/relayer-sdk": "^1.7.0", + "@openzeppelin/relayer-sdk": "^1.9.0", "@types/node": "^24.0.3", + "esbuild": "^0.24.0", "ethers": "^6.14.3", "ioredis": "^5.7.0", + "piscina": "^4.7.0", "uuid": "^11.1.0" }, "devDependencies": { @@ -27,6 +30,7 @@ "ioredis-mock": "^8.9.0", "jest": "^29.7.0", "ts-jest": "^29.1.2", - "typescript": "^5.3.3" + "typescript": "^5.3.3", + "ts-node": "^10.9.2" } } diff --git a/plugins/pnpm-lock.yaml b/plugins/pnpm-lock.yaml index 7fc814cb6..e50c9b435 100644 --- a/plugins/pnpm-lock.yaml +++ b/plugins/pnpm-lock.yaml @@ -7,17 +7,23 @@ importers: .: dependencies: '@openzeppelin/relayer-sdk': - specifier: ^1.7.0 - version: 1.7.0 + specifier: ^1.9.0 + version: 1.9.0 '@types/node': specifier: ^24.0.3 version: 24.9.2 + esbuild: + specifier: ^0.24.0 + version: 0.24.2 ethers: specifier: ^6.14.3 version: 6.15.0 ioredis: specifier: ^5.7.0 version: 5.8.2 + piscina: + specifier: ^4.7.0 + version: 4.9.2 uuid: specifier: ^11.1.0 version: 11.1.0 @@ -39,10 +45,13 @@ importers: version: 8.13.1(@types/ioredis-mock@8.2.6(ioredis@5.8.2))(ioredis@5.8.2) jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@24.9.2) + version: 29.7.0(@types/node@24.9.2)(ts-node@10.9.2(@types/node@24.9.2)(typescript@5.9.3)) ts-jest: specifier: ^29.1.2 - version: 29.4.5(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.9.2))(typescript@5.9.3) + version: 29.4.5(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(esbuild@0.24.2)(jest-util@29.7.0)(jest@29.7.0(@types/node@24.9.2)(ts-node@10.9.2(@types/node@24.9.2)(typescript@5.9.3)))(typescript@5.9.3) + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@24.9.2)(typescript@5.9.3) typescript: specifier: ^5.3.3 version: 5.9.3 @@ -179,6 +188,184 @@ packages: engines: {node: '>=6.9.0'} '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + '@esbuild/aix-ppc64@0.24.2': + resolution: {integrity: sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==} + engines: {node: '>=18'} + cpu: + - ppc64 + os: + - aix + '@esbuild/android-arm64@0.24.2': + resolution: {integrity: sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==} + engines: {node: '>=18'} + cpu: + - arm64 + os: + - android + '@esbuild/android-arm@0.24.2': + resolution: {integrity: sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==} + engines: {node: '>=18'} + cpu: + - arm + os: + - android + '@esbuild/android-x64@0.24.2': + resolution: {integrity: sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==} + engines: {node: '>=18'} + cpu: + - x64 + os: + - android + '@esbuild/darwin-arm64@0.24.2': + resolution: {integrity: sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==} + engines: {node: '>=18'} + cpu: + - arm64 + os: + - darwin + '@esbuild/darwin-x64@0.24.2': + resolution: {integrity: sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==} + engines: {node: '>=18'} + cpu: + - x64 + os: + - darwin + '@esbuild/freebsd-arm64@0.24.2': + resolution: {integrity: sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==} + engines: {node: '>=18'} + cpu: + - arm64 + os: + - freebsd + '@esbuild/freebsd-x64@0.24.2': + resolution: {integrity: sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==} + engines: {node: '>=18'} + cpu: + - x64 + os: + - freebsd + '@esbuild/linux-arm64@0.24.2': + resolution: {integrity: sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==} + engines: {node: '>=18'} + cpu: + - arm64 + os: + - linux + '@esbuild/linux-arm@0.24.2': + resolution: {integrity: sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==} + engines: {node: '>=18'} + cpu: + - arm + os: + - linux + '@esbuild/linux-ia32@0.24.2': + resolution: {integrity: sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==} + engines: {node: '>=18'} + cpu: + - ia32 + os: + - linux + '@esbuild/linux-loong64@0.24.2': + resolution: {integrity: sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==} + engines: {node: '>=18'} + cpu: + - loong64 + os: + - linux + '@esbuild/linux-mips64el@0.24.2': + resolution: {integrity: sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==} + engines: {node: '>=18'} + cpu: + - mips64el + os: + - linux + '@esbuild/linux-ppc64@0.24.2': + resolution: {integrity: sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==} + engines: {node: '>=18'} + cpu: + - ppc64 + os: + - linux + '@esbuild/linux-riscv64@0.24.2': + resolution: {integrity: sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==} + engines: {node: '>=18'} + cpu: + - riscv64 + os: + - linux + '@esbuild/linux-s390x@0.24.2': + resolution: {integrity: sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==} + engines: {node: '>=18'} + cpu: + - s390x + os: + - linux + '@esbuild/linux-x64@0.24.2': + resolution: {integrity: sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==} + engines: {node: '>=18'} + cpu: + - x64 + os: + - linux + '@esbuild/netbsd-arm64@0.24.2': + resolution: {integrity: sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==} + engines: {node: '>=18'} + cpu: + - arm64 + os: + - netbsd + '@esbuild/netbsd-x64@0.24.2': + resolution: {integrity: sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==} + engines: {node: '>=18'} + cpu: + - x64 + os: + - netbsd + '@esbuild/openbsd-arm64@0.24.2': + resolution: {integrity: sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==} + engines: {node: '>=18'} + cpu: + - arm64 + os: + - openbsd + '@esbuild/openbsd-x64@0.24.2': + resolution: {integrity: sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==} + engines: {node: '>=18'} + cpu: + - x64 + os: + - openbsd + '@esbuild/sunos-x64@0.24.2': + resolution: {integrity: sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==} + engines: {node: '>=18'} + cpu: + - x64 + os: + - sunos + '@esbuild/win32-arm64@0.24.2': + resolution: {integrity: sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==} + engines: {node: '>=18'} + cpu: + - arm64 + os: + - win32 + '@esbuild/win32-ia32@0.24.2': + resolution: {integrity: sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==} + engines: {node: '>=18'} + cpu: + - ia32 + os: + - win32 + '@esbuild/win32-x64@0.24.2': + resolution: {integrity: sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==} + engines: {node: '>=18'} + cpu: + - x64 + os: + - win32 '@ioredis/as-callback@3.0.0': resolution: {integrity: sha512-Kqv1rZ3WbgOrS+hgzJ5xG5WQuhvzzSTRYvNeyPMLOAM78MHSnuKI20JeJGbpuAt//LCuP0vsexZcorqW7kWhJg==} '@ioredis/commands@1.4.0': @@ -252,13 +439,137 @@ packages: resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@napi-rs/nice-android-arm-eabi@1.1.1': + resolution: {integrity: sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==} + engines: {node: '>= 10'} + cpu: + - arm + os: + - android + '@napi-rs/nice-android-arm64@1.1.1': + resolution: {integrity: sha512-blG0i7dXgbInN5urONoUCNf+DUEAavRffrO7fZSeoRMJc5qD+BJeNcpr54msPF6qfDD6kzs9AQJogZvT2KD5nw==} + engines: {node: '>= 10'} + cpu: + - arm64 + os: + - android + '@napi-rs/nice-darwin-arm64@1.1.1': + resolution: {integrity: sha512-s/E7w45NaLqTGuOjC2p96pct4jRfo61xb9bU1unM/MJ/RFkKlJyJDx7OJI/O0ll/hrfpqKopuAFDV8yo0hfT7A==} + engines: {node: '>= 10'} + cpu: + - arm64 + os: + - darwin + '@napi-rs/nice-darwin-x64@1.1.1': + resolution: {integrity: sha512-dGoEBnVpsdcC+oHHmW1LRK5eiyzLwdgNQq3BmZIav+9/5WTZwBYX7r5ZkQC07Nxd3KHOCkgbHSh4wPkH1N1LiQ==} + engines: {node: '>= 10'} + cpu: + - x64 + os: + - darwin + '@napi-rs/nice-freebsd-x64@1.1.1': + resolution: {integrity: sha512-kHv4kEHAylMYmlNwcQcDtXjklYp4FCf0b05E+0h6nDHsZ+F0bDe04U/tXNOqrx5CmIAth4vwfkjjUmp4c4JktQ==} + engines: {node: '>= 10'} + cpu: + - x64 + os: + - freebsd + '@napi-rs/nice-linux-arm-gnueabihf@1.1.1': + resolution: {integrity: sha512-E1t7K0efyKXZDoZg1LzCOLxgolxV58HCkaEkEvIYQx12ht2pa8hoBo+4OB3qh7e+QiBlp1SRf+voWUZFxyhyqg==} + engines: {node: '>= 10'} + cpu: + - arm + os: + - linux + '@napi-rs/nice-linux-arm64-gnu@1.1.1': + resolution: {integrity: sha512-CIKLA12DTIZlmTaaKhQP88R3Xao+gyJxNWEn04wZwC2wmRapNnxCUZkVwggInMJvtVElA+D4ZzOU5sX4jV+SmQ==} + engines: {node: '>= 10'} + cpu: + - arm64 + os: + - linux + '@napi-rs/nice-linux-arm64-musl@1.1.1': + resolution: {integrity: sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==} + engines: {node: '>= 10'} + cpu: + - arm64 + os: + - linux + '@napi-rs/nice-linux-ppc64-gnu@1.1.1': + resolution: {integrity: sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==} + engines: {node: '>= 10'} + cpu: + - ppc64 + os: + - linux + '@napi-rs/nice-linux-riscv64-gnu@1.1.1': + resolution: {integrity: sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==} + engines: {node: '>= 10'} + cpu: + - riscv64 + os: + - linux + '@napi-rs/nice-linux-s390x-gnu@1.1.1': + resolution: {integrity: sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==} + engines: {node: '>= 10'} + cpu: + - s390x + os: + - linux + '@napi-rs/nice-linux-x64-gnu@1.1.1': + resolution: {integrity: sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==} + engines: {node: '>= 10'} + cpu: + - x64 + os: + - linux + '@napi-rs/nice-linux-x64-musl@1.1.1': + resolution: {integrity: sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==} + engines: {node: '>= 10'} + cpu: + - x64 + os: + - linux + '@napi-rs/nice-openharmony-arm64@1.1.1': + resolution: {integrity: sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==} + engines: {node: '>= 10'} + cpu: + - arm64 + os: + - openharmony + '@napi-rs/nice-win32-arm64-msvc@1.1.1': + resolution: {integrity: sha512-uoTb4eAvM5B2aj/z8j+Nv8OttPf2m+HVx3UjA5jcFxASvNhQriyCQF1OB1lHL43ZhW+VwZlgvjmP5qF3+59atA==} + engines: {node: '>= 10'} + cpu: + - arm64 + os: + - win32 + '@napi-rs/nice-win32-ia32-msvc@1.1.1': + resolution: {integrity: sha512-CNQqlQT9MwuCsg1Vd/oKXiuH+TcsSPJmlAFc5frFyX/KkOh0UpBLEj7aoY656d5UKZQMQFP7vJNa1DNUNORvug==} + engines: {node: '>= 10'} + cpu: + - ia32 + os: + - win32 + '@napi-rs/nice-win32-x64-msvc@1.1.1': + resolution: {integrity: sha512-vB+4G/jBQCAh0jelMTY3+kgFy00Hlx2f2/1zjMoH821IbplbWZOkLiTYXQkygNTzQJTq5cvwBDgn2ppHD+bglQ==} + engines: {node: '>= 10'} + cpu: + - x64 + os: + - win32 + '@napi-rs/nice@1.1.1': + resolution: {integrity: sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw==} + engines: {node: '>= 10'} '@noble/curves@1.2.0': resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==} '@noble/hashes@1.3.2': resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==} engines: {node: '>= 16'} - '@openzeppelin/relayer-sdk@1.7.0': - resolution: {integrity: sha512-XaQDdfooj6LC9Xa2fhq0ElaF0q/+rn4D0KNKDiwUa+QdZk18LeIiKxyZKxXuA4RynXxosISe1oQW78XYC/nE6Q==} + '@openzeppelin/relayer-sdk@1.9.0': + resolution: {integrity: sha512-G3Zhg0imaT9Ej1cE9Cq5d4uQvW+DinD2GvRuiPWo3+O9KI8ivBnGJkjEGgK9Ja3/QsUNIfx8Gk5Mdw2sPXjVWA==} engines: {node: '>=22.14.0', npm: use pnpm, pnpm: '>=9', yarn: use pnpm} '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} @@ -266,6 +577,14 @@ packages: resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@tsconfig/node10@1.0.12': + resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} + '@tsconfig/node12@1.0.11': + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + '@tsconfig/node14@1.0.3': + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + '@tsconfig/node16@1.0.4': + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} '@types/babel__generator@7.27.0': @@ -300,6 +619,13 @@ packages: resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} '@types/yargs@17.0.34': resolution: {integrity: sha512-KExbHVa92aJpw9WDQvzBaGVE2/Pz+pLZQloT2hjL8IqsZnV62rlPOYvNnLmf/L2dyllfVUOVBj64M0z/46eR2A==} + acorn-walk@8.3.4: + resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} + engines: {node: '>=0.4.0'} + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true aes-js@4.0.0-beta.5: resolution: {integrity: sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==} ansi-escapes@4.3.2: @@ -317,6 +643,8 @@ packages: anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} asynckit@0.4.0: @@ -416,6 +744,8 @@ packages: resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true + create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -449,6 +779,9 @@ packages: diff-sequences@29.6.3: resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + diff@4.0.2: + resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} + engines: {node: '>=0.3.1'} dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -473,6 +806,10 @@ packages: es-set-tostringtag@2.1.0: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + esbuild@0.24.2: + resolution: {integrity: sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==} + engines: {node: '>=18'} + hasBin: true escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -863,6 +1200,8 @@ packages: pirates@4.0.7: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + piscina@4.9.2: + resolution: {integrity: sha512-Fq0FERJWFEUpB4eSY59wSNwXD4RYqR+nR/WiEVcZW8IWfVBxJJafcgTEZDQo8k3w0sUarJ8RyVbbUF4GQ2LGbQ==} pkg-dir@4.2.0: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} @@ -1001,6 +1340,19 @@ packages: optional: true jest-util: optional: true + ts-node@10.9.2: + resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true tslib@2.7.0: resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} type-detect@4.0.8: @@ -1032,6 +1384,8 @@ packages: uuid@11.1.0: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true + v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} v8-to-istanbul@9.3.0: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} @@ -1073,6 +1427,9 @@ packages: yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -1232,6 +1589,59 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 '@bcoe/v8-coverage@0.2.3': {} + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + '@esbuild/aix-ppc64@0.24.2': + optional: true + '@esbuild/android-arm64@0.24.2': + optional: true + '@esbuild/android-arm@0.24.2': + optional: true + '@esbuild/android-x64@0.24.2': + optional: true + '@esbuild/darwin-arm64@0.24.2': + optional: true + '@esbuild/darwin-x64@0.24.2': + optional: true + '@esbuild/freebsd-arm64@0.24.2': + optional: true + '@esbuild/freebsd-x64@0.24.2': + optional: true + '@esbuild/linux-arm64@0.24.2': + optional: true + '@esbuild/linux-arm@0.24.2': + optional: true + '@esbuild/linux-ia32@0.24.2': + optional: true + '@esbuild/linux-loong64@0.24.2': + optional: true + '@esbuild/linux-mips64el@0.24.2': + optional: true + '@esbuild/linux-ppc64@0.24.2': + optional: true + '@esbuild/linux-riscv64@0.24.2': + optional: true + '@esbuild/linux-s390x@0.24.2': + optional: true + '@esbuild/linux-x64@0.24.2': + optional: true + '@esbuild/netbsd-arm64@0.24.2': + optional: true + '@esbuild/netbsd-x64@0.24.2': + optional: true + '@esbuild/openbsd-arm64@0.24.2': + optional: true + '@esbuild/openbsd-x64@0.24.2': + optional: true + '@esbuild/sunos-x64@0.24.2': + optional: true + '@esbuild/win32-arm64@0.24.2': + optional: true + '@esbuild/win32-ia32@0.24.2': + optional: true + '@esbuild/win32-x64@0.24.2': + optional: true '@ioredis/as-callback@3.0.0': {} '@ioredis/commands@1.4.0': {} '@istanbuljs/load-nyc-config@1.1.0': @@ -1250,7 +1660,7 @@ snapshots: jest-message-util: 29.7.0 jest-util: 29.7.0 slash: 3.0.0 - '@jest/core@29.7.0': + '@jest/core@29.7.0(ts-node@10.9.2(@types/node@24.9.2)(typescript@5.9.3))': dependencies: '@jest/console': 29.7.0 '@jest/reporters': 29.7.0 @@ -1264,7 +1674,7 @@ snapshots: exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@24.9.2) + jest-config: 29.7.0(@types/node@24.9.2)(ts-node@10.9.2(@types/node@24.9.2)(typescript@5.9.3)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -1404,11 +1814,69 @@ snapshots: dependencies: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@napi-rs/nice-android-arm-eabi@1.1.1': + optional: true + '@napi-rs/nice-android-arm64@1.1.1': + optional: true + '@napi-rs/nice-darwin-arm64@1.1.1': + optional: true + '@napi-rs/nice-darwin-x64@1.1.1': + optional: true + '@napi-rs/nice-freebsd-x64@1.1.1': + optional: true + '@napi-rs/nice-linux-arm-gnueabihf@1.1.1': + optional: true + '@napi-rs/nice-linux-arm64-gnu@1.1.1': + optional: true + '@napi-rs/nice-linux-arm64-musl@1.1.1': + optional: true + '@napi-rs/nice-linux-ppc64-gnu@1.1.1': + optional: true + '@napi-rs/nice-linux-riscv64-gnu@1.1.1': + optional: true + '@napi-rs/nice-linux-s390x-gnu@1.1.1': + optional: true + '@napi-rs/nice-linux-x64-gnu@1.1.1': + optional: true + '@napi-rs/nice-linux-x64-musl@1.1.1': + optional: true + '@napi-rs/nice-openharmony-arm64@1.1.1': + optional: true + '@napi-rs/nice-win32-arm64-msvc@1.1.1': + optional: true + '@napi-rs/nice-win32-ia32-msvc@1.1.1': + optional: true + '@napi-rs/nice-win32-x64-msvc@1.1.1': + optional: true + '@napi-rs/nice@1.1.1': + optionalDependencies: + '@napi-rs/nice-android-arm-eabi': 1.1.1 + '@napi-rs/nice-android-arm64': 1.1.1 + '@napi-rs/nice-darwin-arm64': 1.1.1 + '@napi-rs/nice-darwin-x64': 1.1.1 + '@napi-rs/nice-freebsd-x64': 1.1.1 + '@napi-rs/nice-linux-arm-gnueabihf': 1.1.1 + '@napi-rs/nice-linux-arm64-gnu': 1.1.1 + '@napi-rs/nice-linux-arm64-musl': 1.1.1 + '@napi-rs/nice-linux-ppc64-gnu': 1.1.1 + '@napi-rs/nice-linux-riscv64-gnu': 1.1.1 + '@napi-rs/nice-linux-s390x-gnu': 1.1.1 + '@napi-rs/nice-linux-x64-gnu': 1.1.1 + '@napi-rs/nice-linux-x64-musl': 1.1.1 + '@napi-rs/nice-openharmony-arm64': 1.1.1 + '@napi-rs/nice-win32-arm64-msvc': 1.1.1 + '@napi-rs/nice-win32-ia32-msvc': 1.1.1 + '@napi-rs/nice-win32-x64-msvc': 1.1.1 + optional: true '@noble/curves@1.2.0': dependencies: '@noble/hashes': 1.3.2 '@noble/hashes@1.3.2': {} - '@openzeppelin/relayer-sdk@1.7.0': + '@openzeppelin/relayer-sdk@1.9.0': dependencies: axios: 1.13.1 transitivePeerDependencies: @@ -1420,6 +1888,10 @@ snapshots: '@sinonjs/fake-timers@10.3.0': dependencies: '@sinonjs/commons': 3.0.1 + '@tsconfig/node10@1.0.12': {} + '@tsconfig/node12@1.0.11': {} + '@tsconfig/node14@1.0.3': {} + '@tsconfig/node16@1.0.4': {} '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.5 @@ -1466,6 +1938,10 @@ snapshots: '@types/yargs@17.0.34': dependencies: '@types/yargs-parser': 21.0.3 + acorn-walk@8.3.4: + dependencies: + acorn: 8.15.0 + acorn@8.15.0: {} aes-js@4.0.0-beta.5: {} ansi-escapes@4.3.2: dependencies: @@ -1479,6 +1955,7 @@ snapshots: dependencies: normalize-path: 3.0.0 picomatch: 2.3.1 + arg@4.1.3: {} argparse@1.0.10: dependencies: sprintf-js: 1.0.3 @@ -1595,13 +2072,13 @@ snapshots: delayed-stream: 1.0.0 concat-map@0.0.1: {} convert-source-map@2.0.0: {} - create-jest@29.7.0(@types/node@24.9.2): + create-jest@29.7.0(@types/node@24.9.2)(ts-node@10.9.2(@types/node@24.9.2)(typescript@5.9.3)): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@24.9.2) + jest-config: 29.7.0(@types/node@24.9.2)(ts-node@10.9.2(@types/node@24.9.2)(typescript@5.9.3)) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -1609,6 +2086,7 @@ snapshots: - babel-plugin-macros - supports-color - ts-node + create-require@1.1.1: {} cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -1623,6 +2101,7 @@ snapshots: denque@2.1.0: {} detect-newline@3.1.0: {} diff-sequences@29.6.3: {} + diff@4.0.2: {} dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -1645,6 +2124,33 @@ snapshots: get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 hasown: 2.0.2 + esbuild@0.24.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.24.2 + '@esbuild/android-arm': 0.24.2 + '@esbuild/android-arm64': 0.24.2 + '@esbuild/android-x64': 0.24.2 + '@esbuild/darwin-arm64': 0.24.2 + '@esbuild/darwin-x64': 0.24.2 + '@esbuild/freebsd-arm64': 0.24.2 + '@esbuild/freebsd-x64': 0.24.2 + '@esbuild/linux-arm': 0.24.2 + '@esbuild/linux-arm64': 0.24.2 + '@esbuild/linux-ia32': 0.24.2 + '@esbuild/linux-loong64': 0.24.2 + '@esbuild/linux-mips64el': 0.24.2 + '@esbuild/linux-ppc64': 0.24.2 + '@esbuild/linux-riscv64': 0.24.2 + '@esbuild/linux-s390x': 0.24.2 + '@esbuild/linux-x64': 0.24.2 + '@esbuild/netbsd-arm64': 0.24.2 + '@esbuild/netbsd-x64': 0.24.2 + '@esbuild/openbsd-arm64': 0.24.2 + '@esbuild/openbsd-x64': 0.24.2 + '@esbuild/sunos-x64': 0.24.2 + '@esbuild/win32-arm64': 0.24.2 + '@esbuild/win32-ia32': 0.24.2 + '@esbuild/win32-x64': 0.24.2 escalade@3.2.0: {} escape-string-regexp@2.0.0: {} esprima@4.0.1: {} @@ -1865,16 +2371,16 @@ snapshots: transitivePeerDependencies: - babel-plugin-macros - supports-color - jest-cli@29.7.0(@types/node@24.9.2): + jest-cli@29.7.0(@types/node@24.9.2)(ts-node@10.9.2(@types/node@24.9.2)(typescript@5.9.3)): dependencies: - '@jest/core': 29.7.0 + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@24.9.2)(typescript@5.9.3)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@24.9.2) + create-jest: 29.7.0(@types/node@24.9.2)(ts-node@10.9.2(@types/node@24.9.2)(typescript@5.9.3)) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@24.9.2) + jest-config: 29.7.0(@types/node@24.9.2)(ts-node@10.9.2(@types/node@24.9.2)(typescript@5.9.3)) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -1883,7 +2389,7 @@ snapshots: - babel-plugin-macros - supports-color - ts-node - jest-config@29.7.0(@types/node@24.9.2): + jest-config@29.7.0(@types/node@24.9.2)(ts-node@10.9.2(@types/node@24.9.2)(typescript@5.9.3)): dependencies: '@babel/core': 7.28.5 '@jest/test-sequencer': 29.7.0 @@ -1909,6 +2415,7 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 24.9.2 + ts-node: 10.9.2(@types/node@24.9.2)(typescript@5.9.3) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -2106,12 +2613,12 @@ snapshots: jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 - jest@29.7.0(@types/node@24.9.2): + jest@29.7.0(@types/node@24.9.2)(ts-node@10.9.2(@types/node@24.9.2)(typescript@5.9.3)): dependencies: - '@jest/core': 29.7.0 + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@24.9.2)(typescript@5.9.3)) '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@24.9.2) + jest-cli: 29.7.0(@types/node@24.9.2)(ts-node@10.9.2(@types/node@24.9.2)(typescript@5.9.3)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -2198,6 +2705,9 @@ snapshots: picocolors@1.1.1: {} picomatch@2.3.1: {} pirates@4.0.7: {} + piscina@4.9.2: + optionalDependencies: + '@napi-rs/nice': 1.1.1 pkg-dir@4.2.0: dependencies: find-up: 4.1.0 @@ -2283,12 +2793,12 @@ snapshots: to-regex-range@5.0.1: dependencies: is-number: 7.0.0 - ? ts-jest@29.4.5(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.9.2))(typescript@5.9.3) + ? ts-jest@29.4.5(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(esbuild@0.24.2)(jest-util@29.7.0)(jest@29.7.0(@types/node@24.9.2)(ts-node@10.9.2(@types/node@24.9.2)(typescript@5.9.3)))(typescript@5.9.3) : dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 handlebars: 4.7.8 - jest: 29.7.0(@types/node@24.9.2) + jest: 29.7.0(@types/node@24.9.2)(ts-node@10.9.2(@types/node@24.9.2)(typescript@5.9.3)) json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 @@ -2301,7 +2811,25 @@ snapshots: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 babel-jest: 29.7.0(@babel/core@7.28.5) + esbuild: 0.24.2 jest-util: 29.7.0 + ts-node@10.9.2(@types/node@24.9.2)(typescript@5.9.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.12 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 24.9.2 + acorn: 8.15.0 + acorn-walk: 8.3.4 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 5.9.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 tslib@2.7.0: {} type-detect@4.0.8: {} type-fest@0.21.3: {} @@ -2317,6 +2845,7 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 uuid@11.1.0: {} + v8-compile-cache-lib@3.0.1: {} v8-to-istanbul@9.3.0: dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -2352,4 +2881,5 @@ snapshots: string-width: 4.2.3 y18n: 5.0.8 yargs-parser: 21.1.1 + yn@3.1.1: {} yocto-queue@0.1.0: {} diff --git a/plugins/tests/lib/direct-executor.test.ts b/plugins/tests/lib/direct-executor.test.ts new file mode 100644 index 000000000..7ffcc1ca8 --- /dev/null +++ b/plugins/tests/lib/direct-executor.test.ts @@ -0,0 +1,1324 @@ +import * as vm from 'node:vm'; +import * as v8 from 'node:v8'; +import * as net from 'node:net'; + +describe('ContextPool logic', () => { + class MockContextPool { + private available: any[] = []; + private readonly maxSize = 10; + + acquire(): any { + const ctx = this.available.pop(); + if (ctx) { + this.resetContext(ctx); + return ctx; + } + return this.createFreshContext(); + } + + release(ctx: any): void { + if (this.available.length < this.maxSize) { + this.available.push(ctx); + } + } + + private createFreshContext(): any { + return { _fresh: true }; + } + + private resetContext(ctx: any): void { + if (ctx.__pluginState) { + delete ctx.__pluginState; + } + } + + clear(): void { + this.available = []; + } + + size(): number { + return this.available.length; + } + } + + it('should create new context when pool is empty', () => { + const pool = new MockContextPool(); + const ctx = pool.acquire(); + + expect(ctx).toBeDefined(); + expect(ctx._fresh).toBe(true); + expect(pool.size()).toBe(0); + }); + + it('should reuse context from pool', () => { + const pool = new MockContextPool(); + const ctx1 = pool.acquire(); + pool.release(ctx1); + + expect(pool.size()).toBe(1); + + const ctx2 = pool.acquire(); + expect(ctx2).toBe(ctx1); // Same object + expect(pool.size()).toBe(0); + }); + + it('should not exceed max pool size', () => { + const pool = new MockContextPool(); + const contexts: any[] = []; + + // Create 15 contexts + for (let i = 0; i < 15; i++) { + const ctx = pool.acquire(); + ctx.id = i; + contexts.push(ctx); + } + + // Release them one by one + for (const ctx of contexts) { + pool.release(ctx); + } + + expect(pool.size()).toBe(10); // Should cap at maxSize + }); + + it('should reset context state before reuse', () => { + const pool = new MockContextPool(); + const ctx = pool.acquire(); + + // Simulate plugin pollution + (ctx as any).__pluginState = { dirty: true }; + pool.release(ctx); + + const reusedCtx = pool.acquire(); + expect((reusedCtx as any).__pluginState).toBeUndefined(); + }); + + it('should clear all contexts', () => { + const pool = new MockContextPool(); + const contexts: any[] = []; + + for (let i = 0; i < 5; i++) { + const ctx = pool.acquire(); + contexts.push(ctx); + } + + // Release them to the pool + for (const ctx of contexts) { + pool.release(ctx); + } + + expect(pool.size()).toBe(5); + pool.clear(); + expect(pool.size()).toBe(0); + }); +}); + +describe('ScriptCache logic', () => { + class MockScriptCache { + private cache = new Map(); + private readonly maxSize = 100; + + get(code: string): any | undefined { + const entry = this.cache.get(code); + if (entry) { + entry.timestamp = Date.now(); + return entry.script; + } + return undefined; + } + + set(code: string, script: any): void { + if (this.cache.size >= this.maxSize) { + this.evictOldest(1); + } + this.cache.set(code, { script, timestamp: Date.now() }); + } + + evictOldest(count: number): void { + const entries = [...this.cache.entries()].sort( + (a, b) => a[1].timestamp - b[1].timestamp + ); + + let evicted = 0; + for (const [key] of entries) { + if (evicted >= count) break; + this.cache.delete(key); + evicted++; + } + } + + clear(): void { + this.cache.clear(); + } + + size(): number { + return this.cache.size; + } + } + + it('should cache compiled scripts', () => { + const cache = new MockScriptCache(); + const code = 'console.log("test")'; + const script = { compiled: true }; + + cache.set(code, script); + const retrieved = cache.get(code); + + expect(retrieved).toBe(script); + }); + + it('should return undefined for cache miss', () => { + const cache = new MockScriptCache(); + const result = cache.get('nonexistent-code'); + + expect(result).toBeUndefined(); + }); + + it('should update timestamp on cache hit (LRU)', async () => { + const cache = new MockScriptCache(); + const code = 'test'; + const script = { compiled: true }; + + cache.set(code, script); + const timestamp1 = (cache as any).cache.get(code).timestamp; + + await new Promise(resolve => setTimeout(resolve, 10)); + + cache.get(code); + const timestamp2 = (cache as any).cache.get(code).timestamp; + expect(timestamp2).toBeGreaterThanOrEqual(timestamp1); + }); + + it('should evict oldest entry when at capacity', () => { + const cache = new MockScriptCache(); + + // Fill cache to capacity + for (let i = 0; i < 100; i++) { + cache.set(`code-${i}`, { id: i }); + } + + expect(cache.size()).toBe(100); + + // Add one more - should evict oldest + cache.set('code-101', { id: 101 }); + + expect(cache.size()).toBe(100); + expect(cache.get('code-0')).toBeUndefined(); // First one evicted + expect(cache.get('code-101')).toBeDefined(); // New one added + }); + + it('should evict multiple oldest entries', () => { + const cache = new MockScriptCache(); + + for (let i = 0; i < 10; i++) { + cache.set(`code-${i}`, { id: i }); + } + + cache.evictOldest(3); + + expect(cache.size()).toBe(7); + expect(cache.get('code-0')).toBeUndefined(); + expect(cache.get('code-1')).toBeUndefined(); + expect(cache.get('code-2')).toBeUndefined(); + expect(cache.get('code-3')).toBeDefined(); + }); + + it('should clear entire cache', () => { + const cache = new MockScriptCache(); + + for (let i = 0; i < 10; i++) { + cache.set(`code-${i}`, { id: i }); + } + + expect(cache.size()).toBe(10); + cache.clear(); + expect(cache.size()).toBe(0); + }); + + it('should handle memory pressure by evicting cache', () => { + const heapLimit = 1000 * 1024 * 1024; // 1000MB + const heapUsed = 700 * 1024 * 1024; // 700MB (70%) + + const heapUsedRatio = heapUsed / heapLimit; + + const cache = new MockScriptCache(); + for (let i = 0; i < 100; i++) { + cache.set(`code-${i}`, { id: i }); + } + + // Simulate eviction at 70% threshold + if (heapUsedRatio >= 0.70 && cache.size() > 0) { + const evictCount = Math.max(1, Math.ceil(cache.size() * 0.25)); + cache.evictOldest(evictCount); + } + + expect(cache.size()).toBeLessThan(100); + expect(cache.size()).toBeGreaterThanOrEqual(75); // 25% evicted + }); +}); + +describe('SocketPool logic', () => { + class MockSocketPool { + private available: any[] = []; + private readonly maxSize = 5; + + acquire(socketPath: string): any | null { + const socket = this.available.pop(); + if (socket && socket.writable && !socket.destroyed) { + return socket; + } + return null; + } + + release(socket: any): void { + if (socket.writable && !socket.destroyed && this.available.length < this.maxSize) { + this.available.push(socket); + } else if (socket.destroy) { + socket.destroy(); + } + } + + clear(): void { + for (const socket of this.available) { + if (socket.destroy) socket.destroy(); + } + this.available = []; + } + + size(): number { + return this.available.length; + } + } + + it('should return null when pool is empty', () => { + const pool = new MockSocketPool(); + const socket = pool.acquire('/tmp/test.sock'); + + expect(socket).toBeNull(); + }); + + it('should reuse healthy socket from pool', () => { + const pool = new MockSocketPool(); + const mockSocket = { + writable: true, + destroyed: false, + destroy: jest.fn(), + }; + + pool.release(mockSocket); + expect(pool.size()).toBe(1); + + const reused = pool.acquire('/tmp/test.sock'); + expect(reused).toBe(mockSocket); + expect(pool.size()).toBe(0); + }); + + it('should destroy unhealthy socket instead of pooling', () => { + const pool = new MockSocketPool(); + const mockSocket = { + writable: false, + destroyed: false, + destroy: jest.fn(), + }; + + pool.release(mockSocket); + + expect(mockSocket.destroy).toHaveBeenCalled(); + expect(pool.size()).toBe(0); + }); + + it('should not exceed max pool size', () => { + const pool = new MockSocketPool(); + + for (let i = 0; i < 10; i++) { + pool.release({ + writable: true, + destroyed: false, + destroy: jest.fn(), + }); + } + + expect(pool.size()).toBe(5); // Max size is 5 + }); + + it('should destroy all sockets on clear', () => { + const pool = new MockSocketPool(); + const sockets = []; + + for (let i = 0; i < 5; i++) { + const socket = { + writable: true, + destroyed: false, + destroy: jest.fn(), + }; + sockets.push(socket); + pool.release(socket); + } + + pool.clear(); + + expect(pool.size()).toBe(0); + for (const socket of sockets) { + expect(socket.destroy).toHaveBeenCalled(); + } + }); + + it('should skip destroyed sockets during reuse', () => { + const pool = new MockSocketPool(); + const mockSocket = { + writable: true, + destroyed: true, // Already destroyed + destroy: jest.fn(), + }; + + pool.release(mockSocket); + const reused = pool.acquire('/tmp/test.sock'); + + expect(reused).toBeNull(); // Should not return destroyed socket + }); +}); + +describe('SocketClosedError', () => { + // Replicate the error class for testing + class SocketClosedError extends Error { + code: string; + constructor(message: string) { + super(message); + this.name = 'SocketClosedError'; + this.code = 'ESOCKETCLOSED'; + } + } + + it('should have correct name', () => { + const error = new SocketClosedError('test message'); + expect(error.name).toBe('SocketClosedError'); + }); + + it('should have ESOCKETCLOSED code', () => { + const error = new SocketClosedError('test message'); + expect(error.code).toBe('ESOCKETCLOSED'); + }); + + it('should preserve message', () => { + const error = new SocketClosedError('Connection lifetime exceeded'); + expect(error.message).toBe('Connection lifetime exceeded'); + }); + + it('should be instanceof Error', () => { + const error = new SocketClosedError('test'); + expect(error).toBeInstanceOf(Error); + }); + + it('should have stack trace', () => { + const error = new SocketClosedError('test'); + expect(error.stack).toBeDefined(); + expect(error.stack).toContain('SocketClosedError'); + }); +}); + +describe('Retry logic for connection errors', () => { + // Simulates the retry logic in sendWithRetry + function isRetryableError(error: any): boolean { + return ( + error.code === 'EPIPE' || + error.code === 'ECONNRESET' || + error.code === 'ESOCKETCLOSED' + ); + } + + it('should identify EPIPE as retryable', () => { + const error: any = new Error('broken pipe'); + error.code = 'EPIPE'; + expect(isRetryableError(error)).toBe(true); + }); + + it('should identify ECONNRESET as retryable', () => { + const error: any = new Error('connection reset'); + error.code = 'ECONNRESET'; + expect(isRetryableError(error)).toBe(true); + }); + + it('should identify ESOCKETCLOSED as retryable', () => { + const error: any = new Error('socket closed'); + error.code = 'ESOCKETCLOSED'; + expect(isRetryableError(error)).toBe(true); + }); + + it('should not retry generic errors', () => { + const error = new Error('generic error'); + expect(isRetryableError(error)).toBe(false); + }); + + it('should not retry timeout errors', () => { + const error: any = new Error('timeout'); + error.code = 'ETIMEDOUT'; + expect(isRetryableError(error)).toBe(false); + }); + + it('should not retry errors without code', () => { + const error = new Error('no code'); + expect(isRetryableError(error)).toBe(false); + }); +}); + +describe('Age-based socket eviction', () => { + const MAX_SOCKET_AGE_MS = 50_000; // 50 seconds, matching production code + + interface PooledSocket { + socket: { writable: boolean; destroyed: boolean; destroy: () => void }; + createdAt: number; + } + + function shouldEvictSocket(pooled: PooledSocket, now: number): boolean { + const age = now - pooled.createdAt; + return age > MAX_SOCKET_AGE_MS; + } + + it('should not evict fresh socket', () => { + const now = Date.now(); + const pooled: PooledSocket = { + socket: { writable: true, destroyed: false, destroy: jest.fn() }, + createdAt: now - 10_000, // 10 seconds old + }; + expect(shouldEvictSocket(pooled, now)).toBe(false); + }); + + it('should evict socket older than 50 seconds', () => { + const now = Date.now(); + const pooled: PooledSocket = { + socket: { writable: true, destroyed: false, destroy: jest.fn() }, + createdAt: now - 51_000, // 51 seconds old + }; + expect(shouldEvictSocket(pooled, now)).toBe(true); + }); + + it('should not evict socket exactly at 50 seconds', () => { + const now = Date.now(); + const pooled: PooledSocket = { + socket: { writable: true, destroyed: false, destroy: jest.fn() }, + createdAt: now - 50_000, // exactly 50 seconds + }; + expect(shouldEvictSocket(pooled, now)).toBe(false); + }); + + it('should evict socket at 55 seconds (within danger zone)', () => { + const now = Date.now(); + const pooled: PooledSocket = { + socket: { writable: true, destroyed: false, destroy: jest.fn() }, + createdAt: now - 55_000, // 55 seconds old + }; + expect(shouldEvictSocket(pooled, now)).toBe(true); + }); + + it('should provide 10 second safety margin before Rust 60s timeout', () => { + // Rust kills connections at 60s, we evict at 50s = 10s safety margin + const RUST_TIMEOUT = 60_000; + const SAFETY_MARGIN = RUST_TIMEOUT - MAX_SOCKET_AGE_MS; + expect(SAFETY_MARGIN).toBe(10_000); + }); +}); + +describe('Socket pool with age tracking', () => { + class MockSocketPoolWithAge { + private available: { socket: any; createdAt: number }[] = []; + private readonly maxSize = 5; + private readonly maxSocketAgeMs = 50_000; + + acquire(): { socket: any; createdAt: number } | null { + const now = Date.now(); + + while (this.available.length > 0) { + const pooled = this.available.pop()!; + const age = now - pooled.createdAt; + + // Discard old sockets + if (age > this.maxSocketAgeMs) { + pooled.socket.destroy(); + continue; + } + + if (pooled.socket.writable && !pooled.socket.destroyed) { + return pooled; + } + + pooled.socket.destroy(); + } + + return null; + } + + release(socket: any, createdAt: number): void { + const now = Date.now(); + const age = now - createdAt; + + if ( + age > this.maxSocketAgeMs || + !socket.writable || + socket.destroyed || + this.available.length >= this.maxSize + ) { + socket.destroy(); + return; + } + + this.available.push({ socket, createdAt }); + } + + size(): number { + return this.available.length; + } + } + + it('should reject old socket on acquire', () => { + const pool = new MockSocketPoolWithAge(); + const oldCreatedAt = Date.now() - 55_000; // 55 seconds ago + + const oldSocket = { + writable: true, + destroyed: false, + destroy: jest.fn(), + }; + + pool.release(oldSocket, oldCreatedAt); + + // Simulate time passing + jest.spyOn(Date, 'now').mockReturnValue(Date.now() + 1); + + const acquired = pool.acquire(); + expect(acquired).toBeNull(); + expect(oldSocket.destroy).toHaveBeenCalled(); + + jest.restoreAllMocks(); + }); + + it('should accept fresh socket on acquire', () => { + const pool = new MockSocketPoolWithAge(); + const freshCreatedAt = Date.now() - 10_000; // 10 seconds ago + + const freshSocket = { + writable: true, + destroyed: false, + destroy: jest.fn(), + }; + + pool.release(freshSocket, freshCreatedAt); + const acquired = pool.acquire(); + + expect(acquired).not.toBeNull(); + expect(acquired?.socket).toBe(freshSocket); + expect(acquired?.createdAt).toBe(freshCreatedAt); + }); + + it('should not pool socket that is too old on release', () => { + const pool = new MockSocketPoolWithAge(); + const oldCreatedAt = Date.now() - 55_000; + + const socket = { + writable: true, + destroyed: false, + destroy: jest.fn(), + }; + + pool.release(socket, oldCreatedAt); + + expect(pool.size()).toBe(0); + expect(socket.destroy).toHaveBeenCalled(); + }); + + it('should track createdAt through acquire/release cycle', () => { + const pool = new MockSocketPoolWithAge(); + const originalCreatedAt = Date.now() - 20_000; // 20 seconds ago + + const socket = { + writable: true, + destroyed: false, + destroy: jest.fn(), + }; + + pool.release(socket, originalCreatedAt); + const acquired = pool.acquire(); + + // createdAt should be preserved (not reset) + expect(acquired?.createdAt).toBe(originalCreatedAt); + }); +}); + +describe('PluginAPIImpl socket handling', () => { + class MockPluginAPIImpl { + private socket: any = null; + private pending = new Map(); + private connected = false; + private connectionPromise: Promise | null = null; + private socketPath: string; + private socketCreatedAt: number = 0; + private readonly maxPendingRequests = 100; + + constructor(socketPath: string) { + this.socketPath = socketPath; + } + + async ensureConnected(): Promise { + if (this.connected) return; + if (!this.connectionPromise) { + this.connectionPromise = this.connect(); + } + await this.connectionPromise; + } + + private async connect(): Promise { + // Simulate connection + this.socket = { writable: true, destroyed: false }; + this.socketCreatedAt = Date.now(); + this.connected = true; + } + + handleSocketError(error: Error): void { + this.connected = false; + this.connectionPromise = null; + this.socket = null; + this.rejectAllPending(error); + } + + handleSocketClose(): void { + this.connected = false; + this.connectionPromise = null; + this.socket = null; + // Use SocketClosedError-like error + const error: any = new Error('Socket closed by server (connection lifetime exceeded)'); + error.code = 'ESOCKETCLOSED'; + error.name = 'SocketClosedError'; + this.rejectAllPending(error); + } + + private rejectAllPending(error: Error): void { + for (const [requestId, resolver] of this.pending.entries()) { + resolver.reject(error); + this.pending.delete(requestId); + } + } + + async send(method: string, payload: any): Promise { + if (this.pending.size >= this.maxPendingRequests) { + throw new Error( + `Too many concurrent API requests (max ${this.maxPendingRequests})` + ); + } + + await this.ensureConnected(); + + return new Promise((resolve, reject) => { + const requestId = 'test-id'; + this.pending.set(requestId, { resolve, reject }); + + // Simulate response + setTimeout(() => { + const resolver = this.pending.get(requestId); + if (resolver) { + resolver.resolve({ success: true }); + this.pending.delete(requestId); + } + }, 10); + }); + } + + getPendingCount(): number { + return this.pending.size; + } + + isConnected(): boolean { + return this.connected; + } + + getSocketCreatedAt(): number { + return this.socketCreatedAt; + } + } + + it('should establish connection on first request', async () => { + const api = new MockPluginAPIImpl('/tmp/test.sock'); + + expect(api.isConnected()).toBe(false); + + await api.send('testMethod', {}); + + expect(api.isConnected()).toBe(true); + }); + + it('should reuse existing connection', async () => { + const api = new MockPluginAPIImpl('/tmp/test.sock'); + + await api.send('method1', {}); + const wasConnected = api.isConnected(); + + await api.send('method2', {}); + expect(api.isConnected()).toBe(wasConnected); + }); + + it('should handle socket error and reject pending requests', async () => { + const api = new MockPluginAPIImpl('/tmp/test.sock'); + + await api.send('method1', {}); + + const error = new Error('Socket error'); + api.handleSocketError(error); + + expect(api.isConnected()).toBe(false); + expect(api.getPendingCount()).toBe(0); + }); + + it('should handle socket close and reset connection', async () => { + const api = new MockPluginAPIImpl('/tmp/test.sock'); + + await api.send('method1', {}); + expect(api.isConnected()).toBe(true); + + api.handleSocketClose(); + + expect(api.isConnected()).toBe(false); + expect(api.getPendingCount()).toBe(0); + }); + + it('should enforce max pending requests limit', async () => { + const api = new MockPluginAPIImpl('/tmp/test.sock'); + + // Mock the pending count to be at limit + for (let i = 0; i < 100; i++) { + (api as any).pending.set(`request-${i}`, { resolve: jest.fn(), reject: jest.fn() }); + } + + await expect(api.send('method', {})).rejects.toThrow('Too many concurrent API requests'); + }); + + it('should reconnect after socket close', async () => { + const api = new MockPluginAPIImpl('/tmp/test.sock'); + + await api.send('method1', {}); + api.handleSocketClose(); + + expect(api.isConnected()).toBe(false); + + // Should reconnect on next request + await api.send('method2', {}); + expect(api.isConnected()).toBe(true); + }); + + it('should reject with ESOCKETCLOSED code on socket close', async () => { + const api = new MockPluginAPIImpl('/tmp/test.sock'); + + await api.send('method1', {}); + + // Add a pending request + const pendingPromise = new Promise((resolve, reject) => { + (api as any).pending.set('test-pending', { resolve, reject }); + }); + + // Trigger socket close + api.handleSocketClose(); + + try { + await pendingPromise; + fail('Should have rejected'); + } catch (error: any) { + expect(error.code).toBe('ESOCKETCLOSED'); + expect(error.name).toBe('SocketClosedError'); + expect(error.message).toContain('connection lifetime exceeded'); + } + }); + + it('should track socket creation time', async () => { + const api = new MockPluginAPIImpl('/tmp/test.sock'); + const beforeConnect = Date.now(); + + await api.send('method1', {}); + + const afterConnect = Date.now(); + const socketCreatedAt = api.getSocketCreatedAt(); + + expect(socketCreatedAt).toBeGreaterThanOrEqual(beforeConnect); + expect(socketCreatedAt).toBeLessThanOrEqual(afterConnect); + }); +}); + +describe('Plugin console implementation', () => { + interface LogEntry { + level: 'error' | 'warn' | 'info' | 'log' | 'debug' | 'result'; + message: string; + } + + function createMockPluginConsole(logs: LogEntry[]): Console { + const log = (level: LogEntry['level']) => (...args: any[]) => { + const message = args.map(arg => + typeof arg === 'object' ? JSON.stringify(arg) : String(arg) + ).join(' '); + logs.push({ level, message }); + }; + + return { + log: log('log'), + info: log('info'), + warn: log('warn'), + error: log('error'), + debug: log('debug'), + } as Console; + } + + it('should capture log messages', () => { + const logs: LogEntry[] = []; + const console = createMockPluginConsole(logs); + + console.log('test message'); + + expect(logs).toHaveLength(1); + expect(logs[0]).toEqual({ level: 'log', message: 'test message' }); + }); + + it('should capture error messages', () => { + const logs: LogEntry[] = []; + const console = createMockPluginConsole(logs); + + console.error('error occurred'); + + expect(logs).toHaveLength(1); + expect(logs[0]).toEqual({ level: 'error', message: 'error occurred' }); + }); + + it('should capture warn messages', () => { + const logs: LogEntry[] = []; + const console = createMockPluginConsole(logs); + + console.warn('warning message'); + + expect(logs).toHaveLength(1); + expect(logs[0]).toEqual({ level: 'warn', message: 'warning message' }); + }); + + it('should stringify objects', () => { + const logs: LogEntry[] = []; + const console = createMockPluginConsole(logs); + + console.log({ foo: 'bar', num: 42 }); + + expect(logs).toHaveLength(1); + expect(logs[0].message).toBe('{"foo":"bar","num":42}'); + }); + + it('should handle multiple arguments', () => { + const logs: LogEntry[] = []; + const console = createMockPluginConsole(logs); + + console.log('Hello', 'world', 123); + + expect(logs).toHaveLength(1); + expect(logs[0].message).toBe('Hello world 123'); + }); + + it('should handle mixed types', () => { + const logs: LogEntry[] = []; + const console = createMockPluginConsole(logs); + + console.log('Count:', 42, { status: 'ok' }); + + expect(logs).toHaveLength(1); + expect(logs[0].message).toBe('Count: 42 {"status":"ok"}'); + }); +}); + +describe('Plugin require blocking', () => { + const BLOCKED_MODULES = new Set([ + 'fs', 'child_process', 'net', 'http', 'https', + 'cluster', 'process', 'vm', 'os', 'v8', + ]); + + function createSandboxRequire(blockedModules: Set) { + return (id: string): any => { + if (blockedModules.has(id)) { + throw new Error( + `Module '${id}' is blocked for security. ` + + `Use the PluginAPI for network operations.` + ); + } + // Allow other modules + return require(id); + }; + } + + it('should block dangerous built-in modules', () => { + const pluginRequire = createSandboxRequire(BLOCKED_MODULES); + + expect(() => pluginRequire('fs')).toThrow('Module \'fs\' is blocked for security'); + expect(() => pluginRequire('child_process')).toThrow('blocked for security'); + expect(() => pluginRequire('net')).toThrow('blocked for security'); + expect(() => pluginRequire('http')).toThrow('blocked for security'); + }); + + it('should allow safe modules', () => { + const pluginRequire = createSandboxRequire(BLOCKED_MODULES); + + // Should not throw + expect(() => pluginRequire('uuid')).not.toThrow(); + }); + + it('should block with node: prefix', () => { + const extendedBlocked = new Set([ + 'fs', 'node:fs', + 'net', 'node:net', + ]); + const pluginRequire = createSandboxRequire(extendedBlocked); + + expect(() => pluginRequire('node:fs')).toThrow('blocked for security'); + expect(() => pluginRequire('node:net')).toThrow('blocked for security'); + }); +}); + +describe('executePlugin function', () => { + interface ExecutorTask { + taskId: string; + pluginId: string; + compiledCode: string; + params: any; + headers?: Record; + socketPath: string; + httpRequestId?: string; + timeout: number; + } + + interface ExecutorResult { + taskId: string; + success: boolean; + result?: any; + error?: { + message: string; + code?: string; + status?: number; + details?: any; + }; + logs: any[]; + } + + async function mockExecuteInSandbox(task: ExecutorTask): Promise { + const logs: any[] = []; + + try { + // Simulate plugin execution + if (task.compiledCode.includes('throw')) { + throw new Error('Plugin error'); + } + + if (task.compiledCode.includes('timeout')) { + await new Promise((resolve) => setTimeout(resolve, task.timeout + 100)); + } + + return { + taskId: task.taskId, + success: true, + result: { processed: task.params }, + logs, + }; + } catch (error: any) { + return { + taskId: task.taskId, + success: false, + error: { + message: error.message, + code: 'PLUGIN_ERROR', + status: 500, + }, + logs, + }; + } + } + + it('should execute plugin successfully', async () => { + const task: ExecutorTask = { + taskId: 'task-1', + pluginId: 'plugin-1', + compiledCode: 'return params;', + params: { test: true }, + socketPath: '/tmp/test.sock', + timeout: 5000, + }; + + const result = await mockExecuteInSandbox(task); + + expect(result.success).toBe(true); + expect(result.taskId).toBe('task-1'); + expect(result.result).toEqual({ processed: { test: true } }); + }); + + it('should handle plugin errors', async () => { + const task: ExecutorTask = { + taskId: 'task-2', + pluginId: 'plugin-2', + compiledCode: 'throw new Error("test error");', + params: {}, + socketPath: '/tmp/test.sock', + timeout: 5000, + }; + + const result = await mockExecuteInSandbox(task); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + expect(result.error?.message).toBe('Plugin error'); + expect(result.error?.code).toBe('PLUGIN_ERROR'); + }); + + it('should include headers in execution context', async () => { + const task: ExecutorTask = { + taskId: 'task-3', + pluginId: 'plugin-3', + compiledCode: 'return headers;', + params: {}, + headers: { 'x-api-key': ['secret'] }, + socketPath: '/tmp/test.sock', + timeout: 5000, + }; + + const result = await mockExecuteInSandbox(task); + + expect(result.success).toBe(true); + }); + + it('should respect execution timeout', async () => { + const task: ExecutorTask = { + taskId: 'task-4', + pluginId: 'plugin-4', + compiledCode: 'timeout', + params: {}, + socketPath: '/tmp/test.sock', + timeout: 100, + }; + + // This would timeout in real execution + const startTime = Date.now(); + await mockExecuteInSandbox(task); + const elapsed = Date.now() - startTime; + + expect(elapsed).toBeGreaterThan(100); + }); + + it('should provide httpRequestId for tracing', async () => { + const task: ExecutorTask = { + taskId: 'task-5', + pluginId: 'plugin-5', + compiledCode: 'return httpRequestId;', + params: {}, + socketPath: '/tmp/test.sock', + httpRequestId: 'http-123', + timeout: 5000, + }; + + const result = await mockExecuteInSandbox(task); + expect(result.success).toBe(true); + }); +}); + +describe('Error handling in executor', () => { + it('should categorize SyntaxError', () => { + const error = new SyntaxError('Unexpected token'); + + const errorCode = error.name === 'SyntaxError' ? 'SYNTAX_ERROR' : 'PLUGIN_ERROR'; + + expect(errorCode).toBe('SYNTAX_ERROR'); + }); + + it('should categorize TypeError', () => { + const error = new TypeError('Cannot read property'); + + const errorCode = error.name === 'TypeError' ? 'TYPE_ERROR' : 'PLUGIN_ERROR'; + + expect(errorCode).toBe('TYPE_ERROR'); + }); + + it('should categorize ReferenceError', () => { + const error = new ReferenceError('x is not defined'); + + const errorCode = error.name === 'ReferenceError' ? 'REFERENCE_ERROR' : 'PLUGIN_ERROR'; + + expect(errorCode).toBe('REFERENCE_ERROR'); + }); + + it('should handle timeout errors', () => { + const error: any = new Error('Timeout'); + error.code = 'ERR_SCRIPT_EXECUTION_TIMEOUT'; + + const errorCode = error.code === 'ERR_SCRIPT_EXECUTION_TIMEOUT' ? 'TIMEOUT' : 'PLUGIN_ERROR'; + const errorStatus = errorCode === 'TIMEOUT' ? 504 : 500; + + expect(errorCode).toBe('TIMEOUT'); + expect(errorStatus).toBe(504); + }); + + it('should handle handler timeout errors', () => { + const error: any = new Error('Handler timeout'); + error.code = 'ERR_HANDLER_TIMEOUT'; + + const errorCode = error.code === 'ERR_HANDLER_TIMEOUT' ? 'TIMEOUT' : 'PLUGIN_ERROR'; + + expect(errorCode).toBe('TIMEOUT'); + }); + + it('should capture stack trace', () => { + const error = new Error('Test error'); + + const stack = error.stack?.split('\n').slice(0, 10).join('\n'); + + expect(stack).toBeDefined(); + expect(stack).toContain('Test error'); + }); + + it('should use custom status if provided', () => { + const error: any = new Error('Bad request'); + error.status = 400; + + const errorStatus = typeof error.status === 'number' ? error.status : 500; + + expect(errorStatus).toBe(400); + }); +}); + +describe('Safe stringify function', () => { + function safeStringify(value: unknown): string { + try { + return JSON.stringify(value, (_, v) => { + if (typeof v === 'bigint') { + return v.toString() + 'n'; + } + return v; + }); + } catch { + try { + return String(value); + } catch { + return '[Unstringifiable value]'; + } + } + } + + it('should stringify simple values', () => { + expect(safeStringify('hello')).toBe('"hello"'); + expect(safeStringify(42)).toBe('42'); + expect(safeStringify(true)).toBe('true'); + expect(safeStringify(null)).toBe('null'); + }); + + it('should stringify objects', () => { + const obj = { foo: 'bar', num: 42 }; + expect(safeStringify(obj)).toBe('{"foo":"bar","num":42}'); + }); + + it('should handle BigInt', () => { + const bigInt = BigInt(123456789); + const result = safeStringify(bigInt); + expect(result).toContain('123456789'); + }); + + it('should handle circular references', () => { + const obj: any = { name: 'test' }; + obj.self = obj; // Circular reference + + const result = safeStringify(obj); + // Will use String() fallback + expect(result).toBeTruthy(); + }); + + it('should handle undefined', () => { + const result = safeStringify(undefined); + // JSON.stringify returns undefined for undefined + expect(result === undefined || result === 'undefined').toBe(true); + }); +}); + +describe('Memory-aware script caching', () => { + it('should check memory periodically', () => { + const lastCheck = Date.now(); + const checkInterval = 5000; + + const shouldCheck = Date.now() - lastCheck >= checkInterval; + + // Initially should check (time has passed in test) + expect(typeof shouldCheck).toBe('boolean'); + }); + + it('should calculate heap usage ratio correctly', () => { + const heapLimit = 1000 * 1024 * 1024; // 1000MB + const heapUsed = 700 * 1024 * 1024; // 700MB + + const ratio = heapUsed / heapLimit; + + expect(ratio).toBe(0.7); + }); + + it('should evict 25% at 70% heap usage', () => { + const cacheSize = 100; + const heapRatio = 0.70; + + const evictCount = Math.max(1, Math.ceil(cacheSize * 0.25)); + + expect(evictCount).toBe(25); + }); + + it('should evict 50% at 85% heap usage', () => { + const cacheSize = 100; + const heapRatio = 0.85; + + const evictCount = Math.max(1, Math.ceil(cacheSize * 0.5)); + + expect(evictCount).toBe(50); + }); +}); + +describe('Context and cache lifecycle', () => { + it('should release context to pool after execution', () => { + const pool = { released: false }; + const context = { id: 'test-context' }; + + // Simulate execution complete + pool.released = true; + + expect(pool.released).toBe(true); + }); + + it('should close API socket after execution', () => { + const api = { + closed: false, + close() { + this.closed = true; + }, + }; + + api.close(); + + expect(api.closed).toBe(true); + }); + + it('should disconnect KV store after execution', async () => { + const kv = { + disconnected: false, + async disconnect() { + this.disconnected = true; + }, + }; + + await kv.disconnect(); + + expect(kv.disconnected).toBe(true); + }); + + it('should handle cleanup errors gracefully', () => { + const api = { + close() { + throw new Error('Close failed'); + }, + }; + + // Should not throw + expect(() => { + try { + api.close(); + } catch { + // Ignore cleanup errors + } + }).not.toThrow(); + }); +}); diff --git a/plugins/tests/lib/plugin.test.ts b/plugins/tests/lib/plugin.test.ts index a67f9be36..b64901541 100644 --- a/plugins/tests/lib/plugin.test.ts +++ b/plugins/tests/lib/plugin.test.ts @@ -213,8 +213,14 @@ describe('PluginAPI', () => { expect(mockWrite).toHaveBeenCalled(); }); - it('should throw error if write fails', async () => { - mockWrite.mockReturnValue(false); + it('should throw error if write callback returns error', async () => { + // Simulate write error via callback (this is how Node.js socket.write reports errors) + const writeError = new Error('EPIPE: broken pipe'); + mockWrite.mockImplementation((data: string, callback: (error?: Error) => void) => { + // Call the callback with an error to simulate write failure + callback(writeError); + return true; + }); const payload: NetworkTransactionRequest = { to: '0x1234567890123456789012345678901234567890', @@ -225,7 +231,7 @@ describe('PluginAPI', () => { }; await expect(pluginAPI._send('test-relayer', 'sendTransaction', payload)) - .rejects.toThrow('Failed to send message to relayer'); + .rejects.toThrow('EPIPE: broken pipe'); }); }); @@ -278,6 +284,160 @@ describe('PluginAPI', () => { }); }); +describe('Socket error handling', () => { + let pluginAPI: DefaultPluginAPI; + let mockSocket: jest.Mocked; + let mockWrite: jest.Mock; + let mockEnd: jest.Mock; + let mockDestroy: jest.Mock; + let eventHandlers: Map; + + beforeEach(() => { + eventHandlers = new Map(); + mockWrite = jest.fn().mockReturnValue(true); + mockEnd = jest.fn(); + mockDestroy = jest.fn(); + + mockSocket = { + write: mockWrite, + end: mockEnd, + destroy: mockDestroy, + on: jest.fn((event: string, handler: Function) => { + eventHandlers.set(event, handler); + return mockSocket; + }), + } as any; + + jest.spyOn(net, 'createConnection').mockReturnValue(mockSocket); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should reject pending requests when socket closes', async () => { + pluginAPI = new DefaultPluginAPI('/tmp/test.sock'); + + // Simulate established connection by triggering connect event + const connectHandler = eventHandlers.get('connect'); + if (connectHandler) { + connectHandler(); + } + + const payload: NetworkTransactionRequest = { + to: '0x1234567890123456789012345678901234567890', + value: 1000000, + data: '0x', + gas_limit: 21000, + speed: Speed.FAST, + }; + + // Start the send (won't complete) + const sendPromise = pluginAPI._send('test-relayer', 'sendTransaction', payload); + + // Trigger socket close + const closeHandler = eventHandlers.get('close'); + if (closeHandler) { + closeHandler(); + } + + // Should reject with SocketClosedError + await expect(sendPromise).rejects.toThrow('Socket closed by server'); + }); + + it('should have ESOCKETCLOSED code on socket close error', async () => { + pluginAPI = new DefaultPluginAPI('/tmp/test.sock'); + + // Simulate established connection + const connectHandler = eventHandlers.get('connect'); + if (connectHandler) { + connectHandler(); + } + + const payload: NetworkTransactionRequest = { + to: '0x1234567890123456789012345678901234567890', + value: 1000000, + data: '0x', + gas_limit: 21000, + speed: Speed.FAST, + }; + + const sendPromise = pluginAPI._send('test-relayer', 'sendTransaction', payload); + + // Trigger socket close + const closeHandler = eventHandlers.get('close'); + if (closeHandler) { + closeHandler(); + } + + try { + await sendPromise; + fail('Should have thrown'); + } catch (error: any) { + expect(error.code).toBe('ESOCKETCLOSED'); + expect(error.name).toBe('SocketClosedError'); + } + }); + + it('should handle multiple pending requests on socket close', async () => { + pluginAPI = new DefaultPluginAPI('/tmp/test.sock'); + + // Simulate established connection + const connectHandler = eventHandlers.get('connect'); + if (connectHandler) { + connectHandler(); + } + + const payload: NetworkTransactionRequest = { + to: '0x1234567890123456789012345678901234567890', + value: 1000000, + data: '0x', + gas_limit: 21000, + speed: Speed.FAST, + }; + + // Start multiple sends + const promises = [ + pluginAPI._send('test-relayer', 'sendTransaction', payload), + pluginAPI._send('test-relayer', 'getRelayer', {}), + pluginAPI._send('test-relayer', 'getRelayerStatus', {}), + ]; + + expect(pluginAPI.pending.size).toBe(3); + + // Trigger socket close + const closeHandler = eventHandlers.get('close'); + if (closeHandler) { + closeHandler(); + } + + // All should reject + for (const promise of promises) { + await expect(promise).rejects.toThrow(); + } + + // Pending should be cleared + expect(pluginAPI.pending.size).toBe(0); + }); + + it('should reject connection promise on socket error during connect', async () => { + pluginAPI = new DefaultPluginAPI('/tmp/test.sock'); + + // Trigger error before connection is established + const errorHandler = eventHandlers.get('error'); + const socketError = new Error('ECONNREFUSED'); + + // The connection promise should reject + const connectionPromise = (pluginAPI as any)._connectionPromise; + + if (errorHandler) { + errorHandler(socketError); + } + + await expect(connectionPromise).rejects.toThrow('ECONNREFUSED'); + }); +}); + describe('PluginContext Headers', () => { it('should have correct PluginHeaders type structure', () => { // Test type structure: Record diff --git a/plugins/tests/lib/pool-server.test.ts b/plugins/tests/lib/pool-server.test.ts new file mode 100644 index 000000000..3bb6d2600 --- /dev/null +++ b/plugins/tests/lib/pool-server.test.ts @@ -0,0 +1,567 @@ +import * as net from 'node:net'; +import * as fs from 'node:fs'; +import * as v8 from 'node:v8'; + +// Mock dependencies before importing the module +jest.mock('../../lib/worker-pool', () => ({ + WorkerPoolManager: jest.fn().mockImplementation(() => ({ + initialize: jest.fn().mockResolvedValue(undefined), + runPlugin: jest.fn().mockResolvedValue({ + success: true, + result: { data: 'test' }, + logs: [], + }), + precompilePlugin: jest.fn().mockResolvedValue({ + code: 'compiled-code', + warnings: [], + }), + precompilePluginSource: jest.fn().mockResolvedValue({ + code: 'compiled-code', + warnings: [], + }), + cacheCompiledCode: jest.fn(), + invalidatePlugin: jest.fn(), + getStats: jest.fn().mockReturnValue({ + uptime: 1000, + pool: { completed: 10, queued: 0 }, + execution: { total: 10, successRate: 1.0 }, + }), + shutdown: jest.fn().mockResolvedValue(undefined), + clearCache: jest.fn(), + })), +})); + +jest.mock('node:fs', () => ({ + unlinkSync: jest.fn(), + statSync: jest.fn().mockReturnValue({ + mode: 0o755, + isSocket: () => true, + }), +})); + +// Import after mocks are set up +const { WorkerPoolManager } = jest.requireMock('../../lib/worker-pool') as any; + +describe('PoolServerMemoryMonitor logic', () => { + describe('Memory pressure detection', () => { + let originalMemoryUsage: typeof process.memoryUsage; + + beforeEach(() => { + originalMemoryUsage = process.memoryUsage; + }); + + afterEach(() => { + process.memoryUsage = originalMemoryUsage; + }); + + it('should detect warning threshold at 75% heap usage', () => { + const heapLimit = 1000 * 1024 * 1024; // 1000MB + const heapUsed = 750 * 1024 * 1024; // 750MB (75%) + + const ratio = heapUsed / heapLimit; + expect(ratio).toBeGreaterThanOrEqual(0.75); + expect(ratio).toBeLessThan(0.80); + }); + + it('should detect GC threshold at 80% heap usage', () => { + const heapLimit = 1000 * 1024 * 1024; + const heapUsed = 800 * 1024 * 1024; // 80% + + const ratio = heapUsed / heapLimit; + expect(ratio).toBeGreaterThanOrEqual(0.80); + expect(ratio).toBeLessThan(0.85); + }); + + it('should detect critical threshold at 85% heap usage', () => { + const heapLimit = 1000 * 1024 * 1024; + const heapUsed = 850 * 1024 * 1024; // 85% + + const ratio = heapUsed / heapLimit; + expect(ratio).toBeGreaterThanOrEqual(0.85); + expect(ratio).toBeLessThan(0.92); + }); + + it('should detect emergency threshold at 92% heap usage', () => { + const heapLimit = 1000 * 1024 * 1024; + const heapUsed = 920 * 1024 * 1024; // 92% + + const ratio = heapUsed / heapLimit; + expect(ratio).toBeGreaterThanOrEqual(0.92); + }); + }); +}); + +describe('PoolServer message handling', () => { + let mockSocket: Partial; + let dataCallback: ((data: Buffer) => void) | undefined; + let errorCallback: ((err: Error) => void) | undefined; + let closeCallback: (() => void) | undefined; + + beforeEach(() => { + jest.clearAllMocks(); + + // Reset WorkerPoolManager mock + (WorkerPoolManager as jest.Mock).mockImplementation(() => ({ + initialize: jest.fn().mockResolvedValue(undefined), + runPlugin: jest.fn().mockResolvedValue({ + success: true, + result: { data: 'test' }, + logs: [], + }), + precompilePlugin: jest.fn().mockResolvedValue({ + code: 'compiled-code', + warnings: [], + }), + precompilePluginSource: jest.fn().mockResolvedValue({ + code: 'compiled-code', + warnings: [], + }), + cacheCompiledCode: jest.fn(), + invalidatePlugin: jest.fn(), + getStats: jest.fn().mockReturnValue({ + uptime: 1000, + pool: { completed: 10, queued: 0 }, + execution: { total: 10, successRate: 1.0 }, + }), + shutdown: jest.fn().mockResolvedValue(undefined), + clearCache: jest.fn(), + })); + + mockSocket = { + setKeepAlive: jest.fn(), + setNoDelay: jest.fn(), + write: jest.fn(), + writable: true, + on: jest.fn().mockReturnThis(), + } as any; + }); + + describe('execute message', () => { + it('should handle valid execute message', async () => { + const message = { + type: 'execute', + taskId: 'task-1', + pluginId: 'plugin-1', + compiledCode: 'code', + params: { test: true }, + socketPath: '/tmp/test.sock', + timeout: 5000, + }; + + // Simulate the message flow + const pool = new WorkerPoolManager({} as any); + const result = await pool.runPlugin({ + pluginId: message.pluginId, + compiledCode: message.compiledCode, + params: message.params, + socketPath: message.socketPath, + timeout: message.timeout, + }); + + expect(result).toEqual({ + success: true, + result: { data: 'test' }, + logs: [], + }); + expect(pool.runPlugin).toHaveBeenCalledWith({ + pluginId: 'plugin-1', + compiledCode: 'code', + params: { test: true }, + socketPath: '/tmp/test.sock', + timeout: 5000, + }); + }); + + it('should handle execute message with error', async () => { + const pool = new WorkerPoolManager({} as any); + (pool.runPlugin as jest.Mock).mockResolvedValue({ + success: false, + error: { + message: 'Plugin error', + code: 'PLUGIN_ERROR', + status: 500, + }, + logs: [], + }); + + const result = await pool.runPlugin({ + pluginId: 'plugin-1', + compiledCode: 'code', + params: {}, + socketPath: '/tmp/test.sock', + timeout: 5000, + }); + + expect(result.success).toBe(false); + expect(result.error).toEqual({ + message: 'Plugin error', + code: 'PLUGIN_ERROR', + status: 500, + }); + }); + }); + + describe('precompile message', () => { + it('should handle precompile with source code', async () => { + const pool = new WorkerPoolManager({} as any); + const result = await pool.precompilePluginSource('plugin-1', 'source code'); + + expect(result).toEqual({ + code: 'compiled-code', + warnings: [], + }); + expect(pool.precompilePluginSource).toHaveBeenCalledWith('plugin-1', 'source code'); + }); + + it('should handle precompile with plugin path', async () => { + const pool = new WorkerPoolManager({} as any); + const result = await pool.precompilePlugin('/path/to/plugin.js'); + + expect(result).toEqual({ + code: 'compiled-code', + warnings: [], + }); + expect(pool.precompilePlugin).toHaveBeenCalledWith('/path/to/plugin.js'); + }); + }); + + describe('cache message', () => { + it('should cache compiled code', () => { + const pool = new WorkerPoolManager({} as any); + pool.cacheCompiledCode('plugin-1', 'compiled-code'); + + expect(pool.cacheCompiledCode).toHaveBeenCalledWith('plugin-1', 'compiled-code'); + }); + }); + + describe('invalidate message', () => { + it('should invalidate plugin cache', () => { + const pool = new WorkerPoolManager({} as any); + pool.invalidatePlugin('plugin-1'); + + expect(pool.invalidatePlugin).toHaveBeenCalledWith('plugin-1'); + }); + }); + + describe('stats message', () => { + it('should return pool statistics', () => { + const pool = new WorkerPoolManager({} as any); + const stats = pool.getStats(); + + expect(stats).toEqual({ + uptime: 1000, + pool: { completed: 10, queued: 0 }, + execution: { total: 10, successRate: 1.0 }, + }); + }); + }); + + describe('health message', () => { + it('should return health status with memory info', () => { + const pool = new WorkerPoolManager({} as any); + const stats = pool.getStats(); + + const health = { + status: 'healthy', + uptime: stats.uptime, + memory: { + heapUsed: process.memoryUsage().heapUsed, + heapTotal: process.memoryUsage().heapTotal, + rss: process.memoryUsage().rss, + }, + pool: { + completed: stats.pool.completed, + queued: stats.pool.queued, + }, + execution: stats.execution, + }; + + expect(health.status).toBe('healthy'); + expect(health.uptime).toBe(1000); + expect(health.memory).toBeDefined(); + expect(health.pool).toBeDefined(); + expect(health.execution).toBeDefined(); + }); + }); + + describe('shutdown message', () => { + it('should initiate graceful shutdown', () => { + const pool = new WorkerPoolManager({} as any); + + let shuttingDown = false; + const activeRequests = 0; + + const response = { + status: 'draining', + activeRequests, + timeoutMs: 30000, + }; + + expect(response.status).toBe('draining'); + expect(response.activeRequests).toBe(0); + expect(response.timeoutMs).toBe(30000); + }); + + it('should indicate already shutting down', () => { + let shuttingDown = true; + const activeRequests = 2; + + const response = { + status: 'already_shutting_down', + activeRequests, + }; + + expect(response.status).toBe('already_shutting_down'); + expect(response.activeRequests).toBe(2); + }); + }); + + describe('unknown message type', () => { + it('should handle unknown message type', () => { + const unknownType = 'invalid_type'; + const error = { + message: `Unknown message type: ${unknownType}`, + code: 'UNKNOWN_MESSAGE_TYPE', + }; + + expect(error.message).toContain('Unknown message type'); + expect(error.code).toBe('UNKNOWN_MESSAGE_TYPE'); + }); + }); +}); + +describe('PoolServer connection handling', () => { + it('should enable keep-alive and no-delay on socket', () => { + const mockSocket: any = { + setKeepAlive: jest.fn(), + setNoDelay: jest.fn(), + on: jest.fn(), + }; + + mockSocket.setKeepAlive(true, 30000); + mockSocket.setNoDelay(true); + + expect(mockSocket.setKeepAlive).toHaveBeenCalledWith(true, 30000); + expect(mockSocket.setNoDelay).toHaveBeenCalledWith(true); + }); + + it('should parse newline-delimited JSON messages', () => { + const buffer = '{"type":"execute","taskId":"1"}\n{"type":"stats","taskId":"2"}\n'; + const messages: any[] = []; + + let remaining = buffer; + let newlineIndex; + while ((newlineIndex = remaining.indexOf('\n')) !== -1) { + const line = remaining.slice(0, newlineIndex); + remaining = remaining.slice(newlineIndex + 1); + if (line.trim()) { + messages.push(JSON.parse(line)); + } + } + + expect(messages).toHaveLength(2); + expect(messages[0]).toEqual({ type: 'execute', taskId: '1' }); + expect(messages[1]).toEqual({ type: 'stats', taskId: '2' }); + }); + + it('should handle partial messages in buffer', () => { + let buffer = '{"type":"exe'; + const messages: any[] = []; + + // First chunk + let newlineIndex = buffer.indexOf('\n'); + expect(newlineIndex).toBe(-1); + + // Second chunk completes the message + buffer += 'cute","taskId":"1"}\n'; + newlineIndex = buffer.indexOf('\n'); + expect(newlineIndex).not.toBe(-1); + + const line = buffer.slice(0, newlineIndex); + messages.push(JSON.parse(line)); + + expect(messages).toHaveLength(1); + expect(messages[0]).toEqual({ type: 'execute', taskId: '1' }); + }); + + it('should handle socket errors gracefully', () => { + const mockSocket: any = { + on: jest.fn((event: string, callback: any) => { + if (event === 'error') { + // Simulate ECONNRESET error + callback({ code: 'ECONNRESET', message: 'Connection reset' }); + } + }), + }; + + let errorReceived = false; + mockSocket.on('error', (err: any) => { + errorReceived = true; + expect(err.code).toBe('ECONNRESET'); + }); + + expect(errorReceived).toBe(true); + }); +}); + +describe('PoolServer graceful shutdown', () => { + it('should wait for active requests to complete', async () => { + let activeRequests = 3; + const checkInterval = 10; + const timeoutMs = 1000; + + // Simulate draining + const drainPromise = new Promise((resolve) => { + const interval = setInterval(() => { + activeRequests--; + if (activeRequests === 0) { + clearInterval(interval); + resolve(); + } + }, checkInterval); + }); + + await drainPromise; + expect(activeRequests).toBe(0); + }); + + it('should force shutdown after timeout', async () => { + let activeRequests = 5; + const checkInterval = 10; + const timeoutMs = 50; + + const startTime = Date.now(); + let timedOut = false; + + const drainPromise = new Promise((resolve) => { + const interval = setInterval(() => { + const elapsed = Date.now() - startTime; + if (elapsed >= timeoutMs) { + timedOut = true; + clearInterval(interval); + resolve(); + } + }, checkInterval); + }); + + await drainPromise; + expect(timedOut).toBe(true); + expect(activeRequests).toBeGreaterThan(0); // Some requests still active + }); + + it('should track active requests properly', () => { + let activeRequests = 0; + + // Execute request + activeRequests++; + expect(activeRequests).toBe(1); + + // Complete request + activeRequests--; + expect(activeRequests).toBe(0); + + // Multiple requests + activeRequests += 3; + expect(activeRequests).toBe(3); + + activeRequests -= 2; + expect(activeRequests).toBe(1); + }); + + it('should reject new requests during shutdown', () => { + let shuttingDown = false; + let messageType: string = 'execute'; + + // Allow before shutdown + let shouldReject = shuttingDown && messageType !== 'shutdown' && messageType !== 'health'; + expect(shouldReject).toBe(false); + + // Reject during shutdown + shuttingDown = true; + shouldReject = shuttingDown && messageType !== 'shutdown' && messageType !== 'health'; + expect(shouldReject).toBe(true); + + // But still allow shutdown and health + messageType = 'shutdown'; + shouldReject = shuttingDown && messageType !== 'shutdown' && messageType !== 'health'; + expect(shouldReject).toBe(false); + + messageType = 'health'; + shouldReject = shuttingDown && messageType !== 'shutdown' && messageType !== 'health'; + expect(shouldReject).toBe(false); + }); +}); + +describe('PoolServer cache eviction', () => { + it('should evict cache on memory pressure', () => { + const pool = new WorkerPoolManager({} as any); + pool.clearCache(); + + expect(pool.clearCache).toHaveBeenCalled(); + }); + + it('should handle cache eviction errors gracefully', () => { + const pool = new WorkerPoolManager({} as any); + (pool.clearCache as jest.Mock).mockImplementation(() => { + throw new Error('Cache eviction failed'); + }); + + try { + pool.clearCache(); + } catch (err) { + expect((err as Error).message).toBe('Cache eviction failed'); + } + }); +}); + +describe('PoolServer configuration', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should use environment variables for configuration', () => { + process.env.PLUGIN_MAX_CONCURRENCY = '4096'; + process.env.PLUGIN_POOL_MIN_THREADS = '4'; + process.env.PLUGIN_POOL_MAX_THREADS = '16'; + process.env.PLUGIN_POOL_CONCURRENT_TASKS = '8'; + process.env.PLUGIN_POOL_IDLE_TIMEOUT = '30000'; + + const config = { + maxConcurrency: parseInt(process.env.PLUGIN_MAX_CONCURRENCY, 10), + minThreads: parseInt(process.env.PLUGIN_POOL_MIN_THREADS, 10), + maxThreads: parseInt(process.env.PLUGIN_POOL_MAX_THREADS, 10), + concurrentTasks: parseInt(process.env.PLUGIN_POOL_CONCURRENT_TASKS, 10), + idleTimeout: parseInt(process.env.PLUGIN_POOL_IDLE_TIMEOUT, 10), + }; + + expect(config.maxConcurrency).toBe(4096); + expect(config.minThreads).toBe(4); + expect(config.maxThreads).toBe(16); + expect(config.concurrentTasks).toBe(8); + expect(config.idleTimeout).toBe(30000); + }); + + it('should fall back to defaults when env vars not set', () => { + delete process.env.PLUGIN_MAX_CONCURRENCY; + delete process.env.PLUGIN_POOL_MIN_THREADS; + + const maxConcurrency = parseInt(process.env.PLUGIN_MAX_CONCURRENCY || '2048', 10); + const minThreads = parseInt(process.env.PLUGIN_POOL_MIN_THREADS || '2', 10); + + expect(maxConcurrency).toBe(2048); + expect(minThreads).toBe(2); + }); + + it('should use socket backlog from environment', () => { + process.env.PLUGIN_POOL_SOCKET_BACKLOG = '2048'; + + const backlog = parseInt(process.env.PLUGIN_POOL_SOCKET_BACKLOG, 10); + expect(backlog).toBe(2048); + }); +}); diff --git a/plugins/tests/lib/worker-pool.test.ts b/plugins/tests/lib/worker-pool.test.ts new file mode 100644 index 000000000..724f85b2a --- /dev/null +++ b/plugins/tests/lib/worker-pool.test.ts @@ -0,0 +1,454 @@ +import '@jest/globals'; + +// Since the classes are not exported, we'll test them via their behavior +// These tests validate the logic patterns used in worker-pool.ts + +describe('CompiledCodeCache logic', () => { + // Simulating the cache logic for testing + + interface CacheEntry { + code: string; + timestamp: number; + size: number; + } + + class MockCache { + private cache = new Map(); + private totalSize = 0; + private readonly maxSize: number; + private readonly ttlMs: number; + + constructor(maxSizeMB: number = 100, ttlMinutes: number = 60) { + this.maxSize = maxSizeMB * 1024 * 1024; + this.ttlMs = ttlMinutes * 60 * 1000; + } + + get(key: string): string | null { + const entry = this.cache.get(key); + if (!entry) return null; + + // Check expiration + if (Date.now() - entry.timestamp > this.ttlMs) { + this.delete(key); + return null; + } + + // Update timestamp for LRU + entry.timestamp = Date.now(); + return entry.code; + } + + set(key: string, code: string): void { + const size = Buffer.byteLength(code, 'utf8'); + + // Delete existing entry if present + if (this.cache.has(key)) { + this.delete(key); + } + + // Evict if over size limit + while (this.totalSize + size > this.maxSize && this.cache.size > 0) { + this.evictOldest(); + } + + this.cache.set(key, { code, timestamp: Date.now(), size }); + this.totalSize += size; + } + + delete(key: string): boolean { + const entry = this.cache.get(key); + if (entry) { + this.totalSize -= entry.size; + return this.cache.delete(key); + } + return false; + } + + clear(): void { + this.cache.clear(); + this.totalSize = 0; + } + + has(key: string): boolean { + return this.cache.has(key); + } + + get size(): number { + return this.cache.size; + } + + get currentSize(): number { + return this.totalSize; + } + + private evictOldest(): void { + let oldest: string | null = null; + let oldestTime = Infinity; + + for (const [key, entry] of this.cache) { + if (entry.timestamp < oldestTime) { + oldestTime = entry.timestamp; + oldest = key; + } + } + + if (oldest) { + this.delete(oldest); + } + } + + evictExpired(): number { + const now = Date.now(); + let evicted = 0; + + for (const [key, entry] of this.cache) { + if (now - entry.timestamp > this.ttlMs) { + this.delete(key); + evicted++; + } + } + + return evicted; + } + } + + let cache: MockCache; + + beforeEach(() => { + cache = new MockCache(1, 60); // 1MB max, 60 min TTL + }); + + describe('basic operations', () => { + it('should store and retrieve values', () => { + cache.set('plugin1', 'code1'); + expect(cache.get('plugin1')).toBe('code1'); + }); + + it('should return null for missing keys', () => { + expect(cache.get('nonexistent')).toBeNull(); + }); + + it('should delete entries', () => { + cache.set('plugin1', 'code1'); + expect(cache.delete('plugin1')).toBe(true); + expect(cache.get('plugin1')).toBeNull(); + }); + + it('should check if key exists', () => { + cache.set('plugin1', 'code1'); + expect(cache.has('plugin1')).toBe(true); + expect(cache.has('nonexistent')).toBe(false); + }); + + it('should clear all entries', () => { + cache.set('plugin1', 'code1'); + cache.set('plugin2', 'code2'); + cache.clear(); + expect(cache.size).toBe(0); + expect(cache.currentSize).toBe(0); + }); + }); + + describe('size tracking', () => { + it('should track total size', () => { + const code = 'a'.repeat(1000); // ~1KB + cache.set('plugin1', code); + expect(cache.currentSize).toBeGreaterThan(0); + }); + + it('should update size on delete', () => { + const code = 'a'.repeat(1000); + cache.set('plugin1', code); + const sizeAfterSet = cache.currentSize; + + cache.delete('plugin1'); + expect(cache.currentSize).toBe(sizeAfterSet - Buffer.byteLength(code, 'utf8')); + }); + }); + + describe('LRU eviction', () => { + it('should evict oldest entry when full', () => { + // Create a smaller cache for testing + const smallCache = new MockCache(0.001, 60); // ~1KB max + + smallCache.set('old', 'a'.repeat(400)); + // Wait a bit to ensure different timestamp + smallCache.set('new', 'b'.repeat(400)); + + // This should trigger eviction of 'old' + smallCache.set('newest', 'c'.repeat(400)); + + expect(smallCache.has('old')).toBe(false); + expect(smallCache.has('newest')).toBe(true); + }); + + it('should update timestamp on get for LRU', async () => { + // Use even smaller cache - 500 bytes max + const smallCache = new MockCache(0.0005, 60); + + smallCache.set('first', 'a'.repeat(200)); + + // Wait to ensure different timestamps + await new Promise((resolve) => setTimeout(resolve, 10)); + + smallCache.set('second', 'b'.repeat(200)); + + // Wait again + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Access 'first' to update its timestamp (makes it newer than 'second') + smallCache.get('first'); + + // Wait again + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Add new entry - should evict 'second' (oldest access time) + smallCache.set('third', 'c'.repeat(200)); + + // Verify 'first' was accessed more recently and survived + expect(smallCache.has('first')).toBe(true); + // 'second' was not accessed and should be evicted (oldest) + expect(smallCache.has('second')).toBe(false); + }); + }); + + describe('TTL expiration', () => { + it('should return null for expired entries', () => { + // Create cache with 1ms TTL for testing + const shortTTLCache = new MockCache(1, 0.00001); // ~0.6ms TTL + + shortTTLCache.set('plugin1', 'code1'); + + // Wait for expiration + return new Promise((resolve) => { + setTimeout(() => { + expect(shortTTLCache.get('plugin1')).toBeNull(); + resolve(); + }, 10); + }); + }); + }); +}); + +describe('MemoryMonitor logic', () => { + // Simulating memory monitor logic for testing + + interface HeapStats { + heapUsed: number; + heapLimit: number; + } + + class MockMemoryMonitor { + private readonly warningThreshold = 0.75; + private readonly gcThreshold = 0.80; + private readonly criticalThreshold = 0.85; + private readonly emergencyThreshold = 0.92; + private consecutiveHighPressure = 0; + private emergencyTriggered = false; + + private onWarning: (() => void) | null = null; + private onCritical: (() => void) | null = null; + private onEmergency: (() => void) | null = null; + + setCallbacks( + onWarning: () => void, + onCritical: () => void, + onEmergency: () => void + ): void { + this.onWarning = onWarning; + this.onCritical = onCritical; + this.onEmergency = onEmergency; + } + + check(stats: HeapStats): string | null { + const ratio = stats.heapUsed / stats.heapLimit; + + if (ratio >= this.emergencyThreshold) { + if (!this.emergencyTriggered) { + this.emergencyTriggered = true; + this.onEmergency?.(); + return 'emergency'; + } + return 'emergency-blocked'; + } + + if (ratio >= this.criticalThreshold) { + this.consecutiveHighPressure++; + this.onCritical?.(); + return 'critical'; + } + + if (ratio >= this.gcThreshold) { + return 'gc'; + } + + if (ratio >= this.warningThreshold) { + this.onWarning?.(); + return 'warning'; + } + + this.consecutiveHighPressure = 0; + return null; + } + + reset(): void { + this.emergencyTriggered = false; + this.consecutiveHighPressure = 0; + } + + get isEmergencyTriggered(): boolean { + return this.emergencyTriggered; + } + } + + let monitor: MockMemoryMonitor; + + beforeEach(() => { + monitor = new MockMemoryMonitor(); + }); + + describe('threshold detection', () => { + it('should return null for normal memory usage', () => { + const result = monitor.check({ heapUsed: 500, heapLimit: 1000 }); // 50% + expect(result).toBeNull(); + }); + + it('should detect warning threshold (75%)', () => { + const result = monitor.check({ heapUsed: 760, heapLimit: 1000 }); + expect(result).toBe('warning'); + }); + + it('should detect GC threshold (80%)', () => { + const result = monitor.check({ heapUsed: 810, heapLimit: 1000 }); + expect(result).toBe('gc'); + }); + + it('should detect critical threshold (85%)', () => { + const result = monitor.check({ heapUsed: 860, heapLimit: 1000 }); + expect(result).toBe('critical'); + }); + + it('should detect emergency threshold (92%)', () => { + const result = monitor.check({ heapUsed: 930, heapLimit: 1000 }); + expect(result).toBe('emergency'); + }); + }); + + describe('callback invocation', () => { + it('should call warning callback', () => { + const warningFn = jest.fn(); + monitor.setCallbacks(warningFn, jest.fn(), jest.fn()); + + monitor.check({ heapUsed: 760, heapLimit: 1000 }); + expect(warningFn).toHaveBeenCalled(); + }); + + it('should call critical callback', () => { + const criticalFn = jest.fn(); + monitor.setCallbacks(jest.fn(), criticalFn, jest.fn()); + + monitor.check({ heapUsed: 860, heapLimit: 1000 }); + expect(criticalFn).toHaveBeenCalled(); + }); + + it('should call emergency callback only once', () => { + const emergencyFn = jest.fn(); + monitor.setCallbacks(jest.fn(), jest.fn(), emergencyFn); + + monitor.check({ heapUsed: 930, heapLimit: 1000 }); + monitor.check({ heapUsed: 940, heapLimit: 1000 }); + monitor.check({ heapUsed: 950, heapLimit: 1000 }); + + expect(emergencyFn).toHaveBeenCalledTimes(1); + }); + }); + + describe('emergency prevention', () => { + it('should prevent duplicate emergency triggers', () => { + monitor.check({ heapUsed: 930, heapLimit: 1000 }); + expect(monitor.isEmergencyTriggered).toBe(true); + + const result = monitor.check({ heapUsed: 940, heapLimit: 1000 }); + expect(result).toBe('emergency-blocked'); + }); + + it('should allow emergency after reset', () => { + monitor.check({ heapUsed: 930, heapLimit: 1000 }); + monitor.reset(); + expect(monitor.isEmergencyTriggered).toBe(false); + + const result = monitor.check({ heapUsed: 930, heapLimit: 1000 }); + expect(result).toBe('emergency'); + }); + }); + + describe('pressure tracking', () => { + it('should reset consecutive pressure on normal usage', () => { + // First trigger critical + monitor.check({ heapUsed: 860, heapLimit: 1000 }); + // Then normal + monitor.check({ heapUsed: 500, heapLimit: 1000 }); + // The counter should reset (validated by no crash and normal return) + const result = monitor.check({ heapUsed: 500, heapLimit: 1000 }); + expect(result).toBeNull(); + }); + }); +}); + +describe('Metrics bounded map logic', () => { + // Testing the bounded map pattern used for metrics + + function incrementBoundedMap( + map: Map, + key: string, + maxSize: number + ): void { + map.set(key, (map.get(key) || 0) + 1); + + // Evict oldest entries if over limit + if (map.size > maxSize) { + const firstKey = map.keys().next().value; + if (firstKey !== undefined) { + map.delete(firstKey); + } + } + } + + it('should increment existing keys', () => { + const map = new Map(); + incrementBoundedMap(map, 'key1', 10); + incrementBoundedMap(map, 'key1', 10); + expect(map.get('key1')).toBe(2); + }); + + it('should initialize new keys to 1', () => { + const map = new Map(); + incrementBoundedMap(map, 'newKey', 10); + expect(map.get('newKey')).toBe(1); + }); + + it('should evict oldest when over limit', () => { + const map = new Map(); + const maxSize = 3; + + incrementBoundedMap(map, 'first', maxSize); + incrementBoundedMap(map, 'second', maxSize); + incrementBoundedMap(map, 'third', maxSize); + incrementBoundedMap(map, 'fourth', maxSize); + + expect(map.size).toBe(3); + expect(map.has('first')).toBe(false); + expect(map.has('fourth')).toBe(true); + }); + + it('should maintain max size with many entries', () => { + const map = new Map(); + const maxSize = 5; + + for (let i = 0; i < 100; i++) { + incrementBoundedMap(map, `key${i}`, maxSize); + } + + expect(map.size).toBeLessThanOrEqual(maxSize); + }); +}); diff --git a/src/api/controllers/plugin.rs b/src/api/controllers/plugin.rs index 5fe12937c..8271ff802 100644 --- a/src/api/controllers/plugin.rs +++ b/src/api/controllers/plugin.rs @@ -2,12 +2,14 @@ //! //! Handles HTTP endpoints for plugin operations including: //! - Calling plugins +//! - Listing plugins +//! - Updating plugin configuration use crate::{ jobs::JobProducerTrait, models::{ ApiError, ApiResponse, NetworkRepoModel, NotificationRepoModel, PaginationMeta, - PaginationQuery, PluginCallRequest, PluginModel, RelayerRepoModel, SignerRepoModel, - ThinDataAppState, TransactionRepoModel, + PaginationQuery, PluginCallRequest, PluginModel, PluginValidationError, RelayerRepoModel, + SignerRepoModel, ThinDataAppState, TransactionRepoModel, UpdatePluginRequest, }, repositories::{ ApiKeyRepositoryTrait, NetworkRepository, PluginRepositoryTrait, RelayerRepository, @@ -166,6 +168,91 @@ where ))) } +/// Get plugin by ID +/// +/// # Arguments +/// +/// * `plugin_id` - The ID of the plugin to retrieve. +/// * `state` - The application state containing the plugin repository. +/// +/// # Returns +/// +/// The plugin model if found. +pub async fn get_plugin( + plugin_id: String, + state: ThinDataAppState, +) -> Result +where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + Repository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, +{ + let plugin = state + .plugin_repository + .get_by_id(&plugin_id) + .await? + .ok_or_else(|| ApiError::NotFound(format!("Plugin with id {plugin_id} not found")))?; + + Ok(HttpResponse::Ok().json(ApiResponse::success(plugin))) +} + +/// Update plugin configuration +/// +/// Updates mutable plugin fields such as timeout, emit_logs, emit_traces, +/// raw_response, allow_get_invocation, config, and forward_logs. +/// The plugin id and path cannot be changed after creation. +/// +/// # Arguments +/// +/// * `plugin_id` - The ID of the plugin to update. +/// * `update_request` - The update request containing the fields to update. +/// * `state` - The application state containing the plugin repository. +/// +/// # Returns +/// +/// The updated plugin model. +pub async fn update_plugin( + plugin_id: String, + update_request: UpdatePluginRequest, + state: ThinDataAppState, +) -> Result +where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + Repository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, +{ + // Get existing plugin + let plugin = state + .plugin_repository + .get_by_id(&plugin_id) + .await? + .ok_or_else(|| ApiError::NotFound(format!("Plugin with id {plugin_id} not found")))?; + + // Apply updates + let updated_plugin = plugin.apply_update(update_request).map_err(|e| match e { + PluginValidationError::InvalidTimeout(msg) => ApiError::BadRequest(msg), + })?; + + // Save the updated plugin + let saved_plugin = state.plugin_repository.update(updated_plugin).await?; + + tracing::info!(plugin_id = %plugin_id, "Plugin configuration updated"); + + Ok(HttpResponse::Ok().json(ApiResponse::success(saved_plugin))) +} + #[cfg(test)] mod tests { use std::time::Duration; @@ -324,6 +411,42 @@ mod tests { assert_eq!(http_response.status(), StatusCode::OK); } + #[actix_web::test] + async fn test_get_plugin_success() { + // Tests getting a plugin by ID + let plugin = PluginModel { + id: "test-plugin".to_string(), + path: "test-path".to_string(), + timeout: Duration::from_secs(DEFAULT_PLUGIN_TIMEOUT_SECONDS), + emit_logs: true, + emit_traces: false, + raw_response: false, + allow_get_invocation: true, + config: None, + forward_logs: true, + }; + let app_state = + create_mock_app_state(None, None, None, None, Some(vec![plugin]), None).await; + + let response = get_plugin("test-plugin".to_string(), web::ThinData(app_state)).await; + assert!(response.is_ok()); + let http_response = response.unwrap(); + assert_eq!(http_response.status(), StatusCode::OK); + } + + #[actix_web::test] + async fn test_get_plugin_not_found() { + // Tests getting a non-existent plugin + let app_state = create_mock_app_state(None, None, None, None, None, None).await; + + let response = get_plugin("non-existent".to_string(), web::ThinData(app_state)).await; + assert!(response.is_err()); + match response.unwrap_err() { + ApiError::NotFound(msg) => assert!(msg.contains("non-existent")), + _ => panic!("Expected NotFound error"), + } + } + #[actix_web::test] async fn test_call_plugin_with_raw_response() { // Tests that raw_response flag returns plugin result directly @@ -567,4 +690,254 @@ mod tests { assert!(response.metadata.is_none()); assert!(response.error.is_none()); } + + // ============================================================================ + // UPDATE PLUGIN CONTROLLER TESTS + // ============================================================================ + + #[actix_web::test] + async fn test_update_plugin_success() { + // Tests successful plugin update + let plugin = PluginModel { + id: "test-plugin".to_string(), + path: "test-path".to_string(), + timeout: Duration::from_secs(30), + emit_logs: false, + emit_traces: false, + raw_response: false, + allow_get_invocation: false, + config: None, + forward_logs: false, + }; + let app_state = + create_mock_app_state(None, None, None, None, Some(vec![plugin]), None).await; + + let update_request = UpdatePluginRequest { + timeout: Some(60), + emit_logs: Some(true), + forward_logs: Some(true), + ..Default::default() + }; + + let response = update_plugin( + "test-plugin".to_string(), + update_request, + web::ThinData(app_state), + ) + .await; + + assert!(response.is_ok()); + let http_response = response.unwrap(); + assert_eq!(http_response.status(), StatusCode::OK); + } + + #[actix_web::test] + async fn test_update_plugin_not_found() { + // Tests update on non-existent plugin + let app_state = create_mock_app_state(None, None, None, None, None, None).await; + + let update_request = UpdatePluginRequest { + timeout: Some(60), + ..Default::default() + }; + + let response = update_plugin( + "non-existent".to_string(), + update_request, + web::ThinData(app_state), + ) + .await; + + assert!(response.is_err()); + match response.unwrap_err() { + ApiError::NotFound(msg) => assert!(msg.contains("non-existent")), + _ => panic!("Expected NotFound error"), + } + } + + #[actix_web::test] + async fn test_update_plugin_invalid_timeout() { + // Tests that timeout=0 returns BadRequest + let plugin = PluginModel { + id: "test-plugin".to_string(), + path: "test-path".to_string(), + timeout: Duration::from_secs(30), + emit_logs: false, + emit_traces: false, + raw_response: false, + allow_get_invocation: false, + config: None, + forward_logs: false, + }; + let app_state = + create_mock_app_state(None, None, None, None, Some(vec![plugin]), None).await; + + let update_request = UpdatePluginRequest { + timeout: Some(0), // Invalid: timeout must be > 0 + ..Default::default() + }; + + let response = update_plugin( + "test-plugin".to_string(), + update_request, + web::ThinData(app_state), + ) + .await; + + assert!(response.is_err()); + match response.unwrap_err() { + ApiError::BadRequest(msg) => assert!(msg.contains("Timeout")), + _ => panic!("Expected BadRequest error"), + } + } + + #[actix_web::test] + async fn test_update_plugin_with_config() { + // Tests updating plugin config + let plugin = PluginModel { + id: "test-plugin".to_string(), + path: "test-path".to_string(), + timeout: Duration::from_secs(30), + emit_logs: false, + emit_traces: false, + raw_response: false, + allow_get_invocation: false, + config: None, + forward_logs: false, + }; + let app_state = + create_mock_app_state(None, None, None, None, Some(vec![plugin]), None).await; + + let mut config_map = serde_json::Map::new(); + config_map.insert("feature_flag".to_string(), serde_json::json!(true)); + config_map.insert("api_key".to_string(), serde_json::json!("secret123")); + + let update_request = UpdatePluginRequest { + config: Some(Some(config_map)), + ..Default::default() + }; + + let response = update_plugin( + "test-plugin".to_string(), + update_request, + web::ThinData(app_state), + ) + .await; + + assert!(response.is_ok()); + let http_response = response.unwrap(); + assert_eq!(http_response.status(), StatusCode::OK); + } + + #[actix_web::test] + async fn test_update_plugin_clear_config() { + // Tests clearing plugin config by setting it to null + let mut initial_config = serde_json::Map::new(); + initial_config.insert("existing".to_string(), serde_json::json!("value")); + + let plugin = PluginModel { + id: "test-plugin".to_string(), + path: "test-path".to_string(), + timeout: Duration::from_secs(30), + emit_logs: false, + emit_traces: false, + raw_response: false, + allow_get_invocation: false, + config: Some(initial_config), + forward_logs: false, + }; + let app_state = + create_mock_app_state(None, None, None, None, Some(vec![plugin]), None).await; + + // Setting config to Some(None) should clear it + let update_request = UpdatePluginRequest { + config: Some(None), + ..Default::default() + }; + + let response = update_plugin( + "test-plugin".to_string(), + update_request, + web::ThinData(app_state), + ) + .await; + + assert!(response.is_ok()); + let http_response = response.unwrap(); + assert_eq!(http_response.status(), StatusCode::OK); + } + + #[actix_web::test] + async fn test_update_plugin_all_fields() { + // Tests updating all mutable fields at once + let plugin = PluginModel { + id: "test-plugin".to_string(), + path: "test-path".to_string(), + timeout: Duration::from_secs(30), + emit_logs: false, + emit_traces: false, + raw_response: false, + allow_get_invocation: false, + config: None, + forward_logs: false, + }; + let app_state = + create_mock_app_state(None, None, None, None, Some(vec![plugin]), None).await; + + let mut config_map = serde_json::Map::new(); + config_map.insert("key".to_string(), serde_json::json!("value")); + + let update_request = UpdatePluginRequest { + timeout: Some(120), + emit_logs: Some(true), + emit_traces: Some(true), + raw_response: Some(true), + allow_get_invocation: Some(true), + config: Some(Some(config_map)), + forward_logs: Some(true), + }; + + let response = update_plugin( + "test-plugin".to_string(), + update_request, + web::ThinData(app_state), + ) + .await; + + assert!(response.is_ok()); + let http_response = response.unwrap(); + assert_eq!(http_response.status(), StatusCode::OK); + } + + #[actix_web::test] + async fn test_update_plugin_empty_request() { + // Tests that an empty update request doesn't change anything (no-op) + let plugin = PluginModel { + id: "test-plugin".to_string(), + path: "test-path".to_string(), + timeout: Duration::from_secs(30), + emit_logs: true, + emit_traces: true, + raw_response: false, + allow_get_invocation: false, + config: None, + forward_logs: true, + }; + let app_state = + create_mock_app_state(None, None, None, None, Some(vec![plugin]), None).await; + + // Empty update request - all fields are None + let update_request = UpdatePluginRequest::default(); + + let response = update_plugin( + "test-plugin".to_string(), + update_request, + web::ThinData(app_state), + ) + .await; + + assert!(response.is_ok()); + let http_response = response.unwrap(); + assert_eq!(http_response.status(), StatusCode::OK); + } } diff --git a/src/api/routes/docs/plugin_docs.rs b/src/api/routes/docs/plugin_docs.rs index 424f6f6fa..8b2db35ce 100644 --- a/src/api/routes/docs/plugin_docs.rs +++ b/src/api/routes/docs/plugin_docs.rs @@ -1,5 +1,5 @@ use crate::{ - models::{ApiResponse, PluginCallRequest, PluginModel}, + models::{ApiResponse, PluginCallRequest, PluginModel, UpdatePluginRequest}, repositories::PaginatedResult, services::plugins::PluginHandlerError, }; @@ -277,3 +277,196 @@ fn doc_call_plugin_get() {} )] #[allow(dead_code)] fn doc_list_plugins() {} + +/// Get plugin by ID. +#[utoipa::path( + get, + path = "/api/v1/plugins/{plugin_id}", + tag = "Plugins", + operation_id = "getPlugin", + summary = "Get plugin by ID", + security( + ("bearer_auth" = []) + ), + params( + ("plugin_id" = String, Path, description = "The unique identifier of the plugin") + ), + responses( + ( + status = 200, + description = "Plugin retrieved successfully", + body = ApiResponse, + example = json!({ + "success": true, + "data": { + "id": "my-plugin", + "path": "plugins/my-plugin.ts", + "timeout": 30, + "emit_logs": false, + "emit_traces": false, + "raw_response": false, + "allow_get_invocation": false, + "config": { + "featureFlag": true + }, + "forward_logs": false + }, + "error": null + }) + ), + ( + status = 401, + description = "Unauthorized", + body = ApiResponse, + example = json!({ + "success": false, + "error": "Unauthorized", + "data": null + }) + ), + ( + status = 404, + description = "Plugin not found", + body = ApiResponse, + example = json!({ + "success": false, + "error": "Plugin with id my-plugin not found", + "data": null + }) + ), + ( + status = 429, + description = "Too Many Requests", + body = ApiResponse, + example = json!({ + "success": false, + "error": "Too Many Requests", + "data": null + }) + ), + ( + status = 500, + description = "Internal server error", + body = ApiResponse, + example = json!({ + "success": false, + "error": "Internal Server Error", + "data": null + }) + ) + ) +)] +#[allow(dead_code)] +fn doc_get_plugin() {} + +/// Update plugin configuration. +/// +/// Updates mutable plugin fields such as timeout, emit_logs, emit_traces, +/// raw_response, allow_get_invocation, config, and forward_logs. +/// The plugin id and path cannot be changed after creation. +/// +/// All fields are optional - only the provided fields will be updated. +/// To clear the `config` field, pass `"config": null`. +#[utoipa::path( + patch, + path = "/api/v1/plugins/{plugin_id}", + tag = "Plugins", + operation_id = "updatePlugin", + summary = "Update plugin configuration", + security( + ("bearer_auth" = []) + ), + params( + ("plugin_id" = String, Path, description = "The unique identifier of the plugin") + ), + request_body( + content = UpdatePluginRequest, + description = "Plugin configuration update. All fields are optional.", + example = json!({ + "timeout": 60, + "emit_logs": true, + "forward_logs": true, + "config": { + "featureFlag": true, + "apiKey": "xyz123" + } + }) + ), + responses( + ( + status = 200, + description = "Plugin updated successfully", + body = ApiResponse, + example = json!({ + "success": true, + "data": { + "id": "my-plugin", + "path": "plugins/my-plugin.ts", + "timeout": 60, + "emit_logs": true, + "emit_traces": false, + "raw_response": false, + "allow_get_invocation": false, + "config": { + "featureFlag": true, + "apiKey": "xyz123" + }, + "forward_logs": true + }, + "error": null + }) + ), + ( + status = 400, + description = "Bad Request (invalid timeout or other validation error)", + body = ApiResponse, + example = json!({ + "success": false, + "error": "Timeout must be greater than 0", + "data": null + }) + ), + ( + status = 401, + description = "Unauthorized", + body = ApiResponse, + example = json!({ + "success": false, + "error": "Unauthorized", + "data": null + }) + ), + ( + status = 404, + description = "Plugin not found", + body = ApiResponse, + example = json!({ + "success": false, + "error": "Plugin with id my-plugin not found", + "data": null + }) + ), + ( + status = 429, + description = "Too Many Requests", + body = ApiResponse, + example = json!({ + "success": false, + "error": "Too Many Requests", + "data": null + }) + ), + ( + status = 500, + description = "Internal server error", + body = ApiResponse, + example = json!({ + "success": false, + "error": "Internal Server Error", + "data": null + }) + ) + ) +)] +#[allow(dead_code)] +fn doc_update_plugin() {} diff --git a/src/api/routes/plugin.rs b/src/api/routes/plugin.rs index 94a9a8310..f388787be 100644 --- a/src/api/routes/plugin.rs +++ b/src/api/routes/plugin.rs @@ -5,10 +5,13 @@ use std::collections::HashMap; use crate::{ api::controllers::plugin, - models::{ApiError, ApiResponse, DefaultAppState, PaginationQuery, PluginCallRequest}, + models::{ + ApiError, ApiResponse, DefaultAppState, PaginationQuery, PluginCallRequest, + UpdatePluginRequest, + }, repositories::PluginRepositoryTrait, }; -use actix_web::{get, post, web, HttpRequest, HttpResponse, Responder}; +use actix_web::{get, patch, post, web, HttpRequest, HttpResponse, Responder}; use url::form_urlencoded; /// List plugins @@ -60,11 +63,12 @@ fn extract_query_params(http_req: &HttpRequest) -> HashMap> /// Resolves the effective route from path and query parameters. /// Path route takes precedence; if empty, falls back to `route` query parameter. fn resolve_route(path_route: &str, http_req: &HttpRequest) -> String { + // Early return to avoid unnecessary query parameter parsing when path_route is provided if !path_route.is_empty() { return path_route.to_string(); } - // Check for route in query parameters + // Only parse query parameters when path_route is empty (lazy evaluation) let query_params = extract_query_params(http_req); query_params .get("route") @@ -175,11 +179,34 @@ async fn plugin_call_get( plugin::call_plugin(plugin_id, plugin_call_request, data).await } +/// Get plugin by ID +#[get("/plugins/{plugin_id}")] +async fn get_plugin( + path: web::Path, + data: web::ThinData, +) -> impl Responder { + let plugin_id = path.into_inner(); + plugin::get_plugin(plugin_id, data).await +} + +/// Update plugin configuration +#[patch("/plugins/{plugin_id}")] +async fn update_plugin( + path: web::Path, + body: web::Json, + data: web::ThinData, +) -> impl Responder { + let plugin_id = path.into_inner(); + plugin::update_plugin(plugin_id, body.into_inner(), data).await +} + /// Initializes the routes for the plugins module. pub fn init(cfg: &mut web::ServiceConfig) { // Register routes with literal segments before routes with path parameters cfg.service(plugin_call); // POST /plugins/{plugin_id}/call cfg.service(plugin_call_get); // GET /plugins/{plugin_id}/call + cfg.service(get_plugin); // GET /plugins/{plugin_id} + cfg.service(update_plugin); // PATCH /plugins/{plugin_id} cfg.service(list_plugins); // GET /plugins } @@ -1761,4 +1788,326 @@ mod tests { resp.status() ); } + + // ============================================================================ + // GET PLUGIN ROUTE TESTS + // ============================================================================ + + /// Mock handler for get plugin that returns a plugin by ID + async fn mock_get_plugin(path: web::Path) -> impl Responder { + let plugin_id = path.into_inner(); + + if plugin_id == "not-found" { + return HttpResponse::NotFound().json(ApiResponse::<()>::error(format!( + "Plugin with id {} not found", + plugin_id + ))); + } + + let plugin = PluginModel { + id: plugin_id, + path: "test-path.ts".to_string(), + timeout: Duration::from_secs(30), + emit_logs: true, + emit_traces: false, + raw_response: false, + allow_get_invocation: true, + config: None, + forward_logs: true, + }; + + HttpResponse::Ok().json(ApiResponse::success(plugin)) + } + + #[actix_web::test] + async fn test_get_plugin_route_success() { + let app = test::init_service( + App::new() + .service( + web::resource("/plugins/{plugin_id}").route(web::get().to(mock_get_plugin)), + ) + .configure(init), + ) + .await; + + let req = test::TestRequest::get() + .uri("/plugins/my-plugin") + .to_request(); + + let resp = test::call_service(&app, req).await; + assert!(resp.status().is_success()); + + let body = test::read_body(resp).await; + let response: ApiResponse = serde_json::from_slice(&body).unwrap(); + assert!(response.success); + assert_eq!(response.data.as_ref().unwrap().id, "my-plugin"); + assert!(response.data.as_ref().unwrap().emit_logs); + assert!(response.data.as_ref().unwrap().forward_logs); + } + + #[actix_web::test] + async fn test_get_plugin_route_not_found() { + let app = test::init_service( + App::new() + .service( + web::resource("/plugins/{plugin_id}").route(web::get().to(mock_get_plugin)), + ) + .configure(init), + ) + .await; + + let req = test::TestRequest::get() + .uri("/plugins/not-found") + .to_request(); + + let resp = test::call_service(&app, req).await; + assert_eq!(resp.status(), actix_web::http::StatusCode::NOT_FOUND); + } + + // ============================================================================ + // UPDATE PLUGIN ROUTE TESTS + // ============================================================================ + + /// Mock handler for update plugin that returns the updated plugin + async fn mock_update_plugin( + path: web::Path, + body: web::Json, + ) -> impl Responder { + let plugin_id = path.into_inner(); + let update = body.into_inner(); + + // Simulate successful update + let updated_plugin = PluginModel { + id: plugin_id, + path: "test-path".to_string(), + timeout: Duration::from_secs(update.timeout.unwrap_or(30)), + emit_logs: update.emit_logs.unwrap_or(false), + emit_traces: update.emit_traces.unwrap_or(false), + raw_response: update.raw_response.unwrap_or(false), + allow_get_invocation: update.allow_get_invocation.unwrap_or(false), + config: update.config.flatten(), + forward_logs: update.forward_logs.unwrap_or(false), + }; + + HttpResponse::Ok().json(ApiResponse::success(updated_plugin)) + } + + #[actix_web::test] + async fn test_update_plugin_route_success() { + let app = test::init_service( + App::new() + .service( + web::resource("/plugins/{plugin_id}") + .route(web::patch().to(mock_update_plugin)), + ) + .configure(init), + ) + .await; + + let req = test::TestRequest::patch() + .uri("/plugins/test-plugin") + .insert_header(("Content-Type", "application/json")) + .set_json(serde_json::json!({ + "timeout": 60, + "emit_logs": true, + "forward_logs": true + })) + .to_request(); + + let resp = test::call_service(&app, req).await; + assert!(resp.status().is_success()); + + let body = test::read_body(resp).await; + let response: ApiResponse = serde_json::from_slice(&body).unwrap(); + assert!(response.success); + assert_eq!(response.data.as_ref().unwrap().id, "test-plugin"); + assert_eq!( + response.data.as_ref().unwrap().timeout, + Duration::from_secs(60) + ); + assert!(response.data.as_ref().unwrap().emit_logs); + assert!(response.data.as_ref().unwrap().forward_logs); + } + + #[actix_web::test] + async fn test_update_plugin_route_with_config() { + let app = test::init_service( + App::new() + .service( + web::resource("/plugins/{plugin_id}") + .route(web::patch().to(mock_update_plugin)), + ) + .configure(init), + ) + .await; + + let req = test::TestRequest::patch() + .uri("/plugins/my-plugin") + .insert_header(("Content-Type", "application/json")) + .set_json(serde_json::json!({ + "config": { + "feature_enabled": true, + "api_key": "secret123" + } + })) + .to_request(); + + let resp = test::call_service(&app, req).await; + assert!(resp.status().is_success()); + + let body = test::read_body(resp).await; + let response: ApiResponse = serde_json::from_slice(&body).unwrap(); + assert!(response.success); + assert!(response.data.as_ref().unwrap().config.is_some()); + let config = response.data.as_ref().unwrap().config.as_ref().unwrap(); + assert_eq!( + config.get("feature_enabled"), + Some(&serde_json::json!(true)) + ); + assert_eq!(config.get("api_key"), Some(&serde_json::json!("secret123"))); + } + + #[actix_web::test] + async fn test_update_plugin_route_clear_config() { + let app = test::init_service( + App::new() + .service( + web::resource("/plugins/{plugin_id}") + .route(web::patch().to(mock_update_plugin)), + ) + .configure(init), + ) + .await; + + let req = test::TestRequest::patch() + .uri("/plugins/test-plugin") + .insert_header(("Content-Type", "application/json")) + .set_json(serde_json::json!({ + "config": null + })) + .to_request(); + + let resp = test::call_service(&app, req).await; + assert!(resp.status().is_success()); + + let body = test::read_body(resp).await; + let response: ApiResponse = serde_json::from_slice(&body).unwrap(); + assert!(response.success); + assert!(response.data.as_ref().unwrap().config.is_none()); + } + + #[actix_web::test] + async fn test_update_plugin_route_empty_body() { + let app = test::init_service( + App::new() + .service( + web::resource("/plugins/{plugin_id}") + .route(web::patch().to(mock_update_plugin)), + ) + .configure(init), + ) + .await; + + // Empty JSON object - no fields to update + let req = test::TestRequest::patch() + .uri("/plugins/test-plugin") + .insert_header(("Content-Type", "application/json")) + .set_json(serde_json::json!({})) + .to_request(); + + let resp = test::call_service(&app, req).await; + assert!(resp.status().is_success()); + } + + #[actix_web::test] + async fn test_update_plugin_route_all_fields() { + let app = test::init_service( + App::new() + .service( + web::resource("/plugins/{plugin_id}") + .route(web::patch().to(mock_update_plugin)), + ) + .configure(init), + ) + .await; + + let req = test::TestRequest::patch() + .uri("/plugins/full-update-plugin") + .insert_header(("Content-Type", "application/json")) + .set_json(serde_json::json!({ + "timeout": 120, + "emit_logs": true, + "emit_traces": true, + "raw_response": true, + "allow_get_invocation": true, + "forward_logs": true, + "config": { + "key": "value" + } + })) + .to_request(); + + let resp = test::call_service(&app, req).await; + assert!(resp.status().is_success()); + + let body = test::read_body(resp).await; + let response: ApiResponse = serde_json::from_slice(&body).unwrap(); + let plugin = response.data.unwrap(); + + assert_eq!(plugin.timeout, Duration::from_secs(120)); + assert!(plugin.emit_logs); + assert!(plugin.emit_traces); + assert!(plugin.raw_response); + assert!(plugin.allow_get_invocation); + assert!(plugin.forward_logs); + assert!(plugin.config.is_some()); + } + + #[actix_web::test] + async fn test_update_plugin_route_invalid_json() { + let app = test::init_service( + App::new() + .service( + web::resource("/plugins/{plugin_id}") + .route(web::patch().to(mock_update_plugin)), + ) + .configure(init), + ) + .await; + + let req = test::TestRequest::patch() + .uri("/plugins/test-plugin") + .insert_header(("Content-Type", "application/json")) + .set_payload("{ invalid json }") + .to_request(); + + let resp = test::call_service(&app, req).await; + assert!(resp.status().is_client_error()); + } + + #[actix_web::test] + async fn test_update_plugin_route_unknown_field_rejected() { + let app = test::init_service( + App::new() + .service( + web::resource("/plugins/{plugin_id}") + .route(web::patch().to(mock_update_plugin)), + ) + .configure(init), + ) + .await; + + // UpdatePluginRequest has deny_unknown_fields, so this should fail + let req = test::TestRequest::patch() + .uri("/plugins/test-plugin") + .insert_header(("Content-Type", "application/json")) + .set_json(serde_json::json!({ + "timeout": 60, + "unknown_field": "should_fail" + })) + .to_request(); + + let resp = test::call_service(&app, req).await; + assert!(resp.status().is_client_error()); + } } diff --git a/src/bootstrap/initialize_plugins.rs b/src/bootstrap/initialize_plugins.rs new file mode 100644 index 000000000..a7e7a8d05 --- /dev/null +++ b/src/bootstrap/initialize_plugins.rs @@ -0,0 +1,511 @@ +//! Plugin Pool Initialization +//! +//! This module handles conditional initialization of the plugin worker pool. +//! The pool is only started if plugins are configured, avoiding overhead +//! when the plugin system is not in use. + +use std::sync::Arc; +use tracing::{info, warn}; + +use crate::repositories::PluginRepositoryTrait; +use crate::services::plugins::{get_pool_manager, PluginRunner, PluginService, PoolManager}; + +/// Initialize the plugin worker pool if plugins are configured. +/// +/// This function checks if any plugins are registered in the repository. +/// If plugins exist, it starts the Piscina worker pool for efficient +/// plugin execution. If no plugins are configured, it skips initialization. +/// +/// # Arguments +/// +/// * `plugin_repository` - Reference to the plugin repository +/// +/// # Returns +/// +/// * `Ok(Some(Arc))` - Pool manager if plugins are configured +/// * `Ok(None)` - If no plugins are configured +/// * `Err` - If pool initialization fails +pub async fn initialize_plugin_pool( + plugin_repository: &PR, +) -> eyre::Result>> { + let has_plugins = plugin_repository + .has_entries() + .await + .map_err(|e| eyre::eyre!("Failed to check plugin repository: {}", e))?; + + if !has_plugins { + info!("No plugins configured, skipping plugin pool initialization"); + return Ok(None); + } + + let plugin_count = plugin_repository.count().await.unwrap_or(0); + info!( + plugin_count = plugin_count, + "Plugins detected, initializing worker pool" + ); + + let pool_manager = get_pool_manager(); + + match pool_manager.ensure_started().await { + Ok(()) => { + info!("Plugin worker pool initialized successfully"); + Ok(Some(pool_manager)) + } + Err(e) => { + warn!(error = %e, "Failed to start plugin worker pool, falling back to ts-node execution"); + Ok(None) + } + } +} + +/// Precompile all configured plugins. +/// +/// This function loads all plugins from the repository and triggers +/// precompilation via the worker pool. Compiled code is cached in +/// the pool for fast execution. +/// +/// # Arguments +/// +/// * `plugin_repository` - Reference to the plugin repository +/// * `pool_manager` - The pool manager to use for compilation +/// +/// # Returns +/// +/// * `Ok(usize)` - Number of plugins successfully precompiled +/// * `Err` - If precompilation fails critically +pub async fn precompile_plugins( + plugin_repository: &PR, + pool_manager: &PoolManager, +) -> eyre::Result { + use crate::models::PaginationQuery; + + let query = PaginationQuery { + page: 1, + per_page: 1000, + }; + + let plugins = plugin_repository + .list_paginated(query) + .await + .map_err(|e| eyre::eyre!("Failed to list plugins: {}", e))?; + + let mut compiled_count = 0; + + for plugin in plugins.items { + let plugin_path = PluginService::::resolve_plugin_path(&plugin.path); + + match pool_manager + .precompile_plugin(plugin.id.clone(), Some(plugin_path), None) + .await + { + Ok(compiled_code) => { + if let Err(e) = pool_manager + .cache_compiled_code(plugin.id.clone(), compiled_code) + .await + { + warn!( + plugin_id = %plugin.id, + error = %e, + "Failed to cache compiled plugin code" + ); + } else { + compiled_count += 1; + info!(plugin_id = %plugin.id, "Plugin precompiled successfully"); + } + } + Err(e) => { + warn!( + plugin_id = %plugin.id, + error = %e, + "Failed to precompile plugin" + ); + } + } + } + + info!( + compiled_count = compiled_count, + total_plugins = plugins.total, + "Plugin precompilation complete" + ); + + Ok(compiled_count) +} + +/// Shutdown the plugin pool gracefully. +/// +/// This should be called during application shutdown to properly +/// terminate the worker pool and clean up resources. +pub async fn shutdown_plugin_pool() -> eyre::Result<()> { + let pool_manager = get_pool_manager(); + pool_manager + .shutdown() + .await + .map_err(|e| eyre::eyre!("Failed to shutdown plugin pool: {}", e))?; + info!("Plugin worker pool shutdown complete"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::RepositoryError; + use crate::repositories::MockPluginRepositoryTrait; + + // ============================================ + // initialize_plugin_pool tests + // ============================================ + + #[tokio::test] + async fn test_initialize_plugin_pool_no_plugins() { + let mut mock_repo = MockPluginRepositoryTrait::new(); + mock_repo + .expect_has_entries() + .returning(|| Box::pin(async { Ok(false) })); + + let result = initialize_plugin_pool(&mock_repo).await; + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); + } + + #[tokio::test] + async fn test_initialize_plugin_pool_has_entries_error() { + let mut mock_repo = MockPluginRepositoryTrait::new(); + mock_repo.expect_has_entries().returning(|| { + Box::pin(async { + Err(RepositoryError::ConnectionError( + "Database unavailable".to_string(), + )) + }) + }); + + let result = initialize_plugin_pool(&mock_repo).await; + assert!(result.is_err()); + + match result { + Err(e) => assert!(e.to_string().contains("Failed to check plugin repository")), + Ok(_) => panic!("Expected error"), + } + } + + #[tokio::test] + async fn test_initialize_plugin_pool_has_entries_unknown_error() { + let mut mock_repo = MockPluginRepositoryTrait::new(); + mock_repo.expect_has_entries().returning(|| { + Box::pin(async { Err(RepositoryError::Unknown("Unknown error".to_string())) }) + }); + + let result = initialize_plugin_pool(&mock_repo).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_initialize_plugin_pool_with_plugins_count_fails() { + let mut mock_repo = MockPluginRepositoryTrait::new(); + + // has_entries returns true (plugins exist) + mock_repo + .expect_has_entries() + .returning(|| Box::pin(async { Ok(true) })); + + // count fails but we handle it gracefully (unwrap_or(0)) + mock_repo.expect_count().returning(|| { + Box::pin(async { Err(RepositoryError::Unknown("Count failed".to_string())) }) + }); + + // The function should still proceed even if count fails + let result = initialize_plugin_pool(&mock_repo).await; + // Result depends on whether pool can be started, but we at least verify + // that the function doesn't panic when count fails + assert!(result.is_ok() || result.is_err()); + } + + #[tokio::test] + async fn test_initialize_plugin_pool_with_plugins_exists() { + let mut mock_repo = MockPluginRepositoryTrait::new(); + + mock_repo + .expect_has_entries() + .returning(|| Box::pin(async { Ok(true) })); + + mock_repo + .expect_count() + .returning(|| Box::pin(async { Ok(5) })); + + // This test verifies the function proceeds when plugins exist + // The actual pool startup may succeed or fail depending on environment + let result = initialize_plugin_pool(&mock_repo).await; + // We just verify it doesn't panic and returns a result + assert!(result.is_ok() || result.is_err()); + } + + #[tokio::test] + async fn test_initialize_plugin_pool_with_zero_count_but_has_entries() { + let mut mock_repo = MockPluginRepositoryTrait::new(); + + // Edge case: has_entries returns true but count returns 0 + // This shouldn't happen in practice but tests defensive coding + mock_repo + .expect_has_entries() + .returning(|| Box::pin(async { Ok(true) })); + + mock_repo + .expect_count() + .returning(|| Box::pin(async { Ok(0) })); + + let result = initialize_plugin_pool(&mock_repo).await; + // Should still attempt to start the pool + assert!(result.is_ok() || result.is_err()); + } + + // ============================================ + // precompile_plugins tests + // ============================================ + + #[tokio::test] + async fn test_precompile_plugins_list_paginated_error() { + use crate::models::PaginationQuery; + + let mut mock_repo = MockPluginRepositoryTrait::new(); + + mock_repo + .expect_list_paginated() + .withf(|query: &PaginationQuery| query.page == 1 && query.per_page == 1000) + .returning(|_| { + Box::pin(async { + Err(RepositoryError::ConnectionError( + "Database unavailable".to_string(), + )) + }) + }); + + // We need a real pool manager for this test, but since we're testing + // the error path, we can use the global one + let pool_manager = get_pool_manager(); + + let result = precompile_plugins(&mock_repo, &pool_manager).await; + assert!(result.is_err()); + + match result { + Err(e) => assert!(e.to_string().contains("Failed to list plugins")), + Ok(_) => panic!("Expected error"), + } + } + + #[tokio::test] + async fn test_precompile_plugins_empty_list() { + use crate::repositories::PaginatedResult; + + let mut mock_repo = MockPluginRepositoryTrait::new(); + + mock_repo.expect_list_paginated().returning(|_| { + Box::pin(async { + Ok(PaginatedResult { + items: vec![], + total: 0, + page: 1, + per_page: 1000, + }) + }) + }); + + let pool_manager = get_pool_manager(); + + let result = precompile_plugins(&mock_repo, &pool_manager).await; + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 0); + } + + #[tokio::test] + async fn test_precompile_plugins_pagination_query_params() { + use crate::models::PaginationQuery; + use crate::repositories::PaginatedResult; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + + let was_called = Arc::new(AtomicBool::new(false)); + let was_called_clone = was_called.clone(); + + let mut mock_repo = MockPluginRepositoryTrait::new(); + + mock_repo + .expect_list_paginated() + .withf(move |query: &PaginationQuery| { + // Verify the correct pagination parameters are used + let correct = query.page == 1 && query.per_page == 1000; + was_called_clone.store(true, Ordering::SeqCst); + correct + }) + .returning(|_| { + Box::pin(async { + Ok(PaginatedResult { + items: vec![], + total: 0, + page: 1, + per_page: 1000, + }) + }) + }); + + let pool_manager = get_pool_manager(); + + let _ = precompile_plugins(&mock_repo, &pool_manager).await; + + assert!( + was_called.load(Ordering::SeqCst), + "list_paginated should have been called" + ); + } + + // ============================================ + // Helper function tests + // ============================================ + + #[test] + fn test_resolve_plugin_path_absolute() { + // Test that PluginService::resolve_plugin_path handles paths correctly + // This is an indirect test of the path resolution used in precompile_plugins + let path = "/absolute/path/to/plugin.ts"; + let resolved = PluginService::::resolve_plugin_path(path); + assert!(resolved.contains("plugin.ts")); + } + + #[test] + fn test_resolve_plugin_path_relative() { + let path = "relative/path/plugin.ts"; + let resolved = PluginService::::resolve_plugin_path(path); + assert!(resolved.contains("plugin.ts")); + } + + // ============================================ + // Error handling tests + // ============================================ + + #[tokio::test] + async fn test_initialize_plugin_pool_not_found_error() { + let mut mock_repo = MockPluginRepositoryTrait::new(); + mock_repo.expect_has_entries().returning(|| { + Box::pin(async { Err(RepositoryError::NotFound("not found".to_string())) }) + }); + + let result = initialize_plugin_pool(&mock_repo).await; + assert!(result.is_err(), "Should fail for NotFound error"); + } + + #[tokio::test] + async fn test_initialize_plugin_pool_lock_error() { + let mut mock_repo = MockPluginRepositoryTrait::new(); + mock_repo.expect_has_entries().returning(|| { + Box::pin(async { Err(RepositoryError::LockError("lock error".to_string())) }) + }); + + let result = initialize_plugin_pool(&mock_repo).await; + assert!(result.is_err(), "Should fail for LockError"); + } + + #[tokio::test] + async fn test_initialize_plugin_pool_invalid_data_error() { + let mut mock_repo = MockPluginRepositoryTrait::new(); + mock_repo.expect_has_entries().returning(|| { + Box::pin(async { Err(RepositoryError::InvalidData("invalid".to_string())) }) + }); + + let result = initialize_plugin_pool(&mock_repo).await; + assert!(result.is_err(), "Should fail for InvalidData error"); + } + + #[tokio::test] + async fn test_precompile_plugins_not_found_error() { + let mut mock_repo = MockPluginRepositoryTrait::new(); + mock_repo.expect_list_paginated().returning(|_| { + Box::pin(async { Err(RepositoryError::NotFound("not found".to_string())) }) + }); + + let pool_manager = get_pool_manager(); + let result = precompile_plugins(&mock_repo, &pool_manager).await; + assert!(result.is_err(), "Should fail for NotFound error"); + } + + #[tokio::test] + async fn test_precompile_plugins_unknown_error() { + let mut mock_repo = MockPluginRepositoryTrait::new(); + mock_repo.expect_list_paginated().returning(|_| { + Box::pin(async { Err(RepositoryError::Unknown("unknown".to_string())) }) + }); + + let pool_manager = get_pool_manager(); + let result = precompile_plugins(&mock_repo, &pool_manager).await; + assert!(result.is_err(), "Should fail for Unknown error"); + } + + #[tokio::test] + async fn test_precompile_plugins_connection_error() { + let mut mock_repo = MockPluginRepositoryTrait::new(); + mock_repo.expect_list_paginated().returning(|_| { + Box::pin(async { Err(RepositoryError::ConnectionError("connection".to_string())) }) + }); + + let pool_manager = get_pool_manager(); + let result = precompile_plugins(&mock_repo, &pool_manager).await; + assert!(result.is_err(), "Should fail for ConnectionError"); + } + + // ============================================ + // Integration-style tests + // ============================================ + + #[tokio::test] + async fn test_initialize_then_check_pool_state() { + let mut mock_repo = MockPluginRepositoryTrait::new(); + mock_repo + .expect_has_entries() + .returning(|| Box::pin(async { Ok(false) })); + + let result = initialize_plugin_pool(&mock_repo).await; + assert!(result.is_ok()); + + // When no plugins, should return None + let pool_manager = result.unwrap(); + assert!(pool_manager.is_none()); + } + + #[tokio::test] + async fn test_multiple_initialize_calls_no_plugins() { + // Verify that multiple calls don't cause issues + for _ in 0..3 { + let mut mock_repo = MockPluginRepositoryTrait::new(); + mock_repo + .expect_has_entries() + .returning(|| Box::pin(async { Ok(false) })); + + let result = initialize_plugin_pool(&mock_repo).await; + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); + } + } + + #[tokio::test] + async fn test_precompile_with_large_plugin_count() { + use crate::repositories::PaginatedResult; + + let mut mock_repo = MockPluginRepositoryTrait::new(); + + // Simulate having many plugins (but empty list for simplicity) + mock_repo.expect_list_paginated().returning(|_| { + Box::pin(async { + Ok(PaginatedResult { + items: vec![], + total: 500, // Large total but empty items + page: 1, + per_page: 1000, + }) + }) + }); + + let pool_manager = get_pool_manager(); + + let result = precompile_plugins(&mock_repo, &pool_manager).await; + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 0); // No items to compile + } +} diff --git a/src/bootstrap/mod.rs b/src/bootstrap/mod.rs index 51a39e917..e186fb4bb 100644 --- a/src/bootstrap/mod.rs +++ b/src/bootstrap/mod.rs @@ -2,7 +2,7 @@ //! //! This module contains functions and utilities for initializing various //! components of the relayer system, including relayers, configuration, -//! application state, and workers. +//! application state, workers, and plugins. //! //! # Submodules //! @@ -10,6 +10,7 @@ //! - `config_processor`: Functions for processing configuration files //! - `initialize_app_state`: Functions for initializing application state //! - `initialize_workers`: Functions for initializing background workers +//! - `initialize_plugins`: Functions for initializing the plugin worker pool mod initialize_relayers; pub use initialize_relayers::*; @@ -21,3 +22,6 @@ pub use initialize_app_state::*; mod initialize_workers; pub use initialize_workers::*; + +mod initialize_plugins; +pub use initialize_plugins::*; diff --git a/src/constants/plugins.rs b/src/constants/plugins.rs index 324e44d1a..e1ac33f25 100644 --- a/src/constants/plugins.rs +++ b/src/constants/plugins.rs @@ -1 +1,115 @@ +// ============================================================================= +// Plugin Configuration Constants +// ============================================================================= +// +// All constants below can be overridden via environment variables. +// See docs/plugins/index.mdx for the full configuration guide. +// +// Environment Variable Naming Convention: +// PLUGIN_POOL_* → Pool server & connection pool settings +// PLUGIN_SOCKET_* → Shared socket service settings +// PLUGIN_TRACE_* → Trace collection settings +// +// ============================================================================= + +/// Default plugin execution timeout in seconds. +/// Override in config.json per-plugin: `"timeout": 60` pub const DEFAULT_PLUGIN_TIMEOUT_SECONDS: u64 = 300; // 5 minutes + +// ============================================================================= +// Plugin Pool Server Configuration +// These constants are the source of truth. The TypeScript pool-server.ts and +// worker-pool.ts files should use matching values. +// ============================================================================= + +/// Maximum concurrent connections from Rust to the Node.js pool server. +/// Env: PLUGIN_POOL_MAX_CONNECTIONS +/// Increase for high concurrency (3000+ VUs). +pub const DEFAULT_POOL_MAX_CONNECTIONS: usize = 2048; + +/// Minimum worker threads in Node.js pool (floor). +/// Internal constant, not user-configurable. +pub const DEFAULT_POOL_MIN_THREADS: usize = 2; + +/// Maximum worker threads floor (minimum threads even on small machines). +/// Internal constant, not user-configurable. +pub const DEFAULT_POOL_MAX_THREADS_FLOOR: usize = 8; + +/// Concurrent tasks per worker thread in Node.js pool. +/// Internal constant, not user-configurable. +pub const DEFAULT_POOL_CONCURRENT_TASKS_PER_WORKER: usize = 20; + +/// Headroom multiplier for calculating concurrent tasks per worker. +/// Applied to base task calculation to provide buffer for: +/// - Queue buildup during traffic spikes +/// - Variable plugin execution latency +/// - Temporary load imbalances across workers +/// Internal constant, not user-configurable. +pub const CONCURRENT_TASKS_HEADROOM_MULTIPLIER: f64 = 1.2; + +/// Maximum concurrent tasks per worker thread (hard cap). +/// This cap prevents excessive memory usage and GC pressure per worker. +/// Validated through load testing as a stable upper bound. +/// Internal constant, not user-configurable. +pub const MAX_CONCURRENT_TASKS_PER_WORKER: usize = 250; + +/// Worker idle timeout in milliseconds. +/// Internal constant, not user-configurable. +pub const DEFAULT_POOL_IDLE_TIMEOUT_MS: u64 = 60000; // 60 seconds + +/// Socket connection backlog for the pool server. +/// Env: PLUGIN_POOL_SOCKET_BACKLOG (internal, rarely needs tuning) +pub const DEFAULT_POOL_SOCKET_BACKLOG: u32 = 2048; + +/// Plugin execution timeout within the pool (milliseconds). +/// Internal constant - use per-plugin `timeout` in config.json instead. +pub const DEFAULT_POOL_EXECUTION_TIMEOUT_MS: u64 = 30000; // 30 seconds + +/// Timeout for individual pool requests (seconds). +/// Env: PLUGIN_POOL_REQUEST_TIMEOUT_SECS +pub const DEFAULT_POOL_REQUEST_TIMEOUT_SECS: u64 = 30; + +/// Maximum queued requests before rejection. +/// Env: PLUGIN_POOL_MAX_QUEUE_SIZE +/// Increase for high concurrency (3000+ VUs). +pub const DEFAULT_POOL_MAX_QUEUE_SIZE: usize = 5000; + +/// Wait time (ms) when queue is full before rejecting. +/// Env: PLUGIN_POOL_QUEUE_SEND_TIMEOUT_MS +/// Increase for bursty traffic patterns. +pub const DEFAULT_POOL_QUEUE_SEND_TIMEOUT_MS: u64 = 500; + +/// Minimum seconds between health checks. +/// Env: PLUGIN_POOL_HEALTH_CHECK_INTERVAL_SECS +/// Prevents health check storms under high load. +pub const DEFAULT_POOL_HEALTH_CHECK_INTERVAL_SECS: u64 = 5; + +/// Retry attempts when connecting to pool server. +/// Env: PLUGIN_POOL_CONNECT_RETRIES +/// Increase for high concurrency scenarios. +pub const DEFAULT_POOL_CONNECT_RETRIES: usize = 15; + +// ============================================================================= +// Shared Socket Service Configuration +// Controls the Unix socket for plugin ↔ relayer communication. +// ============================================================================= + +/// Idle timeout for plugin connections (seconds). +/// Env: PLUGIN_SOCKET_IDLE_TIMEOUT_SECS +/// Connections idle longer than this are closed. +pub const DEFAULT_SOCKET_IDLE_TIMEOUT_SECS: u64 = 60; + +/// Read timeout per line from plugins (seconds). +/// Env: PLUGIN_SOCKET_READ_TIMEOUT_SECS +/// Time to wait for a complete message from a plugin. +pub const DEFAULT_SOCKET_READ_TIMEOUT_SECS: u64 = 30; + +/// Maximum concurrent plugin connections to the relayer. +/// Env: PLUGIN_SOCKET_MAX_CONCURRENT_CONNECTIONS +/// Should be >= PLUGIN_POOL_MAX_CONNECTIONS. +pub const DEFAULT_SOCKET_MAX_CONCURRENT_CONNECTIONS: usize = 4096; + +/// Trace collection timeout (milliseconds). +/// Env: PLUGIN_TRACE_TIMEOUT_MS +/// Short timeout since traces arrive immediately after plugin execution. +pub const DEFAULT_TRACE_TIMEOUT_MS: u64 = 100; diff --git a/src/domain/transaction/stellar/prepare/common.rs b/src/domain/transaction/stellar/prepare/common.rs index f686c263c..a7582341a 100644 --- a/src/domain/transaction/stellar/prepare/common.rs +++ b/src/domain/transaction/stellar/prepare/common.rs @@ -5,7 +5,7 @@ use soroban_rs::{ stellar_rpc_client::SimulateTransactionResponse, xdr::{Limits, TransactionEnvelope, WriteXdr}, }; -use tracing::{error, info, warn}; +use tracing::{debug, error, info, warn}; use crate::{ constants::STELLAR_DEFAULT_TRANSACTION_FEE, @@ -57,7 +57,7 @@ where { // Check if the envelope needs simulation if xdr_needs_simulation(envelope).unwrap_or(false) { - info!("Transaction contains Soroban operations, simulating..."); + debug!("Transaction contains Soroban operations, simulating..."); let resp = provider .simulate_transaction_envelope(envelope) @@ -215,7 +215,7 @@ where { // Check if the inner transaction needs simulation (Soroban operations) if xdr_needs_simulation(inner_envelope).unwrap_or(false) { - info!("Inner transaction contains Soroban operations, simulating to determine resource fee..."); + debug!("Inner transaction contains Soroban operations, simulating to determine resource fee..."); match simulate_if_needed(inner_envelope, provider).await? { Some(sim_resp) => { @@ -224,7 +224,7 @@ where let resource_fee = sim_resp.min_resource_fee; let required_fee = inclusion_fee + resource_fee; - info!( + debug!( "Simulation complete. Inclusion fee: {}, Resource fee: {}, Total: {}", inclusion_fee, resource_fee, required_fee ); diff --git a/src/domain/transaction/stellar/status.rs b/src/domain/transaction/stellar/status.rs index 8d4e8819d..f388d5c26 100644 --- a/src/domain/transaction/stellar/status.rs +++ b/src/domain/transaction/stellar/status.rs @@ -632,7 +632,7 @@ mod tests { && statuses == [TransactionStatus::Pending] && query.page == 1 && query.per_page == 1 - && *oldest_first == true + && *oldest_first }) .times(1) .returning(move |_, _, _, _| { @@ -788,7 +788,7 @@ mod tests { && statuses == [TransactionStatus::Pending] && query.page == 1 && query.per_page == 1 - && *oldest_first == true + && *oldest_first }) .times(1) .returning(move |_, _, _, _| { @@ -933,7 +933,7 @@ mod tests { && statuses == [TransactionStatus::Pending] && query.page == 1 && query.per_page == 1 - && *oldest_first == true + && *oldest_first }) .times(1) .returning(move |_, _, _, _| { @@ -1205,7 +1205,7 @@ mod tests { id == "tx-with-result" && update.status == Some(TransactionStatus::Confirmed) && update.confirmed_at.is_some() - && update.network_data.as_ref().map_or(false, |and| { + && update.network_data.as_ref().is_some_and(|and| { if let NetworkTransactionData::Stellar(stellar_data) = and { // Verify transaction_result_xdr is present stellar_data.transaction_result_xdr.is_some() @@ -1470,7 +1470,7 @@ mod tests { mocks .tx_repo .expect_find_by_status_paginated() - .returning(|_, _, _, _| { + .returning(move |_, _, _, _| { Ok(PaginatedResult { items: vec![], total: 0, @@ -1556,6 +1556,7 @@ mod tests { assert!(result.is_ok()); let failed_tx = result.unwrap(); assert_eq!(failed_tx.status, TransactionStatus::Failed); + // assert_eq!(failed_tx.status_reason.as_ref().unwrap(), "Transaction stuck in Sent status for too long"); assert!(failed_tx .status_reason .as_ref() @@ -1607,7 +1608,7 @@ mod tests { mocks .tx_repo .expect_find_by_status_paginated() - .returning(|_, _, _, _| { + .returning(move |_, _, _, _| { Ok(PaginatedResult { items: vec![], total: 0, @@ -1754,7 +1755,7 @@ mod tests { mocks .tx_repo .expect_find_by_status_paginated() - .returning(|_, _, _, _| { + .returning(move |_, _, _, _| { Ok(PaginatedResult { items: vec![], total: 0, @@ -1831,7 +1832,7 @@ mod tests { mocks .tx_repo .expect_find_by_status_paginated() - .returning(|_, _, _, _| { + .returning(move |_, _, _, _| { Ok(PaginatedResult { items: vec![], total: 0, @@ -1907,7 +1908,7 @@ mod tests { mocks .tx_repo .expect_find_by_status_paginated() - .returning(|_, _, _, _| { + .returning(move |_, _, _, _| { Ok(PaginatedResult { items: vec![], total: 0, diff --git a/src/jobs/handlers/transaction_status_handler.rs b/src/jobs/handlers/transaction_status_handler.rs index 470852ed2..daeb88a37 100644 --- a/src/jobs/handlers/transaction_status_handler.rs +++ b/src/jobs/handlers/transaction_status_handler.rs @@ -87,7 +87,7 @@ fn handle_status_check_result(result: Result) -> Result<() } } Err(e) => { - // Error occurred, retry + // Error occurred, retry the job Err(Error::Failed(Arc::new(format!("{e}").into()))) } } @@ -290,8 +290,7 @@ mod tests { let err_string = arc.to_string(); assert!( err_string.contains(error_message), - "Error message should contain original error: {}", - err_string + "Error message should contain original error: {err_string}" ); } _ => panic!("Expected Error::Failed"), @@ -311,13 +310,11 @@ mod tests { let err_string = arc.to_string(); assert!( err_string.contains("not in final state"), - "Error message should indicate non-final state: {}", - err_string + "Error message should indicate non-final state: {err_string}" ); assert!( err_string.contains("Submitted"), - "Error message should mention the status: {}", - err_string + "Error message should mention the status: {err_string}" ); } _ => panic!("Expected Error::Failed for non-final state"), diff --git a/src/main.rs b/src/main.rs index 532804e12..54678578c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -48,8 +48,9 @@ use tracing::info; use openzeppelin_relayer::{ api, bootstrap::{ - initialize_app_state, initialize_relayers, initialize_token_swap_workers, - initialize_workers, process_config_file, + initialize_app_state, initialize_plugin_pool, initialize_relayers, + initialize_token_swap_workers, initialize_workers, precompile_plugins, process_config_file, + shutdown_plugin_pool, }, config, constants::{DEFAULT_CLIENT_DISCONNECT_TIMEOUT_SECONDS, PUBLIC_ENDPOINTS}, @@ -99,6 +100,33 @@ async fn main() -> Result<()> { // Setup workers for processing jobs initialize_workers(app_state.clone()).await?; + // Initialize plugin worker pool (enabled by default for better performance) + // Set PLUGIN_USE_POOL=false to use legacy ts-node mode + let pool_manager = if std::env::var("PLUGIN_USE_POOL") + .map(|v| v.eq_ignore_ascii_case("true") || v == "1") + .unwrap_or(true) + { + info!("Pool-based plugin execution enabled, initializing plugin pool"); + match initialize_plugin_pool(app_state.plugin_repository.as_ref()).await { + Ok(Some(pm)) => { + // Precompile all plugins + if let Err(e) = + precompile_plugins(app_state.plugin_repository.as_ref(), pm.as_ref()).await + { + tracing::warn!(error = %e, "Failed to precompile some plugins"); + } + Some(pm) + } + Ok(None) => None, + Err(e) => { + tracing::warn!(error = %e, "Failed to initialize plugin pool"); + None + } + } + } else { + None + }; + // Rate limit configuration let rate_limit_config = GovernorConfigBuilder::default() .requests_per_second(config.rate_limit_requests_per_second) @@ -193,6 +221,13 @@ async fn main() -> Result<()> { app_server.await?; } + // Graceful shutdown: cleanup plugin pool if it was started + if pool_manager.is_some() { + if let Err(e) = shutdown_plugin_pool().await { + tracing::warn!(error = %e, "Failed to shutdown plugin pool gracefully"); + } + } + Ok(()) } diff --git a/src/models/plugin.rs b/src/models/plugin.rs index 7ea1d9ca7..7edda0c7f 100644 --- a/src/models/plugin.rs +++ b/src/models/plugin.rs @@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize}; use serde_json::Map; use utoipa::ToSchema; +use crate::constants::DEFAULT_PLUGIN_TIMEOUT_SECONDS; + #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct PluginModel { /// Plugin ID @@ -51,3 +53,197 @@ pub struct PluginCallRequest { #[serde(default, skip_deserializing)] pub query: Option>>, } + +/// Request model for updating an existing plugin. +/// All fields are optional to allow partial updates. +/// Note: `id` and `path` are not updateable after creation. +#[derive(Debug, Clone, Serialize, Deserialize, Default, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct UpdatePluginRequest { + /// Plugin timeout in seconds + #[schema(value_type = Option)] + pub timeout: Option, + /// Whether to include logs in the HTTP response + pub emit_logs: Option, + /// Whether to include traces in the HTTP response + pub emit_traces: Option, + /// Whether to return raw plugin response without ApiResponse wrapper + pub raw_response: Option, + /// Whether to allow GET requests to invoke plugin logic + pub allow_get_invocation: Option, + /// User-defined configuration accessible to the plugin (must be a JSON object) + /// Use `null` to clear the config + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config: Option>>, + /// Whether to forward plugin logs into the relayer's tracing output + pub forward_logs: Option, +} + +/// Validation errors for plugin updates +#[derive(Debug, thiserror::Error)] +pub enum PluginValidationError { + #[error("Invalid timeout: {0}")] + InvalidTimeout(String), +} + +impl PluginModel { + /// Apply an update request to this plugin model. + /// Returns the updated plugin model or a validation error. + pub fn apply_update(&self, update: UpdatePluginRequest) -> Result { + let mut updated = self.clone(); + + if let Some(timeout_secs) = update.timeout { + if timeout_secs == 0 { + return Err(PluginValidationError::InvalidTimeout( + "Timeout must be greater than 0".to_string(), + )); + } + updated.timeout = Duration::from_secs(timeout_secs); + } + + if let Some(emit_logs) = update.emit_logs { + updated.emit_logs = emit_logs; + } + + if let Some(emit_traces) = update.emit_traces { + updated.emit_traces = emit_traces; + } + + if let Some(raw_response) = update.raw_response { + updated.raw_response = raw_response; + } + + if let Some(allow_get_invocation) = update.allow_get_invocation { + updated.allow_get_invocation = allow_get_invocation; + } + + // config uses Option> to distinguish between: + // - None: field not provided, don't change + // - Some(None): explicitly set to null, clear the config + // - Some(Some(value)): update to new value + if let Some(config) = update.config { + updated.config = config; + } + + if let Some(forward_logs) = update.forward_logs { + updated.forward_logs = forward_logs; + } + + Ok(updated) + } +} + +impl Default for PluginModel { + fn default() -> Self { + Self { + id: String::new(), + path: String::new(), + timeout: Duration::from_secs(DEFAULT_PLUGIN_TIMEOUT_SECONDS), + emit_logs: false, + emit_traces: false, + raw_response: false, + allow_get_invocation: false, + config: None, + forward_logs: false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_plugin() -> PluginModel { + PluginModel { + id: "test-plugin".to_string(), + path: "plugins/test.ts".to_string(), + timeout: Duration::from_secs(30), + emit_logs: false, + emit_traces: false, + raw_response: false, + allow_get_invocation: false, + config: None, + forward_logs: false, + } + } + + #[test] + fn test_apply_update_timeout() { + let plugin = create_test_plugin(); + let update = UpdatePluginRequest { + timeout: Some(60), + ..Default::default() + }; + + let updated = plugin.apply_update(update).unwrap(); + assert_eq!(updated.timeout, Duration::from_secs(60)); + // Other fields unchanged + assert_eq!(updated.emit_logs, false); + } + + #[test] + fn test_apply_update_timeout_zero_fails() { + let plugin = create_test_plugin(); + let update = UpdatePluginRequest { + timeout: Some(0), + ..Default::default() + }; + + let result = plugin.apply_update(update); + assert!(result.is_err()); + } + + #[test] + fn test_apply_update_all_fields() { + let plugin = create_test_plugin(); + let mut config_map = Map::new(); + config_map.insert("key".to_string(), serde_json::json!("value")); + + let update = UpdatePluginRequest { + timeout: Some(120), + emit_logs: Some(true), + emit_traces: Some(true), + raw_response: Some(true), + allow_get_invocation: Some(true), + config: Some(Some(config_map.clone())), + forward_logs: Some(true), + }; + + let updated = plugin.apply_update(update).unwrap(); + assert_eq!(updated.timeout, Duration::from_secs(120)); + assert!(updated.emit_logs); + assert!(updated.emit_traces); + assert!(updated.raw_response); + assert!(updated.allow_get_invocation); + assert_eq!(updated.config, Some(config_map)); + assert!(updated.forward_logs); + } + + #[test] + fn test_apply_update_clear_config() { + let mut plugin = create_test_plugin(); + let mut config_map = Map::new(); + config_map.insert("key".to_string(), serde_json::json!("value")); + plugin.config = Some(config_map); + + // Clear config by setting to null + let update = UpdatePluginRequest { + config: Some(None), + ..Default::default() + }; + + let updated = plugin.apply_update(update).unwrap(); + assert!(updated.config.is_none()); + } + + #[test] + fn test_apply_update_no_changes() { + let plugin = create_test_plugin(); + let update = UpdatePluginRequest::default(); + + let updated = plugin.apply_update(update).unwrap(); + assert_eq!(updated.id, plugin.id); + assert_eq!(updated.path, plugin.path); + assert_eq!(updated.timeout, plugin.timeout); + } +} diff --git a/src/openapi.rs b/src/openapi.rs index b42988cc3..c6b4ff39f 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -88,6 +88,8 @@ impl Modify for SecurityAddon { plugin_docs::doc_call_plugin, plugin_docs::doc_call_plugin_get, plugin_docs::doc_list_plugins, + plugin_docs::doc_get_plugin, + plugin_docs::doc_update_plugin, notification_docs::doc_list_notifications, notification_docs::doc_get_notification, notification_docs::doc_create_notification, @@ -119,6 +121,7 @@ impl Modify for SecurityAddon { domain::SignTransactionExternalResponse, models::PluginCallRequest, models::PluginMetadata, + models::UpdatePluginRequest, plugins::PluginHandlerError, plugins::LogEntry, plugins::LogLevel diff --git a/src/repositories/plugin/mod.rs b/src/repositories/plugin/mod.rs index e3fde746e..6872c3b25 100644 --- a/src/repositories/plugin/mod.rs +++ b/src/repositories/plugin/mod.rs @@ -10,6 +10,7 @@ //! - **Path Resolution**: Manage plugin script paths for execution //! - **Duplicate Prevention**: Ensure unique plugin IDs //! - **Configuration Loading**: Convert from file configurations to repository models +//! - **Compiled Code Caching**: Cache pre-compiled JavaScript code for performance //! //! ## Repository Implementations //! @@ -46,8 +47,11 @@ use crate::{ #[allow(dead_code)] #[cfg_attr(test, automock)] pub trait PluginRepositoryTrait { + // Plugin CRUD operations async fn get_by_id(&self, id: &str) -> Result, RepositoryError>; async fn add(&self, plugin: PluginModel) -> Result<(), RepositoryError>; + /// Update an existing plugin. Returns the updated plugin if found. + async fn update(&self, plugin: PluginModel) -> Result; async fn list_paginated( &self, query: PaginationQuery, @@ -55,6 +59,25 @@ pub trait PluginRepositoryTrait { async fn count(&self) -> Result; async fn has_entries(&self) -> Result; async fn drop_all_entries(&self) -> Result<(), RepositoryError>; + + // Compiled code cache operations + /// Get compiled JavaScript code for a plugin + async fn get_compiled_code(&self, plugin_id: &str) -> Result, RepositoryError>; + /// Store compiled JavaScript code for a plugin + async fn store_compiled_code( + &self, + plugin_id: &str, + compiled_code: &str, + source_hash: Option<&str>, + ) -> Result<(), RepositoryError>; + /// Invalidate cached code for a plugin + async fn invalidate_compiled_code(&self, plugin_id: &str) -> Result<(), RepositoryError>; + /// Invalidate all cached plugin code + async fn invalidate_all_compiled_code(&self) -> Result<(), RepositoryError>; + /// Check if a plugin has cached compiled code + async fn has_compiled_code(&self, plugin_id: &str) -> Result; + /// Get the source hash for cache validation + async fn get_source_hash(&self, plugin_id: &str) -> Result, RepositoryError>; } /// Enum wrapper for different plugin repository implementations @@ -94,6 +117,13 @@ impl PluginRepositoryTrait for PluginRepositoryStorage { } } + async fn update(&self, plugin: PluginModel) -> Result { + match self { + PluginRepositoryStorage::InMemory(repo) => repo.update(plugin).await, + PluginRepositoryStorage::Redis(repo) => repo.update(plugin).await, + } + } + async fn list_paginated( &self, query: PaginationQuery, @@ -124,6 +154,61 @@ impl PluginRepositoryTrait for PluginRepositoryStorage { PluginRepositoryStorage::Redis(repo) => repo.drop_all_entries().await, } } + + async fn get_compiled_code(&self, plugin_id: &str) -> Result, RepositoryError> { + match self { + PluginRepositoryStorage::InMemory(repo) => repo.get_compiled_code(plugin_id).await, + PluginRepositoryStorage::Redis(repo) => repo.get_compiled_code(plugin_id).await, + } + } + + async fn store_compiled_code( + &self, + plugin_id: &str, + compiled_code: &str, + source_hash: Option<&str>, + ) -> Result<(), RepositoryError> { + match self { + PluginRepositoryStorage::InMemory(repo) => { + repo.store_compiled_code(plugin_id, compiled_code, source_hash) + .await + } + PluginRepositoryStorage::Redis(repo) => { + repo.store_compiled_code(plugin_id, compiled_code, source_hash) + .await + } + } + } + + async fn invalidate_compiled_code(&self, plugin_id: &str) -> Result<(), RepositoryError> { + match self { + PluginRepositoryStorage::InMemory(repo) => { + repo.invalidate_compiled_code(plugin_id).await + } + PluginRepositoryStorage::Redis(repo) => repo.invalidate_compiled_code(plugin_id).await, + } + } + + async fn invalidate_all_compiled_code(&self) -> Result<(), RepositoryError> { + match self { + PluginRepositoryStorage::InMemory(repo) => repo.invalidate_all_compiled_code().await, + PluginRepositoryStorage::Redis(repo) => repo.invalidate_all_compiled_code().await, + } + } + + async fn has_compiled_code(&self, plugin_id: &str) -> Result { + match self { + PluginRepositoryStorage::InMemory(repo) => repo.has_compiled_code(plugin_id).await, + PluginRepositoryStorage::Redis(repo) => repo.has_compiled_code(plugin_id).await, + } + } + + async fn get_source_hash(&self, plugin_id: &str) -> Result, RepositoryError> { + match self { + PluginRepositoryStorage::InMemory(repo) => repo.get_source_hash(plugin_id).await, + PluginRepositoryStorage::Redis(repo) => repo.get_source_hash(plugin_id).await, + } + } } impl TryFrom for PluginModel { @@ -159,9 +244,51 @@ mod tests { use super::*; + // ============================================ + // Helper functions + // ============================================ + + fn create_test_plugin(id: &str, path: &str) -> PluginModel { + PluginModel { + id: id.to_string(), + path: path.to_string(), + timeout: Duration::from_secs(30), + emit_logs: false, + emit_traces: false, + raw_response: false, + allow_get_invocation: false, + config: None, + forward_logs: false, + } + } + + fn create_test_plugin_with_options( + id: &str, + path: &str, + emit_logs: bool, + emit_traces: bool, + raw_response: bool, + ) -> PluginModel { + PluginModel { + id: id.to_string(), + path: path.to_string(), + timeout: Duration::from_secs(30), + emit_logs, + emit_traces, + raw_response, + allow_get_invocation: false, + config: None, + forward_logs: false, + } + } + + // ============================================ + // PluginModel TryFrom tests + // ============================================ + #[tokio::test] - async fn test_try_from() { - let plugin = PluginFileConfig { + async fn test_try_from_default_timeout() { + let config = PluginFileConfig { id: "test-plugin".to_string(), path: "test-path".to_string(), timeout: None, @@ -172,48 +299,171 @@ mod tests { config: None, forward_logs: false, }; - let result = PluginModel::try_from(plugin); + + let result = PluginModel::try_from(config); assert!(result.is_ok()); + + let plugin = result.unwrap(); + assert_eq!(plugin.id, "test-plugin"); + assert_eq!(plugin.path, "test-path"); assert_eq!( - result.unwrap(), - PluginModel { - id: "test-plugin".to_string(), - path: "test-path".to_string(), - timeout: Duration::from_secs(DEFAULT_PLUGIN_TIMEOUT_SECONDS), - emit_logs: false, - emit_traces: false, - raw_response: false, - allow_get_invocation: false, - config: None, - forward_logs: false, - } + plugin.timeout, + Duration::from_secs(DEFAULT_PLUGIN_TIMEOUT_SECONDS) ); } - // Helper function to create a test plugin - fn create_test_plugin(id: &str, path: &str) -> PluginModel { - PluginModel { - id: id.to_string(), - path: path.to_string(), - timeout: Duration::from_secs(30), + #[tokio::test] + async fn test_try_from_custom_timeout() { + let config = PluginFileConfig { + id: "test-plugin".to_string(), + path: "test-path".to_string(), + timeout: Some(120), emit_logs: false, emit_traces: false, raw_response: false, allow_get_invocation: false, config: None, forward_logs: false, - } + }; + + let result = PluginModel::try_from(config); + assert!(result.is_ok()); + + let plugin = result.unwrap(); + assert_eq!(plugin.timeout, Duration::from_secs(120)); } + #[tokio::test] + async fn test_try_from_all_options_enabled() { + let mut config_map = serde_json::Map::new(); + config_map.insert("key".to_string(), serde_json::json!("value")); + + let config = PluginFileConfig { + id: "full-plugin".to_string(), + path: "/scripts/full.js".to_string(), + timeout: Some(60), + emit_logs: true, + emit_traces: true, + raw_response: true, + allow_get_invocation: true, + config: Some(config_map), + forward_logs: true, + }; + + let result = PluginModel::try_from(config); + assert!(result.is_ok()); + + let plugin = result.unwrap(); + assert_eq!(plugin.id, "full-plugin"); + assert!(plugin.emit_logs); + assert!(plugin.emit_traces); + assert!(plugin.raw_response); + assert!(plugin.allow_get_invocation); + assert!(plugin.config.is_some()); + assert!(plugin.forward_logs); + } + + #[tokio::test] + async fn test_try_from_zero_timeout() { + let config = PluginFileConfig { + id: "test".to_string(), + path: "path".to_string(), + timeout: Some(0), + emit_logs: false, + emit_traces: false, + raw_response: false, + allow_get_invocation: false, + config: None, + forward_logs: false, + }; + + let result = PluginModel::try_from(config); + assert!(result.is_ok()); + assert_eq!(result.unwrap().timeout, Duration::from_secs(0)); + } + + // ============================================ + // PluginModel PartialEq tests + // ============================================ + + #[test] + fn test_plugin_model_equality_same_id_and_path() { + let plugin1 = create_test_plugin("plugin-1", "/path/script.js"); + let plugin2 = create_test_plugin("plugin-1", "/path/script.js"); + + assert_eq!(plugin1, plugin2); + } + + #[test] + fn test_plugin_model_equality_different_id() { + let plugin1 = create_test_plugin("plugin-1", "/path/script.js"); + let plugin2 = create_test_plugin("plugin-2", "/path/script.js"); + + assert_ne!(plugin1, plugin2); + } + + #[test] + fn test_plugin_model_equality_different_path() { + let plugin1 = create_test_plugin("plugin-1", "/path/script1.js"); + let plugin2 = create_test_plugin("plugin-1", "/path/script2.js"); + + assert_ne!(plugin1, plugin2); + } + + #[test] + fn test_plugin_model_equality_ignores_other_fields() { + // Same id and path, different other fields + let plugin1 = + create_test_plugin_with_options("plugin-1", "/path/script.js", false, false, false); + let plugin2 = + create_test_plugin_with_options("plugin-1", "/path/script.js", true, true, true); + + // Should be equal because only id and path matter + assert_eq!(plugin1, plugin2); + } + + #[test] + fn test_plugin_model_equality_different_timeout() { + let mut plugin1 = create_test_plugin("plugin-1", "/path/script.js"); + plugin1.timeout = Duration::from_secs(30); + + let mut plugin2 = create_test_plugin("plugin-1", "/path/script.js"); + plugin2.timeout = Duration::from_secs(60); + + // Should be equal because timeout is not part of equality + assert_eq!(plugin1, plugin2); + } + + // ============================================ + // PluginRepositoryStorage constructor tests + // ============================================ + + #[tokio::test] + async fn test_new_in_memory_creates_empty_storage() { + let storage = PluginRepositoryStorage::new_in_memory(); + + assert_eq!(storage.count().await.unwrap(), 0); + assert!(!storage.has_entries().await.unwrap()); + } + + #[test] + fn test_storage_enum_debug() { + let storage = PluginRepositoryStorage::new_in_memory(); + let debug_str = format!("{:?}", storage); + assert!(debug_str.contains("InMemory")); + } + + // ============================================ + // Basic CRUD tests + // ============================================ + #[tokio::test] async fn test_plugin_repository_storage_get_by_id_existing() { let storage = PluginRepositoryStorage::new_in_memory(); let plugin = create_test_plugin("test-plugin", "/path/to/script.js"); - // Add the plugin first storage.add(plugin.clone()).await.unwrap(); - // Get the plugin let result = storage.get_by_id("test-plugin").await.unwrap(); assert_eq!(result, Some(plugin)); } @@ -240,7 +490,6 @@ mod tests { let storage = PluginRepositoryStorage::new_in_memory(); let plugin = create_test_plugin("test-plugin", "/path/to/script.js"); - // Add the plugin first time storage.add(plugin.clone()).await.unwrap(); // Try to add the same plugin again - should succeed (overwrite) @@ -248,6 +497,109 @@ mod tests { assert!(result.is_ok()); } + #[tokio::test] + async fn test_plugin_repository_storage_add_multiple() { + let storage = PluginRepositoryStorage::new_in_memory(); + + for i in 1..=10 { + let plugin = create_test_plugin(&format!("plugin-{}", i), &format!("/path/{}.js", i)); + storage.add(plugin).await.unwrap(); + } + + assert_eq!(storage.count().await.unwrap(), 10); + } + + // ============================================ + // Update tests + // ============================================ + + #[tokio::test] + async fn test_plugin_repository_storage_update_existing() { + let storage = PluginRepositoryStorage::new_in_memory(); + + let plugin = + create_test_plugin_with_options("test-plugin", "/path/script.js", false, false, false); + storage.add(plugin).await.unwrap(); + + let updated = + create_test_plugin_with_options("test-plugin", "/path/script.js", true, true, true); + let result = storage.update(updated.clone()).await; + + assert!(result.is_ok()); + let returned = result.unwrap(); + assert!(returned.emit_logs); + assert!(returned.emit_traces); + assert!(returned.raw_response); + } + + #[tokio::test] + async fn test_plugin_repository_storage_update_nonexistent() { + let storage = PluginRepositoryStorage::new_in_memory(); + + let plugin = create_test_plugin("nonexistent", "/path/script.js"); + let result = storage.update(plugin).await; + + assert!(result.is_err()); + match result { + Err(RepositoryError::NotFound(msg)) => { + assert!(msg.contains("nonexistent")); + } + _ => panic!("Expected NotFound error"), + } + } + + #[tokio::test] + async fn test_plugin_repository_storage_update_persists_changes() { + let storage = PluginRepositoryStorage::new_in_memory(); + + let plugin = create_test_plugin("test-plugin", "/path/script.js"); + storage.add(plugin).await.unwrap(); + + let mut updated = create_test_plugin("test-plugin", "/path/updated.js"); + updated.emit_logs = true; + storage.update(updated).await.unwrap(); + + // Verify persisted changes + let retrieved = storage.get_by_id("test-plugin").await.unwrap().unwrap(); + assert!(retrieved.emit_logs); + assert_eq!(retrieved.path, "/path/updated.js"); + } + + #[tokio::test] + async fn test_plugin_repository_storage_update_does_not_affect_others() { + let storage = PluginRepositoryStorage::new_in_memory(); + + storage + .add(create_test_plugin("plugin-1", "/path/1.js")) + .await + .unwrap(); + storage + .add(create_test_plugin("plugin-2", "/path/2.js")) + .await + .unwrap(); + storage + .add(create_test_plugin("plugin-3", "/path/3.js")) + .await + .unwrap(); + + let mut updated = create_test_plugin("plugin-2", "/path/updated.js"); + updated.emit_logs = true; + storage.update(updated).await.unwrap(); + + // Others unchanged + let p1 = storage.get_by_id("plugin-1").await.unwrap().unwrap(); + assert_eq!(p1.path, "/path/1.js"); + assert!(!p1.emit_logs); + + let p3 = storage.get_by_id("plugin-3").await.unwrap().unwrap(); + assert_eq!(p3.path, "/path/3.js"); + assert!(!p3.emit_logs); + } + + // ============================================ + // Count tests + // ============================================ + #[tokio::test] async fn test_plugin_repository_storage_count_empty() { let storage = PluginRepositoryStorage::new_in_memory(); @@ -260,7 +612,6 @@ mod tests { async fn test_plugin_repository_storage_count_with_plugins() { let storage = PluginRepositoryStorage::new_in_memory(); - // Add multiple plugins storage .add(create_test_plugin("plugin1", "/path/1.js")) .await @@ -278,6 +629,31 @@ mod tests { assert_eq!(count, 3); } + #[tokio::test] + async fn test_plugin_repository_storage_count_after_drop() { + let storage = PluginRepositoryStorage::new_in_memory(); + + for i in 1..=5 { + storage + .add(create_test_plugin( + &format!("p{}", i), + &format!("/{}.js", i), + )) + .await + .unwrap(); + } + + assert_eq!(storage.count().await.unwrap(), 5); + + storage.drop_all_entries().await.unwrap(); + + assert_eq!(storage.count().await.unwrap(), 0); + } + + // ============================================ + // has_entries tests + // ============================================ + #[tokio::test] async fn test_plugin_repository_storage_has_entries_empty() { let storage = PluginRepositoryStorage::new_in_memory(); @@ -299,6 +675,10 @@ mod tests { assert!(has_entries); } + // ============================================ + // drop_all_entries tests + // ============================================ + #[tokio::test] async fn test_plugin_repository_storage_drop_all_entries_empty() { let storage = PluginRepositoryStorage::new_in_memory(); @@ -314,7 +694,6 @@ mod tests { async fn test_plugin_repository_storage_drop_all_entries_with_plugins() { let storage = PluginRepositoryStorage::new_in_memory(); - // Add multiple plugins storage .add(create_test_plugin("plugin1", "/path/1.js")) .await @@ -334,6 +713,10 @@ mod tests { assert!(!has_entries); } + // ============================================ + // Pagination tests + // ============================================ + #[tokio::test] async fn test_plugin_repository_storage_list_paginated_empty() { let storage = PluginRepositoryStorage::new_in_memory(); @@ -351,35 +734,361 @@ mod tests { } #[tokio::test] - async fn test_plugin_repository_storage_list_paginated_with_plugins() { + async fn test_plugin_repository_storage_list_paginated_first_page() { + let storage = PluginRepositoryStorage::new_in_memory(); + + for i in 1..=10 { + storage + .add(create_test_plugin( + &format!("plugin{}", i), + &format!("/{}.js", i), + )) + .await + .unwrap(); + } + + let query = PaginationQuery { + page: 1, + per_page: 3, + }; + let result = storage.list_paginated(query).await.unwrap(); + + assert_eq!(result.items.len(), 3); + assert_eq!(result.total, 10); + assert_eq!(result.page, 1); + assert_eq!(result.per_page, 3); + } + + #[tokio::test] + async fn test_plugin_repository_storage_list_paginated_middle_page() { + let storage = PluginRepositoryStorage::new_in_memory(); + + for i in 1..=10 { + storage + .add(create_test_plugin( + &format!("plugin{}", i), + &format!("/{}.js", i), + )) + .await + .unwrap(); + } + + let query = PaginationQuery { + page: 2, + per_page: 3, + }; + let result = storage.list_paginated(query).await.unwrap(); + + assert_eq!(result.items.len(), 3); + assert_eq!(result.total, 10); + assert_eq!(result.page, 2); + } + + #[tokio::test] + async fn test_plugin_repository_storage_list_paginated_last_partial_page() { + let storage = PluginRepositoryStorage::new_in_memory(); + + for i in 1..=10 { + storage + .add(create_test_plugin( + &format!("plugin{}", i), + &format!("/{}.js", i), + )) + .await + .unwrap(); + } + + // 10 items, 3 per page, page 4 should have 1 item + let query = PaginationQuery { + page: 4, + per_page: 3, + }; + let result = storage.list_paginated(query).await.unwrap(); + + assert_eq!(result.items.len(), 1); + assert_eq!(result.total, 10); + } + + #[tokio::test] + async fn test_plugin_repository_storage_list_paginated_beyond_data() { + let storage = PluginRepositoryStorage::new_in_memory(); + + for i in 1..=5 { + storage + .add(create_test_plugin( + &format!("plugin{}", i), + &format!("/{}.js", i), + )) + .await + .unwrap(); + } + + let query = PaginationQuery { + page: 100, + per_page: 10, + }; + let result = storage.list_paginated(query).await.unwrap(); + + assert_eq!(result.items.len(), 0); + assert_eq!(result.total, 5); + } + + #[tokio::test] + async fn test_plugin_repository_storage_list_paginated_large_per_page() { + let storage = PluginRepositoryStorage::new_in_memory(); + + for i in 1..=5 { + storage + .add(create_test_plugin( + &format!("plugin{}", i), + &format!("/{}.js", i), + )) + .await + .unwrap(); + } + + let query = PaginationQuery { + page: 1, + per_page: 100, + }; + let result = storage.list_paginated(query).await.unwrap(); + + assert_eq!(result.items.len(), 5); + assert_eq!(result.total, 5); + } + + // ============================================ + // Compiled code cache tests + // ============================================ + + #[tokio::test] + async fn test_store_and_get_compiled_code() { let storage = PluginRepositoryStorage::new_in_memory(); - // Add multiple plugins storage - .add(create_test_plugin("plugin1", "/path/1.js")) + .store_compiled_code("plugin-1", "compiled code", None) .await .unwrap(); + + let code = storage.get_compiled_code("plugin-1").await.unwrap(); + assert_eq!(code, Some("compiled code".to_string())); + } + + #[tokio::test] + async fn test_get_compiled_code_nonexistent() { + let storage = PluginRepositoryStorage::new_in_memory(); + + let code = storage.get_compiled_code("nonexistent").await.unwrap(); + assert_eq!(code, None); + } + + #[tokio::test] + async fn test_store_compiled_code_with_source_hash() { + let storage = PluginRepositoryStorage::new_in_memory(); + storage - .add(create_test_plugin("plugin2", "/path/2.js")) + .store_compiled_code("plugin-1", "code", Some("sha256:abc123")) .await .unwrap(); + + let code = storage.get_compiled_code("plugin-1").await.unwrap(); + assert_eq!(code, Some("code".to_string())); + + let hash = storage.get_source_hash("plugin-1").await.unwrap(); + assert_eq!(hash, Some("sha256:abc123".to_string())); + } + + #[tokio::test] + async fn test_store_compiled_code_overwrites() { + let storage = PluginRepositoryStorage::new_in_memory(); + storage - .add(create_test_plugin("plugin3", "/path/3.js")) + .store_compiled_code("plugin-1", "old code", Some("old-hash")) + .await + .unwrap(); + storage + .store_compiled_code("plugin-1", "new code", Some("new-hash")) .await .unwrap(); - let query = PaginationQuery { - page: 1, - per_page: 2, - }; - let result = storage.list_paginated(query).await.unwrap(); + let code = storage.get_compiled_code("plugin-1").await.unwrap(); + assert_eq!(code, Some("new code".to_string())); - assert_eq!(result.items.len(), 2); - assert_eq!(result.total, 3); - assert_eq!(result.page, 1); - assert_eq!(result.per_page, 2); + let hash = storage.get_source_hash("plugin-1").await.unwrap(); + assert_eq!(hash, Some("new-hash".to_string())); + } + + #[tokio::test] + async fn test_has_compiled_code() { + let storage = PluginRepositoryStorage::new_in_memory(); + + assert!(!storage.has_compiled_code("plugin-1").await.unwrap()); + + storage + .store_compiled_code("plugin-1", "code", None) + .await + .unwrap(); + + assert!(storage.has_compiled_code("plugin-1").await.unwrap()); + assert!(!storage.has_compiled_code("plugin-2").await.unwrap()); + } + + #[tokio::test] + async fn test_invalidate_compiled_code() { + let storage = PluginRepositoryStorage::new_in_memory(); + + storage + .store_compiled_code("plugin-1", "code1", None) + .await + .unwrap(); + storage + .store_compiled_code("plugin-2", "code2", None) + .await + .unwrap(); + + storage.invalidate_compiled_code("plugin-1").await.unwrap(); + + assert!(!storage.has_compiled_code("plugin-1").await.unwrap()); + assert!(storage.has_compiled_code("plugin-2").await.unwrap()); + } + + #[tokio::test] + async fn test_invalidate_compiled_code_nonexistent() { + let storage = PluginRepositoryStorage::new_in_memory(); + + // Should not fail + let result = storage.invalidate_compiled_code("nonexistent").await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_invalidate_all_compiled_code() { + let storage = PluginRepositoryStorage::new_in_memory(); + + for i in 1..=5 { + storage + .store_compiled_code(&format!("plugin-{}", i), &format!("code-{}", i), None) + .await + .unwrap(); + } + + storage.invalidate_all_compiled_code().await.unwrap(); + + for i in 1..=5 { + assert!(!storage + .has_compiled_code(&format!("plugin-{}", i)) + .await + .unwrap()); + } } + #[tokio::test] + async fn test_invalidate_all_compiled_code_empty() { + let storage = PluginRepositoryStorage::new_in_memory(); + + // Should not fail + let result = storage.invalidate_all_compiled_code().await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_get_source_hash() { + let storage = PluginRepositoryStorage::new_in_memory(); + + // No hash + storage + .store_compiled_code("plugin-1", "code", None) + .await + .unwrap(); + let hash = storage.get_source_hash("plugin-1").await.unwrap(); + assert_eq!(hash, None); + + // With hash + storage + .store_compiled_code("plugin-2", "code", Some("hash123")) + .await + .unwrap(); + let hash = storage.get_source_hash("plugin-2").await.unwrap(); + assert_eq!(hash, Some("hash123".to_string())); + } + + #[tokio::test] + async fn test_get_source_hash_nonexistent() { + let storage = PluginRepositoryStorage::new_in_memory(); + + let hash = storage.get_source_hash("nonexistent").await.unwrap(); + assert_eq!(hash, None); + } + + // ============================================ + // Cache independence tests + // ============================================ + + #[tokio::test] + async fn test_compiled_cache_independent_of_plugin_store() { + let storage = PluginRepositoryStorage::new_in_memory(); + + // Store compiled code without adding plugin + storage + .store_compiled_code("plugin-1", "code", None) + .await + .unwrap(); + + // Plugin doesn't exist + assert!(storage.get_by_id("plugin-1").await.unwrap().is_none()); + + // But compiled code does + assert!(storage.has_compiled_code("plugin-1").await.unwrap()); + } + + #[tokio::test] + async fn test_drop_all_does_not_clear_compiled_cache() { + let storage = PluginRepositoryStorage::new_in_memory(); + + storage + .add(create_test_plugin("plugin-1", "/path.js")) + .await + .unwrap(); + storage + .store_compiled_code("plugin-1", "code", None) + .await + .unwrap(); + + storage.drop_all_entries().await.unwrap(); + + // Plugin gone + assert!(storage.get_by_id("plugin-1").await.unwrap().is_none()); + + // Compiled cache still has entry + assert!(storage.has_compiled_code("plugin-1").await.unwrap()); + } + + #[tokio::test] + async fn test_invalidate_all_compiled_does_not_clear_store() { + let storage = PluginRepositoryStorage::new_in_memory(); + + storage + .add(create_test_plugin("plugin-1", "/path.js")) + .await + .unwrap(); + storage + .store_compiled_code("plugin-1", "code", None) + .await + .unwrap(); + + storage.invalidate_all_compiled_code().await.unwrap(); + + // Compiled cache cleared + assert!(!storage.has_compiled_code("plugin-1").await.unwrap()); + + // Plugin still exists + assert!(storage.get_by_id("plugin-1").await.unwrap().is_some()); + } + + // ============================================ + // Workflow/integration tests + // ============================================ + #[tokio::test] async fn test_plugin_repository_storage_workflow() { let storage = PluginRepositoryStorage::new_in_memory(); @@ -403,6 +1112,15 @@ mod tests { let retrieved = storage.get_by_id("auth-plugin").await.unwrap(); assert_eq!(retrieved, Some(plugin1)); + // Update plugin + let mut updated = create_test_plugin("auth-plugin", "/scripts/auth_v2.js"); + updated.emit_logs = true; + storage.update(updated).await.unwrap(); + + let after_update = storage.get_by_id("auth-plugin").await.unwrap().unwrap(); + assert_eq!(after_update.path, "/scripts/auth_v2.js"); + assert!(after_update.emit_logs); + // List all plugins let query = PaginationQuery { page: 1, @@ -417,4 +1135,105 @@ mod tests { assert!(!storage.has_entries().await.unwrap()); assert_eq!(storage.count().await.unwrap(), 0); } + + #[tokio::test] + async fn test_compiled_code_workflow() { + let storage = PluginRepositoryStorage::new_in_memory(); + + // Add plugin + storage + .add(create_test_plugin("my-plugin", "/scripts/plugin.js")) + .await + .unwrap(); + + // Initially no compiled code + assert!(!storage.has_compiled_code("my-plugin").await.unwrap()); + + // Store compiled code + storage + .store_compiled_code("my-plugin", "compiled JS", Some("hash-v1")) + .await + .unwrap(); + + // Verify + assert!(storage.has_compiled_code("my-plugin").await.unwrap()); + assert_eq!( + storage.get_compiled_code("my-plugin").await.unwrap(), + Some("compiled JS".to_string()) + ); + assert_eq!( + storage.get_source_hash("my-plugin").await.unwrap(), + Some("hash-v1".to_string()) + ); + + // Update compiled code + storage + .store_compiled_code("my-plugin", "updated JS", Some("hash-v2")) + .await + .unwrap(); + + assert_eq!( + storage.get_compiled_code("my-plugin").await.unwrap(), + Some("updated JS".to_string()) + ); + assert_eq!( + storage.get_source_hash("my-plugin").await.unwrap(), + Some("hash-v2".to_string()) + ); + + // Invalidate + storage.invalidate_compiled_code("my-plugin").await.unwrap(); + + assert!(!storage.has_compiled_code("my-plugin").await.unwrap()); + assert_eq!(storage.get_compiled_code("my-plugin").await.unwrap(), None); + } + + #[tokio::test] + async fn test_multiple_plugins_compiled_code() { + let storage = PluginRepositoryStorage::new_in_memory(); + + // Store compiled code for multiple plugins + for i in 1..=5 { + storage + .store_compiled_code( + &format!("plugin-{}", i), + &format!("code for plugin {}", i), + Some(&format!("hash-{}", i)), + ) + .await + .unwrap(); + } + + // Verify all + for i in 1..=5 { + assert!(storage + .has_compiled_code(&format!("plugin-{}", i)) + .await + .unwrap()); + assert_eq!( + storage + .get_compiled_code(&format!("plugin-{}", i)) + .await + .unwrap(), + Some(format!("code for plugin {}", i)) + ); + assert_eq!( + storage + .get_source_hash(&format!("plugin-{}", i)) + .await + .unwrap(), + Some(format!("hash-{}", i)) + ); + } + + // Invalidate one + storage.invalidate_compiled_code("plugin-3").await.unwrap(); + + // Verify selective invalidation + assert!(storage.has_compiled_code("plugin-1").await.unwrap()); + assert!(storage.has_compiled_code("plugin-2").await.unwrap()); + assert!(!storage.has_compiled_code("plugin-3").await.unwrap()); + assert!(storage.has_compiled_code("plugin-4").await.unwrap()); + assert!(storage.has_compiled_code("plugin-5").await.unwrap()); + } } diff --git a/src/repositories/plugin/plugin_in_memory.rs b/src/repositories/plugin/plugin_in_memory.rs index b6df26e0d..7765752ce 100644 --- a/src/repositories/plugin/plugin_in_memory.rs +++ b/src/repositories/plugin/plugin_in_memory.rs @@ -1,7 +1,7 @@ //! This module provides an in-memory implementation of plugins. //! //! The `InMemoryPluginRepository` struct is used to store and retrieve plugins -//! script paths for further execution. +//! script paths for further execution. Also provides compiled code caching. use crate::{ models::{PaginationQuery, PluginModel}, repositories::{PaginatedResult, PluginRepositoryTrait, RepositoryError}, @@ -12,9 +12,17 @@ use async_trait::async_trait; use std::collections::HashMap; use tokio::sync::{Mutex, MutexGuard}; +/// Compiled plugin code entry +#[derive(Debug, Clone)] +struct CompiledCodeEntry { + code: String, + source_hash: Option, +} + #[derive(Debug)] pub struct InMemoryPluginRepository { store: Mutex>, + compiled_cache: Mutex>, } impl Clone for InMemoryPluginRepository { @@ -26,8 +34,15 @@ impl Clone for InMemoryPluginRepository { .map(|guard| guard.clone()) .unwrap_or_else(|_| HashMap::new()); + let compiled = self + .compiled_cache + .try_lock() + .map(|guard| guard.clone()) + .unwrap_or_else(|_| HashMap::new()); + Self { store: Mutex::new(data), + compiled_cache: Mutex::new(compiled), } } } @@ -36,6 +51,7 @@ impl InMemoryPluginRepository { pub fn new() -> Self { Self { store: Mutex::new(HashMap::new()), + compiled_cache: Mutex::new(HashMap::new()), } } @@ -68,6 +84,18 @@ impl PluginRepositoryTrait for InMemoryPluginRepository { Ok(()) } + async fn update(&self, plugin: PluginModel) -> Result { + let mut store = Self::acquire_lock(&self.store).await?; + if !store.contains_key(&plugin.id) { + return Err(RepositoryError::NotFound(format!( + "Plugin with id {} not found", + plugin.id + ))); + } + store.insert(plugin.id.clone(), plugin.clone()); + Ok(plugin) + } + async fn list_paginated( &self, query: PaginationQuery, @@ -108,6 +136,52 @@ impl PluginRepositoryTrait for InMemoryPluginRepository { store.clear(); Ok(()) } + + // Compiled code cache methods + + async fn get_compiled_code(&self, plugin_id: &str) -> Result, RepositoryError> { + let cache = Self::acquire_lock(&self.compiled_cache).await?; + Ok(cache.get(plugin_id).map(|e| e.code.clone())) + } + + async fn store_compiled_code( + &self, + plugin_id: &str, + compiled_code: &str, + source_hash: Option<&str>, + ) -> Result<(), RepositoryError> { + let mut cache = Self::acquire_lock(&self.compiled_cache).await?; + cache.insert( + plugin_id.to_string(), + CompiledCodeEntry { + code: compiled_code.to_string(), + source_hash: source_hash.map(|s| s.to_string()), + }, + ); + Ok(()) + } + + async fn invalidate_compiled_code(&self, plugin_id: &str) -> Result<(), RepositoryError> { + let mut cache = Self::acquire_lock(&self.compiled_cache).await?; + cache.remove(plugin_id); + Ok(()) + } + + async fn invalidate_all_compiled_code(&self) -> Result<(), RepositoryError> { + let mut cache = Self::acquire_lock(&self.compiled_cache).await?; + cache.clear(); + Ok(()) + } + + async fn has_compiled_code(&self, plugin_id: &str) -> Result { + let cache = Self::acquire_lock(&self.compiled_cache).await?; + Ok(cache.contains_key(plugin_id)) + } + + async fn get_source_hash(&self, plugin_id: &str) -> Result, RepositoryError> { + let cache = Self::acquire_lock(&self.compiled_cache).await?; + Ok(cache.get(plugin_id).and_then(|e| e.source_hash.clone())) + } } #[cfg(test)] @@ -117,14 +191,14 @@ mod tests { use super::*; use std::{sync::Arc, time::Duration}; - #[tokio::test] - async fn test_in_memory_plugin_repository() { - let plugin_repository = Arc::new(InMemoryPluginRepository::new()); + // ============================================ + // Helper functions + // ============================================ - // Test add and get_by_id - let plugin = PluginModel { - id: "test-plugin".to_string(), - path: "test-path".to_string(), + fn create_test_plugin(id: &str) -> PluginModel { + PluginModel { + id: id.to_string(), + path: format!("path/{}", id), timeout: Duration::from_secs(DEFAULT_PLUGIN_TIMEOUT_SECONDS), emit_logs: false, emit_traces: false, @@ -132,25 +206,608 @@ mod tests { allow_get_invocation: false, config: None, forward_logs: false, + } + } + + fn create_test_plugin_with_options( + id: &str, + emit_logs: bool, + emit_traces: bool, + raw_response: bool, + ) -> PluginModel { + PluginModel { + id: id.to_string(), + path: format!("path/{}", id), + timeout: Duration::from_secs(DEFAULT_PLUGIN_TIMEOUT_SECONDS), + emit_logs, + emit_traces, + raw_response, + allow_get_invocation: false, + config: None, + forward_logs: false, + } + } + + // ============================================ + // Basic repository tests + // ============================================ + + #[tokio::test] + async fn test_new_creates_empty_repository() { + let repo = InMemoryPluginRepository::new(); + + assert_eq!(repo.count().await.unwrap(), 0); + assert!(!repo.has_entries().await.unwrap()); + } + + #[tokio::test] + async fn test_default_creates_empty_repository() { + let repo = InMemoryPluginRepository::default(); + + assert_eq!(repo.count().await.unwrap(), 0); + assert!(!repo.has_entries().await.unwrap()); + } + + #[tokio::test] + async fn test_add_and_get_by_id() { + let repo = Arc::new(InMemoryPluginRepository::new()); + + let plugin = create_test_plugin("test-plugin"); + repo.add(plugin.clone()).await.unwrap(); + + let retrieved = repo.get_by_id("test-plugin").await.unwrap(); + assert_eq!(retrieved, Some(plugin)); + } + + #[tokio::test] + async fn test_get_nonexistent_plugin() { + let repo = Arc::new(InMemoryPluginRepository::new()); + + let result = repo.get_by_id("nonexistent").await; + assert!(matches!(result, Ok(None))); + } + + #[tokio::test] + async fn test_add_multiple_plugins() { + let repo = Arc::new(InMemoryPluginRepository::new()); + + for i in 1..=5 { + let plugin = create_test_plugin(&format!("plugin-{}", i)); + repo.add(plugin).await.unwrap(); + } + + assert_eq!(repo.count().await.unwrap(), 5); + + for i in 1..=5 { + let result = repo.get_by_id(&format!("plugin-{}", i)).await.unwrap(); + assert!(result.is_some()); + } + } + + #[tokio::test] + async fn test_add_overwrites_existing() { + let repo = Arc::new(InMemoryPluginRepository::new()); + + let plugin1 = create_test_plugin_with_options("test-plugin", false, false, false); + repo.add(plugin1).await.unwrap(); + + let plugin2 = create_test_plugin_with_options("test-plugin", true, true, true); + repo.add(plugin2.clone()).await.unwrap(); + + // Should have overwritten + let retrieved = repo.get_by_id("test-plugin").await.unwrap().unwrap(); + assert!(retrieved.emit_logs); + assert!(retrieved.emit_traces); + assert!(retrieved.raw_response); + + // Count should still be 1 + assert_eq!(repo.count().await.unwrap(), 1); + } + + // ============================================ + // Update tests + // ============================================ + + #[tokio::test] + async fn test_update_existing_plugin() { + let repo = Arc::new(InMemoryPluginRepository::new()); + + let plugin = create_test_plugin("test-plugin"); + repo.add(plugin).await.unwrap(); + + let updated_plugin = create_test_plugin_with_options("test-plugin", true, true, true); + let result = repo.update(updated_plugin.clone()).await; + + assert!(result.is_ok()); + let returned = result.unwrap(); + assert_eq!(returned.id, "test-plugin"); + assert!(returned.emit_logs); + assert!(returned.emit_traces); + + // Verify persisted + let retrieved = repo.get_by_id("test-plugin").await.unwrap().unwrap(); + assert!(retrieved.emit_logs); + } + + #[tokio::test] + async fn test_update_nonexistent_plugin_returns_error() { + let repo = Arc::new(InMemoryPluginRepository::new()); + + let plugin = create_test_plugin("nonexistent"); + let result = repo.update(plugin).await; + + assert!(result.is_err()); + match result { + Err(RepositoryError::NotFound(msg)) => { + assert!(msg.contains("nonexistent")); + } + _ => panic!("Expected NotFound error"), + } + } + + #[tokio::test] + async fn test_update_preserves_other_plugins() { + let repo = Arc::new(InMemoryPluginRepository::new()); + + repo.add(create_test_plugin("plugin-1")).await.unwrap(); + repo.add(create_test_plugin("plugin-2")).await.unwrap(); + repo.add(create_test_plugin("plugin-3")).await.unwrap(); + + let updated = create_test_plugin_with_options("plugin-2", true, false, false); + repo.update(updated).await.unwrap(); + + // Check other plugins unchanged + let p1 = repo.get_by_id("plugin-1").await.unwrap().unwrap(); + assert!(!p1.emit_logs); + + let p3 = repo.get_by_id("plugin-3").await.unwrap().unwrap(); + assert!(!p3.emit_logs); + + // Check updated plugin changed + let p2 = repo.get_by_id("plugin-2").await.unwrap().unwrap(); + assert!(p2.emit_logs); + } + + // ============================================ + // Count tests + // ============================================ + + #[tokio::test] + async fn test_count_empty_repository() { + let repo = InMemoryPluginRepository::new(); + assert_eq!(repo.count().await.unwrap(), 0); + } + + #[tokio::test] + async fn test_count_with_entries() { + let repo = Arc::new(InMemoryPluginRepository::new()); + + for i in 1..=10 { + repo.add(create_test_plugin(&format!("plugin-{}", i))) + .await + .unwrap(); + } + + assert_eq!(repo.count().await.unwrap(), 10); + } + + #[tokio::test] + async fn test_count_after_drop_all() { + let repo = Arc::new(InMemoryPluginRepository::new()); + + for i in 1..=5 { + repo.add(create_test_plugin(&format!("plugin-{}", i))) + .await + .unwrap(); + } + + assert_eq!(repo.count().await.unwrap(), 5); + + repo.drop_all_entries().await.unwrap(); + + assert_eq!(repo.count().await.unwrap(), 0); + } + + // ============================================ + // Pagination tests + // ============================================ + + #[tokio::test] + async fn test_list_paginated_first_page() { + let repo = Arc::new(InMemoryPluginRepository::new()); + + for i in 1..=10 { + repo.add(create_test_plugin(&format!("plugin-{:02}", i))) + .await + .unwrap(); + } + + let query = PaginationQuery { + page: 1, + per_page: 3, + }; + + let result = repo.list_paginated(query).await.unwrap(); + + assert_eq!(result.items.len(), 3); + assert_eq!(result.total, 10); + assert_eq!(result.page, 1); + assert_eq!(result.per_page, 3); + } + + #[tokio::test] + async fn test_list_paginated_middle_page() { + let repo = Arc::new(InMemoryPluginRepository::new()); + + for i in 1..=10 { + repo.add(create_test_plugin(&format!("plugin-{:02}", i))) + .await + .unwrap(); + } + + let query = PaginationQuery { + page: 2, + per_page: 3, + }; + + let result = repo.list_paginated(query).await.unwrap(); + + assert_eq!(result.items.len(), 3); + assert_eq!(result.total, 10); + assert_eq!(result.page, 2); + } + + #[tokio::test] + async fn test_list_paginated_last_partial_page() { + let repo = Arc::new(InMemoryPluginRepository::new()); + + for i in 1..=10 { + repo.add(create_test_plugin(&format!("plugin-{:02}", i))) + .await + .unwrap(); + } + + let query = PaginationQuery { + page: 4, + per_page: 3, + }; + + let result = repo.list_paginated(query).await.unwrap(); + + // 10 items, 3 per page: page 4 has only 1 item + assert_eq!(result.items.len(), 1); + assert_eq!(result.total, 10); + } + + #[tokio::test] + async fn test_list_paginated_empty_repository() { + let repo = Arc::new(InMemoryPluginRepository::new()); + + let query = PaginationQuery { + page: 1, + per_page: 10, + }; + + let result = repo.list_paginated(query).await.unwrap(); + + assert_eq!(result.items.len(), 0); + assert_eq!(result.total, 0); + } + + #[tokio::test] + async fn test_list_paginated_page_beyond_data() { + let repo = Arc::new(InMemoryPluginRepository::new()); + + for i in 1..=5 { + repo.add(create_test_plugin(&format!("plugin-{}", i))) + .await + .unwrap(); + } + + let query = PaginationQuery { + page: 10, // Way beyond available data + per_page: 2, + }; + + let result = repo.list_paginated(query).await.unwrap(); + + assert_eq!(result.items.len(), 0); + assert_eq!(result.total, 5); + } + + #[tokio::test] + async fn test_list_paginated_large_per_page() { + let repo = Arc::new(InMemoryPluginRepository::new()); + + for i in 1..=5 { + repo.add(create_test_plugin(&format!("plugin-{}", i))) + .await + .unwrap(); + } + + let query = PaginationQuery { + page: 1, + per_page: 100, // More than available }; - plugin_repository.add(plugin.clone()).await.unwrap(); + + let result = repo.list_paginated(query).await.unwrap(); + + assert_eq!(result.items.len(), 5); + assert_eq!(result.total, 5); + } + + // ============================================ + // has_entries and drop_all tests + // ============================================ + + #[tokio::test] + async fn test_has_entries_empty() { + let repo = InMemoryPluginRepository::new(); + assert!(!repo.has_entries().await.unwrap()); + } + + #[tokio::test] + async fn test_has_entries_with_data() { + let repo = Arc::new(InMemoryPluginRepository::new()); + repo.add(create_test_plugin("test")).await.unwrap(); + assert!(repo.has_entries().await.unwrap()); + } + + #[tokio::test] + async fn test_drop_all_entries_clears_store() { + let repo = Arc::new(InMemoryPluginRepository::new()); + + for i in 1..=5 { + repo.add(create_test_plugin(&format!("plugin-{}", i))) + .await + .unwrap(); + } + + assert!(repo.has_entries().await.unwrap()); + assert_eq!(repo.count().await.unwrap(), 5); + + repo.drop_all_entries().await.unwrap(); + + assert!(!repo.has_entries().await.unwrap()); + assert_eq!(repo.count().await.unwrap(), 0); + } + + #[tokio::test] + async fn test_drop_all_entries_on_empty_repo() { + let repo = InMemoryPluginRepository::new(); + + // Should not fail on empty repo + let result = repo.drop_all_entries().await; + assert!(result.is_ok()); + } + + // ============================================ + // Compiled code cache tests + // ============================================ + + #[tokio::test] + async fn test_store_and_get_compiled_code() { + let repo = InMemoryPluginRepository::new(); + + repo.store_compiled_code("plugin-1", "compiled code here", None) + .await + .unwrap(); + + let result = repo.get_compiled_code("plugin-1").await.unwrap(); + assert_eq!(result, Some("compiled code here".to_string())); + } + + #[tokio::test] + async fn test_get_compiled_code_nonexistent() { + let repo = InMemoryPluginRepository::new(); + + let result = repo.get_compiled_code("nonexistent").await.unwrap(); + assert_eq!(result, None); + } + + #[tokio::test] + async fn test_store_compiled_code_with_source_hash() { + let repo = InMemoryPluginRepository::new(); + + repo.store_compiled_code("plugin-1", "code", Some("abc123hash")) + .await + .unwrap(); + + let code = repo.get_compiled_code("plugin-1").await.unwrap(); + assert_eq!(code, Some("code".to_string())); + + let hash = repo.get_source_hash("plugin-1").await.unwrap(); + assert_eq!(hash, Some("abc123hash".to_string())); + } + + #[tokio::test] + async fn test_store_compiled_code_overwrites_existing() { + let repo = InMemoryPluginRepository::new(); + + repo.store_compiled_code("plugin-1", "old code", Some("oldhash")) + .await + .unwrap(); + + repo.store_compiled_code("plugin-1", "new code", Some("newhash")) + .await + .unwrap(); + + let code = repo.get_compiled_code("plugin-1").await.unwrap(); + assert_eq!(code, Some("new code".to_string())); + + let hash = repo.get_source_hash("plugin-1").await.unwrap(); + assert_eq!(hash, Some("newhash".to_string())); + } + + #[tokio::test] + async fn test_has_compiled_code() { + let repo = InMemoryPluginRepository::new(); + + assert!(!repo.has_compiled_code("plugin-1").await.unwrap()); + + repo.store_compiled_code("plugin-1", "code", None) + .await + .unwrap(); + + assert!(repo.has_compiled_code("plugin-1").await.unwrap()); + assert!(!repo.has_compiled_code("plugin-2").await.unwrap()); + } + + #[tokio::test] + async fn test_invalidate_compiled_code() { + let repo = InMemoryPluginRepository::new(); + + repo.store_compiled_code("plugin-1", "code1", None) + .await + .unwrap(); + repo.store_compiled_code("plugin-2", "code2", None) + .await + .unwrap(); + + assert!(repo.has_compiled_code("plugin-1").await.unwrap()); + assert!(repo.has_compiled_code("plugin-2").await.unwrap()); + + repo.invalidate_compiled_code("plugin-1").await.unwrap(); + + assert!(!repo.has_compiled_code("plugin-1").await.unwrap()); + assert!(repo.has_compiled_code("plugin-2").await.unwrap()); + } + + #[tokio::test] + async fn test_invalidate_compiled_code_nonexistent() { + let repo = InMemoryPluginRepository::new(); + + // Should not fail on nonexistent + let result = repo.invalidate_compiled_code("nonexistent").await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_invalidate_all_compiled_code() { + let repo = InMemoryPluginRepository::new(); + + for i in 1..=5 { + repo.store_compiled_code(&format!("plugin-{}", i), &format!("code-{}", i), None) + .await + .unwrap(); + } + + for i in 1..=5 { + assert!(repo + .has_compiled_code(&format!("plugin-{}", i)) + .await + .unwrap()); + } + + repo.invalidate_all_compiled_code().await.unwrap(); + + for i in 1..=5 { + assert!(!repo + .has_compiled_code(&format!("plugin-{}", i)) + .await + .unwrap()); + } + } + + #[tokio::test] + async fn test_invalidate_all_compiled_code_empty() { + let repo = InMemoryPluginRepository::new(); + + // Should not fail on empty cache + let result = repo.invalidate_all_compiled_code().await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_get_source_hash() { + let repo = InMemoryPluginRepository::new(); + + // No hash stored + repo.store_compiled_code("plugin-1", "code", None) + .await + .unwrap(); + let hash = repo.get_source_hash("plugin-1").await.unwrap(); + assert_eq!(hash, None); + + // Hash stored + repo.store_compiled_code("plugin-2", "code", Some("sha256:abc")) + .await + .unwrap(); + let hash = repo.get_source_hash("plugin-2").await.unwrap(); + assert_eq!(hash, Some("sha256:abc".to_string())); + } + + #[tokio::test] + async fn test_get_source_hash_nonexistent() { + let repo = InMemoryPluginRepository::new(); + + let hash = repo.get_source_hash("nonexistent").await.unwrap(); + assert_eq!(hash, None); + } + + // ============================================ + // Clone tests + // ============================================ + + #[tokio::test] + async fn test_clone_copies_store_data() { + let repo = InMemoryPluginRepository::new(); + repo.add(create_test_plugin("plugin-1")).await.unwrap(); + repo.add(create_test_plugin("plugin-2")).await.unwrap(); + + let cloned = repo.clone(); + + // Cloned should have same data + assert_eq!(cloned.count().await.unwrap(), 2); + assert!(cloned.get_by_id("plugin-1").await.unwrap().is_some()); + assert!(cloned.get_by_id("plugin-2").await.unwrap().is_some()); + } + + #[tokio::test] + async fn test_clone_copies_compiled_cache() { + let repo = InMemoryPluginRepository::new(); + repo.store_compiled_code("plugin-1", "code1", Some("hash1")) + .await + .unwrap(); + + let cloned = repo.clone(); + + // Cloned should have same compiled cache + assert!(cloned.has_compiled_code("plugin-1").await.unwrap()); + assert_eq!( + cloned.get_compiled_code("plugin-1").await.unwrap(), + Some("code1".to_string()) + ); assert_eq!( - plugin_repository.get_by_id("test-plugin").await.unwrap(), - Some(plugin) + cloned.get_source_hash("plugin-1").await.unwrap(), + Some("hash1".to_string()) ); } #[tokio::test] - async fn test_get_nonexistent_plugin() { - let plugin_repository = Arc::new(InMemoryPluginRepository::new()); + async fn test_clone_is_independent() { + let repo = InMemoryPluginRepository::new(); + repo.add(create_test_plugin("plugin-1")).await.unwrap(); - let result = plugin_repository.get_by_id("test-plugin").await; - assert!(matches!(result, Ok(None))); + let cloned = repo.clone(); + + // Modify original + repo.add(create_test_plugin("plugin-2")).await.unwrap(); + + // Clone should not have the new plugin (independent copy) + // Note: This tests the independence after clone + assert_eq!(repo.count().await.unwrap(), 2); + // cloned was made before plugin-2 was added, so it only has 1 + assert_eq!(cloned.count().await.unwrap(), 1); } + // ============================================ + // PluginModel conversion tests + // ============================================ + #[tokio::test] - async fn test_try_from() { - let plugin = PluginFileConfig { + async fn test_plugin_model_try_from_config() { + let config = PluginFileConfig { id: "test-plugin".to_string(), path: "test-path".to_string(), timeout: None, @@ -161,132 +818,102 @@ mod tests { config: None, forward_logs: false, }; - let result = PluginModel::try_from(plugin); + + let result = PluginModel::try_from(config); assert!(result.is_ok()); + + let plugin = result.unwrap(); + assert_eq!(plugin.id, "test-plugin"); + assert_eq!(plugin.path, "test-path"); assert_eq!( - result.unwrap(), - PluginModel { - id: "test-plugin".to_string(), - path: "test-path".to_string(), - timeout: Duration::from_secs(DEFAULT_PLUGIN_TIMEOUT_SECONDS), - emit_logs: false, - emit_traces: false, - raw_response: false, - allow_get_invocation: false, - config: None, - forward_logs: false, - } + plugin.timeout, + Duration::from_secs(DEFAULT_PLUGIN_TIMEOUT_SECONDS) ); } #[tokio::test] - async fn test_get_by_id() { - let plugin_repository = Arc::new(InMemoryPluginRepository::new()); + async fn test_plugin_model_try_from_config_with_timeout() { + let mut config_map = serde_json::Map::new(); + config_map.insert("key".to_string(), serde_json::json!("value")); - let plugin = PluginModel { + let config = PluginFileConfig { id: "test-plugin".to_string(), path: "test-path".to_string(), - timeout: Duration::from_secs(DEFAULT_PLUGIN_TIMEOUT_SECONDS), - emit_logs: false, - emit_traces: false, - raw_response: false, - allow_get_invocation: false, - config: None, - forward_logs: false, + timeout: Some(120), + emit_logs: true, + emit_traces: true, + raw_response: true, + allow_get_invocation: true, + config: Some(config_map), + forward_logs: true, }; - plugin_repository.add(plugin.clone()).await.unwrap(); - assert_eq!( - plugin_repository.get_by_id("test-plugin").await.unwrap(), - Some(plugin) - ); + + let result = PluginModel::try_from(config); + assert!(result.is_ok()); + + let plugin = result.unwrap(); + assert_eq!(plugin.timeout, Duration::from_secs(120)); + assert!(plugin.emit_logs); + assert!(plugin.emit_traces); + assert!(plugin.raw_response); + assert!(plugin.allow_get_invocation); + assert!(plugin.config.is_some()); + assert!(plugin.forward_logs); } + // ============================================ + // Compiled cache independence from store + // ============================================ + #[tokio::test] - async fn test_list_paginated() { - let plugin_repository = Arc::new(InMemoryPluginRepository::new()); + async fn test_compiled_cache_independent_of_plugin_store() { + let repo = InMemoryPluginRepository::new(); - let plugin1 = PluginModel { - id: "test-plugin1".to_string(), - path: "test-path1".to_string(), - timeout: Duration::from_secs(DEFAULT_PLUGIN_TIMEOUT_SECONDS), - emit_logs: false, - emit_traces: false, - raw_response: false, - allow_get_invocation: false, - config: None, - forward_logs: false, - }; + // Store compiled code without adding plugin to store + repo.store_compiled_code("plugin-1", "compiled", None) + .await + .unwrap(); - let plugin2 = PluginModel { - id: "test-plugin2".to_string(), - path: "test-path2".to_string(), - timeout: Duration::from_secs(DEFAULT_PLUGIN_TIMEOUT_SECONDS), - emit_logs: false, - emit_traces: false, - raw_response: false, - allow_get_invocation: false, - config: None, - forward_logs: false, - }; + // Plugin doesn't exist in store + assert!(repo.get_by_id("plugin-1").await.unwrap().is_none()); - plugin_repository.add(plugin1.clone()).await.unwrap(); - plugin_repository.add(plugin2.clone()).await.unwrap(); + // But compiled code exists in cache + assert!(repo.has_compiled_code("plugin-1").await.unwrap()); + } - let query = PaginationQuery { - page: 1, - per_page: 2, - }; + #[tokio::test] + async fn test_drop_all_entries_does_not_clear_compiled_cache() { + let repo = InMemoryPluginRepository::new(); - let result = plugin_repository.list_paginated(query).await; - assert!(result.is_ok()); - let result = result.unwrap(); - assert_eq!(result.items.len(), 2); - } - - #[tokio::test] - async fn test_has_entries() { - let plugin_repository = Arc::new(InMemoryPluginRepository::new()); - assert!(!plugin_repository.has_entries().await.unwrap()); - plugin_repository - .add(PluginModel { - id: "test-plugin".to_string(), - path: "test-path".to_string(), - timeout: Duration::from_secs(DEFAULT_PLUGIN_TIMEOUT_SECONDS), - emit_logs: false, - emit_traces: false, - raw_response: false, - allow_get_invocation: false, - config: None, - forward_logs: false, - }) + repo.add(create_test_plugin("plugin-1")).await.unwrap(); + repo.store_compiled_code("plugin-1", "compiled", None) .await .unwrap(); - assert!(plugin_repository.has_entries().await.unwrap()); - plugin_repository.drop_all_entries().await.unwrap(); - assert!(!plugin_repository.has_entries().await.unwrap()); - } - - #[tokio::test] - async fn test_drop_all_entries() { - let plugin_repository = Arc::new(InMemoryPluginRepository::new()); - plugin_repository - .add(PluginModel { - id: "test-plugin".to_string(), - path: "test-path".to_string(), - timeout: Duration::from_secs(DEFAULT_PLUGIN_TIMEOUT_SECONDS), - emit_logs: false, - emit_traces: false, - raw_response: false, - allow_get_invocation: false, - config: None, - forward_logs: false, - }) + repo.drop_all_entries().await.unwrap(); + + // Plugin gone + assert!(repo.get_by_id("plugin-1").await.unwrap().is_none()); + + // But compiled cache still has entry + assert!(repo.has_compiled_code("plugin-1").await.unwrap()); + } + + #[tokio::test] + async fn test_invalidate_all_compiled_does_not_clear_store() { + let repo = InMemoryPluginRepository::new(); + + repo.add(create_test_plugin("plugin-1")).await.unwrap(); + repo.store_compiled_code("plugin-1", "compiled", None) .await .unwrap(); - assert!(plugin_repository.has_entries().await.unwrap()); - plugin_repository.drop_all_entries().await.unwrap(); - assert!(!plugin_repository.has_entries().await.unwrap()); + repo.invalidate_all_compiled_code().await.unwrap(); + + // Compiled cache cleared + assert!(!repo.has_compiled_code("plugin-1").await.unwrap()); + + // But plugin still exists in store + assert!(repo.get_by_id("plugin-1").await.unwrap().is_some()); } } diff --git a/src/repositories/plugin/plugin_redis.rs b/src/repositories/plugin/plugin_redis.rs index 445afcdd9..9576301d5 100644 --- a/src/repositories/plugin/plugin_redis.rs +++ b/src/repositories/plugin/plugin_redis.rs @@ -12,6 +12,8 @@ use tracing::{debug, error, warn}; const PLUGIN_PREFIX: &str = "plugin"; const PLUGIN_LIST_KEY: &str = "plugin_list"; +const COMPILED_CODE_PREFIX: &str = "compiled_code"; +const SOURCE_HASH_PREFIX: &str = "source_hash"; #[derive(Clone)] pub struct RedisPluginRepository { @@ -48,6 +50,16 @@ impl RedisPluginRepository { format!("{}:{}", self.key_prefix, PLUGIN_LIST_KEY) } + /// Generate key for compiled code: compiled_code:{plugin_id} + fn compiled_code_key(&self, plugin_id: &str) -> String { + format!("{}:{}:{}", self.key_prefix, COMPILED_CODE_PREFIX, plugin_id) + } + + /// Generate key for source hash: source_hash:{plugin_id} + fn source_hash_key(&self, plugin_id: &str) -> String { + format!("{}:{}:{}", self.key_prefix, SOURCE_HASH_PREFIX, plugin_id) + } + /// Get plugin by ID using an existing connection. /// This method is useful to prevent creating new connections for /// getting individual plugins on list operations. @@ -204,6 +216,43 @@ impl PluginRepositoryTrait for RedisPluginRepository { Ok(()) } + async fn update(&self, plugin: PluginModel) -> Result { + if plugin.id.is_empty() { + return Err(RepositoryError::InvalidData( + "Plugin ID cannot be empty".to_string(), + )); + } + + let mut conn = self.client.as_ref().clone(); + let key = self.plugin_key(&plugin.id); + + debug!(plugin_id = %plugin.id, "updating plugin"); + + // Check if plugin exists + let exists: bool = conn + .exists(&key) + .await + .map_err(|e| self.map_redis_error(e, &format!("check_plugin_exists_{}", plugin.id)))?; + + if !exists { + return Err(RepositoryError::NotFound(format!( + "Plugin with ID {} not found", + plugin.id + ))); + } + + // Serialize plugin + let json = self.serialize_entity(&plugin, |p| &p.id, "plugin")?; + + // Update the plugin data + conn.set::<_, _, ()>(&key, &json) + .await + .map_err(|e| self.map_redis_error(e, &format!("update_plugin_{}", plugin.id)))?; + + debug!(plugin_id = %plugin.id, "successfully updated plugin"); + Ok(plugin) + } + async fn list_paginated( &self, query: PaginationQuery, @@ -322,6 +371,118 @@ impl PluginRepositoryTrait for RedisPluginRepository { debug!(count = %plugin_ids.len(), "dropped plugin entries"); Ok(()) } + + // Compiled code cache methods + + async fn get_compiled_code(&self, plugin_id: &str) -> Result, RepositoryError> { + let mut conn = self.client.as_ref().clone(); + let key = self.compiled_code_key(plugin_id); + + debug!(plugin_id = %plugin_id, "fetching compiled code from Redis"); + + let code: Option = conn + .get(&key) + .await + .map_err(|e| self.map_redis_error(e, &format!("get_compiled_code_{plugin_id}")))?; + + Ok(code) + } + + async fn store_compiled_code( + &self, + plugin_id: &str, + compiled_code: &str, + source_hash: Option<&str>, + ) -> Result<(), RepositoryError> { + let mut conn = self.client.as_ref().clone(); + let code_key = self.compiled_code_key(plugin_id); + + debug!(plugin_id = %plugin_id, "storing compiled code in Redis"); + + // Use pipeline to store both code and hash atomically + let mut pipe = redis::pipe(); + pipe.atomic(); + pipe.set(&code_key, compiled_code); + + if let Some(hash) = source_hash { + let hash_key = self.source_hash_key(plugin_id); + pipe.set(&hash_key, hash); + } + + pipe.exec_async(&mut conn) + .await + .map_err(|e| self.map_redis_error(e, &format!("store_compiled_code_{plugin_id}")))?; + + Ok(()) + } + + async fn invalidate_compiled_code(&self, plugin_id: &str) -> Result<(), RepositoryError> { + let mut conn = self.client.as_ref().clone(); + let code_key = self.compiled_code_key(plugin_id); + let hash_key = self.source_hash_key(plugin_id); + + debug!(plugin_id = %plugin_id, "invalidating compiled code in Redis"); + + // Use pipeline to delete both keys atomically + let mut pipe = redis::pipe(); + pipe.atomic(); + pipe.del(&code_key); + pipe.del(&hash_key); + + pipe.exec_async(&mut conn).await.map_err(|e| { + self.map_redis_error(e, &format!("invalidate_compiled_code_{plugin_id}")) + })?; + + Ok(()) + } + + async fn invalidate_all_compiled_code(&self) -> Result<(), RepositoryError> { + let mut conn = self.client.as_ref().clone(); + let plugin_list_key = self.plugin_list_key(); + + debug!("invalidating all compiled code in Redis"); + + // Get all plugin IDs from the list + let plugin_ids: Vec = conn + .smembers(&plugin_list_key) + .await + .map_err(|e| self.map_redis_error(e, "get_plugin_list_for_invalidate"))?; + + // Delete all compiled code and hash keys + for plugin_id in &plugin_ids { + let code_key = self.compiled_code_key(plugin_id); + let hash_key = self.source_hash_key(plugin_id); + + let _ = conn.del::<_, ()>(&code_key).await; + let _ = conn.del::<_, ()>(&hash_key).await; + } + + Ok(()) + } + + async fn has_compiled_code(&self, plugin_id: &str) -> Result { + let mut conn = self.client.as_ref().clone(); + let key = self.compiled_code_key(plugin_id); + + let exists: bool = conn + .exists(&key) + .await + .map_err(|e| self.map_redis_error(e, &format!("exists_compiled_code_{plugin_id}")))?; + + Ok(exists) + } + + async fn get_source_hash(&self, plugin_id: &str) -> Result, RepositoryError> { + let mut conn = self.client.as_ref().clone(); + let key = self.source_hash_key(plugin_id); + + let hash: Option = conn + .get(&key) + .await + .map_err(|e| self.map_redis_error(e, &format!("get_source_hash_{plugin_id}")))?; + + Ok(hash) + } } #[cfg(test)] diff --git a/src/services/plugins/config.rs b/src/services/plugins/config.rs new file mode 100644 index 000000000..5a2551d0e --- /dev/null +++ b/src/services/plugins/config.rs @@ -0,0 +1,675 @@ +//! Plugin Configuration +//! +//! Centralized configuration for the plugin system with auto-derivation. +//! +//! # Simple Usage (80% of users) +//! +//! Set one variable and everything else is auto-calculated: +//! +//! ```bash +//! export PLUGIN_MAX_CONCURRENCY=3000 +//! ``` +//! +//! # Advanced Usage (power users) +//! +//! Override individual settings when needed: +//! +//! ```bash +//! export PLUGIN_MAX_CONCURRENCY=3000 +//! export PLUGIN_POOL_MAX_QUEUE_SIZE=10000 # Override just this one +//! ``` + +use crate::constants::{ + CONCURRENT_TASKS_HEADROOM_MULTIPLIER, DEFAULT_POOL_CONCURRENT_TASKS_PER_WORKER, + DEFAULT_POOL_CONNECT_RETRIES, DEFAULT_POOL_HEALTH_CHECK_INTERVAL_SECS, + DEFAULT_POOL_IDLE_TIMEOUT_MS, DEFAULT_POOL_MAX_CONNECTIONS, DEFAULT_POOL_MAX_THREADS_FLOOR, + DEFAULT_POOL_MIN_THREADS, DEFAULT_POOL_QUEUE_SEND_TIMEOUT_MS, + DEFAULT_POOL_REQUEST_TIMEOUT_SECS, DEFAULT_POOL_SOCKET_BACKLOG, + DEFAULT_SOCKET_IDLE_TIMEOUT_SECS, DEFAULT_SOCKET_READ_TIMEOUT_SECS, DEFAULT_TRACE_TIMEOUT_MS, + MAX_CONCURRENT_TASKS_PER_WORKER, +}; +use std::sync::OnceLock; + +/// Cached plugin configuration (computed once at startup) +static CONFIG: OnceLock = OnceLock::new(); + +/// Plugin system configuration with auto-derived values +#[derive(Debug, Clone)] +pub struct PluginConfig { + // === Primary scaling knob === + /// Maximum concurrent plugin executions (the main knob users should adjust) + pub max_concurrency: usize, + + // === Connection Pool (Rust side, auto-derived from max_concurrency) === + /// Maximum connections to the Node.js pool server + pub pool_max_connections: usize, + /// Retry attempts when connecting to pool + pub pool_connect_retries: usize, + /// Request timeout in seconds + pub pool_request_timeout_secs: u64, + + // === Request Queue (Rust side, auto-derived from max_concurrency) === + /// Maximum queued requests + pub pool_max_queue_size: usize, + /// Wait time when queue is full before rejecting (ms) + pub pool_queue_send_timeout_ms: u64, + /// Number of queue workers (0 = auto based on CPU cores) + pub pool_workers: usize, + + // === Socket Service (Rust side, auto-derived from max_concurrency) === + /// Maximum concurrent socket connections + pub socket_max_connections: usize, + /// Idle timeout for connections (seconds) + pub socket_idle_timeout_secs: u64, + /// Read timeout per message (seconds) + pub socket_read_timeout_secs: u64, + + // === Node.js Worker Pool (passed to pool-server.ts) === + /// Minimum worker threads in Node.js pool + pub nodejs_pool_min_threads: usize, + /// Maximum worker threads in Node.js pool + pub nodejs_pool_max_threads: usize, + /// Concurrent tasks per worker thread + pub nodejs_pool_concurrent_tasks: usize, + /// Worker idle timeout in milliseconds + pub nodejs_pool_idle_timeout_ms: u64, + /// Worker thread heap size in MB (each worker handles concurrent_tasks contexts) + pub nodejs_worker_heap_mb: usize, + + // === Socket Backlog (derived from max_concurrency) === + /// Socket connection backlog for pending connections + pub pool_socket_backlog: usize, + + // === Health & Monitoring === + /// Minimum seconds between health checks + pub health_check_interval_secs: u64, + /// Trace collection timeout (ms) + pub trace_timeout_ms: u64, +} + +impl PluginConfig { + /// Load configuration from environment variables with auto-derivation + pub fn from_env() -> Self { + // === Primary scaling knob === + // If set, this drives the auto-derivation of other values + let max_concurrency = env_parse("PLUGIN_MAX_CONCURRENCY", DEFAULT_POOL_MAX_CONNECTIONS); + + // === Auto-derived values (can be individually overridden) === + + // Pool connections = max_concurrency (1:1 ratio) + let pool_max_connections = env_parse("PLUGIN_POOL_MAX_CONNECTIONS", max_concurrency); + + // Socket connections = 1.5x max_concurrency (headroom for connection churn) + let socket_max_connections = env_parse( + "PLUGIN_SOCKET_MAX_CONCURRENT_CONNECTIONS", + (max_concurrency as f64 * 1.5) as usize, + ); + + // Queue size = 2x max_concurrency (absorb bursts) + let pool_max_queue_size = env_parse("PLUGIN_POOL_MAX_QUEUE_SIZE", max_concurrency * 2); + + // Calculate thread count early for queue timeout derivation + // NOTE: This must use the SAME formula as the actual thread calculation below + let cpu_count = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(4); + + // Memory-aware estimation (same logic as actual calculation below) + // Assume 16GB default for estimation since we detect actual memory later + let estimated_memory_budget = 16384_u64 / 2; // 8GB budget + let estimated_memory_threads = (estimated_memory_budget / 1024).max(4) as usize; + let estimated_concurrency_threads = (max_concurrency / 200).max(cpu_count); + let estimated_max_threads = estimated_memory_threads + .min(estimated_concurrency_threads) + .clamp(DEFAULT_POOL_MAX_THREADS_FLOOR, 32); // Same cap as actual calculation + + // Queue timeout scales with concurrency AND thread count + // Formula: base_timeout * (concurrency / threads) with caps + // This ensures timeout grows when there are more items per thread + let base_queue_timeout = DEFAULT_POOL_QUEUE_SEND_TIMEOUT_MS; + let workload_per_thread = max_concurrency / estimated_max_threads.max(1); + let derived_queue_timeout = if workload_per_thread > 100 { + // Heavy load per thread: allow more time + base_queue_timeout * 2 // 1000ms + } else if workload_per_thread > 50 { + // Medium load per thread + base_queue_timeout + 250 // 750ms + } else { + // Light load per thread + base_queue_timeout // 500ms default + }; + let pool_queue_send_timeout_ms = + env_parse("PLUGIN_POOL_QUEUE_SEND_TIMEOUT_MS", derived_queue_timeout); + + // Other settings with defaults + let pool_connect_retries = + env_parse("PLUGIN_POOL_CONNECT_RETRIES", DEFAULT_POOL_CONNECT_RETRIES); + let pool_request_timeout_secs = env_parse( + "PLUGIN_POOL_REQUEST_TIMEOUT_SECS", + DEFAULT_POOL_REQUEST_TIMEOUT_SECS, + ); + let pool_workers = env_parse("PLUGIN_POOL_WORKERS", 0); // 0 = auto + + let socket_idle_timeout_secs = env_parse( + "PLUGIN_SOCKET_IDLE_TIMEOUT_SECS", + DEFAULT_SOCKET_IDLE_TIMEOUT_SECS, + ); + let socket_read_timeout_secs = env_parse( + "PLUGIN_SOCKET_READ_TIMEOUT_SECS", + DEFAULT_SOCKET_READ_TIMEOUT_SECS, + ); + + let health_check_interval_secs = env_parse( + "PLUGIN_POOL_HEALTH_CHECK_INTERVAL_SECS", + DEFAULT_POOL_HEALTH_CHECK_INTERVAL_SECS, + ); + let trace_timeout_ms = env_parse("PLUGIN_TRACE_TIMEOUT_MS", DEFAULT_TRACE_TIMEOUT_MS); + + // === Node.js Worker Pool settings (auto-derived from max_concurrency) === + // These are passed to pool-server.ts when spawning the Node.js process + // Note: cpu_count and scaling_threads already calculated above for queue timeout + + // minThreads = max(2, cpuCount / 2) - keeps some workers warm + let derived_min_threads = DEFAULT_POOL_MIN_THREADS.max(cpu_count / 2); + let nodejs_pool_min_threads = env_parse("PLUGIN_POOL_MIN_THREADS", derived_min_threads); + + // === Memory-aware thread scaling === + // The previous formula (concurrency / 50) was too aggressive and caused GC issues + // on systems with limited memory (e.g., laptops with 16-36GB RAM). + // + // New approach: Scale threads based on BOTH concurrency AND available memory + // + // Memory budget calculation: + // - Each worker thread needs ~1-2GB heap for high concurrent task loads + // - On a 16GB system, we shouldn't use more than ~8GB for workers (50%) + // - On a 32GB system, we can use ~16GB for workers + // + // Thread limits based on system memory: + // - 8GB RAM: max 4 threads (conservative) + // - 16GB RAM: max 8 threads + // - 32GB RAM: max 16 threads + // - 64GB+ RAM: max 32 threads (hard cap for efficiency) + // + // This prevents the previous issue where 5000 VU would spawn 64 threads + // requiring 128GB+ of potential heap allocation. + let total_memory_mb = { + #[cfg(target_os = "macos")] + { + // On macOS, use sysctl to get total memory + use std::process::Command; + Command::new("sysctl") + .args(["-n", "hw.memsize"]) + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .and_then(|s| s.trim().parse::().ok()) + .map(|bytes| bytes / 1024 / 1024) + .unwrap_or(16384) // Default to 16GB if detection fails + } + #[cfg(target_os = "linux")] + { + // On Linux, read from /proc/meminfo + std::fs::read_to_string("/proc/meminfo") + .ok() + .and_then(|contents| { + contents + .lines() + .find(|l| l.starts_with("MemTotal:")) + .and_then(|l| { + l.split_whitespace() + .nth(1) + .and_then(|s| s.parse::().ok()) + }) + }) + .map(|kb| kb / 1024) + .unwrap_or(16384) // Default to 16GB + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + 16384_u64 // Default to 16GB on other platforms + } + }; + + // Calculate memory-based thread limit + // Use ~50% of system memory for workers, with 1GB budget per worker + // (Workers with good GC pressure management don't actually use 2GB each) + let memory_budget_mb = total_memory_mb / 2; + let heap_per_worker_mb = 1024_u64; // ~1GB per worker (realistic with GC) + let memory_based_max_threads = (memory_budget_mb / heap_per_worker_mb).max(4) as usize; + + // Concurrency-based thread scaling (more conservative than before) + // Changed from /50 to /200 - each thread can handle ~200 VUs with async I/O + // Example: 10,000 VUs / 200 = 50 threads (capped by memory) + let concurrency_based_threads = (max_concurrency / 200).max(cpu_count); + + // Final thread count: minimum of memory-based and concurrency-based limits + // This ensures we don't exceed either memory or concurrency constraints + let derived_max_threads = memory_based_max_threads + .min(concurrency_based_threads) + .clamp(DEFAULT_POOL_MAX_THREADS_FLOOR, 32); // At least the floor, hard cap at 32 + + tracing::debug!( + total_memory_mb = total_memory_mb, + memory_based_max = memory_based_max_threads, + concurrency_based = concurrency_based_threads, + derived_max_threads = derived_max_threads, + "Thread scaling calculation" + ); + + let nodejs_pool_max_threads = env_parse("PLUGIN_POOL_MAX_THREADS", derived_max_threads); + + // concurrentTasksPerWorker: Node.js async can handle many concurrent tasks + // Formula: (concurrency / max_threads) * CONCURRENT_TASKS_HEADROOM_MULTIPLIER for some headroom + // The multiplier provides headroom for: + // - Queue buildup during traffic spikes + // - Variable plugin execution latency + // Examples with new formula (on 16GB system with ~8 threads): + // - 10000 VUs / 16 threads * 1.2 = 750, capped at MAX_CONCURRENT_TASKS_PER_WORKER + // - 5000 VUs / 8 threads * 1.2 = 750, capped at MAX_CONCURRENT_TASKS_PER_WORKER + // - 1000 VUs / 8 threads * 1.2 = 150 + let base_tasks = max_concurrency / nodejs_pool_max_threads.max(1); + let derived_concurrent_tasks = + ((base_tasks as f64 * CONCURRENT_TASKS_HEADROOM_MULTIPLIER) as usize).clamp( + DEFAULT_POOL_CONCURRENT_TASKS_PER_WORKER, + MAX_CONCURRENT_TASKS_PER_WORKER, + ); + let nodejs_pool_concurrent_tasks = + env_parse("PLUGIN_POOL_CONCURRENT_TASKS", derived_concurrent_tasks); + + let nodejs_pool_idle_timeout_ms = + env_parse("PLUGIN_POOL_IDLE_TIMEOUT", DEFAULT_POOL_IDLE_TIMEOUT_MS); + + // Worker heap size calculation + // Each vm.createContext() uses ~4-6MB, and we need headroom for GC + // Formula: base_heap + (concurrent_tasks * 5MB) + // This ensures workers can handle burst context creation without OOM + // Examples: + // - 50 concurrent tasks: 512 + (50 * 5) = 762MB + // - 150 concurrent tasks: 512 + (150 * 5) = 1262MB + // - 250 concurrent tasks: 512 + (250 * 5) = 1762MB + let base_worker_heap = 512_usize; + let heap_per_task = 5_usize; + let derived_worker_heap_mb = + (base_worker_heap + (nodejs_pool_concurrent_tasks * heap_per_task)).clamp(1024, 2048); // At least 1GB, cap at 2GB + let nodejs_worker_heap_mb = env_parse("PLUGIN_WORKER_HEAP_MB", derived_worker_heap_mb); + + // Socket backlog calculation + // Use max of concurrency or default backlog to handle connection bursts + // The 1.5x socket_max_connections provides headroom for connection churn: + // - Client reconnections + // - Connection pool cycling + // - Load balancer health checks + // This ratio should be validated through load testing if workload characteristics change. + let default_backlog = DEFAULT_POOL_SOCKET_BACKLOG as usize; + let pool_socket_backlog = env_parse( + "PLUGIN_POOL_SOCKET_BACKLOG", + max_concurrency.max(default_backlog), + ); + + let config = Self { + max_concurrency, + pool_max_connections, + pool_connect_retries, + pool_request_timeout_secs, + pool_max_queue_size, + pool_queue_send_timeout_ms, + pool_workers, + socket_max_connections, + socket_idle_timeout_secs, + socket_read_timeout_secs, + nodejs_pool_min_threads, + nodejs_pool_max_threads, + nodejs_pool_concurrent_tasks, + nodejs_pool_idle_timeout_ms, + nodejs_worker_heap_mb, + pool_socket_backlog, + health_check_interval_secs, + trace_timeout_ms, + }; + + // Validate derived configuration + config.validate(); + + config + } + + /// Validate that derived configuration values are sensible + fn validate(&self) { + // Critical invariants + assert!( + self.pool_max_connections <= self.socket_max_connections, + "pool_max_connections ({}) must be <= socket_max_connections ({})", + self.pool_max_connections, + self.socket_max_connections + ); + assert!( + self.nodejs_pool_min_threads <= self.nodejs_pool_max_threads, + "nodejs_pool_min_threads ({}) must be <= nodejs_pool_max_threads ({})", + self.nodejs_pool_min_threads, + self.nodejs_pool_max_threads + ); + assert!( + self.max_concurrency > 0, + "max_concurrency must be > 0, got {}", + self.max_concurrency + ); + assert!( + self.nodejs_pool_max_threads > 0, + "nodejs_pool_max_threads must be > 0, got {}", + self.nodejs_pool_max_threads + ); + + // Warnings for potentially problematic configurations + if self.pool_max_queue_size < self.max_concurrency { + tracing::warn!( + "pool_max_queue_size ({}) is less than max_concurrency ({}). \ + This may cause request rejections under load.", + self.pool_max_queue_size, + self.max_concurrency + ); + } + if self.nodejs_pool_concurrent_tasks > 500 { + tracing::warn!( + "nodejs_pool_concurrent_tasks ({}) is very high. \ + This may cause excessive memory usage per worker.", + self.nodejs_pool_concurrent_tasks + ); + } + } + + /// Log the effective configuration for debugging + pub fn log_config(&self) { + let tasks_per_thread = self.max_concurrency / self.nodejs_pool_max_threads.max(1); + let socket_ratio = self.socket_max_connections as f64 / self.max_concurrency as f64; + let queue_ratio = self.pool_max_queue_size as f64 / self.max_concurrency as f64; + let total_worker_heap_mb = self.nodejs_pool_max_threads * self.nodejs_worker_heap_mb; + + tracing::info!( + max_concurrency = self.max_concurrency, + pool_max_connections = self.pool_max_connections, + pool_max_queue_size = self.pool_max_queue_size, + queue_timeout_ms = self.pool_queue_send_timeout_ms, + socket_max_connections = self.socket_max_connections, + socket_backlog = self.pool_socket_backlog, + nodejs_min_threads = self.nodejs_pool_min_threads, + nodejs_max_threads = self.nodejs_pool_max_threads, + nodejs_concurrent_tasks = self.nodejs_pool_concurrent_tasks, + nodejs_worker_heap_mb = self.nodejs_worker_heap_mb, + total_worker_heap_mb = total_worker_heap_mb, + tasks_per_thread = tasks_per_thread, + socket_multiplier = %format!("{:.2}x", socket_ratio), + queue_multiplier = %format!("{:.2}x", queue_ratio), + "Plugin configuration loaded (Rust + Node.js)" + ); + } +} + +impl Default for PluginConfig { + /// Default configuration uses the same derivation logic as from_env() + /// but without any environment variable overrides. + /// This ensures tests and production use consistent formulas. + fn default() -> Self { + // Use hardcoded defaults without reading environment variables + // Note: This differs from from_env() which reads env vars + let max_concurrency = DEFAULT_POOL_MAX_CONNECTIONS; + let cpu_count = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(4); + + // Apply same formulas as from_env() + let pool_max_connections = max_concurrency; + let socket_max_connections = (max_concurrency as f64 * 1.5) as usize; + let pool_max_queue_size = max_concurrency * 2; + + // Memory-aware thread scaling (same as from_env) + // Assume 16GB for default since we can't easily detect memory here + let assumed_memory_mb = 16384_u64; + let memory_budget_mb = assumed_memory_mb / 2; + let heap_per_worker_mb = 1024_u64; // ~1GB per worker + let memory_based_max_threads = (memory_budget_mb / heap_per_worker_mb).max(4) as usize; + let concurrency_based_threads = (max_concurrency / 200).max(cpu_count); + + let nodejs_pool_max_threads = memory_based_max_threads + .min(concurrency_based_threads) + .clamp(DEFAULT_POOL_MAX_THREADS_FLOOR, 32); + let nodejs_pool_min_threads = DEFAULT_POOL_MIN_THREADS.max(cpu_count / 2); + + let base_tasks = max_concurrency / nodejs_pool_max_threads.max(1); + let nodejs_pool_concurrent_tasks = + ((base_tasks as f64 * CONCURRENT_TASKS_HEADROOM_MULTIPLIER) as usize).clamp( + DEFAULT_POOL_CONCURRENT_TASKS_PER_WORKER, + MAX_CONCURRENT_TASKS_PER_WORKER, + ); + + // Worker heap for Default impl (same formula as from_env) + let base_worker_heap = 512_usize; + let heap_per_task = 5_usize; + let nodejs_worker_heap_mb = + (base_worker_heap + (nodejs_pool_concurrent_tasks * heap_per_task)).clamp(1024, 2048); + + let default_backlog = DEFAULT_POOL_SOCKET_BACKLOG as usize; + let pool_socket_backlog = max_concurrency.max(default_backlog); + + Self { + max_concurrency, + pool_max_connections, + pool_connect_retries: DEFAULT_POOL_CONNECT_RETRIES, + pool_request_timeout_secs: DEFAULT_POOL_REQUEST_TIMEOUT_SECS, + pool_max_queue_size, + pool_queue_send_timeout_ms: DEFAULT_POOL_QUEUE_SEND_TIMEOUT_MS, + pool_workers: 0, + socket_max_connections, + socket_idle_timeout_secs: DEFAULT_SOCKET_IDLE_TIMEOUT_SECS, + socket_read_timeout_secs: DEFAULT_SOCKET_READ_TIMEOUT_SECS, + nodejs_pool_min_threads, + nodejs_pool_max_threads, + nodejs_pool_concurrent_tasks, + nodejs_pool_idle_timeout_ms: DEFAULT_POOL_IDLE_TIMEOUT_MS, + nodejs_worker_heap_mb, + pool_socket_backlog, + health_check_interval_secs: DEFAULT_POOL_HEALTH_CHECK_INTERVAL_SECS, + trace_timeout_ms: DEFAULT_TRACE_TIMEOUT_MS, + } + } +} + +/// Get the global plugin configuration (cached after first call) +pub fn get_config() -> &'static PluginConfig { + CONFIG.get_or_init(|| { + let config = PluginConfig::from_env(); + config.log_config(); + config + }) +} + +/// Parse an environment variable or return default +fn env_parse(name: &str, default: T) -> T { + std::env::var(name) + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(default) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_config() { + let config = PluginConfig::default(); + assert_eq!(config.max_concurrency, DEFAULT_POOL_MAX_CONNECTIONS); + assert_eq!(config.pool_max_connections, DEFAULT_POOL_MAX_CONNECTIONS); + // Validate derived ratios + assert_eq!(config.pool_max_queue_size, config.max_concurrency * 2); + assert!( + config.socket_max_connections >= config.pool_max_connections, + "socket connections should be >= pool connections" + ); + } + + #[test] + fn test_auto_derivation_ratios() { + // When max_concurrency is set, other values should be derived + let config = PluginConfig { + max_concurrency: 1000, + pool_max_connections: 1000, + socket_max_connections: 1500, // 1.5x + pool_max_queue_size: 2000, // 2x + ..Default::default() + }; + + assert_eq!( + config.socket_max_connections, + config.max_concurrency * 3 / 2 + ); + assert_eq!(config.pool_max_queue_size, config.max_concurrency * 2); + } + + #[test] + fn test_very_low_concurrency() { + // Test edge case: very low concurrency (10) + // We can't use from_env() in tests easily due to OnceLock caching, + // so we manually construct the config with the same logic + let max_concurrency = 10; + let cpu_count = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(4); + + let pool_max_connections = max_concurrency; + let socket_max_connections = (max_concurrency as f64 * 1.5) as usize; + let pool_max_queue_size = max_concurrency * 2; + + // New memory-aware formula (assuming 16GB) + let memory_budget_mb = 16384 / 2; + let memory_based_max = (memory_budget_mb / 1024).max(4); + let concurrency_based = (max_concurrency / 200).max(cpu_count); + let nodejs_pool_max_threads = memory_based_max + .min(concurrency_based) + .max(DEFAULT_POOL_MAX_THREADS_FLOOR) + .min(32); + + assert_eq!(pool_max_connections, 10); + assert_eq!(socket_max_connections, 15); // 1.5x + assert_eq!(pool_max_queue_size, 20); // 2x + + // Should still have reasonable thread count (warm pool) + assert!(nodejs_pool_max_threads >= DEFAULT_POOL_MAX_THREADS_FLOOR); + } + + #[test] + fn test_medium_concurrency() { + // Test edge case: medium concurrency (1000) + let max_concurrency = 1000; + let cpu_count = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(4); + + let socket_max_connections = (max_concurrency as f64 * 1.5) as usize; + let pool_max_queue_size = max_concurrency * 2; + + // New memory-aware formula (assuming 16GB) + let memory_budget_mb = 16384 / 2; + let memory_based_max = (memory_budget_mb / 1024).max(4); + let concurrency_based = (max_concurrency / 200).max(cpu_count); + let nodejs_pool_max_threads = memory_based_max + .min(concurrency_based) + .max(DEFAULT_POOL_MAX_THREADS_FLOOR) + .min(32); + + assert_eq!(socket_max_connections, 1500); // 1.5x + assert_eq!(pool_max_queue_size, 2000); // 2x + + // With 16GB memory and 1000 concurrency: + // memory_based = 8, concurrency_based = max(5, cpu_count) + // Result should be reasonable (not 64!) + assert!(nodejs_pool_max_threads <= 16); + } + + #[test] + fn test_high_concurrency() { + // Test edge case: high concurrency (10000) + // This simulates your load test scenario + let max_concurrency = 10000; + + let socket_max_connections = (max_concurrency as f64 * 1.5) as usize; + let pool_max_queue_size = max_concurrency * 2; + + let cpu_count = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(4); + + // New memory-aware formula (assuming 16GB) + let memory_budget_mb = 16384 / 2; + let memory_based_max = (memory_budget_mb / 1024).max(4); + let concurrency_based = (max_concurrency / 200).max(cpu_count); + let nodejs_pool_max_threads = memory_based_max + .min(concurrency_based) + .max(DEFAULT_POOL_MAX_THREADS_FLOOR) + .min(32); + + assert_eq!(socket_max_connections, 15000); // 1.5x + assert_eq!(pool_max_queue_size, 20000); // 2x + + // With 16GB: memory_based=8, concurrency_based=50 -> result = 8 + // Should NOT hit 64 threads anymore (memory-constrained) + assert!(nodejs_pool_max_threads <= 32); + + // Concurrent tasks per worker + let base_tasks = max_concurrency / nodejs_pool_max_threads; + let derived_concurrent_tasks = ((base_tasks as f64 * CONCURRENT_TASKS_HEADROOM_MULTIPLIER) + as usize) + .max(DEFAULT_POOL_CONCURRENT_TASKS_PER_WORKER) + .min(MAX_CONCURRENT_TASKS_PER_WORKER); + // Should be capped at MAX_CONCURRENT_TASKS_PER_WORKER + assert!(derived_concurrent_tasks <= MAX_CONCURRENT_TASKS_PER_WORKER); + } + + #[test] + fn test_validation_catches_invalid_config() { + let mut config = PluginConfig::default(); + + // Test that validation catches pool > socket connections + config.pool_max_connections = 1000; + config.socket_max_connections = 500; + + let result = std::panic::catch_unwind(|| { + config.validate(); + }); + assert!( + result.is_err(), + "Should panic on invalid pool > socket connections" + ); + } + + #[test] + fn test_validation_catches_invalid_threads() { + let mut config = PluginConfig::default(); + + // Test that validation catches min > max threads + config.nodejs_pool_min_threads = 64; + config.nodejs_pool_max_threads = 8; + + let result = std::panic::catch_unwind(|| { + config.validate(); + }); + assert!(result.is_err(), "Should panic on invalid min > max threads"); + } + + #[test] + fn test_overridden_values_respected() { + // Test that individual overrides work + // Note: Due to OnceLock caching in get_config(), we test the derivation logic directly + let max_concurrency = 1000; + let pool_max_queue_size = 5000; // What we'd override to + let pool_max_connections = 1000; // Auto-derived from max_concurrency + + // Verify the override would be respected + assert_eq!(pool_max_connections, max_concurrency); // Auto-derived + assert_eq!(pool_max_queue_size, 5000); // Manual override (not 2000) + + // Also test that auto-derivation would have given 2000 + let auto_derived_queue = max_concurrency * 2; + assert_eq!(auto_derived_queue, 2000); + assert_ne!(pool_max_queue_size, auto_derived_queue); // Override is different + } +} diff --git a/src/services/plugins/connection.rs b/src/services/plugins/connection.rs new file mode 100644 index 000000000..b98043b79 --- /dev/null +++ b/src/services/plugins/connection.rs @@ -0,0 +1,749 @@ +//! Connection management for Unix socket communication with the pool server. +//! +//! Provides: +//! - Fresh connection per request (prevents response mixing under high concurrency) +//! - Semaphore-based concurrency limiting +//! - RAII connection guards for automatic cleanup + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::UnixStream; +use tokio::sync::Semaphore; + +use super::config::get_config; +use super::protocol::{PoolRequest, PoolResponse}; +use super::PluginError; + +/// A single connection to the pool server (single-use, not pooled) +pub struct PoolConnection { + stream: UnixStream, + /// Connection ID for tracking/debugging + id: usize, +} + +impl PoolConnection { + pub async fn new(socket_path: &str, id: usize) -> Result { + let max_attempts = get_config().pool_connect_retries; + let mut attempts = 0; + let mut delay_ms = 10u64; + + tracing::debug!(connection_id = id, socket_path = %socket_path, "Connecting to pool server"); + + loop { + match UnixStream::connect(socket_path).await { + Ok(stream) => { + if attempts > 0 { + tracing::debug!( + connection_id = id, + attempts = attempts, + "Connected to pool server after retries" + ); + } + return Ok(Self { stream, id }); + } + Err(e) => { + attempts += 1; + + if attempts >= max_attempts { + return Err(PluginError::SocketError(format!( + "Failed to connect to pool after {max_attempts} attempts: {e}. \ + Consider increasing PLUGIN_POOL_CONNECT_RETRIES or PLUGIN_POOL_MAX_CONNECTIONS." + ))); + } + + if attempts <= 3 || attempts % 5 == 0 { + tracing::debug!( + connection_id = id, + attempt = attempts, + max_attempts = max_attempts, + delay_ms = delay_ms, + "Retrying connection to pool server" + ); + } + + tokio::time::sleep(Duration::from_millis(delay_ms)).await; + delay_ms = std::cmp::min(delay_ms * 2, 1000); + } + } + } + } + + pub async fn send_request( + &mut self, + request: &PoolRequest, + ) -> Result { + // Extract task_id from request for validation + let request_task_id = Self::extract_task_id(request); + + let json = serde_json::to_string(request) + .map_err(|e| PluginError::PluginError(format!("Failed to serialize request: {e}")))?; + + if let Err(e) = self.stream.write_all(format!("{json}\n").as_bytes()).await { + return Err(PluginError::SocketError(format!( + "Failed to send request: {e}" + ))); + } + + if let Err(e) = self.stream.flush().await { + return Err(PluginError::SocketError(format!( + "Failed to flush request: {e}" + ))); + } + + let mut reader = BufReader::new(&mut self.stream); + let mut line = String::new(); + + if let Err(e) = reader.read_line(&mut line).await { + return Err(PluginError::SocketError(format!( + "Failed to read response: {e}" + ))); + } + + tracing::debug!(response_len = line.len(), "Received response from pool"); + + let response: PoolResponse = serde_json::from_str(&line) + .map_err(|e| PluginError::PluginError(format!("Failed to parse response: {e}")))?; + + // CRITICAL: Validate that response task_id matches request task_id + if response.task_id != request_task_id { + tracing::error!( + request_task_id = %request_task_id, + response_task_id = %response.task_id, + connection_id = self.id, + "Response task_id mismatch" + ); + return Err(PluginError::PluginError( + "Internal plugin error: response task_id mismatch".to_string(), + )); + } + + Ok(response) + } + + /// Extract task_id from any PoolRequest variant + fn extract_task_id(request: &PoolRequest) -> String { + match request { + PoolRequest::Execute(req) => req.task_id.clone(), + PoolRequest::Precompile { task_id, .. } => task_id.clone(), + PoolRequest::Cache { task_id, .. } => task_id.clone(), + PoolRequest::Invalidate { task_id, .. } => task_id.clone(), + PoolRequest::Stats { task_id } => task_id.clone(), + PoolRequest::Health { task_id } => task_id.clone(), + PoolRequest::Shutdown { task_id } => task_id.clone(), + } + } + + pub async fn send_request_with_timeout( + &mut self, + request: &PoolRequest, + timeout_secs: u64, + ) -> Result { + tokio::time::timeout( + Duration::from_secs(timeout_secs), + self.send_request(request), + ) + .await + .map_err(|_| PluginError::SocketError("Request timed out".to_string()))? + } + + /// Get the connection ID + pub fn id(&self) -> usize { + self.id + } +} + +/// Connection manager for Unix socket connections. +/// +/// Creates fresh connections per request (pooling disabled to prevent response mixing). +/// Uses semaphore to limit concurrent connections. +pub struct ConnectionPool { + socket_path: String, + /// Maximum number of connections (used for logging) + #[allow(dead_code)] + max_connections: usize, + /// Next connection ID (atomic for lock-free increment) + next_id: Arc, + /// Semaphore for limiting concurrent connections + pub semaphore: Arc, +} + +impl ConnectionPool { + pub fn new(socket_path: String, max_connections: usize) -> Self { + Self { + socket_path, + max_connections, + next_id: Arc::new(AtomicUsize::new(0)), + semaphore: Arc::new(Semaphore::new(max_connections)), + } + } + + /// Acquire a connection. Always creates a fresh connection to prevent response mixing. + /// Uses semaphore for concurrency limiting. + /// Accepts optional pre-acquired permit for fast path optimization. + pub async fn acquire_with_permit( + &self, + permit: Option, + ) -> Result, PluginError> { + let permit = match permit { + Some(p) => p, + None => { + let available_permits = self.semaphore.available_permits(); + if available_permits == 0 { + tracing::warn!( + max_connections = self.max_connections, + "All connection permits exhausted - waiting for connection" + ); + } + self.semaphore.clone().acquire_owned().await.map_err(|_| { + PluginError::PluginError("Connection semaphore closed".to_string()) + })? + } + }; + + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + tracing::debug!(connection_id = id, "Creating connection"); + + let conn = PoolConnection::new(&self.socket_path, id).await?; + + Ok(PooledConnection { + conn: Some(conn), + pool: self, + _permit: permit, + }) + } + + /// Convenience method for acquiring without pre-acquired permit + pub async fn acquire(&self) -> Result, PluginError> { + self.acquire_with_permit(None).await + } + + /// Release a connection (closes the socket) + pub fn release(&self, conn: PoolConnection) { + let conn_id = conn.id(); + tracing::debug!(connection_id = conn_id, "Connection closed"); + drop(conn); + } + + /// Get the next connection ID from the atomic counter. + /// This is useful for creating connections outside the pool (e.g., shutdown requests). + pub fn next_connection_id(&self) -> usize { + self.next_id.fetch_add(1, Ordering::Relaxed) + } +} + +/// RAII wrapper that returns connection to pool on drop +pub struct PooledConnection<'a> { + conn: Option, + pool: &'a ConnectionPool, + /// Semaphore permit - released when dropped + _permit: tokio::sync::OwnedSemaphorePermit, +} + +impl<'a> PooledConnection<'a> { + pub async fn send_request_with_timeout( + &mut self, + request: &PoolRequest, + timeout_secs: u64, + ) -> Result { + if let Some(ref mut conn) = self.conn { + conn.send_request_with_timeout(request, timeout_secs).await + } else { + Err(PluginError::PluginError( + "Connection already released".to_string(), + )) + } + } +} + +impl<'a> Drop for PooledConnection<'a> { + fn drop(&mut self) { + if let Some(conn) = self.conn.take() { + self.pool.release(conn); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::plugins::protocol::ExecuteRequest; + + // ============================================ + // ConnectionPool creation tests + // ============================================ + + #[test] + fn test_connection_pool_creation() { + let pool = ConnectionPool::new("/tmp/test.sock".to_string(), 10); + // Verify semaphore has correct number of permits + assert_eq!(pool.semaphore.available_permits(), 10); + } + + #[test] + fn test_connection_pool_creation_single_connection() { + let pool = ConnectionPool::new("/tmp/single.sock".to_string(), 1); + assert_eq!(pool.semaphore.available_permits(), 1); + } + + #[test] + fn test_connection_pool_creation_large_pool() { + let pool = ConnectionPool::new("/tmp/large.sock".to_string(), 1000); + assert_eq!(pool.semaphore.available_permits(), 1000); + } + + #[test] + fn test_connection_pool_stores_socket_path() { + let path = "/var/run/custom.sock"; + let pool = ConnectionPool::new(path.to_string(), 5); + assert_eq!(pool.socket_path, path); + } + + #[test] + fn test_connection_pool_stores_max_connections() { + let pool = ConnectionPool::new("/tmp/test.sock".to_string(), 42); + assert_eq!(pool.max_connections, 42); + } + + // ============================================ + // Semaphore tests + // ============================================ + + #[tokio::test] + async fn test_connection_pool_semaphore_limits() { + let pool = ConnectionPool::new("/tmp/test.sock".to_string(), 2); + + let permit1 = pool.semaphore.clone().try_acquire_owned(); + assert!(permit1.is_ok()); + + let permit2 = pool.semaphore.clone().try_acquire_owned(); + assert!(permit2.is_ok()); + + // Third permit should fail - all permits exhausted + let permit3 = pool.semaphore.clone().try_acquire_owned(); + assert!(permit3.is_err()); + } + + #[tokio::test] + async fn test_semaphore_permit_release_restores_capacity() { + let pool = ConnectionPool::new("/tmp/test.sock".to_string(), 2); + + // Acquire all permits + let permit1 = pool.semaphore.clone().try_acquire_owned().unwrap(); + let permit2 = pool.semaphore.clone().try_acquire_owned().unwrap(); + + // No permits available + assert_eq!(pool.semaphore.available_permits(), 0); + + // Drop one permit + drop(permit1); + + // One permit available again + assert_eq!(pool.semaphore.available_permits(), 1); + + // Can acquire again + let permit3 = pool.semaphore.clone().try_acquire_owned(); + assert!(permit3.is_ok()); + + // Drop remaining permits + drop(permit2); + drop(permit3.unwrap()); + + // All permits restored + assert_eq!(pool.semaphore.available_permits(), 2); + } + + #[tokio::test] + async fn test_semaphore_async_acquire() { + let pool = ConnectionPool::new("/tmp/test.sock".to_string(), 1); + + // Acquire the only permit + let permit = pool.semaphore.clone().acquire_owned().await; + assert!(permit.is_ok()); + let _permit = permit.unwrap(); + + // Verify no permits available + assert_eq!(pool.semaphore.available_permits(), 0); + } + + // ============================================ + // Connection ID tests + // ============================================ + + #[test] + fn test_connection_id_increment() { + let pool = ConnectionPool::new("/tmp/test.sock".to_string(), 10); + assert_eq!(pool.next_connection_id(), 0); + assert_eq!(pool.next_connection_id(), 1); + assert_eq!(pool.next_connection_id(), 2); + } + + #[test] + fn test_connection_id_starts_at_zero() { + let pool = ConnectionPool::new("/tmp/test.sock".to_string(), 10); + assert_eq!(pool.next_connection_id(), 0); + } + + #[test] + fn test_connection_id_monotonically_increasing() { + let pool = ConnectionPool::new("/tmp/test.sock".to_string(), 10); + + let mut last_id = pool.next_connection_id(); + for _ in 0..100 { + let current_id = pool.next_connection_id(); + assert!( + current_id > last_id, + "IDs should be monotonically increasing" + ); + last_id = current_id; + } + } + + #[test] + fn test_connection_id_thread_safe() { + use std::thread; + + let pool = Arc::new(ConnectionPool::new("/tmp/test.sock".to_string(), 100)); + let mut handles = vec![]; + + // Spawn multiple threads getting connection IDs + for _ in 0..10 { + let pool_clone = pool.clone(); + handles.push(thread::spawn(move || { + let mut ids = vec![]; + for _ in 0..100 { + ids.push(pool_clone.next_connection_id()); + } + ids + })); + } + + // Collect all IDs + let mut all_ids: Vec = handles + .into_iter() + .flat_map(|h| h.join().unwrap()) + .collect(); + + // Sort and verify uniqueness + all_ids.sort(); + let unique_count = all_ids.windows(2).filter(|w| w[0] != w[1]).count() + 1; + assert_eq!(unique_count, all_ids.len(), "All IDs should be unique"); + } + + // ============================================ + // extract_task_id tests + // ============================================ + + #[test] + fn test_extract_task_id_from_execute_request() { + let request = PoolRequest::Execute(Box::new(ExecuteRequest { + task_id: "execute-task-123".to_string(), + plugin_id: "test-plugin".to_string(), + compiled_code: None, + plugin_path: None, + params: serde_json::json!({}), + headers: None, + socket_path: "/tmp/test.sock".to_string(), + http_request_id: None, + timeout: Some(30000), + route: None, + config: None, + method: None, + query: None, + })); + + let task_id = PoolConnection::extract_task_id(&request); + assert_eq!(task_id, "execute-task-123"); + } + + #[test] + fn test_extract_task_id_from_precompile_request() { + let request = PoolRequest::Precompile { + task_id: "precompile-task-456".to_string(), + plugin_id: "test-plugin".to_string(), + plugin_path: Some("/path/to/plugin.ts".to_string()), + source_code: None, + }; + + let task_id = PoolConnection::extract_task_id(&request); + assert_eq!(task_id, "precompile-task-456"); + } + + #[test] + fn test_extract_task_id_from_cache_request() { + let request = PoolRequest::Cache { + task_id: "cache-task-789".to_string(), + plugin_id: "test-plugin".to_string(), + compiled_code: "compiled code".to_string(), + }; + + let task_id = PoolConnection::extract_task_id(&request); + assert_eq!(task_id, "cache-task-789"); + } + + #[test] + fn test_extract_task_id_from_invalidate_request() { + let request = PoolRequest::Invalidate { + task_id: "invalidate-task-abc".to_string(), + plugin_id: "test-plugin".to_string(), + }; + + let task_id = PoolConnection::extract_task_id(&request); + assert_eq!(task_id, "invalidate-task-abc"); + } + + #[test] + fn test_extract_task_id_from_stats_request() { + let request = PoolRequest::Stats { + task_id: "stats-task-def".to_string(), + }; + + let task_id = PoolConnection::extract_task_id(&request); + assert_eq!(task_id, "stats-task-def"); + } + + #[test] + fn test_extract_task_id_from_health_request() { + let request = PoolRequest::Health { + task_id: "health-task-ghi".to_string(), + }; + + let task_id = PoolConnection::extract_task_id(&request); + assert_eq!(task_id, "health-task-ghi"); + } + + #[test] + fn test_extract_task_id_from_shutdown_request() { + let request = PoolRequest::Shutdown { + task_id: "shutdown-task-jkl".to_string(), + }; + + let task_id = PoolConnection::extract_task_id(&request); + assert_eq!(task_id, "shutdown-task-jkl"); + } + + #[test] + fn test_extract_task_id_preserves_special_characters() { + let request = PoolRequest::Stats { + task_id: "task-with-special_chars.and/slashes:colons".to_string(), + }; + + let task_id = PoolConnection::extract_task_id(&request); + assert_eq!(task_id, "task-with-special_chars.and/slashes:colons"); + } + + #[test] + fn test_extract_task_id_handles_empty_string() { + let request = PoolRequest::Health { + task_id: "".to_string(), + }; + + let task_id = PoolConnection::extract_task_id(&request); + assert_eq!(task_id, ""); + } + + #[test] + fn test_extract_task_id_handles_uuid_format() { + let uuid = "550e8400-e29b-41d4-a716-446655440000"; + let request = PoolRequest::Stats { + task_id: uuid.to_string(), + }; + + let task_id = PoolConnection::extract_task_id(&request); + assert_eq!(task_id, uuid); + } + + // ============================================ + // acquire_with_permit tests + // ============================================ + + #[tokio::test] + async fn test_acquire_without_server_fails() { + let pool = ConnectionPool::new("/tmp/nonexistent_socket_12345.sock".to_string(), 10); + + let result = pool.acquire().await; + assert!(result.is_err()); + + match result { + Err(PluginError::SocketError(msg)) => { + assert!(msg.contains("Failed to connect")); + } + _ => panic!("Expected SocketError"), + } + } + + #[tokio::test] + async fn test_acquire_with_pre_acquired_permit() { + let pool = ConnectionPool::new("/tmp/nonexistent_socket_67890.sock".to_string(), 10); + + // Pre-acquire a permit + let permit = pool.semaphore.clone().acquire_owned().await.unwrap(); + assert_eq!(pool.semaphore.available_permits(), 9); + + // Try to acquire with pre-acquired permit (will fail due to no server, but permit logic works) + let result = pool.acquire_with_permit(Some(permit)).await; + + // Connection fails but permit was used + assert!(result.is_err()); + } + + // ============================================ + // PooledConnection tests + // ============================================ + + #[test] + fn test_pooled_connection_cannot_be_used_after_release() { + // This tests the Option pattern - we can't easily + // test this without a live connection, but we document the behavior + // that send_request_with_timeout returns error when conn is None + } + + // ============================================ + // Error message tests + // ============================================ + + #[tokio::test] + async fn test_acquire_error_message_contains_helpful_info() { + let pool = ConnectionPool::new("/tmp/no_server_here_xyz.sock".to_string(), 10); + + let result = pool.acquire().await; + assert!(result.is_err()); + + if let Err(PluginError::SocketError(msg)) = result { + // Verify error message contains helpful suggestions + assert!( + msg.contains("PLUGIN_POOL_CONNECT_RETRIES") + || msg.contains("PLUGIN_POOL_MAX_CONNECTIONS") + || msg.contains("Failed to connect"), + "Error message should contain helpful info: {}", + msg + ); + } + } + + // ============================================ + // Multiple pool instances tests + // ============================================ + + #[test] + fn test_multiple_pools_independent() { + let pool1 = ConnectionPool::new("/tmp/pool1.sock".to_string(), 5); + let pool2 = ConnectionPool::new("/tmp/pool2.sock".to_string(), 10); + + // Each pool has its own semaphore + assert_eq!(pool1.semaphore.available_permits(), 5); + assert_eq!(pool2.semaphore.available_permits(), 10); + + // Each pool has its own connection ID counter + assert_eq!(pool1.next_connection_id(), 0); + assert_eq!(pool2.next_connection_id(), 0); + assert_eq!(pool1.next_connection_id(), 1); + assert_eq!(pool2.next_connection_id(), 1); + } + + // ============================================ + // Concurrent access tests + // ============================================ + + #[tokio::test] + async fn test_concurrent_semaphore_acquire() { + let pool = Arc::new(ConnectionPool::new("/tmp/concurrent.sock".to_string(), 3)); + + let mut handles = vec![]; + + // Spawn tasks that try to acquire permits + for i in 0..3 { + let pool_clone = pool.clone(); + handles.push(tokio::spawn(async move { + let permit = pool_clone.semaphore.clone().acquire_owned().await; + assert!(permit.is_ok(), "Task {} should acquire permit", i); + // Hold permit briefly + tokio::time::sleep(Duration::from_millis(10)).await; + })); + } + + // All tasks should complete successfully + for handle in handles { + handle.await.unwrap(); + } + + // All permits should be released + assert_eq!(pool.semaphore.available_permits(), 3); + } + + #[tokio::test] + async fn test_semaphore_fairness() { + use std::sync::atomic::AtomicU32; + + let pool = Arc::new(ConnectionPool::new("/tmp/fairness.sock".to_string(), 1)); + let counter = Arc::new(AtomicU32::new(0)); + + // Acquire the only permit + let permit = pool.semaphore.clone().acquire_owned().await.unwrap(); + + let mut handles = vec![]; + + // Spawn waiting tasks + for _ in 0..3 { + let pool_clone = pool.clone(); + let counter_clone = counter.clone(); + handles.push(tokio::spawn(async move { + let _permit = pool_clone.semaphore.clone().acquire_owned().await.unwrap(); + counter_clone.fetch_add(1, Ordering::SeqCst); + })); + } + + // Give tasks time to start waiting + tokio::time::sleep(Duration::from_millis(50)).await; + + // No task should have completed yet + assert_eq!(counter.load(Ordering::SeqCst), 0); + + // Release the permit + drop(permit); + + // Wait for all tasks + for handle in handles { + handle.await.unwrap(); + } + + // All tasks should have completed + assert_eq!(counter.load(Ordering::SeqCst), 3); + } + + // ============================================ + // Edge cases + // ============================================ + + #[test] + fn test_zero_max_connections_creates_closed_semaphore() { + let pool = ConnectionPool::new("/tmp/zero.sock".to_string(), 0); + assert_eq!(pool.semaphore.available_permits(), 0); + + // Can't acquire any permits + let permit = pool.semaphore.clone().try_acquire_owned(); + assert!(permit.is_err()); + } + + #[test] + fn test_socket_path_with_spaces() { + let path = "/tmp/path with spaces/test.sock"; + let pool = ConnectionPool::new(path.to_string(), 5); + assert_eq!(pool.socket_path, path); + } + + #[test] + fn test_socket_path_with_unicode() { + let path = "/tmp/тест/套接字.sock"; + let pool = ConnectionPool::new(path.to_string(), 5); + assert_eq!(pool.socket_path, path); + } + + #[test] + fn test_very_long_socket_path() { + let path = format!("/tmp/{}/test.sock", "a".repeat(200)); + let pool = ConnectionPool::new(path.clone(), 5); + assert_eq!(pool.socket_path, path); + } +} diff --git a/src/services/plugins/health.rs b/src/services/plugins/health.rs new file mode 100644 index 000000000..5b45ecb74 --- /dev/null +++ b/src/services/plugins/health.rs @@ -0,0 +1,657 @@ +//! Health monitoring and circuit breaker for the plugin pool. +//! +//! This module provides: +//! - Circuit breaker pattern for automatic degradation under stress +//! - Health status reporting for monitoring +//! - Dead server detection for automatic recovery + +use std::sync::atomic::{AtomicU32, AtomicU64, AtomicU8, AtomicUsize, Ordering}; +use std::sync::Arc; + +/// Lock-free ring buffer for tracking recent results (sliding window) +pub struct ResultRingBuffer { + buffer: Vec, // 0 = empty, 1 = success, 2 = failure + index: AtomicUsize, + size: usize, +} + +impl ResultRingBuffer { + pub fn new(size: usize) -> Self { + assert!(size > 0, "ResultRingBuffer size must be greater than 0"); + let mut buffer = Vec::with_capacity(size); + for _ in 0..size { + buffer.push(AtomicU8::new(0)); + } + Self { + buffer, + index: AtomicUsize::new(0), + size, + } + } + + pub fn record(&self, success: bool) { + let idx = self.index.fetch_add(1, Ordering::Relaxed) % self.size; + self.buffer[idx].store(if success { 1 } else { 2 }, Ordering::Relaxed); + } + + pub fn failure_rate(&self) -> f32 { + let mut total = 0; + let mut failures = 0; + + for slot in &self.buffer { + match slot.load(Ordering::Relaxed) { + 0 => {} // Empty slot + 1 => total += 1, // Success + 2 => { + total += 1; + failures += 1; + } + _ => {} + } + } + + if total < 10 { + return 0.0; // Not enough data to make decision + } + + (failures as f32) / (total as f32) + } +} + +/// Circuit breaker state for automatic degradation under stress +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CircuitState { + /// Normal operation - all requests allowed + Closed, + /// Degraded - some requests rejected to reduce load + HalfOpen, + /// Fully open - most requests rejected, recovery in progress + Open, +} + +/// Indicators that the pool server is dead or unreachable. +/// Using an enum instead of string matching for type safety and documentation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeadServerIndicator { + /// JSON parse error - connection closed mid-message + EofWhileParsing, + /// Write to closed socket + BrokenPipe, + /// Server not listening + ConnectionRefused, + /// Server forcefully closed connection + ConnectionReset, + /// Socket not connected + NotConnected, + /// Connection establishment failed + FailedToConnect, + /// Unix socket file deleted + SocketFileMissing, + /// Socket file doesn't exist + NoSuchFile, + /// Connection timeout (not execution timeout) + ConnectionTimedOut, +} + +impl DeadServerIndicator { + /// All patterns that indicate a dead server + const ALL: &'static [(&'static str, DeadServerIndicator)] = &[ + ("eof while parsing", DeadServerIndicator::EofWhileParsing), + ("broken pipe", DeadServerIndicator::BrokenPipe), + ("connection refused", DeadServerIndicator::ConnectionRefused), + ("connection reset", DeadServerIndicator::ConnectionReset), + ("not connected", DeadServerIndicator::NotConnected), + ("failed to connect", DeadServerIndicator::FailedToConnect), + ( + "socket file missing", + DeadServerIndicator::SocketFileMissing, + ), + ("no such file", DeadServerIndicator::NoSuchFile), + ( + "connection timed out", + DeadServerIndicator::ConnectionTimedOut, + ), + ("connect timed out", DeadServerIndicator::ConnectionTimedOut), + ]; + + /// Check if an error string matches any dead server indicator + pub fn from_error_str(error_str: &str) -> Option { + let lower = error_str.to_lowercase(); + Self::ALL + .iter() + .find(|(pattern, _)| lower.contains(pattern)) + .map(|(_, indicator)| *indicator) + } +} + +/// Process status for health check decisions +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProcessStatus { + /// Process is running normally + Running, + /// Process has exited + Exited, + /// Could not determine status + Unknown, + /// No process handle exists + NoProcess, +} + +/// Circuit breaker for managing pool health and automatic recovery. +/// Tracks failure rates and response times to detect GC pressure. +pub struct CircuitBreaker { + /// Current circuit state (encoded as u8 for atomic access) + /// 0 = Closed, 1 = HalfOpen, 2 = Open + state: AtomicU32, + /// Time when circuit opened (for recovery timing) + opened_at_ms: AtomicU64, + /// Time of last activity (for idle auto-close) + last_activity_ms: AtomicU64, + /// Consecutive successful requests in half-open state + recovery_successes: AtomicU32, + /// Average response time in ms (exponential moving average) + avg_response_time_ms: AtomicU32, + /// Number of restart attempts + restart_attempts: AtomicU32, + /// Sliding window of recent results (100 most recent) + recent_results: Arc, +} + +impl Default for CircuitBreaker { + fn default() -> Self { + Self::new() + } +} + +impl CircuitBreaker { + /// Idle timeout for auto-closing circuit (2 minutes of no activity) + const IDLE_AUTO_CLOSE_MS: u64 = 120_000; + + /// Initial backoff delay in milliseconds before attempting recovery. + /// First recovery attempt waits this long after circuit opens. + const INITIAL_BACKOFF_MS: u64 = 5000; + + /// Maximum number of backoff attempts before capping the delay. + /// Exponential backoff doubles each attempt: 5s, 10s, 20s, 40s, then capped. + const MAX_BACKOFF_ATTEMPTS: u32 = 4; + + /// Maximum backoff delay in milliseconds (hard cap). + /// Prevents excessive wait times between recovery attempts. + const MAX_BACKOFF_MS: u64 = 60_000; + + pub fn new() -> Self { + Self { + state: AtomicU32::new(0), // Closed + opened_at_ms: AtomicU64::new(0), + last_activity_ms: AtomicU64::new(0), + recovery_successes: AtomicU32::new(0), + avg_response_time_ms: AtomicU32::new(0), + restart_attempts: AtomicU32::new(0), + recent_results: Arc::new(ResultRingBuffer::new(100)), + } + } + + fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 + } + + fn update_last_activity(&self) { + self.last_activity_ms + .store(Self::now_ms(), Ordering::Relaxed); + } + + pub fn state(&self) -> CircuitState { + match self.state.load(Ordering::Relaxed) { + 0 => CircuitState::Closed, + 1 => CircuitState::HalfOpen, + _ => CircuitState::Open, + } + } + + pub fn set_state(&self, state: CircuitState) { + let val = match state { + CircuitState::Closed => 0, + CircuitState::HalfOpen => 1, + CircuitState::Open => 2, + }; + self.state.store(val, Ordering::Relaxed); + } + + /// Record a successful request with response time + pub fn record_success(&self, response_time_ms: u32) { + self.update_last_activity(); + self.recent_results.record(true); + + // Update exponential moving average (alpha = 0.1) + let current = self.avg_response_time_ms.load(Ordering::Relaxed); + let new_avg = if current == 0 { + response_time_ms + } else { + (current * 9 + response_time_ms) / 10 + }; + self.avg_response_time_ms.store(new_avg, Ordering::Relaxed); + + // Handle state transitions on success + match self.state() { + CircuitState::HalfOpen => { + let successes = self.recovery_successes.fetch_add(1, Ordering::Relaxed) + 1; + // Require 10 consecutive successes to close circuit + if successes >= 10 { + tracing::info!("Circuit breaker closing - recovery successful"); + self.set_state(CircuitState::Closed); + self.recovery_successes.store(0, Ordering::Relaxed); + self.restart_attempts.store(0, Ordering::Relaxed); + } + } + CircuitState::Open => { + // Check if enough time has passed to try half-open + self.maybe_transition_to_half_open(); + } + CircuitState::Closed => {} + } + } + + /// Record a failed request + pub fn record_failure(&self) { + self.update_last_activity(); + self.recent_results.record(false); + self.recovery_successes.store(0, Ordering::Relaxed); + + let failure_rate = self.recent_results.failure_rate(); + + match self.state() { + CircuitState::Closed => { + // Open circuit if failure rate > 50% (ring buffer requires at least 10 samples) + if failure_rate > 0.5 { + tracing::warn!( + failure_rate = %format!("{:.1}%", failure_rate * 100.0), + "Circuit breaker opening - high failure rate" + ); + self.open_circuit(); + } + } + CircuitState::HalfOpen => { + // Any failure in half-open sends back to open + tracing::warn!("Circuit breaker reopening - failure during recovery"); + self.open_circuit(); + } + CircuitState::Open => { + // Already open, just check for transition + self.maybe_transition_to_half_open(); + } + } + } + + fn open_circuit(&self) { + self.set_state(CircuitState::Open); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + self.opened_at_ms.store(now, Ordering::Relaxed); + self.recovery_successes.store(0, Ordering::Relaxed); + } + + fn maybe_transition_to_half_open(&self) { + let opened_at = self.opened_at_ms.load(Ordering::Relaxed); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + // Exponential backoff: INITIAL_BACKOFF_MS, 2x, 4x, 8x, 16x, capped at MAX_BACKOFF_MS + // This prevents rapid restart attempts that could worsen the situation + let attempts = self.restart_attempts.load(Ordering::Relaxed); + let backoff_ms = (Self::INITIAL_BACKOFF_MS + * (1 << attempts.min(Self::MAX_BACKOFF_ATTEMPTS))) + .min(Self::MAX_BACKOFF_MS); + + if now - opened_at >= backoff_ms { + tracing::info!( + backoff_ms = backoff_ms, + "Circuit breaker transitioning to half-open" + ); + self.set_state(CircuitState::HalfOpen); + self.restart_attempts.fetch_add(1, Ordering::Relaxed); + } + } + + /// Check if request should be allowed based on circuit state. + /// If recovery_allowance is provided, use it in HalfOpen state instead of default 10%. + /// Auto-closes circuit if idle for longer than IDLE_AUTO_CLOSE_MS. + pub fn should_allow_request(&self, recovery_allowance: Option) -> bool { + // Auto-close if idle for too long (no failures means system likely recovered) + if !matches!(self.state(), CircuitState::Closed) { + let last_activity = self.last_activity_ms.load(Ordering::Relaxed); + let now = Self::now_ms(); + if last_activity > 0 && now - last_activity >= Self::IDLE_AUTO_CLOSE_MS { + tracing::info!( + idle_ms = now - last_activity, + "Circuit breaker auto-closing after idle period" + ); + self.force_close(); + return true; + } + } + + match self.state() { + CircuitState::Closed => true, + CircuitState::HalfOpen => { + // Use recovery allowance if provided, otherwise default to 10% + let allowance = recovery_allowance.unwrap_or(10); + (rand::random::() % 100) < allowance + } + CircuitState::Open => { + // Check if we should transition to half-open + self.maybe_transition_to_half_open(); + // Recheck state after potential transition + matches!(self.state(), CircuitState::HalfOpen) + } + } + } + + /// Get current response time average for monitoring + pub fn avg_response_time(&self) -> u32 { + self.avg_response_time_ms.load(Ordering::Relaxed) + } + + /// Force circuit to closed state (for manual recovery) + pub fn force_close(&self) { + self.set_state(CircuitState::Closed); + self.recovery_successes.store(0, Ordering::Relaxed); + self.restart_attempts.store(0, Ordering::Relaxed); + } + + /// Access recovery_successes for testing + #[cfg(test)] + pub fn recovery_successes(&self) -> &AtomicU32 { + &self.recovery_successes + } +} + +/// Health status information from the pool server +#[derive(Debug, Clone)] +pub struct HealthStatus { + pub healthy: bool, + pub status: String, + pub uptime_ms: Option, + pub memory: Option, + pub pool_completed: Option, + pub pool_queued: Option, + pub success_rate: Option, + /// Circuit breaker state (Closed/HalfOpen/Open) + pub circuit_state: Option, + /// Average response time in ms + pub avg_response_time_ms: Option, + /// Whether recovery mode is active + pub recovering: Option, + /// Current recovery allowance percentage + pub recovery_percent: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + // ========================================================================= + // DeadServerIndicator Tests + // ========================================================================= + + #[test] + fn test_dead_server_indicator_eof_while_parsing() { + let result = DeadServerIndicator::from_error_str("eof while parsing a value"); + assert_eq!(result, Some(DeadServerIndicator::EofWhileParsing)); + } + + #[test] + fn test_dead_server_indicator_broken_pipe() { + let result = DeadServerIndicator::from_error_str("write failed: Broken pipe"); + assert_eq!(result, Some(DeadServerIndicator::BrokenPipe)); + } + + #[test] + fn test_dead_server_indicator_connection_refused() { + let result = DeadServerIndicator::from_error_str("Connection refused (os error 61)"); + assert_eq!(result, Some(DeadServerIndicator::ConnectionRefused)); + } + + #[test] + fn test_dead_server_indicator_connection_reset() { + let result = DeadServerIndicator::from_error_str("Connection reset by peer"); + assert_eq!(result, Some(DeadServerIndicator::ConnectionReset)); + } + + #[test] + fn test_dead_server_indicator_not_connected() { + let result = DeadServerIndicator::from_error_str("Socket is not connected"); + assert_eq!(result, Some(DeadServerIndicator::NotConnected)); + } + + #[test] + fn test_dead_server_indicator_failed_to_connect() { + let result = DeadServerIndicator::from_error_str("Failed to connect to server"); + assert_eq!(result, Some(DeadServerIndicator::FailedToConnect)); + } + + #[test] + fn test_dead_server_indicator_socket_file_missing() { + let result = DeadServerIndicator::from_error_str("Socket file missing at /tmp/pool.sock"); + assert_eq!(result, Some(DeadServerIndicator::SocketFileMissing)); + } + + #[test] + fn test_dead_server_indicator_no_such_file() { + let result = DeadServerIndicator::from_error_str("No such file or directory"); + assert_eq!(result, Some(DeadServerIndicator::NoSuchFile)); + } + + #[test] + fn test_dead_server_indicator_connection_timed_out() { + let result = DeadServerIndicator::from_error_str("Connection timed out after 5s"); + assert_eq!(result, Some(DeadServerIndicator::ConnectionTimedOut)); + } + + #[test] + fn test_dead_server_indicator_connect_timed_out() { + let result = DeadServerIndicator::from_error_str("Connect timed out"); + assert_eq!(result, Some(DeadServerIndicator::ConnectionTimedOut)); + } + + #[test] + fn test_dead_server_indicator_case_insensitive() { + let result = DeadServerIndicator::from_error_str("BROKEN PIPE ERROR"); + assert_eq!(result, Some(DeadServerIndicator::BrokenPipe)); + + let result = DeadServerIndicator::from_error_str("EOF While Parsing"); + assert_eq!(result, Some(DeadServerIndicator::EofWhileParsing)); + } + + #[test] + fn test_dead_server_indicator_no_match() { + let result = DeadServerIndicator::from_error_str("Plugin execution failed"); + assert_eq!(result, None); + + let result = DeadServerIndicator::from_error_str("Timeout exceeded"); + assert_eq!(result, None); + + let result = DeadServerIndicator::from_error_str(""); + assert_eq!(result, None); + } + + // ========================================================================= + // ResultRingBuffer Tests + // ========================================================================= + + #[test] + fn test_result_ring_buffer_empty() { + let buffer = ResultRingBuffer::new(100); + assert_eq!(buffer.failure_rate(), 0.0); + } + + #[test] + fn test_result_ring_buffer_all_successes() { + let buffer = ResultRingBuffer::new(100); + for _ in 0..50 { + buffer.record(true); + } + assert_eq!(buffer.failure_rate(), 0.0); + } + + #[test] + fn test_result_ring_buffer_all_failures() { + let buffer = ResultRingBuffer::new(100); + for _ in 0..50 { + buffer.record(false); + } + assert_eq!(buffer.failure_rate(), 1.0); + } + + #[test] + fn test_result_ring_buffer_mixed_results() { + let buffer = ResultRingBuffer::new(100); + for _ in 0..25 { + buffer.record(true); + } + for _ in 0..25 { + buffer.record(false); + } + assert!((buffer.failure_rate() - 0.5).abs() < 0.01); + } + + #[test] + fn test_result_ring_buffer_wraps_around() { + let buffer = ResultRingBuffer::new(100); + for _ in 0..100 { + buffer.record(true); + } + for _ in 0..50 { + buffer.record(false); + } + assert!((buffer.failure_rate() - 0.5).abs() < 0.01); + } + + // ========================================================================= + // CircuitBreaker Tests + // ========================================================================= + + #[test] + fn test_circuit_breaker_initial_state() { + let cb = CircuitBreaker::new(); + assert_eq!(cb.state(), CircuitState::Closed); + assert!(cb.should_allow_request(None)); + } + + #[test] + fn test_circuit_breaker_stays_closed_on_successes() { + let cb = CircuitBreaker::new(); + for _ in 0..100 { + cb.record_success(50); + } + assert_eq!(cb.state(), CircuitState::Closed); + assert!(cb.should_allow_request(None)); + } + + #[test] + fn test_circuit_breaker_opens_on_high_failure_rate() { + let cb = CircuitBreaker::new(); + for _ in 0..100 { + cb.record_failure(); + } + assert_eq!(cb.state(), CircuitState::Open); + } + + #[test] + fn test_circuit_breaker_half_open_allows_some_requests() { + let cb = CircuitBreaker::new(); + cb.set_state(CircuitState::HalfOpen); + + let mut allowed = 0; + for _ in 0..100 { + if cb.should_allow_request(Some(10)) { + allowed += 1; + } + } + assert!(allowed > 0, "Half-open should allow some requests"); + assert!(allowed < 50, "Half-open should not allow too many requests"); + } + + #[test] + fn test_circuit_breaker_force_close() { + let cb = CircuitBreaker::new(); + cb.set_state(CircuitState::Open); + assert_eq!(cb.state(), CircuitState::Open); + + cb.force_close(); + assert_eq!(cb.state(), CircuitState::Closed); + } + + #[test] + fn test_circuit_breaker_half_open_to_closed_on_successes() { + let cb = CircuitBreaker::new(); + cb.set_state(CircuitState::HalfOpen); + cb.recovery_successes().store(0, Ordering::Relaxed); + + for _ in 0..10 { + cb.record_success(50); + } + + assert_eq!(cb.state(), CircuitState::Closed); + } + + #[test] + fn test_circuit_breaker_half_open_to_open_on_failure() { + let cb = CircuitBreaker::new(); + cb.set_state(CircuitState::HalfOpen); + cb.recovery_successes().store(5, Ordering::Relaxed); + + cb.record_failure(); + + assert_eq!(cb.state(), CircuitState::Open); + } + + #[test] + fn test_circuit_breaker_response_time_tracking() { + let cb = CircuitBreaker::new(); + + cb.record_success(100); + cb.record_success(200); + cb.record_success(150); + + let avg = cb.avg_response_time(); + assert!(avg > 0, "Average should be positive"); + assert!(avg < 300, "Average should be reasonable"); + } + + // ========================================================================= + // CircuitState Tests + // ========================================================================= + + #[test] + fn test_circuit_state_debug() { + assert_eq!(format!("{:?}", CircuitState::Closed), "Closed"); + assert_eq!(format!("{:?}", CircuitState::HalfOpen), "HalfOpen"); + assert_eq!(format!("{:?}", CircuitState::Open), "Open"); + } + + #[test] + fn test_circuit_state_equality() { + assert_eq!(CircuitState::Closed, CircuitState::Closed); + assert_ne!(CircuitState::Closed, CircuitState::Open); + assert_ne!(CircuitState::HalfOpen, CircuitState::Open); + } + + // ========================================================================= + // ProcessStatus Tests + // ========================================================================= + + #[test] + fn test_process_status_variants() { + assert_eq!(ProcessStatus::Running, ProcessStatus::Running); + assert_eq!(ProcessStatus::Exited, ProcessStatus::Exited); + assert_eq!(ProcessStatus::Unknown, ProcessStatus::Unknown); + assert_ne!(ProcessStatus::Running, ProcessStatus::Exited); + } +} diff --git a/src/services/plugins/mod.rs b/src/services/plugins/mod.rs index 5b161de00..0353095b7 100644 --- a/src/services/plugins/mod.rs +++ b/src/services/plugins/mod.rs @@ -20,6 +20,18 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use uuid::Uuid; +pub mod config; +pub use config::*; + +pub mod health; +pub use health::*; + +pub mod protocol; +pub use protocol::*; + +pub mod connection; +pub use connection::*; + pub mod runner; pub use runner::*; @@ -29,8 +41,11 @@ pub use relayer_api::*; pub mod script_executor; pub use script_executor::*; -pub mod socket; -pub use socket::*; +pub mod pool_executor; +pub use pool_executor::*; + +pub mod shared_socket; +pub use shared_socket::*; #[cfg(test)] use mockall::automock; @@ -216,7 +231,7 @@ impl PluginService { Self { runner } } - fn resolve_plugin_path(plugin_path: &str) -> String { + pub fn resolve_plugin_path(plugin_path: &str) -> String { if plugin_path.starts_with("plugins/") { plugin_path.to_string() } else { @@ -277,6 +292,7 @@ impl PluginService { config_json, method, query_json, + plugin.emit_traces, state, ) .await; @@ -461,7 +477,7 @@ mod tests { plugin_runner .expect_run::() - .returning(|_, _, _, _, _, _, _, _, _, _, _, _| { + .returning(|_, _, _, _, _, _, _, _, _, _, _, _, _| { Ok(ScriptResult { logs: vec![LogEntry { level: LogLevel::Log, @@ -767,7 +783,7 @@ mod tests { plugin_runner .expect_run::() - .returning(move |_, _, _, _, _, _, _, _, _, _, _, _| { + .returning(move |_, _, _, _, _, _, _, _, _, _, _, _, _| { Err(PluginError::HandlerError(Box::new(PluginHandlerPayload { status: 400, message: "Plugin handler error".to_string(), @@ -829,7 +845,7 @@ mod tests { plugin_runner .expect_run::() - .returning(|_, _, _, _, _, _, _, _, _, _, _, _| { + .returning(|_, _, _, _, _, _, _, _, _, _, _, _, _| { Err(PluginError::PluginExecutionError("Fatal error".to_string())) }); @@ -876,7 +892,7 @@ mod tests { plugin_runner .expect_run::() - .returning(|_, _, _, _, _, _, _, _, _, _, _, _| { + .returning(|_, _, _, _, _, _, _, _, _, _, _, _, _| { Ok(ScriptResult { logs: vec![LogEntry { level: LogLevel::Log, @@ -973,7 +989,7 @@ mod tests { plugin_runner .expect_run::() - .returning(|_, _, _, _, _, _, _, _, _, _, _, _| { + .returning(|_, _, _, _, _, _, _, _, _, _, _, _, _| { Ok(ScriptResult { logs: vec![ LogEntry { @@ -1059,7 +1075,7 @@ mod tests { let mut plugin_runner = MockPluginRunnerTrait::default(); plugin_runner .expect_run::() - .returning(|_, _, _, _, _, _, _, _, _, _, _, _| { + .returning(|_, _, _, _, _, _, _, _, _, _, _, _, _| { Ok(ScriptResult { logs: vec![LogEntry { level: LogLevel::Warn, @@ -1087,9 +1103,12 @@ mod tests { .await; let captured = logs_buffer.lock().unwrap().join("\n"); + // When forward_logs is disabled, plugin log messages should not appear in tracing output + // (internal framework logs like "Calling plugin" may still appear) assert!( - captured.is_empty(), - "logs should not be forwarded when disabled" + !captured.contains("should-not-emit"), + "plugin logs should not be forwarded when disabled, but found: {}", + captured ); } @@ -1117,7 +1136,7 @@ mod tests { let mut plugin_runner = MockPluginRunnerTrait::default(); plugin_runner .expect_run::() - .returning(|_, _, _, _, _, _, _, _, _, _, _, _| { + .returning(|_, _, _, _, _, _, _, _, _, _, _, _, _| { Err(PluginError::HandlerError(Box::new(PluginHandlerPayload { status: 400, message: "handler failed".to_string(), @@ -1172,7 +1191,7 @@ mod tests { plugin_runner .expect_run::() - .returning(|_, _, _, _, _, _, _, _, _, _, _, _| { + .returning(|_, _, _, _, _, _, _, _, _, _, _, _, _| { Ok(ScriptResult { logs: vec![], error: "".to_string(), @@ -1233,7 +1252,7 @@ mod tests { plugin_runner .expect_run::() - .returning(move |_, _, _, _, _, _, headers_json, _, _, _, _, _| { + .returning(move |_, _, _, _, _, _, headers_json, _, _, _, _, _, _| { // Capture the headers_json parameter *captured_headers_clone.lock().unwrap() = headers_json; Ok(ScriptResult { @@ -1316,7 +1335,7 @@ mod tests { plugin_runner .expect_run::() - .returning(move |_, _, _, _, _, _, headers_json, _, _, _, _, _| { + .returning(move |_, _, _, _, _, _, headers_json, _, _, _, _, _, _| { *captured_headers_clone.lock().unwrap() = headers_json; Ok(ScriptResult { logs: vec![], diff --git a/src/services/plugins/pool_executor.rs b/src/services/plugins/pool_executor.rs new file mode 100644 index 000000000..ffb5ff33e --- /dev/null +++ b/src/services/plugins/pool_executor.rs @@ -0,0 +1,2496 @@ +//! Pool-based Plugin Executor +//! +//! This module provides execution of pre-compiled JavaScript plugins via +//! a persistent Piscina worker pool, replacing the per-request ts-node approach. +//! +//! Communication with the Node.js pool server happens via Unix socket using +//! a JSON-line protocol. + +use std::collections::HashMap; +use std::process::Stdio; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::{Child, Command}; +use tokio::sync::{oneshot, RwLock}; +use uuid::Uuid; + +use super::config::get_config; +use super::connection::{ConnectionPool, PoolConnection}; +use super::health::{ + CircuitBreaker, CircuitState, DeadServerIndicator, HealthStatus, ProcessStatus, +}; +use super::protocol::{PoolError, PoolRequest, PoolResponse}; +use super::{LogEntry, PluginError, PluginHandlerPayload, ScriptResult}; + +/// Request queue entry for throttling +struct QueuedRequest { + plugin_id: String, + compiled_code: Option, + plugin_path: Option, + params: serde_json::Value, + headers: Option>>, + socket_path: String, + http_request_id: Option, + timeout_secs: Option, + route: Option, + config: Option, + method: Option, + query: Option, + response_tx: oneshot::Sender>, +} + +/// Parsed health check result fields extracted from pool server JSON response. +/// +/// This struct replaces a complex tuple return type to satisfy Clippy's +/// `type_complexity` lint and improve readability. +#[derive(Debug, Default, PartialEq)] +pub struct ParsedHealthResult { + pub status: String, + pub uptime_ms: Option, + pub memory: Option, + pub pool_completed: Option, + pub pool_queued: Option, + pub success_rate: Option, +} + +/// Manages the pool server process and connections +pub struct PoolManager { + socket_path: String, + process: tokio::sync::Mutex>, + initialized: RwLock, + /// Lock to prevent concurrent restarts (thundering herd) + restart_lock: tokio::sync::Mutex<()>, + /// Connection pool for reusing connections + connection_pool: Arc, + /// Request queue for throttling/backpressure (multi-consumer channel) + request_tx: async_channel::Sender, + /// Actual configured queue size (for error messages) + max_queue_size: usize, + /// Flag indicating if health check is needed (set by background task) + health_check_needed: Arc, + /// Consecutive failure count for health checks + consecutive_failures: Arc, + /// Circuit breaker for automatic degradation under GC pressure + circuit_breaker: Arc, + /// Last successful restart time (for backoff calculation) + last_restart_time_ms: Arc, + /// Is currently in recovery mode (gradual ramp-up) + recovery_mode: Arc, + /// Requests allowed during recovery (gradual increase) + recovery_allowance: Arc, + /// Shutdown signal for background tasks (queue workers, health check, etc.) + shutdown_signal: Arc, +} + +impl Default for PoolManager { + fn default() -> Self { + Self::new() + } +} + +impl PoolManager { + /// Base heap size in MB for the pool server process. + /// This provides the minimum memory needed for the Node.js runtime and core pool infrastructure. + const BASE_HEAP_MB: usize = 512; + + /// Concurrency divisor for heap calculation. + /// Heap is incremented for every N concurrent requests to scale with load. + const CONCURRENCY_DIVISOR: usize = 10; + + /// Heap increment in MB per CONCURRENCY_DIVISOR concurrent requests. + /// Formula: BASE_HEAP_MB + ((max_concurrency / CONCURRENCY_DIVISOR) * HEAP_INCREMENT_PER_DIVISOR_MB) + /// This accounts for additional memory needed per concurrent plugin execution context. + const HEAP_INCREMENT_PER_DIVISOR_MB: usize = 32; + + /// Maximum heap size in MB (hard cap) for the pool server process. + /// Prevents excessive memory allocation that could cause system instability. + /// Set to 8GB (8192 MB) as a reasonable upper bound for Node.js processes. + const MAX_HEAP_MB: usize = 8192; + + /// Calculate heap size based on concurrency level. + /// + /// Formula: BASE_HEAP_MB + ((max_concurrency / CONCURRENCY_DIVISOR) * HEAP_INCREMENT_PER_DIVISOR_MB) + /// Result is capped at MAX_HEAP_MB. + /// + /// This scales memory allocation with expected load while maintaining a reasonable minimum. + pub fn calculate_heap_size(max_concurrency: usize) -> usize { + let calculated = Self::BASE_HEAP_MB + + ((max_concurrency / Self::CONCURRENCY_DIVISOR) * Self::HEAP_INCREMENT_PER_DIVISOR_MB); + calculated.min(Self::MAX_HEAP_MB) + } + + /// Format a result value from the pool response into a string. + /// + /// If the value is already a string, returns it directly. + /// Otherwise, serializes it to JSON. + pub fn format_return_value(value: Option) -> String { + value + .map(|v| { + if v.is_string() { + v.as_str().unwrap_or("").to_string() + } else { + serde_json::to_string(&v).unwrap_or_default() + } + }) + .unwrap_or_default() + } + + /// Parse a successful pool response into a ScriptResult. + /// + /// Converts logs from PoolLogEntry to LogEntry and extracts the return value. + pub fn parse_success_response(response: PoolResponse) -> ScriptResult { + let logs: Vec = response + .logs + .map(|logs| logs.into_iter().map(|l| l.into()).collect()) + .unwrap_or_default(); + + ScriptResult { + logs, + error: String::new(), + return_value: Self::format_return_value(response.result), + trace: Vec::new(), + } + } + + /// Parse a failed pool response into a PluginError. + /// + /// Extracts error details and converts logs for inclusion in the error payload. + pub fn parse_error_response(response: PoolResponse) -> PluginError { + let logs: Vec = response + .logs + .map(|logs| logs.into_iter().map(|l| l.into()).collect()) + .unwrap_or_default(); + + let error = response.error.unwrap_or(PoolError { + message: "Unknown error".to_string(), + code: None, + status: None, + details: None, + }); + + PluginError::HandlerError(Box::new(PluginHandlerPayload { + message: error.message, + status: error.status.unwrap_or(500), + code: error.code, + details: error.details, + logs: Some(logs), + traces: None, + })) + } + + /// Parse a pool response into either a success result or an error. + /// + /// This is the main entry point for response parsing, dispatching to + /// either parse_success_response or parse_error_response based on the success flag. + pub fn parse_pool_response(response: PoolResponse) -> Result { + if response.success { + Ok(Self::parse_success_response(response)) + } else { + Err(Self::parse_error_response(response)) + } + } + + /// Parse health check result JSON into individual fields. + /// + /// Extracts status, uptime, memory usage, pool stats, and success rate + /// from the nested JSON structure returned by the pool server. + pub fn parse_health_result(result: &serde_json::Value) -> ParsedHealthResult { + ParsedHealthResult { + status: result + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(), + uptime_ms: result.get("uptime").and_then(|v| v.as_u64()), + memory: result + .get("memory") + .and_then(|v| v.get("heapUsed")) + .and_then(|v| v.as_u64()), + pool_completed: result + .get("pool") + .and_then(|v| v.get("completed")) + .and_then(|v| v.as_u64()), + pool_queued: result + .get("pool") + .and_then(|v| v.get("queued")) + .and_then(|v| v.as_u64()), + success_rate: result + .get("execution") + .and_then(|v| v.get("successRate")) + .and_then(|v| v.as_f64()), + } + } + + /// Create a new PoolManager with default socket path + pub fn new() -> Self { + Self::init(format!("/tmp/relayer-plugin-pool-{}.sock", Uuid::new_v4())) + } + + /// Create a new PoolManager with custom socket path + pub fn with_socket_path(socket_path: String) -> Self { + Self::init(socket_path) + } + + /// Common initialization logic + fn init(socket_path: String) -> Self { + let config = get_config(); + let max_connections = config.pool_max_connections; + let max_queue_size = config.pool_max_queue_size; + + let (tx, rx) = async_channel::bounded(max_queue_size); + + let connection_pool = Arc::new(ConnectionPool::new(socket_path.clone(), max_connections)); + let connection_pool_clone = connection_pool.clone(); + + let shutdown_signal = Arc::new(tokio::sync::Notify::new()); + + Self::spawn_queue_workers( + rx, + connection_pool_clone, + config.pool_workers, + shutdown_signal.clone(), + ); + + let health_check_needed = Arc::new(AtomicBool::new(false)); + let consecutive_failures = Arc::new(AtomicU32::new(0)); + let circuit_breaker = Arc::new(CircuitBreaker::new()); + let last_restart_time_ms = Arc::new(AtomicU64::new(0)); + let recovery_mode = Arc::new(AtomicBool::new(false)); + let recovery_allowance = Arc::new(AtomicU32::new(0)); + + Self::spawn_health_check_task( + health_check_needed.clone(), + config.health_check_interval_secs, + shutdown_signal.clone(), + ); + + Self::spawn_recovery_task( + recovery_mode.clone(), + recovery_allowance.clone(), + shutdown_signal.clone(), + ); + + Self { + connection_pool, + socket_path, + process: tokio::sync::Mutex::new(None), + initialized: RwLock::new(false), + restart_lock: tokio::sync::Mutex::new(()), + request_tx: tx, + max_queue_size, + health_check_needed, + consecutive_failures, + circuit_breaker, + last_restart_time_ms, + recovery_mode, + recovery_allowance, + shutdown_signal, + } + } + + /// Spawn background task to gradually increase recovery allowance + fn spawn_recovery_task( + recovery_mode: Arc, + recovery_allowance: Arc, + shutdown_signal: Arc, + ) { + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_millis(500)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + tokio::select! { + biased; + + _ = shutdown_signal.notified() => { + tracing::debug!("Recovery task received shutdown signal"); + break; + } + + _ = interval.tick() => { + if recovery_mode.load(Ordering::Relaxed) { + let current = recovery_allowance.load(Ordering::Relaxed); + if current < 100 { + let new_allowance = (current + 10).min(100); + recovery_allowance.store(new_allowance, Ordering::Relaxed); + tracing::debug!( + allowance = new_allowance, + "Recovery mode: increasing request allowance" + ); + } else { + recovery_mode.store(false, Ordering::Relaxed); + tracing::info!("Recovery mode complete - full capacity restored"); + } + } + } + } + } + }); + } + + /// Spawn background task to set health check flag periodically + fn spawn_health_check_task( + health_check_needed: Arc, + interval_secs: u64, + shutdown_signal: Arc, + ) { + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(interval_secs)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + tokio::select! { + biased; + + _ = shutdown_signal.notified() => { + tracing::debug!("Health check task received shutdown signal"); + break; + } + + _ = interval.tick() => { + health_check_needed.store(true, Ordering::Relaxed); + } + } + } + }); + } + + /// Spawn multiple worker tasks to process queued requests concurrently + fn spawn_queue_workers( + rx: async_channel::Receiver, + connection_pool: Arc, + configured_workers: usize, + shutdown_signal: Arc, + ) { + let num_workers = if configured_workers > 0 { + configured_workers + } else { + std::thread::available_parallelism() + .map(|n| n.get().clamp(4, 32)) + .unwrap_or(8) + }; + + tracing::info!(num_workers = num_workers, "Starting request queue workers"); + + for worker_id in 0..num_workers { + let rx_clone = rx.clone(); + let pool_clone = connection_pool.clone(); + let shutdown = shutdown_signal.clone(); + + tokio::spawn(async move { + loop { + tokio::select! { + biased; + + _ = shutdown.notified() => { + tracing::debug!(worker_id = worker_id, "Request queue worker received shutdown signal"); + break; + } + + request_result = rx_clone.recv() => { + let request = match request_result { + Ok(r) => r, + Err(_) => break, + }; + + let start = std::time::Instant::now(); + let plugin_id = request.plugin_id.clone(); + + let result = Self::execute_plugin_internal( + &pool_clone, + request.plugin_id, + request.compiled_code, + request.plugin_path, + request.params, + request.headers, + request.socket_path, + request.http_request_id, + request.timeout_secs, + request.route, + request.config, + request.method, + request.query, + ) + .await; + + let elapsed = start.elapsed(); + if let Err(ref e) = result { + let error_str = format!("{e:?}"); + if error_str.contains("shutdown") || error_str.contains("Shutdown") { + tracing::debug!( + worker_id = worker_id, + plugin_id = %plugin_id, + "Plugin execution cancelled during shutdown" + ); + } else { + tracing::warn!( + worker_id = worker_id, + plugin_id = %plugin_id, + elapsed_ms = elapsed.as_millis() as u64, + error = ?e, + "Plugin execution failed" + ); + } + } else if elapsed.as_secs() > 1 { + tracing::debug!( + worker_id = worker_id, + plugin_id = %plugin_id, + elapsed_ms = elapsed.as_millis() as u64, + "Slow plugin execution" + ); + } + + let _ = request.response_tx.send(result); + } + } + } + + tracing::debug!(worker_id = worker_id, "Request queue worker exited"); + }); + } + } + + /// Spawn a rate-limited stderr reader to prevent log flooding + fn spawn_rate_limited_stderr_reader(stderr: tokio::process::ChildStderr) { + tokio::spawn(async move { + let reader = BufReader::new(stderr); + let mut lines = reader.lines(); + + let mut last_log_time = std::time::Instant::now(); + let mut suppressed_count = 0u64; + let min_interval = Duration::from_millis(100); + + while let Ok(Some(line)) = lines.next_line().await { + let now = std::time::Instant::now(); + let elapsed = now.duration_since(last_log_time); + + if elapsed >= min_interval { + if suppressed_count > 0 { + tracing::warn!( + target: "pool_server", + suppressed = suppressed_count, + "... ({} lines suppressed due to rate limiting)", + suppressed_count + ); + suppressed_count = 0; + } + tracing::error!(target: "pool_server", "{}", line); + last_log_time = now; + } else { + suppressed_count += 1; + if suppressed_count % 100 == 0 { + tracing::warn!( + target: "pool_server", + suppressed = suppressed_count, + "Pool server producing excessive stderr output" + ); + } + } + } + + if suppressed_count > 0 { + tracing::warn!( + target: "pool_server", + suppressed = suppressed_count, + "Pool server stderr closed ({} final lines suppressed)", + suppressed_count + ); + } + }); + } + + /// Execute plugin with optional pre-acquired permit (unified fast/slow path) + #[allow(clippy::too_many_arguments)] + async fn execute_with_permit( + connection_pool: &Arc, + permit: Option, + plugin_id: String, + compiled_code: Option, + plugin_path: Option, + params: serde_json::Value, + headers: Option>>, + socket_path: String, + http_request_id: Option, + timeout_secs: Option, + route: Option, + config: Option, + method: Option, + query: Option, + ) -> Result { + let mut conn = connection_pool.acquire_with_permit(permit).await?; + + let request = PoolRequest::Execute(Box::new(super::protocol::ExecuteRequest { + task_id: Uuid::new_v4().to_string(), + plugin_id: plugin_id.clone(), + compiled_code, + plugin_path, + params, + headers, + socket_path, + http_request_id, + timeout: timeout_secs.map(|s| s * 1000), + route, + config, + method, + query, + })); + + let timeout = timeout_secs.unwrap_or(get_config().pool_request_timeout_secs); + let response = conn.send_request_with_timeout(&request, timeout).await?; + + // Use extracted parsing function for cleaner code and testability + Self::parse_pool_response(response) + } + + /// Internal execution method (wrapper for execute_with_permit) + #[allow(clippy::too_many_arguments)] + async fn execute_plugin_internal( + connection_pool: &Arc, + plugin_id: String, + compiled_code: Option, + plugin_path: Option, + params: serde_json::Value, + headers: Option>>, + socket_path: String, + http_request_id: Option, + timeout_secs: Option, + route: Option, + config: Option, + method: Option, + query: Option, + ) -> Result { + Self::execute_with_permit( + connection_pool, + None, + plugin_id, + compiled_code, + plugin_path, + params, + headers, + socket_path, + http_request_id, + timeout_secs, + route, + config, + method, + query, + ) + .await + } + + /// Start the pool server if not already running + pub async fn ensure_started(&self) -> Result<(), PluginError> { + if *self.initialized.read().await { + return Ok(()); + } + + let _startup_guard = self.restart_lock.lock().await; + + if *self.initialized.read().await { + return Ok(()); + } + + let mut initialized = self.initialized.write().await; + if *initialized { + return Ok(()); + } + + self.start_pool_server().await?; + *initialized = true; + Ok(()) + } + + /// Ensure pool is started and healthy, with auto-recovery on failure + async fn ensure_started_and_healthy(&self) -> Result<(), PluginError> { + self.ensure_started().await?; + + if !self.health_check_needed.load(Ordering::Relaxed) { + return Ok(()); + } + + if self + .health_check_needed + .compare_exchange(true, false, Ordering::Relaxed, Ordering::Relaxed) + .is_err() + { + return Ok(()); + } + + self.check_and_restart_if_needed().await + } + + /// Check process status and restart if needed + async fn check_and_restart_if_needed(&self) -> Result<(), PluginError> { + let _restart_guard = self.restart_lock.lock().await; + + let process_status = { + let mut process_guard = self.process.lock().await; + if let Some(child) = process_guard.as_mut() { + match child.try_wait() { + Ok(Some(exit_status)) => { + tracing::warn!( + exit_status = ?exit_status, + "Pool server process has exited" + ); + *process_guard = None; + ProcessStatus::Exited + } + Ok(None) => ProcessStatus::Running, + Err(e) => { + tracing::warn!( + error = %e, + "Failed to check pool server process status, assuming dead" + ); + *process_guard = None; + ProcessStatus::Unknown + } + } + } else { + ProcessStatus::NoProcess + } + }; + + match process_status { + ProcessStatus::Running => { + let socket_exists = std::path::Path::new(&self.socket_path).exists(); + if !socket_exists { + tracing::warn!( + socket_path = %self.socket_path, + "Pool server socket file missing, restarting" + ); + self.consecutive_failures.fetch_add(1, Ordering::Relaxed); + self.restart_internal().await?; + self.consecutive_failures.store(0, Ordering::Relaxed); + } + } + ProcessStatus::Exited | ProcessStatus::Unknown | ProcessStatus::NoProcess => { + tracing::warn!("Pool server not running, restarting"); + self.consecutive_failures.fetch_add(1, Ordering::Relaxed); + self.restart_internal().await?; + self.consecutive_failures.store(0, Ordering::Relaxed); + } + } + + Ok(()) + } + + /// Clean up socket file with retry logic + async fn cleanup_socket_file(socket_path: &str) { + let max_cleanup_attempts = 5; + let mut attempts = 0; + + while attempts < max_cleanup_attempts { + match std::fs::remove_file(socket_path) { + Ok(_) => break, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => break, + Err(e) => { + attempts += 1; + if attempts >= max_cleanup_attempts { + tracing::warn!( + socket_path = %socket_path, + error = %e, + "Failed to remove socket file after {} attempts, proceeding anyway", + max_cleanup_attempts + ); + break; + } + let delay_ms = 10 * (1 << attempts.min(3)); + tokio::time::sleep(Duration::from_millis(delay_ms)).await; + } + } + } + + tokio::time::sleep(Duration::from_millis(50)).await; + } + + /// Spawn the pool server process with proper configuration + async fn spawn_pool_server_process( + socket_path: &str, + context: &str, + ) -> Result { + let pool_server_path = std::env::current_dir() + .map(|cwd| cwd.join("plugins/lib/pool-server.ts").display().to_string()) + .unwrap_or_else(|_| "plugins/lib/pool-server.ts".to_string()); + + let config = get_config(); + + // Use extracted function for heap calculation + let pool_server_heap_mb = Self::calculate_heap_size(config.max_concurrency); + + // Log warning if heap was capped (for observability) + let uncapped_heap = Self::BASE_HEAP_MB + + ((config.max_concurrency / Self::CONCURRENCY_DIVISOR) + * Self::HEAP_INCREMENT_PER_DIVISOR_MB); + if uncapped_heap > Self::MAX_HEAP_MB { + tracing::warn!( + calculated_heap_mb = uncapped_heap, + capped_heap_mb = pool_server_heap_mb, + max_concurrency = config.max_concurrency, + "Pool server heap calculation exceeded 8GB cap" + ); + } + + tracing::info!( + socket_path = %socket_path, + heap_mb = pool_server_heap_mb, + max_concurrency = config.max_concurrency, + context = context, + "Spawning plugin pool server" + ); + + let node_options = format!("--max-old-space-size={pool_server_heap_mb} --expose-gc"); + + let mut child = Command::new("ts-node") + .arg("--transpile-only") + .arg(&pool_server_path) + .arg(socket_path) + .env("NODE_OPTIONS", node_options) + .env("PLUGIN_MAX_CONCURRENCY", config.max_concurrency.to_string()) + .env( + "PLUGIN_POOL_MIN_THREADS", + config.nodejs_pool_min_threads.to_string(), + ) + .env( + "PLUGIN_POOL_MAX_THREADS", + config.nodejs_pool_max_threads.to_string(), + ) + .env( + "PLUGIN_POOL_CONCURRENT_TASKS", + config.nodejs_pool_concurrent_tasks.to_string(), + ) + .env( + "PLUGIN_POOL_IDLE_TIMEOUT", + config.nodejs_pool_idle_timeout_ms.to_string(), + ) + .env( + "PLUGIN_WORKER_HEAP_MB", + config.nodejs_worker_heap_mb.to_string(), + ) + .env( + "PLUGIN_POOL_SOCKET_BACKLOG", + config.pool_socket_backlog.to_string(), + ) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| { + PluginError::PluginExecutionError(format!("Failed to {context} pool server: {e}")) + })?; + + if let Some(stderr) = child.stderr.take() { + Self::spawn_rate_limited_stderr_reader(stderr); + } + + if let Some(stdout) = child.stdout.take() { + let reader = BufReader::new(stdout); + let mut lines = reader.lines(); + + let timeout_result = tokio::time::timeout(Duration::from_secs(10), async { + while let Ok(Some(line)) = lines.next_line().await { + if line.contains("POOL_SERVER_READY") { + return Ok(()); + } + } + Err(PluginError::PluginExecutionError( + "Pool server did not send ready signal".to_string(), + )) + }) + .await; + + match timeout_result { + Ok(Ok(())) => { + tracing::info!(context = context, "Plugin pool server ready"); + } + Ok(Err(e)) => return Err(e), + Err(_) => { + return Err(PluginError::PluginExecutionError(format!( + "Timeout waiting for pool server to {context}" + ))) + } + } + } + + Ok(child) + } + + async fn start_pool_server(&self) -> Result<(), PluginError> { + let mut process_guard = self.process.lock().await; + + if process_guard.is_some() { + return Ok(()); + } + + Self::cleanup_socket_file(&self.socket_path).await; + + let child = Self::spawn_pool_server_process(&self.socket_path, "start").await?; + + *process_guard = Some(child); + Ok(()) + } + + /// Execute a plugin via the pool + #[allow(clippy::too_many_arguments)] + pub async fn execute_plugin( + &self, + plugin_id: String, + compiled_code: Option, + plugin_path: Option, + params: serde_json::Value, + headers: Option>>, + socket_path: String, + http_request_id: Option, + timeout_secs: Option, + route: Option, + config: Option, + method: Option, + query: Option, + ) -> Result { + let recovery_allowance = if self.recovery_mode.load(Ordering::Relaxed) { + Some(self.recovery_allowance.load(Ordering::Relaxed)) + } else { + None + }; + + if !self + .circuit_breaker + .should_allow_request(recovery_allowance) + { + let state = self.circuit_breaker.state(); + tracing::warn!( + plugin_id = %plugin_id, + circuit_state = ?state, + recovery_allowance = ?recovery_allowance, + "Request rejected by circuit breaker" + ); + return Err(PluginError::PluginExecutionError( + "Plugin system temporarily unavailable due to high load. Please retry shortly." + .to_string(), + )); + } + + let start_time = Instant::now(); + + self.ensure_started_and_healthy().await?; + + let circuit_breaker = self.circuit_breaker.clone(); + match self.connection_pool.semaphore.clone().try_acquire_owned() { + Ok(permit) => { + let result = Self::execute_with_permit( + &self.connection_pool, + Some(permit), + plugin_id, + compiled_code, + plugin_path, + params, + headers, + socket_path, + http_request_id, + timeout_secs, + route, + config, + method, + query, + ) + .await; + + let elapsed_ms = start_time.elapsed().as_millis() as u32; + match &result { + Ok(_) => circuit_breaker.record_success(elapsed_ms), + Err(e) => { + // Only count infrastructure errors for circuit breaker, not business errors + // Business errors (RPC failures, plugin logic errors) mean the pool is healthy + if Self::is_dead_server_error(e) { + circuit_breaker.record_failure(); + tracing::warn!( + error = %e, + "Detected dead pool server error, triggering health check for restart" + ); + self.health_check_needed.store(true, Ordering::Relaxed); + } else { + // Plugin executed but returned error - infrastructure is healthy + circuit_breaker.record_success(elapsed_ms); + } + } + } + + result + } + Err(_) => { + let (response_tx, response_rx) = oneshot::channel(); + + let queued_request = QueuedRequest { + plugin_id, + compiled_code, + plugin_path, + params, + headers, + socket_path, + http_request_id, + timeout_secs, + route, + config, + method, + query, + response_tx, + }; + + let result = match self.request_tx.try_send(queued_request) { + Ok(()) => { + let queue_len = self.request_tx.len(); + if queue_len > self.max_queue_size / 2 { + tracing::warn!( + queue_len = queue_len, + max_queue_size = self.max_queue_size, + "Plugin queue is over 50% capacity" + ); + } + response_rx.await.map_err(|_| { + PluginError::PluginExecutionError( + "Request queue processor closed".to_string(), + ) + })? + } + Err(async_channel::TrySendError::Full(req)) => { + let queue_timeout_ms = get_config().pool_queue_send_timeout_ms; + let queue_timeout = Duration::from_millis(queue_timeout_ms); + match tokio::time::timeout(queue_timeout, self.request_tx.send(req)).await { + Ok(Ok(())) => { + let queue_len = self.request_tx.len(); + tracing::debug!( + queue_len = queue_len, + "Request queued after waiting for queue space" + ); + response_rx.await.map_err(|_| { + PluginError::PluginExecutionError( + "Request queue processor closed".to_string(), + ) + })? + } + Ok(Err(async_channel::SendError(_))) => { + Err(PluginError::PluginExecutionError( + "Plugin execution queue is closed".to_string(), + )) + } + Err(_) => { + let queue_len = self.request_tx.len(); + tracing::error!( + queue_len = queue_len, + max_queue_size = self.max_queue_size, + timeout_ms = queue_timeout.as_millis(), + "Plugin execution queue is FULL - timeout waiting for space" + ); + Err(PluginError::PluginExecutionError(format!( + "Plugin execution queue is full (max: {}) and timeout waiting for space. \ + Consider increasing PLUGIN_POOL_MAX_QUEUE_SIZE or PLUGIN_POOL_MAX_CONNECTIONS.", + self.max_queue_size + ))) + } + } + } + Err(async_channel::TrySendError::Closed(_)) => { + Err(PluginError::PluginExecutionError( + "Plugin execution queue is closed".to_string(), + )) + } + }; + + let elapsed_ms = start_time.elapsed().as_millis() as u32; + match &result { + Ok(_) => circuit_breaker.record_success(elapsed_ms), + Err(e) => { + // Only count infrastructure errors for circuit breaker, not business errors + if Self::is_dead_server_error(e) { + circuit_breaker.record_failure(); + tracing::warn!( + error = %e, + "Detected dead pool server error (queued path), triggering health check for restart" + ); + self.health_check_needed.store(true, Ordering::Relaxed); + } else { + // Plugin executed but returned error - infrastructure is healthy + circuit_breaker.record_success(elapsed_ms); + } + } + } + + result + } + } + } + + /// Check if an error indicates the pool server is dead and needs restart + pub fn is_dead_server_error(err: &PluginError) -> bool { + let error_str = err.to_string(); + let lower = error_str.to_lowercase(); + + if lower.contains("handler timed out") + || (lower.contains("plugin") && lower.contains("timed out")) + { + return false; + } + + DeadServerIndicator::from_error_str(&error_str).is_some() + } + + /// Precompile a plugin + pub async fn precompile_plugin( + &self, + plugin_id: String, + plugin_path: Option, + source_code: Option, + ) -> Result { + self.ensure_started().await?; + + let mut conn = self.connection_pool.acquire().await?; + + let request = PoolRequest::Precompile { + task_id: Uuid::new_v4().to_string(), + plugin_id: plugin_id.clone(), + plugin_path, + source_code, + }; + + let response = conn + .send_request_with_timeout(&request, get_config().pool_request_timeout_secs) + .await?; + + if response.success { + response + .result + .and_then(|v| { + v.get("code") + .and_then(|c| c.as_str()) + .map(|s| s.to_string()) + }) + .ok_or_else(|| { + PluginError::PluginExecutionError("No compiled code in response".to_string()) + }) + } else { + let error = response.error.unwrap_or(PoolError { + message: "Compilation failed".to_string(), + code: None, + status: None, + details: None, + }); + Err(PluginError::PluginExecutionError(error.message)) + } + } + + /// Cache compiled code in the pool + pub async fn cache_compiled_code( + &self, + plugin_id: String, + compiled_code: String, + ) -> Result<(), PluginError> { + self.ensure_started().await?; + + let mut conn = self.connection_pool.acquire().await?; + + let request = PoolRequest::Cache { + task_id: Uuid::new_v4().to_string(), + plugin_id: plugin_id.clone(), + compiled_code, + }; + + let response = conn + .send_request_with_timeout(&request, get_config().pool_request_timeout_secs) + .await?; + + if response.success { + Ok(()) + } else { + let error = response.error.unwrap_or(PoolError { + message: "Cache failed".to_string(), + code: None, + status: None, + details: None, + }); + Err(PluginError::PluginError(error.message)) + } + } + + /// Invalidate a cached plugin + pub async fn invalidate_plugin(&self, plugin_id: String) -> Result<(), PluginError> { + if !*self.initialized.read().await { + return Ok(()); + } + + let mut conn = self.connection_pool.acquire().await?; + + let request = PoolRequest::Invalidate { + task_id: Uuid::new_v4().to_string(), + plugin_id, + }; + + let _ = conn + .send_request_with_timeout(&request, get_config().pool_request_timeout_secs) + .await?; + Ok(()) + } + + /// Health check - verify the pool server is responding + pub async fn health_check(&self) -> Result { + let circuit_info = || { + let state = match self.circuit_breaker.state() { + CircuitState::Closed => "closed", + CircuitState::HalfOpen => "half_open", + CircuitState::Open => "open", + }; + ( + Some(state.to_string()), + Some(self.circuit_breaker.avg_response_time()), + Some(self.recovery_mode.load(Ordering::Relaxed)), + Some(self.recovery_allowance.load(Ordering::Relaxed)), + ) + }; + + if !*self.initialized.read().await { + let (circuit_state, avg_rt, recovering, recovery_pct) = circuit_info(); + return Ok(HealthStatus { + healthy: false, + status: "not_initialized".to_string(), + uptime_ms: None, + memory: None, + pool_completed: None, + pool_queued: None, + success_rate: None, + circuit_state, + avg_response_time_ms: avg_rt, + recovering, + recovery_percent: recovery_pct, + }); + } + + if !std::path::Path::new(&self.socket_path).exists() { + let (circuit_state, avg_rt, recovering, recovery_pct) = circuit_info(); + return Ok(HealthStatus { + healthy: false, + status: "socket_missing".to_string(), + uptime_ms: None, + memory: None, + pool_completed: None, + pool_queued: None, + success_rate: None, + circuit_state, + avg_response_time_ms: avg_rt, + recovering, + recovery_percent: recovery_pct, + }); + } + + let mut conn = + match tokio::time::timeout(Duration::from_millis(100), self.connection_pool.acquire()) + .await + { + Ok(Ok(c)) => c, + Ok(Err(e)) => { + let err_str = e.to_string(); + let is_pool_exhausted = + err_str.contains("semaphore") || err_str.contains("Connection refused"); + + let (circuit_state, avg_rt, recovering, recovery_pct) = circuit_info(); + return Ok(HealthStatus { + healthy: is_pool_exhausted, + status: if is_pool_exhausted { + format!("pool_exhausted: {e}") + } else { + format!("connection_failed: {e}") + }, + uptime_ms: None, + memory: None, + pool_completed: None, + pool_queued: None, + success_rate: None, + circuit_state, + avg_response_time_ms: avg_rt, + recovering, + recovery_percent: recovery_pct, + }); + } + Err(_) => { + let (circuit_state, avg_rt, recovering, recovery_pct) = circuit_info(); + return Ok(HealthStatus { + healthy: true, + status: "pool_busy".to_string(), + uptime_ms: None, + memory: None, + pool_completed: None, + pool_queued: None, + success_rate: None, + circuit_state, + avg_response_time_ms: avg_rt, + recovering, + recovery_percent: recovery_pct, + }); + } + }; + + let request = PoolRequest::Health { + task_id: Uuid::new_v4().to_string(), + }; + + let (circuit_state, avg_rt, recovering, recovery_pct) = circuit_info(); + + match conn.send_request_with_timeout(&request, 5).await { + Ok(response) => { + if response.success { + let result = response.result.unwrap_or_default(); + // Use extracted parsing function for testability + let parsed = Self::parse_health_result(&result); + + Ok(HealthStatus { + healthy: true, + status: parsed.status, + uptime_ms: parsed.uptime_ms, + memory: parsed.memory, + pool_completed: parsed.pool_completed, + pool_queued: parsed.pool_queued, + success_rate: parsed.success_rate, + circuit_state, + avg_response_time_ms: avg_rt, + recovering, + recovery_percent: recovery_pct, + }) + } else { + Ok(HealthStatus { + healthy: false, + status: response + .error + .map(|e| e.message) + .unwrap_or_else(|| "unknown_error".to_string()), + uptime_ms: None, + memory: None, + pool_completed: None, + pool_queued: None, + success_rate: None, + circuit_state, + avg_response_time_ms: avg_rt, + recovering, + recovery_percent: recovery_pct, + }) + } + } + Err(e) => Ok(HealthStatus { + healthy: false, + status: format!("request_failed: {e}"), + uptime_ms: None, + memory: None, + pool_completed: None, + pool_queued: None, + success_rate: None, + circuit_state, + avg_response_time_ms: avg_rt, + recovering, + recovery_percent: recovery_pct, + }), + } + } + + /// Check health and restart if unhealthy + pub async fn ensure_healthy(&self) -> Result { + let health = self.health_check().await?; + + if health.healthy { + return Ok(true); + } + + match self.restart_lock.try_lock() { + Ok(_guard) => { + let health_recheck = self.health_check().await?; + if health_recheck.healthy { + return Ok(true); + } + + tracing::warn!(status = %health.status, "Pool server unhealthy, attempting restart"); + self.restart_internal().await?; + } + Err(_) => { + tracing::debug!("Waiting for another task to complete pool server restart"); + let _guard = self.restart_lock.lock().await; + } + } + + let health_after = self.health_check().await?; + Ok(health_after.healthy) + } + + /// Force restart the pool server (public API - acquires lock) + pub async fn restart(&self) -> Result<(), PluginError> { + let _guard = self.restart_lock.lock().await; + self.restart_internal().await + } + + /// Internal restart without lock (must be called with restart_lock held) + async fn restart_internal(&self) -> Result<(), PluginError> { + tracing::info!("Restarting plugin pool server"); + + { + let mut process_guard = self.process.lock().await; + if let Some(mut child) = process_guard.take() { + let _ = child.kill().await; + tokio::time::sleep(Duration::from_millis(100)).await; + } + } + + Self::cleanup_socket_file(&self.socket_path).await; + + { + let mut initialized = self.initialized.write().await; + *initialized = false; + } + + let mut process_guard = self.process.lock().await; + if process_guard.is_some() { + return Ok(()); + } + + let child = Self::spawn_pool_server_process(&self.socket_path, "restart").await?; + *process_guard = Some(child); + + { + let mut initialized = self.initialized.write().await; + *initialized = true; + } + + self.recovery_allowance.store(10, Ordering::Relaxed); + self.recovery_mode.store(true, Ordering::Relaxed); + + self.circuit_breaker.force_close(); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + self.last_restart_time_ms.store(now, Ordering::Relaxed); + + tracing::info!("Recovery mode enabled - requests will gradually increase from 10%"); + + Ok(()) + } + + /// Get current circuit breaker state for monitoring + pub fn circuit_state(&self) -> CircuitState { + self.circuit_breaker.state() + } + + /// Get average response time in ms (for monitoring) + pub fn avg_response_time_ms(&self) -> u32 { + self.circuit_breaker.avg_response_time() + } + + /// Check if currently in recovery mode + pub fn is_recovering(&self) -> bool { + self.recovery_mode.load(Ordering::Relaxed) + } + + /// Get current recovery allowance percentage (0-100) + pub fn recovery_allowance_percent(&self) -> u32 { + self.recovery_allowance.load(Ordering::Relaxed) + } + + /// Shutdown the pool server gracefully + pub async fn shutdown(&self) -> Result<(), PluginError> { + let mut initialized = self.initialized.write().await; + if !*initialized { + return Ok(()); + } + + tracing::info!("Initiating graceful shutdown of plugin pool server"); + + self.shutdown_signal.notify_waiters(); + + let shutdown_timeout = std::time::Duration::from_secs(35); + let shutdown_result = self.send_shutdown_request(shutdown_timeout).await; + + match &shutdown_result { + Ok(response) => { + tracing::info!( + response = ?response, + "Pool server acknowledged shutdown, waiting for graceful exit" + ); + } + Err(e) => { + tracing::warn!( + error = %e, + "Failed to send shutdown request, will force kill" + ); + } + } + + let mut process_guard = self.process.lock().await; + if let Some(ref mut child) = *process_guard { + let graceful_wait = std::time::Duration::from_secs(35); + let start = std::time::Instant::now(); + + loop { + match child.try_wait() { + Ok(Some(status)) => { + tracing::info!( + exit_status = ?status, + elapsed_ms = start.elapsed().as_millis(), + "Pool server exited gracefully" + ); + break; + } + Ok(None) => { + if start.elapsed() >= graceful_wait { + tracing::warn!( + "Pool server did not exit within graceful timeout, force killing" + ); + let _ = child.kill().await; + break; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + Err(e) => { + tracing::warn!(error = %e, "Error checking pool server status"); + let _ = child.kill().await; + break; + } + } + } + } + *process_guard = None; + + let _ = std::fs::remove_file(&self.socket_path); + + *initialized = false; + tracing::info!("Plugin pool server shutdown complete"); + Ok(()) + } + + /// Send shutdown request to the pool server + async fn send_shutdown_request( + &self, + timeout: std::time::Duration, + ) -> Result { + let request = PoolRequest::Shutdown { + task_id: Uuid::new_v4().to_string(), + }; + + // Use the pool's connection ID counter to ensure unique IDs + // even for shutdown connections that bypass the pool + let connection_id = self.connection_pool.next_connection_id(); + let mut conn = match PoolConnection::new(&self.socket_path, connection_id).await { + Ok(c) => c, + Err(e) => { + return Err(PluginError::PluginExecutionError(format!( + "Failed to connect for shutdown: {e}" + ))); + } + }; + + conn.send_request_with_timeout(&request, timeout.as_secs()) + .await + } +} + +impl Drop for PoolManager { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.socket_path); + } +} + +/// Global pool manager instance +static POOL_MANAGER: std::sync::OnceLock> = std::sync::OnceLock::new(); + +/// Get or create the global pool manager +pub fn get_pool_manager() -> Arc { + POOL_MANAGER + .get_or_init(|| Arc::new(PoolManager::new())) + .clone() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::plugins::script_executor::LogLevel; + + // ============================================ + // is_dead_server_error tests + // ============================================ + + #[test] + fn test_is_dead_server_error_detects_dead_server() { + let err = PluginError::PluginExecutionError("Connection refused".to_string()); + assert!(PoolManager::is_dead_server_error(&err)); + + let err = PluginError::PluginExecutionError("Broken pipe".to_string()); + assert!(PoolManager::is_dead_server_error(&err)); + } + + #[test] + fn test_is_dead_server_error_excludes_plugin_timeouts() { + let err = PluginError::PluginExecutionError("Plugin timed out after 30s".to_string()); + assert!(!PoolManager::is_dead_server_error(&err)); + + let err = PluginError::PluginExecutionError("Handler timed out".to_string()); + assert!(!PoolManager::is_dead_server_error(&err)); + } + + #[test] + fn test_is_dead_server_error_normal_errors() { + let err = + PluginError::PluginExecutionError("TypeError: undefined is not a function".to_string()); + assert!(!PoolManager::is_dead_server_error(&err)); + + let err = PluginError::PluginExecutionError("Plugin returned invalid JSON".to_string()); + assert!(!PoolManager::is_dead_server_error(&err)); + } + + #[test] + fn test_is_dead_server_error_detects_all_dead_server_indicators() { + // Test common DeadServerIndicator patterns + let dead_server_errors = vec![ + "EOF while parsing JSON response", + "Broken pipe when writing to socket", + "Connection refused: server not running", + "Connection reset by peer", + "Socket not connected", + "Failed to connect to pool server", + "Socket file missing: /tmp/test.sock", + "No such file or directory", + ]; + + for error_msg in dead_server_errors { + let err = PluginError::PluginExecutionError(error_msg.to_string()); + assert!( + PoolManager::is_dead_server_error(&err), + "Expected '{}' to be detected as dead server error", + error_msg + ); + } + } + + #[test] + fn test_dead_server_indicator_patterns() { + // Test the DeadServerIndicator pattern matching directly + use super::super::health::DeadServerIndicator; + + // These should all match + assert!(DeadServerIndicator::from_error_str("eof while parsing").is_some()); + assert!(DeadServerIndicator::from_error_str("broken pipe").is_some()); + assert!(DeadServerIndicator::from_error_str("connection refused").is_some()); + assert!(DeadServerIndicator::from_error_str("connection reset").is_some()); + assert!(DeadServerIndicator::from_error_str("not connected").is_some()); + assert!(DeadServerIndicator::from_error_str("failed to connect").is_some()); + assert!(DeadServerIndicator::from_error_str("socket file missing").is_some()); + assert!(DeadServerIndicator::from_error_str("no such file").is_some()); + assert!(DeadServerIndicator::from_error_str("connection timed out").is_some()); + assert!(DeadServerIndicator::from_error_str("connect timed out").is_some()); + + // These should NOT match + assert!(DeadServerIndicator::from_error_str("handler timed out").is_none()); + assert!(DeadServerIndicator::from_error_str("validation error").is_none()); + assert!(DeadServerIndicator::from_error_str("TypeError: undefined").is_none()); + } + + #[test] + fn test_is_dead_server_error_excludes_plugin_timeouts_with_connection() { + // Plugin timeout should NOT be detected even if it mentions connection + let plugin_timeout = + PluginError::PluginExecutionError("plugin connection timed out".to_string()); + // This contains both "plugin" and "timed out" so it's excluded + assert!(!PoolManager::is_dead_server_error(&plugin_timeout)); + } + + #[test] + fn test_is_dead_server_error_case_insensitive() { + // Test case insensitivity + let err = PluginError::PluginExecutionError("CONNECTION REFUSED".to_string()); + assert!(PoolManager::is_dead_server_error(&err)); + + let err = PluginError::PluginExecutionError("BROKEN PIPE".to_string()); + assert!(PoolManager::is_dead_server_error(&err)); + + let err = PluginError::PluginExecutionError("Connection Reset By Peer".to_string()); + assert!(PoolManager::is_dead_server_error(&err)); + } + + #[test] + fn test_is_dead_server_error_handler_timeout_variations() { + // All variations of plugin/handler timeouts should NOT trigger restart + let timeout_errors = vec![ + "Handler timed out", + "handler timed out after 30000ms", + "Plugin handler timed out", + "plugin timed out", + "Plugin execution timed out after 60s", + ]; + + for error_msg in timeout_errors { + let err = PluginError::PluginExecutionError(error_msg.to_string()); + assert!( + !PoolManager::is_dead_server_error(&err), + "Expected '{}' to NOT be detected as dead server error", + error_msg + ); + } + } + + #[test] + fn test_is_dead_server_error_business_errors_not_detected() { + // Business logic errors should not trigger restart + let business_errors = vec![ + "ReferenceError: x is not defined", + "SyntaxError: Unexpected token", + "TypeError: Cannot read property 'foo' of undefined", + "Plugin returned status 400: Bad Request", + "Validation error: missing required field", + "Authorization failed", + "Rate limit exceeded", + "Plugin threw an error: Invalid input", + ]; + + for error_msg in business_errors { + let err = PluginError::PluginExecutionError(error_msg.to_string()); + assert!( + !PoolManager::is_dead_server_error(&err), + "Expected '{}' to NOT be detected as dead server error", + error_msg + ); + } + } + + #[test] + fn test_is_dead_server_error_with_handler_error_type() { + // HandlerError type should also be checked + let handler_payload = PluginHandlerPayload { + message: "Connection refused".to_string(), + status: 500, + code: None, + details: None, + logs: None, + traces: None, + }; + let err = PluginError::HandlerError(Box::new(handler_payload)); + // The error message contains "Connection refused" but it's wrapped differently + // This tests that we check the string representation + assert!(PoolManager::is_dead_server_error(&err)); + } + + // ============================================ + // Heap calculation tests + // ============================================ + + #[test] + fn test_heap_calculation_base_case() { + // With default concurrency, should get base heap + let base = PoolManager::BASE_HEAP_MB; + let divisor = PoolManager::CONCURRENCY_DIVISOR; + let increment = PoolManager::HEAP_INCREMENT_PER_DIVISOR_MB; + + // For 100 concurrent requests: + // 512 + (100 / 10) * 32 = 512 + 320 = 832 MB + let concurrency = 100; + let expected = base + ((concurrency / divisor) * increment); + assert_eq!(expected, 832); + } + + #[test] + fn test_heap_calculation_minimum() { + // With very low concurrency, should still get base heap + let base = PoolManager::BASE_HEAP_MB; + let divisor = PoolManager::CONCURRENCY_DIVISOR; + let increment = PoolManager::HEAP_INCREMENT_PER_DIVISOR_MB; + + // For 5 concurrent requests: + // 512 + (5 / 10) * 32 = 512 + 0 = 512 MB (integer division) + let concurrency = 5; + let expected = base + ((concurrency / divisor) * increment); + assert_eq!(expected, 512); + } + + #[test] + fn test_heap_calculation_high_concurrency() { + // With high concurrency, should scale appropriately + let base = PoolManager::BASE_HEAP_MB; + let divisor = PoolManager::CONCURRENCY_DIVISOR; + let increment = PoolManager::HEAP_INCREMENT_PER_DIVISOR_MB; + + // For 500 concurrent requests: + // 512 + (500 / 10) * 32 = 512 + 1600 = 2112 MB + let concurrency = 500; + let expected = base + ((concurrency / divisor) * increment); + assert_eq!(expected, 2112); + } + + #[test] + fn test_heap_calculation_max_cap() { + // Verify max heap cap is respected + let max_heap = PoolManager::MAX_HEAP_MB; + assert_eq!(max_heap, 8192); + + // For extreme concurrency that would exceed cap: + // e.g., 3000 concurrent: 512 + (3000 / 10) * 32 = 512 + 9600 = 10112 MB + // Should be capped to 8192 MB + let base = PoolManager::BASE_HEAP_MB; + let divisor = PoolManager::CONCURRENCY_DIVISOR; + let increment = PoolManager::HEAP_INCREMENT_PER_DIVISOR_MB; + + let concurrency = 3000; + let calculated = base + ((concurrency / divisor) * increment); + let capped = calculated.min(max_heap); + + assert_eq!(calculated, 10112); + assert_eq!(capped, 8192); + } + + // ============================================ + // Constants verification tests + // ============================================ + + #[test] + fn test_pool_manager_constants() { + // Verify important constants have reasonable values + assert_eq!(PoolManager::BASE_HEAP_MB, 512); + assert_eq!(PoolManager::CONCURRENCY_DIVISOR, 10); + assert_eq!(PoolManager::HEAP_INCREMENT_PER_DIVISOR_MB, 32); + assert_eq!(PoolManager::MAX_HEAP_MB, 8192); + } + + // ============================================ + // Extracted function tests: calculate_heap_size + // ============================================ + + #[test] + fn test_calculate_heap_size_low_concurrency() { + // Low concurrency should give base heap + assert_eq!(PoolManager::calculate_heap_size(5), 512); + assert_eq!(PoolManager::calculate_heap_size(9), 512); + } + + #[test] + fn test_calculate_heap_size_medium_concurrency() { + // 10 concurrent: 512 + (10/10)*32 = 544 + assert_eq!(PoolManager::calculate_heap_size(10), 544); + // 50 concurrent: 512 + (50/10)*32 = 672 + assert_eq!(PoolManager::calculate_heap_size(50), 672); + // 100 concurrent: 512 + (100/10)*32 = 832 + assert_eq!(PoolManager::calculate_heap_size(100), 832); + } + + #[test] + fn test_calculate_heap_size_high_concurrency() { + // 500 concurrent: 512 + (500/10)*32 = 2112 + assert_eq!(PoolManager::calculate_heap_size(500), 2112); + // 1000 concurrent: 512 + (1000/10)*32 = 3712 + assert_eq!(PoolManager::calculate_heap_size(1000), 3712); + } + + #[test] + fn test_calculate_heap_size_capped_at_max() { + // 3000 concurrent would be 10112, but capped at 8192 + assert_eq!(PoolManager::calculate_heap_size(3000), 8192); + // Even higher should still be capped + assert_eq!(PoolManager::calculate_heap_size(10000), 8192); + } + + #[test] + fn test_calculate_heap_size_zero_concurrency() { + // Zero concurrency gives base heap + assert_eq!(PoolManager::calculate_heap_size(0), 512); + } + + // ============================================ + // Extracted function tests: format_return_value + // ============================================ + + #[test] + fn test_format_return_value_none() { + assert_eq!(PoolManager::format_return_value(None), ""); + } + + #[test] + fn test_format_return_value_string() { + let value = Some(serde_json::json!("hello world")); + assert_eq!(PoolManager::format_return_value(value), "hello world"); + } + + #[test] + fn test_format_return_value_empty_string() { + let value = Some(serde_json::json!("")); + assert_eq!(PoolManager::format_return_value(value), ""); + } + + #[test] + fn test_format_return_value_object() { + let value = Some(serde_json::json!({"key": "value", "num": 42})); + let result = PoolManager::format_return_value(value); + // JSON object gets serialized + assert!(result.contains("key")); + assert!(result.contains("value")); + assert!(result.contains("42")); + } + + #[test] + fn test_format_return_value_array() { + let value = Some(serde_json::json!([1, 2, 3])); + assert_eq!(PoolManager::format_return_value(value), "[1,2,3]"); + } + + #[test] + fn test_format_return_value_number() { + let value = Some(serde_json::json!(42)); + assert_eq!(PoolManager::format_return_value(value), "42"); + } + + #[test] + fn test_format_return_value_boolean() { + assert_eq!( + PoolManager::format_return_value(Some(serde_json::json!(true))), + "true" + ); + assert_eq!( + PoolManager::format_return_value(Some(serde_json::json!(false))), + "false" + ); + } + + #[test] + fn test_format_return_value_null() { + let value = Some(serde_json::json!(null)); + assert_eq!(PoolManager::format_return_value(value), "null"); + } + + // ============================================ + // Extracted function tests: parse_pool_response + // ============================================ + + #[test] + fn test_parse_pool_response_success_with_string_result() { + use super::super::protocol::{PoolLogEntry, PoolResponse}; + + let response = PoolResponse { + task_id: "test-123".to_string(), + success: true, + result: Some(serde_json::json!("success result")), + error: None, + logs: Some(vec![PoolLogEntry { + level: "info".to_string(), + message: "test log".to_string(), + }]), + }; + + let result = PoolManager::parse_pool_response(response).unwrap(); + assert_eq!(result.return_value, "success result"); + assert!(result.error.is_empty()); + assert_eq!(result.logs.len(), 1); + assert_eq!(result.logs[0].level, LogLevel::Info); + assert_eq!(result.logs[0].message, "test log"); + } + + #[test] + fn test_parse_pool_response_success_with_object_result() { + use super::super::protocol::PoolResponse; + + let response = PoolResponse { + task_id: "test-456".to_string(), + success: true, + result: Some(serde_json::json!({"data": "value"})), + error: None, + logs: None, + }; + + let result = PoolManager::parse_pool_response(response).unwrap(); + assert!(result.return_value.contains("data")); + assert!(result.return_value.contains("value")); + assert!(result.logs.is_empty()); + } + + #[test] + fn test_parse_pool_response_success_no_result() { + use super::super::protocol::PoolResponse; + + let response = PoolResponse { + task_id: "test-789".to_string(), + success: true, + result: None, + error: None, + logs: None, + }; + + let result = PoolManager::parse_pool_response(response).unwrap(); + assert_eq!(result.return_value, ""); + assert!(result.error.is_empty()); + } + + #[test] + fn test_parse_pool_response_failure_with_error() { + use super::super::protocol::{PoolError, PoolResponse}; + + let response = PoolResponse { + task_id: "test-error".to_string(), + success: false, + result: None, + error: Some(PoolError { + message: "Something went wrong".to_string(), + code: Some("ERR_001".to_string()), + status: Some(400), + details: Some(serde_json::json!({"field": "name"})), + }), + logs: None, + }; + + let err = PoolManager::parse_pool_response(response).unwrap_err(); + match err { + PluginError::HandlerError(payload) => { + assert_eq!(payload.message, "Something went wrong"); + assert_eq!(payload.status, 400); + assert_eq!(payload.code, Some("ERR_001".to_string())); + } + _ => panic!("Expected HandlerError"), + } + } + + #[test] + fn test_parse_pool_response_failure_no_error_details() { + use super::super::protocol::PoolResponse; + + let response = PoolResponse { + task_id: "test-unknown".to_string(), + success: false, + result: None, + error: None, + logs: None, + }; + + let err = PoolManager::parse_pool_response(response).unwrap_err(); + match err { + PluginError::HandlerError(payload) => { + assert_eq!(payload.message, "Unknown error"); + assert_eq!(payload.status, 500); + } + _ => panic!("Expected HandlerError"), + } + } + + #[test] + fn test_parse_pool_response_failure_preserves_logs() { + use super::super::protocol::{PoolError, PoolLogEntry, PoolResponse}; + + let response = PoolResponse { + task_id: "test-logs".to_string(), + success: false, + result: None, + error: Some(PoolError { + message: "Error with logs".to_string(), + code: None, + status: None, + details: None, + }), + logs: Some(vec![ + PoolLogEntry { + level: "debug".to_string(), + message: "debug message".to_string(), + }, + PoolLogEntry { + level: "error".to_string(), + message: "error message".to_string(), + }, + ]), + }; + + let err = PoolManager::parse_pool_response(response).unwrap_err(); + match err { + PluginError::HandlerError(payload) => { + let logs = payload.logs.unwrap(); + assert_eq!(logs.len(), 2); + assert_eq!(logs[0].level, LogLevel::Debug); + assert_eq!(logs[1].level, LogLevel::Error); + } + _ => panic!("Expected HandlerError"), + } + } + + // ============================================ + // Extracted function tests: parse_success_response + // ============================================ + + #[test] + fn test_parse_success_response_complete() { + use super::super::protocol::{PoolLogEntry, PoolResponse}; + + let response = PoolResponse { + task_id: "task-1".to_string(), + success: true, + result: Some(serde_json::json!("completed")), + error: None, + logs: Some(vec![ + PoolLogEntry { + level: "log".to_string(), + message: "starting".to_string(), + }, + PoolLogEntry { + level: "result".to_string(), + message: "finished".to_string(), + }, + ]), + }; + + let result = PoolManager::parse_success_response(response); + assert_eq!(result.return_value, "completed"); + assert!(result.error.is_empty()); + assert_eq!(result.logs.len(), 2); + assert_eq!(result.logs[0].level, LogLevel::Log); + assert_eq!(result.logs[1].level, LogLevel::Result); + } + + // ============================================ + // Extracted function tests: parse_error_response + // ============================================ + + #[test] + fn test_parse_error_response_with_all_fields() { + use super::super::protocol::{PoolError, PoolLogEntry, PoolResponse}; + + let response = PoolResponse { + task_id: "err-task".to_string(), + success: false, + result: None, + error: Some(PoolError { + message: "Validation failed".to_string(), + code: Some("VALIDATION_ERROR".to_string()), + status: Some(422), + details: Some(serde_json::json!({"fields": ["email"]})), + }), + logs: Some(vec![PoolLogEntry { + level: "warn".to_string(), + message: "validation warning".to_string(), + }]), + }; + + let err = PoolManager::parse_error_response(response); + match err { + PluginError::HandlerError(payload) => { + assert_eq!(payload.message, "Validation failed"); + assert_eq!(payload.status, 422); + assert_eq!(payload.code, Some("VALIDATION_ERROR".to_string())); + assert!(payload.details.is_some()); + let logs = payload.logs.unwrap(); + assert_eq!(logs.len(), 1); + assert_eq!(logs[0].level, LogLevel::Warn); + } + _ => panic!("Expected HandlerError"), + } + } + + // ============================================ + // Extracted function tests: parse_health_result + // ============================================ + + #[test] + fn test_parse_health_result_complete() { + let json = serde_json::json!({ + "status": "healthy", + "uptime": 123456, + "memory": { + "heapUsed": 50000000, + "heapTotal": 100000000 + }, + "pool": { + "completed": 1000, + "queued": 5 + }, + "execution": { + "successRate": 0.99 + } + }); + + let result = PoolManager::parse_health_result(&json); + + assert_eq!(result.status, "healthy"); + assert_eq!(result.uptime_ms, Some(123456)); + assert_eq!(result.memory, Some(50000000)); + assert_eq!(result.pool_completed, Some(1000)); + assert_eq!(result.pool_queued, Some(5)); + assert!((result.success_rate.unwrap() - 0.99).abs() < 0.001); + } + + #[test] + fn test_parse_health_result_minimal() { + let json = serde_json::json!({}); + + let result = PoolManager::parse_health_result(&json); + + assert_eq!(result.status, "unknown"); + assert_eq!(result.uptime_ms, None); + assert_eq!(result.memory, None); + assert_eq!(result.pool_completed, None); + assert_eq!(result.pool_queued, None); + assert_eq!(result.success_rate, None); + } + + #[test] + fn test_parse_health_result_partial() { + let json = serde_json::json!({ + "status": "degraded", + "uptime": 5000, + "memory": { + "heapTotal": 100000000 + // heapUsed missing + } + }); + + let result = PoolManager::parse_health_result(&json); + + assert_eq!(result.status, "degraded"); + assert_eq!(result.uptime_ms, Some(5000)); + assert_eq!(result.memory, None); // heapUsed was missing + assert_eq!(result.pool_completed, None); + assert_eq!(result.pool_queued, None); + assert_eq!(result.success_rate, None); + } + + #[test] + fn test_parse_health_result_wrong_types() { + let json = serde_json::json!({ + "status": 123, // Should be string, will use "unknown" + "uptime": "not a number", // Should be u64, will be None + "memory": "invalid" // Should be object, will give None + }); + + let result = PoolManager::parse_health_result(&json); + + assert_eq!(result.status, "unknown"); // Falls back when not a string + assert_eq!(result.uptime_ms, None); + assert_eq!(result.memory, None); + assert_eq!(result.pool_completed, None); + assert_eq!(result.pool_queued, None); + assert_eq!(result.success_rate, None); + } + + #[test] + fn test_parse_health_result_nested_values() { + let json = serde_json::json!({ + "pool": { + "completed": 0, + "queued": 0 + }, + "execution": { + "successRate": 1.0 + } + }); + + let result = PoolManager::parse_health_result(&json); + + assert_eq!(result.status, "unknown"); + assert_eq!(result.uptime_ms, None); + assert_eq!(result.memory, None); + assert_eq!(result.pool_completed, Some(0)); + assert_eq!(result.pool_queued, Some(0)); + assert!((result.success_rate.unwrap() - 1.0).abs() < 0.001); + } + + // ============================================ + // PoolManager creation tests + // ============================================ + + #[tokio::test] + async fn test_pool_manager_new_creates_unique_socket_path() { + // Two PoolManagers should have different socket paths + let manager1 = PoolManager::new(); + let manager2 = PoolManager::new(); + + assert_ne!(manager1.socket_path, manager2.socket_path); + assert!(manager1 + .socket_path + .starts_with("/tmp/relayer-plugin-pool-")); + assert!(manager2 + .socket_path + .starts_with("/tmp/relayer-plugin-pool-")); + } + + #[tokio::test] + async fn test_pool_manager_with_custom_socket_path() { + let custom_path = "/tmp/custom-test-pool.sock".to_string(); + let manager = PoolManager::with_socket_path(custom_path.clone()); + + assert_eq!(manager.socket_path, custom_path); + } + + #[tokio::test] + async fn test_pool_manager_default_trait() { + // Verify Default trait creates a valid manager + let manager = PoolManager::default(); + assert!(manager.socket_path.starts_with("/tmp/relayer-plugin-pool-")); + } + + // ============================================ + // Circuit breaker state tests + // ============================================ + + #[tokio::test] + async fn test_circuit_state_initial() { + let manager = PoolManager::new(); + + // Initial state should be Closed + assert_eq!(manager.circuit_state(), CircuitState::Closed); + } + + #[tokio::test] + async fn test_avg_response_time_initial() { + let manager = PoolManager::new(); + + // Initial response time should be 0 + assert_eq!(manager.avg_response_time_ms(), 0); + } + + // ============================================ + // Recovery mode tests + // ============================================ + + #[tokio::test] + async fn test_recovery_mode_initial() { + let manager = PoolManager::new(); + + // Should not be in recovery mode initially + assert!(!manager.is_recovering()); + assert_eq!(manager.recovery_allowance_percent(), 0); + } + + // ============================================ + // ScriptResult construction tests + // ============================================ + + #[test] + fn test_script_result_success_construction() { + let result = ScriptResult { + logs: vec![LogEntry { + level: LogLevel::Info, + message: "Test log".to_string(), + }], + error: String::new(), + return_value: r#"{"success": true}"#.to_string(), + trace: vec![], + }; + + assert!(result.error.is_empty()); + assert_eq!(result.logs.len(), 1); + assert_eq!(result.logs[0].level, LogLevel::Info); + } + + #[test] + fn test_script_result_with_multiple_logs() { + let result = ScriptResult { + logs: vec![ + LogEntry { + level: LogLevel::Log, + message: "Starting execution".to_string(), + }, + LogEntry { + level: LogLevel::Debug, + message: "Processing data".to_string(), + }, + LogEntry { + level: LogLevel::Warn, + message: "Deprecated API used".to_string(), + }, + LogEntry { + level: LogLevel::Error, + message: "Non-fatal error".to_string(), + }, + ], + error: String::new(), + return_value: "done".to_string(), + trace: vec![], + }; + + assert_eq!(result.logs.len(), 4); + assert_eq!(result.logs[0].level, LogLevel::Log); + assert_eq!(result.logs[1].level, LogLevel::Debug); + assert_eq!(result.logs[2].level, LogLevel::Warn); + assert_eq!(result.logs[3].level, LogLevel::Error); + } + + // ============================================ + // QueuedRequest structure tests + // ============================================ + + #[test] + fn test_queued_request_required_fields() { + let (tx, _rx) = oneshot::channel(); + + let request = QueuedRequest { + plugin_id: "test-plugin".to_string(), + compiled_code: Some("module.exports.handler = () => {}".to_string()), + plugin_path: None, + params: serde_json::json!({"key": "value"}), + headers: None, + socket_path: "/tmp/test.sock".to_string(), + http_request_id: Some("req-123".to_string()), + timeout_secs: Some(30), + route: Some("/api/test".to_string()), + config: Some(serde_json::json!({"setting": true})), + method: Some("POST".to_string()), + query: Some(serde_json::json!({"page": "1"})), + response_tx: tx, + }; + + assert_eq!(request.plugin_id, "test-plugin"); + assert!(request.compiled_code.is_some()); + assert!(request.plugin_path.is_none()); + assert_eq!(request.timeout_secs, Some(30)); + } + + #[test] + fn test_queued_request_minimal() { + let (tx, _rx) = oneshot::channel(); + + let request = QueuedRequest { + plugin_id: "minimal".to_string(), + compiled_code: None, + plugin_path: Some("/path/to/plugin.ts".to_string()), + params: serde_json::json!(null), + headers: None, + socket_path: "/tmp/min.sock".to_string(), + http_request_id: None, + timeout_secs: None, + route: None, + config: None, + method: None, + query: None, + response_tx: tx, + }; + + assert_eq!(request.plugin_id, "minimal"); + assert!(request.compiled_code.is_none()); + assert!(request.plugin_path.is_some()); + } + + // ============================================ + // Error type tests + // ============================================ + + #[test] + fn test_plugin_error_socket_error() { + let err = PluginError::SocketError("Connection failed".to_string()); + let display = format!("{}", err); + assert!(display.contains("Socket error")); + assert!(display.contains("Connection failed")); + } + + #[test] + fn test_plugin_error_plugin_execution_error() { + let err = PluginError::PluginExecutionError("Execution failed".to_string()); + let display = format!("{}", err); + assert!(display.contains("Execution failed")); + } + + #[test] + fn test_plugin_error_handler_error() { + let payload = PluginHandlerPayload { + message: "Handler error".to_string(), + status: 400, + code: Some("BAD_REQUEST".to_string()), + details: Some(serde_json::json!({"field": "name"})), + logs: None, + traces: None, + }; + let err = PluginError::HandlerError(Box::new(payload)); + + // Check that it can be displayed + let display = format!("{:?}", err); + assert!(display.contains("HandlerError")); + } + + // ============================================ + // Handler payload tests + // ============================================ + + #[test] + fn test_plugin_handler_payload_full() { + let payload = PluginHandlerPayload { + message: "Validation failed".to_string(), + status: 422, + code: Some("VALIDATION_ERROR".to_string()), + details: Some(serde_json::json!({ + "errors": [ + {"field": "email", "message": "Invalid format"} + ] + })), + logs: Some(vec![LogEntry { + level: LogLevel::Error, + message: "Validation failed for email".to_string(), + }]), + traces: Some(vec![serde_json::json!({"stack": "Error at line 10"})]), + }; + + assert_eq!(payload.status, 422); + assert_eq!(payload.code, Some("VALIDATION_ERROR".to_string())); + assert!(payload.logs.is_some()); + assert!(payload.traces.is_some()); + } + + #[test] + fn test_plugin_handler_payload_minimal() { + let payload = PluginHandlerPayload { + message: "Error".to_string(), + status: 500, + code: None, + details: None, + logs: None, + traces: None, + }; + + assert_eq!(payload.status, 500); + assert!(payload.code.is_none()); + assert!(payload.details.is_none()); + } + + // ============================================ + // Async tests (tokio runtime) + // ============================================ + + #[tokio::test] + async fn test_pool_manager_not_initialized_health_check() { + let manager = PoolManager::with_socket_path("/tmp/test-health.sock".to_string()); + + // Health check on uninitialized manager should return not_initialized + let health = manager.health_check().await.unwrap(); + + assert!(!health.healthy); + assert_eq!(health.status, "not_initialized"); + assert!(health.uptime_ms.is_none()); + assert!(health.memory.is_none()); + } + + #[tokio::test] + async fn test_pool_manager_circuit_info_in_health_status() { + let manager = PoolManager::with_socket_path("/tmp/test-circuit.sock".to_string()); + + let health = manager.health_check().await.unwrap(); + + // Circuit state info should be present even when not initialized + assert!(health.circuit_state.is_some()); + assert_eq!(health.circuit_state, Some("closed".to_string())); + assert!(health.avg_response_time_ms.is_some()); + assert!(health.recovering.is_some()); + assert!(health.recovery_percent.is_some()); + } + + #[tokio::test] + async fn test_invalidate_plugin_when_not_initialized() { + let manager = PoolManager::with_socket_path("/tmp/test-invalidate.sock".to_string()); + + // Invalidating when not initialized should be a no-op + let result = manager.invalidate_plugin("test-plugin".to_string()).await; + + // Should succeed (no-op) + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_shutdown_when_not_initialized() { + let manager = PoolManager::with_socket_path("/tmp/test-shutdown.sock".to_string()); + + // Shutdown when not initialized should be a no-op + let result = manager.shutdown().await; + + // Should succeed (no-op) + assert!(result.is_ok()); + } +} diff --git a/src/services/plugins/protocol.rs b/src/services/plugins/protocol.rs new file mode 100644 index 000000000..525c2831e --- /dev/null +++ b/src/services/plugins/protocol.rs @@ -0,0 +1,306 @@ +//! Protocol types for pool server communication. +//! +//! Defines the JSON-line protocol messages exchanged between +//! the Rust pool executor and Node.js pool server via Unix socket. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +use super::{LogEntry, LogLevel}; + +/// Execute request payload (boxed to reduce enum size) +#[derive(Serialize, Debug)] +pub struct ExecuteRequest { + #[serde(rename = "taskId")] + pub task_id: String, + #[serde(rename = "pluginId")] + pub plugin_id: String, + #[serde(rename = "compiledCode", skip_serializing_if = "Option::is_none")] + pub compiled_code: Option, + #[serde(rename = "pluginPath", skip_serializing_if = "Option::is_none")] + pub plugin_path: Option, + pub params: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub headers: Option>>, + #[serde(rename = "socketPath")] + pub socket_path: String, + #[serde(rename = "httpRequestId", skip_serializing_if = "Option::is_none")] + pub http_request_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub route: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub config: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub method: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub query: Option, +} + +/// Request messages sent to the pool server +#[derive(Serialize, Debug)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum PoolRequest { + Execute(Box), + Precompile { + #[serde(rename = "taskId")] + task_id: String, + #[serde(rename = "pluginId")] + plugin_id: String, + #[serde(rename = "pluginPath", skip_serializing_if = "Option::is_none")] + plugin_path: Option, + #[serde(rename = "sourceCode", skip_serializing_if = "Option::is_none")] + source_code: Option, + }, + Cache { + #[serde(rename = "taskId")] + task_id: String, + #[serde(rename = "pluginId")] + plugin_id: String, + #[serde(rename = "compiledCode")] + compiled_code: String, + }, + Invalidate { + #[serde(rename = "taskId")] + task_id: String, + #[serde(rename = "pluginId")] + plugin_id: String, + }, + Stats { + #[serde(rename = "taskId")] + task_id: String, + }, + Health { + #[serde(rename = "taskId")] + task_id: String, + }, + Shutdown { + #[serde(rename = "taskId")] + task_id: String, + }, +} + +/// Response from the pool server +#[derive(Deserialize, Debug)] +pub struct PoolResponse { + #[serde(rename = "taskId")] + pub task_id: String, + pub success: bool, + pub result: Option, + pub error: Option, + pub logs: Option>, +} + +/// Error details from the pool server +#[derive(Deserialize, Debug)] +pub struct PoolError { + pub message: String, + pub code: Option, + pub status: Option, + pub details: Option, +} + +/// Log entry from plugin execution +#[derive(Deserialize, Debug)] +pub struct PoolLogEntry { + pub level: String, + pub message: String, +} + +impl From for LogEntry { + fn from(entry: PoolLogEntry) -> Self { + let level = match entry.level.as_str() { + "error" => LogLevel::Error, + "warn" => LogLevel::Warn, + "info" => LogLevel::Info, + "debug" => LogLevel::Debug, + "result" => LogLevel::Result, + _ => LogLevel::Log, + }; + LogEntry { + level, + message: entry.message, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pool_request_execute_serialization() { + let request = PoolRequest::Execute(Box::new(ExecuteRequest { + task_id: "test-123".to_string(), + plugin_id: "my-plugin".to_string(), + compiled_code: Some("console.log('hello')".to_string()), + plugin_path: None, + params: serde_json::json!({"key": "value"}), + headers: None, + socket_path: "/tmp/test.sock".to_string(), + http_request_id: Some("req-456".to_string()), + timeout: Some(30000), + route: None, + config: None, + method: None, + query: None, + })); + + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("\"type\":\"execute\"")); + assert!(json.contains("\"taskId\":\"test-123\"")); + assert!(json.contains("\"pluginId\":\"my-plugin\"")); + } + + #[test] + fn test_pool_request_precompile_serialization() { + let request = PoolRequest::Precompile { + task_id: "precompile-123".to_string(), + plugin_id: "test-plugin".to_string(), + plugin_path: Some("/plugins/test.ts".to_string()), + source_code: None, + }; + + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("\"type\":\"precompile\"")); + assert!(json.contains("\"taskId\":\"precompile-123\"")); + assert!(json.contains("\"pluginPath\":\"/plugins/test.ts\"")); + } + + #[test] + fn test_pool_request_cache_serialization() { + let request = PoolRequest::Cache { + task_id: "cache-123".to_string(), + plugin_id: "test-plugin".to_string(), + compiled_code: "compiled code here".to_string(), + }; + + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("\"type\":\"cache\"")); + assert!(json.contains("\"compiledCode\":\"compiled code here\"")); + } + + #[test] + fn test_pool_request_invalidate_serialization() { + let request = PoolRequest::Invalidate { + task_id: "inv-123".to_string(), + plugin_id: "test-plugin".to_string(), + }; + + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("\"type\":\"invalidate\"")); + } + + #[test] + fn test_pool_request_stats_serialization() { + let request = PoolRequest::Stats { + task_id: "stats-123".to_string(), + }; + + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("\"type\":\"stats\"")); + } + + #[test] + fn test_pool_request_health_serialization() { + let request = PoolRequest::Health { + task_id: "health-123".to_string(), + }; + + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("\"type\":\"health\"")); + } + + #[test] + fn test_pool_request_shutdown_serialization() { + let request = PoolRequest::Shutdown { + task_id: "shutdown-123".to_string(), + }; + + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("\"type\":\"shutdown\"")); + } + + #[test] + fn test_pool_response_success_deserialization() { + let json = r#"{ + "taskId": "test-123", + "success": true, + "result": {"data": "hello"}, + "logs": [{"level": "info", "message": "test log"}] + }"#; + + let response: PoolResponse = serde_json::from_str(json).unwrap(); + assert_eq!(response.task_id, "test-123"); + assert!(response.success); + assert!(response.error.is_none()); + assert!(response + .logs + .as_ref() + .map(|l| !l.is_empty()) + .unwrap_or(false)); + } + + #[test] + fn test_pool_response_error_deserialization() { + let json = r#"{ + "taskId": "test-123", + "success": false, + "error": { + "message": "Plugin failed", + "code": "EXEC_ERROR", + "status": 500 + }, + "logs": [] + }"#; + + let response: PoolResponse = serde_json::from_str(json).unwrap(); + assert_eq!(response.task_id, "test-123"); + assert!(!response.success); + assert!(response.error.is_some()); + let err = response.error.unwrap(); + assert_eq!(err.message, "Plugin failed"); + assert_eq!(err.code, Some("EXEC_ERROR".to_string())); + assert_eq!(err.status, Some(500)); + } + + #[test] + fn test_pool_log_entry_conversion() { + let pool_entry = PoolLogEntry { + level: "error".to_string(), + message: "test error".to_string(), + }; + + let log_entry: LogEntry = pool_entry.into(); + assert!(matches!(log_entry.level, LogLevel::Error)); + assert_eq!(log_entry.message, "test error"); + } + + #[test] + fn test_pool_log_entry_level_conversion() { + let levels = vec![ + ("log", LogLevel::Log), + ("info", LogLevel::Info), + ("warn", LogLevel::Warn), + ("error", LogLevel::Error), + ("debug", LogLevel::Debug), + ("unknown", LogLevel::Log), + ]; + + for (input, expected) in levels { + let pool_entry = PoolLogEntry { + level: input.to_string(), + message: "test".to_string(), + }; + let log_entry: LogEntry = pool_entry.into(); + assert!( + matches!(log_entry.level, ref e if std::mem::discriminant(e) == std::mem::discriminant(&expected)), + "Expected {:?} for input '{}', got {:?}", + expected, + input, + log_entry.level + ); + } + } +} diff --git a/src/services/plugins/runner.rs b/src/services/plugins/runner.rs index b3f625335..dd2ba41c7 100644 --- a/src/services/plugins/runner.rs +++ b/src/services/plugins/runner.rs @@ -1,14 +1,23 @@ //! This module is the orchestrator of the plugin execution. //! -//! 1. Initiates a socket connection to the relayer server - socket.rs -//! 2. Executes the plugin script - script_executor.rs -//! 3. Sends the shutdown signal to the relayer server - socket.rs -//! 4. Waits for the relayer server to finish the execution - socket.rs -//! 5. Returns the output of the script - script_executor.rs +//! 1. Initiates connection to shared socket service - shared_socket.rs +//! 2. Executes the plugin script - script_executor.rs OR pool_executor.rs +//! 3. Collects traces via ExecutionGuard - shared_socket.rs +//! 4. Returns the output of the script - script_executor.rs //! -use std::{sync::Arc, time::Duration}; +//! ## Execution Modes +//! +//! - **Pool mode** (default): Uses persistent Piscina worker pool. +//! Faster execution with precompilation and worker reuse. +//! - **ts-node mode** (`PLUGIN_USE_POOL=false`): Spawns ts-node per request. +//! Simple but slower. Uses the shared socket for bidirectional communication. +//! +use std::{collections::HashMap, sync::Arc, time::Duration}; -use crate::services::plugins::{RelayerApi, ScriptExecutor, ScriptResult, SocketService}; +use crate::services::plugins::{ + ensure_shared_socket_started, get_pool_manager, get_shared_socket_service, ScriptExecutor, + ScriptResult, +}; use crate::{ jobs::JobProducerTrait, models::{ @@ -21,13 +30,28 @@ use crate::{ }, }; -use super::PluginError; +use super::{config::get_config, PluginError}; use async_trait::async_trait; -use tokio::{sync::oneshot, time::timeout}; +use tokio::time::timeout; +use tracing::debug; +use uuid::Uuid; #[cfg(test)] use mockall::automock; +/// Check if pool-based execution is enabled via environment variable +/// Pool mode is enabled by default for better performance +fn use_pool_executor() -> bool { + std::env::var("PLUGIN_USE_POOL") + .map(|v| v.eq_ignore_ascii_case("true") || v == "1") + .unwrap_or(true) // Pool mode is now the default +} + +/// Get trace timeout duration from centralized config +fn get_trace_timeout() -> Duration { + Duration::from_millis(get_config().trace_timeout_ms) +} + #[cfg_attr(test, automock)] #[async_trait] pub trait PluginRunnerTrait { @@ -45,6 +69,7 @@ pub trait PluginRunnerTrait { config_json: Option, method: Option, query_json: Option, + emit_traces: bool, state: Arc>, ) -> Result where @@ -81,6 +106,82 @@ impl PluginRunnerTrait for PluginRunner { config_json: Option, method: Option, query_json: Option, + emit_traces: bool, + state: Arc>, + ) -> Result + where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + + Repository + + Send + + Sync + + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, + { + // Choose execution mode based on environment variable + if use_pool_executor() { + return self + .run_with_pool( + plugin_id, + socket_path, + script_path, + timeout_duration, + script_params, + http_request_id, + headers_json, + route, + config_json, + method, + query_json, + emit_traces, + state, + ) + .await; + } + + // Default: ts-node execution + self.run_with_tsnode( + plugin_id, + socket_path, + script_path, + timeout_duration, + script_params, + http_request_id, + headers_json, + route, + config_json, + method, + query_json, + emit_traces, + state, + ) + .await + } +} + +impl PluginRunner { + /// Execute plugin using ts-node with shared socket + #[allow(clippy::too_many_arguments, clippy::type_complexity)] + async fn run_with_tsnode( + &self, + plugin_id: String, + _socket_path: &str, // Unused - kept for signature compatibility + script_path: String, + timeout_duration: Duration, + script_params: String, + http_request_id: Option, + headers_json: Option, + route: Option, + config_json: Option, + method: Option, + query_json: Option, + _emit_traces: bool, state: Arc>, ) -> Result where @@ -98,24 +199,29 @@ impl PluginRunnerTrait for PluginRunner { PR: PluginRepositoryTrait + Send + Sync + 'static, AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, { - let socket_service = SocketService::new(socket_path)?; - let socket_path_clone = socket_service.socket_path().to_string(); + // Ensure shared socket is started + ensure_shared_socket_started(Arc::clone(&state)).await?; + + // Get the shared socket service + let shared_socket = get_shared_socket_service()?; + let shared_socket_path = shared_socket.socket_path().to_string(); - let (shutdown_tx, shutdown_rx) = oneshot::channel(); + // Generate execution_id from http_request_id or plugin_id + let execution_id = http_request_id + .clone() + .unwrap_or_else(|| format!("{}-{}", plugin_id, uuid::Uuid::new_v4())); - let server_handle = tokio::spawn(async move { - let relayer_api = Arc::new(RelayerApi); - socket_service.listen(shutdown_rx, state, relayer_api).await - }); + // Register execution (RAII guard auto-unregisters on drop) + let guard = shared_socket.register_execution(execution_id.clone()).await; let exec_outcome = match timeout( timeout_duration, ScriptExecutor::execute_typescript( plugin_id, script_path, - socket_path_clone, + shared_socket_path, // Use shared socket path script_params, - http_request_id, + Some(execution_id), headers_json, route, config_json, @@ -127,21 +233,19 @@ impl PluginRunnerTrait for PluginRunner { { Ok(result) => result, Err(_) => { - // ensures the socket gets closed. - let _ = shutdown_tx.send(()); return Err(PluginError::ScriptTimeout(timeout_duration.as_secs())); } }; - let _ = shutdown_tx.send(()); - - let server_handle = server_handle - .await - .map_err(|e| PluginError::SocketError(e.to_string()))?; - - let traces = match server_handle { - Ok(traces) => traces, - Err(e) => return Err(PluginError::SocketError(e.to_string())), + // Collect traces from the guard + let mut traces_rx = guard.into_receiver(); + let traces = match tokio::time::timeout(get_trace_timeout(), traces_rx.recv()).await { + Ok(Some(traces)) => traces, + Ok(None) => Vec::new(), + Err(_) => { + debug!("Timeout waiting for traces"); + Vec::new() + } }; match exec_outcome { @@ -153,6 +257,131 @@ impl PluginRunnerTrait for PluginRunner { Err(err) => Err(err.with_traces(traces)), } } + + /// Execute plugin using worker pool (new high-performance mode) + /// Uses shared socket service for better scalability + #[allow(clippy::too_many_arguments, clippy::type_complexity)] + async fn run_with_pool( + &self, + plugin_id: String, + _socket_path: &str, // Unused - we use shared socket instead + script_path: String, + timeout_duration: Duration, + script_params: String, + http_request_id: Option, + headers_json: Option, + route: Option, + config_json: Option, + method: Option, + query_json: Option, + emit_traces: bool, + state: Arc>, + ) -> Result + where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + + Repository + + Send + + Sync + + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, + { + // Ensure shared socket service is started + ensure_shared_socket_started(Arc::clone(&state)).await?; + + // Get shared socket service + let shared_socket = get_shared_socket_service()?; + let shared_socket_path = shared_socket.socket_path().to_string(); + + // Generate execution ID (use http_request_id if available, otherwise generate one) + let execution_id = http_request_id + .clone() + .unwrap_or_else(|| format!("exec-{}", Uuid::new_v4())); + + // Always register execution so API calls from plugin can be validated + // ExecutionGuard will auto-unregister on drop (RAII pattern) + let execution_guard = shared_socket.register_execution(execution_id.clone()).await; + + // Execute via pool manager (using shared socket path) + let pool_manager = get_pool_manager(); + + // Parse params as JSON Value + let params: serde_json::Value = serde_json::from_str(&script_params) + .unwrap_or(serde_json::Value::String(script_params.clone())); + + // Parse headers if present + let headers: Option>> = headers_json + .as_ref() + .and_then(|h| serde_json::from_str(h).ok()); + + // Parse config if present + let config: Option = config_json + .as_ref() + .and_then(|c| serde_json::from_str(c).ok()); + + // Parse query if present + let query: Option = query_json + .as_ref() + .and_then(|q| serde_json::from_str(q).ok()); + + let exec_outcome = match timeout( + timeout_duration, + pool_manager.execute_plugin( + plugin_id.clone(), + None, // compiled_code - will be fetched from cache + Some(script_path), // plugin_path + params, + headers, + shared_socket_path, // Use shared socket path instead of unique one + Some(execution_id.clone()), // Pass the registered execution_id + Some(timeout_duration.as_secs()), + route, + config, + method, + query, + ), + ) + .await + { + Ok(result) => result, + Err(_) => { + // No need to manually unregister - ExecutionGuard handles it + return Err(PluginError::ScriptTimeout(timeout_duration.as_secs())); + } + }; + + // Collect traces only if emit_traces is enabled + let traces = if emit_traces { + // Convert guard to receiver only now, keeping guard alive during execution + let mut rx = execution_guard.into_receiver(); + // Wait for traces with short timeout - they arrive immediately if the plugin used the API + let trace_timeout = get_trace_timeout().min(timeout_duration); + match timeout(trace_timeout, rx.recv()).await { + Ok(Some(traces)) => traces, + Ok(None) | Err(_) => Vec::new(), + } + } else { + // Drop the guard without waiting for traces + drop(execution_guard); + Vec::new() + }; + + // ExecutionGuard auto-unregisters when guard is dropped (after trace collection) + + match exec_outcome { + Ok(mut script_result) => { + script_result.trace = traces; + Ok(script_result) + } + Err(e) => Err(e.with_traces(traces)), + } + } } #[cfg(test)] @@ -189,6 +418,9 @@ mod tests { #[tokio::test] async fn test_run() { + // Use ts-node mode for this test since temp files are outside plugins directory + std::env::set_var("PLUGIN_USE_POOL", "false"); + let temp_dir = tempdir().unwrap(); let ts_config = temp_dir.path().join("tsconfig.json"); let script_path = temp_dir.path().join("test_run.ts"); @@ -223,9 +455,14 @@ mod tests { None, None, None, + false, // emit_traces Arc::new(web::ThinData(state)), ) .await; + + // Cleanup env var + std::env::remove_var("PLUGIN_USE_POOL"); + if matches!( result, Err(PluginError::SocketError(ref msg)) if msg.contains("Operation not permitted") @@ -244,6 +481,9 @@ mod tests { #[tokio::test] async fn test_run_timeout() { + // Use ts-node mode for this test since temp files are outside plugins directory + std::env::set_var("PLUGIN_USE_POOL", "false"); + let temp_dir = tempdir().unwrap(); let ts_config = temp_dir.path().join("tsconfig.json"); let script_path = temp_dir.path().join("test_simple_timeout.ts"); @@ -286,10 +526,14 @@ mod tests { None, None, None, + false, // emit_traces Arc::new(web::ThinData(state)), ) .await; + // Cleanup env var + std::env::remove_var("PLUGIN_USE_POOL"); + // Should timeout if matches!( result, diff --git a/src/services/plugins/script_executor.rs b/src/services/plugins/script_executor.rs index e661257b2..0346d70c6 100644 --- a/src/services/plugins/script_executor.rs +++ b/src/services/plugins/script_executor.rs @@ -474,6 +474,509 @@ mod tests { assert_eq!(return_obj["params"], serde_json::json!({"foo": "bar"})); } + #[tokio::test] + async fn test_execute_typescript_all_log_levels() { + let temp_dir = tempdir().unwrap(); + let ts_config = temp_dir.path().join("tsconfig.json"); + let script_path = temp_dir + .path() + .join("test_execute_typescript_all_log_levels.ts"); + let socket_path = temp_dir + .path() + .join("test_execute_typescript_all_log_levels.sock"); + + let content = r#" + export async function handler(api: any, params: any) { + console.log('log message'); + console.info('info message'); + console.warn('warn message'); + console.error('error message'); + console.debug('debug message'); + return 'success'; + } + "#; + fs::write(script_path.clone(), content).unwrap(); + fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap(); + + let result = ScriptExecutor::execute_typescript( + "test-plugin-log-levels".to_string(), + script_path.display().to_string(), + socket_path.display().to_string(), + "{}".to_string(), + None, + None, + None, + None, + None, + None, + ) + .await; + + assert!(result.is_ok()); + let result = result.unwrap(); + assert_eq!(result.logs.len(), 5); + assert_eq!(result.logs[0].level, LogLevel::Log); + assert_eq!(result.logs[0].message, "log message"); + assert_eq!(result.logs[1].level, LogLevel::Info); + assert_eq!(result.logs[1].message, "info message"); + assert_eq!(result.logs[2].level, LogLevel::Warn); + assert_eq!(result.logs[2].message, "warn message"); + assert_eq!(result.logs[3].level, LogLevel::Error); + assert_eq!(result.logs[3].message, "error message"); + assert_eq!(result.logs[4].level, LogLevel::Debug); + assert_eq!(result.logs[4].message, "debug message"); + assert_eq!(result.return_value, "success"); + } + + #[tokio::test] + async fn test_execute_typescript_with_request_id() { + let temp_dir = tempdir().unwrap(); + let ts_config = temp_dir.path().join("tsconfig.json"); + let script_path = temp_dir + .path() + .join("test_execute_typescript_with_request_id.ts"); + let socket_path = temp_dir + .path() + .join("test_execute_typescript_with_request_id.sock"); + + // Note: The request ID is passed to the executor but not directly accessible + // in the plugin handler. It's used for internal tracing/logging. + // This test verifies the parameter is accepted without errors. + let content = r#" + export async function handler(api: any, params: any) { + console.log('handler executed'); + return { status: 'ok' }; + } + "#; + fs::write(script_path.clone(), content).unwrap(); + fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap(); + + let result = ScriptExecutor::execute_typescript( + "test-plugin-request-id".to_string(), + script_path.display().to_string(), + socket_path.display().to_string(), + "{}".to_string(), + Some("req-12345-abcde".to_string()), + None, + None, + None, + None, + None, + ) + .await; + + assert!(result.is_ok()); + let result = result.unwrap(); + assert_eq!(result.logs[0].level, LogLevel::Log); + assert_eq!(result.logs[0].message, "handler executed"); + assert_eq!(result.return_value, "{\"status\":\"ok\"}"); + } + + #[tokio::test] + async fn test_execute_typescript_empty_return() { + let temp_dir = tempdir().unwrap(); + let ts_config = temp_dir.path().join("tsconfig.json"); + let script_path = temp_dir + .path() + .join("test_execute_typescript_empty_return.ts"); + let socket_path = temp_dir + .path() + .join("test_execute_typescript_empty_return.sock"); + + let content = r#" + export async function handler(api: any, params: any) { + console.log('handler called'); + // Return undefined (no explicit return) + } + "#; + fs::write(script_path.clone(), content).unwrap(); + fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap(); + + let result = ScriptExecutor::execute_typescript( + "test-plugin-empty-return".to_string(), + script_path.display().to_string(), + socket_path.display().to_string(), + "{}".to_string(), + None, + None, + None, + None, + None, + None, + ) + .await; + + assert!(result.is_ok()); + let result = result.unwrap(); + assert_eq!(result.logs[0].level, LogLevel::Log); + assert_eq!(result.logs[0].message, "handler called"); + // undefined becomes empty string or "undefined" depending on serialization + assert!(result.return_value.is_empty() || result.return_value == "undefined"); + } + + #[tokio::test] + async fn test_execute_typescript_null_return() { + let temp_dir = tempdir().unwrap(); + let ts_config = temp_dir.path().join("tsconfig.json"); + let script_path = temp_dir + .path() + .join("test_execute_typescript_null_return.ts"); + let socket_path = temp_dir + .path() + .join("test_execute_typescript_null_return.sock"); + + let content = r#" + export async function handler(api: any, params: any) { + console.log('returning null'); + return null; + } + "#; + fs::write(script_path.clone(), content).unwrap(); + fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap(); + + let result = ScriptExecutor::execute_typescript( + "test-plugin-null-return".to_string(), + script_path.display().to_string(), + socket_path.display().to_string(), + "{}".to_string(), + None, + None, + None, + None, + None, + None, + ) + .await; + + assert!(result.is_ok()); + let result = result.unwrap(); + assert_eq!(result.return_value, "null"); + } + + #[tokio::test] + async fn test_execute_typescript_legacy_two_param_handler() { + let temp_dir = tempdir().unwrap(); + let ts_config = temp_dir.path().join("tsconfig.json"); + let script_path = temp_dir + .path() + .join("test_execute_typescript_legacy_handler.ts"); + let socket_path = temp_dir + .path() + .join("test_execute_typescript_legacy_handler.sock"); + + // Explicitly test the legacy 2-parameter handler pattern + let content = r#" + export async function handler(api: any, params: any) { + console.log('legacy handler'); + return { + paramsReceived: params, + handlerType: 'legacy-2-param' + }; + } + "#; + fs::write(script_path.clone(), content).unwrap(); + fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap(); + + let result = ScriptExecutor::execute_typescript( + "test-plugin-legacy".to_string(), + script_path.display().to_string(), + socket_path.display().to_string(), + r#"{"key":"value"}"#.to_string(), + None, + None, + None, + None, + None, + None, + ) + .await; + + assert!(result.is_ok()); + let result = result.unwrap(); + assert_eq!(result.logs[0].level, LogLevel::Log); + assert_eq!(result.logs[0].message, "legacy handler"); + + let return_obj: serde_json::Value = + serde_json::from_str(&result.return_value).expect("Failed to parse return value"); + assert_eq!(return_obj["handlerType"], "legacy-2-param"); + assert_eq!( + return_obj["paramsReceived"], + serde_json::json!({"key": "value"}) + ); + } + + #[tokio::test] + async fn test_execute_typescript_context_single_param_handler() { + let temp_dir = tempdir().unwrap(); + let ts_config = temp_dir.path().join("tsconfig.json"); + let script_path = temp_dir + .path() + .join("test_execute_typescript_context_handler.ts"); + let socket_path = temp_dir + .path() + .join("test_execute_typescript_context_handler.sock"); + + // Test the modern context-based single parameter handler + let content = r#" + export async function handler(context: any) { + const { api, params, kv, headers } = context; + console.log('modern context handler'); + return { + paramsReceived: params, + hasApi: !!api, + hasKv: !!kv, + hasHeaders: !!headers, + handlerType: 'modern-context' + }; + } + "#; + fs::write(script_path.clone(), content).unwrap(); + fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap(); + + let result = ScriptExecutor::execute_typescript( + "test-plugin-context".to_string(), + script_path.display().to_string(), + socket_path.display().to_string(), + r#"{"foo":"bar"}"#.to_string(), + None, + None, + None, + None, + None, + None, + ) + .await; + + assert!(result.is_ok()); + let result = result.unwrap(); + + let return_obj: serde_json::Value = + serde_json::from_str(&result.return_value).expect("Failed to parse return value"); + assert_eq!(return_obj["handlerType"], "modern-context"); + assert_eq!(return_obj["hasApi"], true); + assert_eq!(return_obj["hasKv"], true); + assert_eq!(return_obj["hasHeaders"], true); + assert_eq!( + return_obj["paramsReceived"], + serde_json::json!({"foo": "bar"}) + ); + } + + #[tokio::test] + async fn test_execute_typescript_complex_params() { + let temp_dir = tempdir().unwrap(); + let ts_config = temp_dir.path().join("tsconfig.json"); + let script_path = temp_dir + .path() + .join("test_execute_typescript_complex_params.ts"); + let socket_path = temp_dir + .path() + .join("test_execute_typescript_complex_params.sock"); + + let content = r#" + export async function handler(api: any, params: any) { + console.log(`Received ${params.items.length} items`); + return { + processedItems: params.items.map((item: any) => ({ + ...item, + processed: true + })), + metadata: params.metadata, + totalCount: params.items.length + }; + } + "#; + fs::write(script_path.clone(), content).unwrap(); + fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap(); + + let complex_params = r#"{ + "items": [ + {"id": 1, "name": "item1", "tags": ["a", "b"]}, + {"id": 2, "name": "item2", "tags": ["c", "d"]} + ], + "metadata": { + "source": "test", + "timestamp": 1234567890, + "nested": { + "deep": { + "value": true + } + } + } + }"#; + + let result = ScriptExecutor::execute_typescript( + "test-plugin-complex-params".to_string(), + script_path.display().to_string(), + socket_path.display().to_string(), + complex_params.to_string(), + None, + None, + None, + None, + None, + None, + ) + .await; + + assert!(result.is_ok()); + let result = result.unwrap(); + assert_eq!(result.logs[0].level, LogLevel::Log); + assert!(result.logs[0].message.contains("Received 2 items")); + + let return_obj: serde_json::Value = + serde_json::from_str(&result.return_value).expect("Failed to parse return value"); + assert_eq!(return_obj["totalCount"], 2); + assert_eq!(return_obj["processedItems"][0]["processed"], true); + assert_eq!(return_obj["processedItems"][1]["processed"], true); + assert_eq!(return_obj["processedItems"][0]["name"], "item1"); + assert_eq!(return_obj["metadata"]["source"], "test"); + assert_eq!(return_obj["metadata"]["nested"]["deep"]["value"], true); + } + + #[tokio::test] + async fn test_execute_typescript_empty_params() { + let temp_dir = tempdir().unwrap(); + let ts_config = temp_dir.path().join("tsconfig.json"); + let script_path = temp_dir + .path() + .join("test_execute_typescript_empty_params.ts"); + let socket_path = temp_dir + .path() + .join("test_execute_typescript_empty_params.sock"); + + let content = r#" + export async function handler(api: any, params: any) { + console.log(`Params is empty: ${Object.keys(params).length === 0}`); + return { receivedEmptyParams: Object.keys(params).length === 0 }; + } + "#; + fs::write(script_path.clone(), content).unwrap(); + fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap(); + + let result = ScriptExecutor::execute_typescript( + "test-plugin-empty-params".to_string(), + script_path.display().to_string(), + socket_path.display().to_string(), + "{}".to_string(), + None, + None, + None, + None, + None, + None, + ) + .await; + + assert!(result.is_ok()); + let result = result.unwrap(); + assert!(result.logs[0].message.contains("Params is empty: true")); + + let return_obj: serde_json::Value = + serde_json::from_str(&result.return_value).expect("Failed to parse return value"); + assert_eq!(return_obj["receivedEmptyParams"], true); + } + + #[tokio::test] + async fn test_execute_typescript_array_return() { + let temp_dir = tempdir().unwrap(); + let ts_config = temp_dir.path().join("tsconfig.json"); + let script_path = temp_dir + .path() + .join("test_execute_typescript_array_return.ts"); + let socket_path = temp_dir + .path() + .join("test_execute_typescript_array_return.sock"); + + let content = r#" + export async function handler(api: any, params: any) { + console.log('returning array'); + return [1, 2, 3, { nested: 'value' }, 'string']; + } + "#; + fs::write(script_path.clone(), content).unwrap(); + fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap(); + + let result = ScriptExecutor::execute_typescript( + "test-plugin-array-return".to_string(), + script_path.display().to_string(), + socket_path.display().to_string(), + "{}".to_string(), + None, + None, + None, + None, + None, + None, + ) + .await; + + assert!(result.is_ok()); + let result = result.unwrap(); + + let return_array: serde_json::Value = + serde_json::from_str(&result.return_value).expect("Failed to parse return value"); + assert!(return_array.is_array()); + assert_eq!(return_array[0], 1); + assert_eq!(return_array[1], 2); + assert_eq!(return_array[2], 3); + assert_eq!(return_array[3]["nested"], "value"); + assert_eq!(return_array[4], "string"); + } + + #[tokio::test] + async fn test_execute_typescript_multiple_errors() { + let temp_dir = tempdir().unwrap(); + let ts_config = temp_dir.path().join("tsconfig.json"); + let script_path = temp_dir + .path() + .join("test_execute_typescript_multiple_errors.ts"); + let socket_path = temp_dir + .path() + .join("test_execute_typescript_multiple_errors.sock"); + + let content = r#" + export async function handler(api: any, params: any) { + console.error('Error message 1'); + console.error('Error message 2'); + console.warn('Warning message'); + throw new Error('Handler failed'); + } + "#; + fs::write(script_path.clone(), content).unwrap(); + fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap(); + + let result = ScriptExecutor::execute_typescript( + "test-plugin-multiple-errors".to_string(), + script_path.display().to_string(), + socket_path.display().to_string(), + "{}".to_string(), + None, + None, + None, + None, + None, + None, + ) + .await; + + assert!(result.is_err()); + if let Err(PluginError::HandlerError(ctx)) = result { + // Should capture logs even when handler fails + let logs = ctx.logs.expect("logs should be present"); + assert_eq!(logs.len(), 3); + assert_eq!(logs[0].level, LogLevel::Error); + assert_eq!(logs[0].message, "Error message 1"); + assert_eq!(logs[1].level, LogLevel::Error); + assert_eq!(logs[1].message, "Error message 2"); + assert_eq!(logs[2].level, LogLevel::Warn); + assert_eq!(logs[2].message, "Warning message"); + assert!(ctx.message.contains("Handler failed")); + } else { + panic!("Expected HandlerError"); + } + } + #[tokio::test] async fn test_execute_typescript_with_route() { let temp_dir = tempdir().unwrap(); diff --git a/src/services/plugins/shared_socket.rs b/src/services/plugins/shared_socket.rs new file mode 100644 index 000000000..e8db243a8 --- /dev/null +++ b/src/services/plugins/shared_socket.rs @@ -0,0 +1,1599 @@ +//! Shared Socket Service +//! +//! This module provides a unified bidirectional Unix socket service for plugin communication. +//! Instead of creating separate sockets for registration and API calls, all communication +//! happens over a single shared socket, dramatically reducing overhead and complexity. +//! +//! ## Architecture +//! +//! **Single Shared Socket**: All plugins connect to `/tmp/relayer-plugin-shared.sock` +//! +//! **Bidirectional Communication**: +//! - Plugins → Host: Register, ApiRequest, Trace, Shutdown +//! - Host → Plugins: ApiResponse +//! +//! **Connection Tagging (Security)**: Each connection is "tagged" with an execution_id +//! after the first Register message. All subsequent messages are validated against this +//! tagged ID to prevent spoofing attacks (Plugin A cannot impersonate Plugin B). +//! +//! ## Message Protocol +//! +//! All messages are JSON objects with a `type` field that discriminates the message type: +//! +//! ### Plugin → Host Messages +//! +//! **Register** (first message, required): +//! ```json +//! { +//! "type": "register", +//! "execution_id": "abc-123" +//! } +//! ``` +//! +//! **ApiRequest** (call Relayer API): +//! ```json +//! { +//! "type": "api_request", +//! "request_id": "req-1", +//! "relayer_id": "relayer-1", +//! "method": "sendTransaction", +//! "payload": { "to": "0x...", "value": "100" } +//! } +//! ``` +//! +//! **Trace** (observability event): +//! ```json +//! { +//! "type": "trace", +//! "trace": { "event": "processing", "timestamp": 1234567890 } +//! } +//! ``` +//! +//! **Shutdown** (graceful close): +//! ```json +//! { +//! "type": "shutdown" +//! } +//! ``` +//! +//! ### Host → Plugin Messages +//! +//! **ApiResponse** (Relayer API result): +//! ```json +//! { +//! "type": "api_response", +//! "request_id": "req-1", +//! "result": { "id": "tx-123", "status": "success" }, +//! "error": null +//! } +//! ``` +//! +//! ## Security Model +//! +//! The connection tagging mechanism prevents execution_id spoofing: +//! +//! 1. Plugin connects to shared socket +//! 2. Plugin sends Register message with execution_id +//! 3. Host "tags" the connection (file descriptor) with that execution_id +//! 4. All subsequent messages are validated against the tagged ID +//! 5. Attempts to change execution_id are rejected and connection is closed +//! +//! This ensures Plugin A cannot send requests pretending to be Plugin B, even though +//! they share the same socket file. +//! +//! ## Backward Compatibility +//! +//! The handle_connection method maintains backward compatibility with the legacy +//! Request/Response format from socket.rs. If a message doesn't parse as PluginMessage, +//! it attempts to parse as the legacy Request format and handles it accordingly. +//! +//! ## Performance Benefits vs Per-Execution Sockets +//! +//! | Metric | Shared Socket | Per-Execution Socket | +//! |--------|---------------|----------------------| +//! | File descriptors | 1 per plugin | 2 per plugin | +//! | Syscalls | ~50% fewer | Baseline | +//! | Connection setup | Reuse existing | Create new each time | +//! | Memory overhead | O(active executions) | O(active executions × 2) | +//! | Debugging | Single stream | Two separate streams | +//! +//! ## Example Usage +//! +//! ```rust,no_run +//! use openzeppelin_relayer::services::plugins::shared_socket::{ +//! get_shared_socket_service, ensure_shared_socket_started +//! }; +//! +//! # async fn example() -> Result<(), Box> { +//! // Get the global shared socket instance +//! let service = get_shared_socket_service()?; +//! +//! // Register an execution (returns RAII guard) +//! let guard = service.register_execution("exec-123".to_string()).await; +//! +//! // Plugin connects and sends messages over the shared socket... +//! // (handled automatically by the background listener) +//! +//! // Collect traces when done +//! let mut traces_rx = guard.into_receiver(); +//! let traces = traces_rx.recv().await; +//! # Ok(()) +//! # } +//! ``` + +use super::config::get_config; +use crate::jobs::JobProducerTrait; +use crate::models::{ + NetworkRepoModel, NotificationRepoModel, RelayerRepoModel, SignerRepoModel, ThinDataAppState, + TransactionRepoModel, +}; +use crate::repositories::{ + ApiKeyRepositoryTrait, NetworkRepository, PluginRepositoryTrait, RelayerRepository, Repository, + TransactionCounterTrait, TransactionRepository, +}; +use crate::services::plugins::relayer_api::{RelayerApi, Request}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::{UnixListener, UnixStream}; +use tokio::sync::{mpsc, watch, RwLock, Semaphore}; +use tracing::{debug, info, warn}; + +use super::PluginError; + +/// Unified message protocol for bidirectional communication +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum PluginMessage { + /// Plugin registers its execution_id (first message from plugin) + Register { execution_id: String }, + /// Plugin requests a Relayer API call + ApiRequest { + request_id: String, + relayer_id: String, + method: crate::services::plugins::relayer_api::PluginMethod, + payload: serde_json::Value, + }, + /// Host responds to an API request + ApiResponse { + request_id: String, + result: Option, + error: Option, + }, + /// Plugin sends a trace event (for observability) + Trace { trace: serde_json::Value }, + /// Plugin signals completion + Shutdown, +} + +/// Execution context for trace collection +struct ExecutionContext { + /// Channel to send traces back to the execution + traces_tx: mpsc::Sender>, + /// Creation timestamp for TTL cleanup + created_at: Instant, + /// The execution_id bound to this connection (for security) + /// Once set, all messages must match this ID to prevent spoofing + #[allow(dead_code)] // Used for security validation, not directly read + bound_execution_id: String, +} + +/// RAII guard for execution registration that auto-unregisters on drop +pub struct ExecutionGuard { + execution_id: String, + executions: Arc>>, + rx: Option>>, +} + +impl ExecutionGuard { + /// Get the trace receiver + pub fn into_receiver(mut self) -> mpsc::Receiver> { + self.rx.take().expect("Receiver already taken") + } +} + +impl Drop for ExecutionGuard { + fn drop(&mut self) { + // Auto-unregister on drop (prevents memory leaks) + let executions = self.executions.clone(); + let execution_id = self.execution_id.clone(); + tokio::spawn(async move { + let mut map = executions.write().await; + map.remove(&execution_id); + }); + } +} + +/// Shared socket service that handles multiple concurrent plugin executions +pub struct SharedSocketService { + /// Socket path + socket_path: String, + /// Active execution contexts (execution_id -> ExecutionContext) + /// RwLock is sufficient for write-once, read-once pattern + executions: Arc>>, + /// Whether the listener has been started (instance-level flag) + started: AtomicBool, + /// Shutdown signal sender + shutdown_tx: watch::Sender, + /// Semaphore for connection limiting (prevents race conditions) + connection_semaphore: Arc, + /// Connection idle timeout + idle_timeout: Duration, + /// Read timeout per line + read_timeout: Duration, +} + +impl SharedSocketService { + /// Create a new shared socket service + pub fn new(socket_path: &str) -> Result { + // Remove existing socket file if it exists (from previous runs or crashed processes) + let _ = std::fs::remove_file(socket_path); + + let (shutdown_tx, _) = watch::channel(false); + + // Use centralized config + let config = get_config(); + let idle_timeout = Duration::from_secs(config.socket_idle_timeout_secs); + let read_timeout = Duration::from_secs(config.socket_read_timeout_secs); + let max_connections = config.socket_max_connections; + + let executions: Arc>> = + Arc::new(RwLock::new(HashMap::new())); + + // Spawn background cleanup task for stale executions (prevents memory leaks) + let executions_clone = executions.clone(); + let mut cleanup_shutdown_rx = shutdown_tx.subscribe(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(60)); + loop { + tokio::select! { + _ = interval.tick() => {} + _ = cleanup_shutdown_rx.changed() => { + if *cleanup_shutdown_rx.borrow() { + break; + } + } + } + let now = Instant::now(); + let mut map = executions_clone.write().await; + // Remove entries older than 5 minutes + map.retain(|_, ctx| now.duration_since(ctx.created_at) < Duration::from_secs(300)); + } + }); + + Ok(Self { + socket_path: socket_path.to_string(), + executions, + started: AtomicBool::new(false), + shutdown_tx, + connection_semaphore: Arc::new(Semaphore::new(max_connections)), + idle_timeout, + read_timeout, + }) + } + + pub fn socket_path(&self) -> &str { + &self.socket_path + } + + /// Register an execution and return a guard that auto-unregisters on drop + /// This prevents memory leaks from forgotten unregister calls + pub async fn register_execution(&self, execution_id: String) -> ExecutionGuard { + let (tx, rx) = mpsc::channel(1); + let mut map = self.executions.write().await; + map.insert( + execution_id.clone(), + ExecutionContext { + traces_tx: tx, + created_at: Instant::now(), + bound_execution_id: execution_id.clone(), + }, + ); + + ExecutionGuard { + execution_id, + executions: self.executions.clone(), + rx: Some(rx), + } + } + + /// Get current number of available connection slots + pub fn available_connection_slots(&self) -> usize { + self.connection_semaphore.available_permits() + } + + /// Get current active connection count + pub fn active_connection_count(&self) -> usize { + get_config().socket_max_connections - self.connection_semaphore.available_permits() + } + + /// Signal shutdown to the listener and wait for active connections to drain + pub async fn shutdown(&self) { + let _ = self.shutdown_tx.send(true); + info!("Shared socket service: shutdown signal sent"); + + // Wait for active connections to drain (max 30 seconds) + let max_wait = Duration::from_secs(30); + let start = Instant::now(); + + while start.elapsed() < max_wait { + let available = self.connection_semaphore.available_permits(); + if available == get_config().socket_max_connections { + // All permits returned - no active connections + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + + // Remove socket file after connections drained + let _ = std::fs::remove_file(&self.socket_path); + info!("Shared socket service: shutdown complete"); + } + + /// Start the shared socket service + /// This spawns a background task that listens for connections + /// Safe to call multiple times - will only start once per instance + #[allow(clippy::type_complexity)] + pub async fn start( + self: Arc, + state: Arc>, + ) -> Result<(), PluginError> + where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + + Repository + + Send + + Sync + + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, + { + // Check if already started (instance-level flag) + if self.started.swap(true, Ordering::Acquire) { + return Ok(()); + } + + // Create the listener and move it into the task + let listener = UnixListener::bind(&self.socket_path) + .map_err(|e| PluginError::SocketError(format!("Failed to bind listener: {e}")))?; + let executions = self.executions.clone(); + let relayer_api = Arc::new(RelayerApi); + let socket_path = self.socket_path.clone(); + let mut shutdown_rx = self.shutdown_tx.subscribe(); + let connection_semaphore = self.connection_semaphore.clone(); + let idle_timeout = self.idle_timeout; + let read_timeout = self.read_timeout; + + debug!( + "Shared socket service: starting listener on {}", + socket_path + ); + + // Spawn the listener task + tokio::spawn(async move { + debug!("Shared socket service: listener task started"); + loop { + tokio::select! { + // Check for shutdown signal + _ = shutdown_rx.changed() => { + if *shutdown_rx.borrow() { + info!("Shared socket service: shutting down listener"); + break; + } + } + // Accept new connections + accept_result = listener.accept() => { + match accept_result { + Ok((stream, _)) => { + // Try to acquire semaphore permit (no race condition!) + match connection_semaphore.clone().try_acquire_owned() { + Ok(permit) => { + debug!("Shared socket service: accepted new connection"); + + let relayer_api_clone = relayer_api.clone(); + let state_clone = Arc::clone(&state); + let executions_clone = executions.clone(); + + tokio::spawn(async move { + // Permit held until task completes (auto-released on drop) + let _permit = permit; + + let result = Self::handle_connection_with_timeout( + stream, + relayer_api_clone, + state_clone, + executions_clone, + idle_timeout, + read_timeout, + ) + .await; + + if let Err(e) = result { + debug!("Connection handler finished with error: {}", e); + } + }); + } + Err(_) => { + warn!( + "Connection limit reached, rejecting new connection. \ + Consider increasing PLUGIN_MAX_CONCURRENCY or PLUGIN_SOCKET_MAX_CONCURRENT_CONNECTIONS." + ); + drop(stream); + } + } + } + Err(e) => { + warn!("Error accepting connection: {}", e); + } + } + } + } + } + + // Cleanup on shutdown + let _ = std::fs::remove_file(&socket_path); + info!("Shared socket service: listener stopped"); + }); + + Ok(()) + } + + /// Handle connection with overall idle timeout + #[allow(clippy::type_complexity)] + async fn handle_connection_with_timeout( + stream: UnixStream, + relayer_api: Arc, + state: Arc>, + executions: Arc>>, + idle_timeout: Duration, + read_timeout: Duration, + ) -> Result<(), PluginError> + where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + + Repository + + Send + + Sync + + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, + { + // Wrap the entire connection handling with an idle timeout + match tokio::time::timeout( + idle_timeout, + Self::handle_connection(stream, relayer_api, state, executions, read_timeout), + ) + .await + { + Ok(result) => result, + Err(_) => { + debug!("Connection idle timeout reached"); + Ok(()) + } + } + } + + /// Handle a connection from a plugin + /// + /// Security: The first message must be a Register message. Once registered, + /// the connection is "tagged" with that execution_id and cannot be changed. + /// This prevents Plugin A from spoofing Plugin B's execution_id. + #[allow(clippy::type_complexity)] + async fn handle_connection( + stream: UnixStream, + relayer_api: Arc, + state: Arc>, + executions: Arc>>, + read_timeout: Duration, + ) -> Result<(), PluginError> + where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + + Repository + + Send + + Sync + + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, + { + let (r, mut w) = stream.into_split(); + let mut reader = BufReader::new(r).lines(); + let mut traces = Vec::new(); + + // Connection-bound execution_id (prevents spoofing) + // Once set, this cannot be changed for the lifetime of the connection + let mut bound_execution_id: Option = None; + + loop { + // Read line with timeout to prevent hanging connections + let line = match tokio::time::timeout(read_timeout, reader.next_line()).await { + Ok(Ok(Some(line))) => line, + Ok(Ok(None)) => break, // EOF + Ok(Err(e)) => { + warn!("Error reading from connection: {}", e); + break; + } + Err(_) => { + debug!("Read timeout on connection"); + break; + } + }; + + debug!("Shared socket service: received message"); + + // Parse once, discriminate on "type" field for efficiency + let json_value: serde_json::Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(e) => { + warn!("Failed to parse JSON: {}", e); + continue; + } + }; + + let has_type_field = json_value.get("type").is_some(); + + if has_type_field { + // New unified protocol + let message: PluginMessage = match serde_json::from_value(json_value) { + Ok(msg) => msg, + Err(e) => { + warn!("Failed to parse PluginMessage: {}", e); + continue; + } + }; + + // Handle message based on type + match message { + PluginMessage::Register { execution_id } => { + // First message must be Register + if bound_execution_id.is_some() { + warn!("Attempted to re-register connection (security violation)"); + break; + } + + // Validate execution_id exists in registry + let map = executions.read().await; + if !map.contains_key(&execution_id) { + warn!("Unknown execution_id: {}", execution_id); + break; + } + drop(map); + + debug!("Connection registered with execution_id: {}", execution_id); + bound_execution_id = Some(execution_id); + } + + PluginMessage::ApiRequest { + request_id, + relayer_id, + method, + payload, + } => { + // Must be registered first + let exec_id = match &bound_execution_id { + Some(id) => id, + None => { + warn!("ApiRequest before Register (security violation)"); + break; + } + }; + + // Create Request for RelayerApi (method is already PluginMethod) + let request = Request { + request_id: request_id.clone(), + relayer_id, + method, + payload, + http_request_id: Some(exec_id.clone()), + }; + + // Handle the request + let response = relayer_api.handle_request(request, &state).await; + + // Send ApiResponse back + let api_response = PluginMessage::ApiResponse { + request_id: response.request_id, + result: response.result, + error: response.error, + }; + + let response_str = serde_json::to_string(&api_response) + .map_err(|e| PluginError::PluginError(e.to_string()))? + + "\n"; + + if let Err(e) = w.write_all(response_str.as_bytes()).await { + warn!("Failed to write API response: {}", e); + break; + } + + if let Err(e) = w.flush().await { + warn!("Failed to flush API response: {}", e); + break; + } + } + + PluginMessage::Trace { trace } => { + // Collect trace for observability + traces.push(trace); + } + + PluginMessage::Shutdown => { + debug!("Plugin requested shutdown"); + break; + } + + PluginMessage::ApiResponse { .. } => { + warn!("Received ApiResponse from plugin (invalid direction)"); + continue; + } + } + } else { + // Legacy protocol (no "type" field) + if let Ok(request) = serde_json::from_value::(json_value.clone()) { + // Legacy format - API requests are not trace events + + // Set execution_id from http_request_id or request_id if not bound + if bound_execution_id.is_none() { + let candidate_id = request + .http_request_id + .clone() + .or_else(|| Some(request.request_id.clone())); + + // Validate execution_id exists (same as new protocol) + if let Some(ref id) = candidate_id { + let map = executions.read().await; + if map.contains_key(id) { + bound_execution_id = candidate_id; + } else { + debug!("Legacy request with unknown execution_id: {}", id); + } + } + } + + // Handle legacy request + let response = relayer_api.handle_request(request, &state).await; + let response_str = serde_json::to_string(&response) + .map_err(|e| PluginError::PluginError(e.to_string()))? + + "\n"; + + if let Err(e) = w.write_all(response_str.as_bytes()).await { + warn!("Failed to write response: {}", e); + break; + } + + if let Err(e) = w.flush().await { + warn!("Failed to flush response: {}", e); + break; + } + } else { + warn!("Failed to parse message as either PluginMessage or legacy Request"); + } + } + } + + // Send traces back to caller if execution context exists + if let Some(exec_id) = bound_execution_id { + let map = executions.read().await; + if let Some(ctx) = map.get(&exec_id) { + // Send traces with timeout to prevent blocking + let trace_count = traces.len(); + match tokio::time::timeout(Duration::from_secs(5), ctx.traces_tx.send(traces)).await + { + Ok(Ok(())) => {} + Ok(Err(_)) => { + // Only warn if traces were collected but couldn't be delivered + // Channel closed with empty traces is expected when emit_traces=false + if trace_count > 0 { + warn!( + "Trace channel closed for execution_id: {} ({} traces lost)", + exec_id, trace_count + ); + } else { + debug!( + "Trace channel closed for execution_id: {} (no traces to deliver)", + exec_id + ); + } + } + Err(_) => warn!("Timeout sending traces for execution_id: {}", exec_id), + } + } + } + + debug!("Shared socket service: connection closed"); + Ok(()) + } +} + +impl Drop for SharedSocketService { + fn drop(&mut self) { + // Signal shutdown (cleanup happens in shutdown() method) + let _ = self.shutdown_tx.send(true); + // Note: Socket file cleanup happens in shutdown() after connections drain + // Drop can't be async, so proper cleanup should use shutdown() method + } +} + +/// Global shared socket service instance with proper error handling +static SHARED_SOCKET: std::sync::OnceLock, String>> = + std::sync::OnceLock::new(); + +/// Get or create the global shared socket service +/// Returns error if initialization fails instead of panicking +pub fn get_shared_socket_service() -> Result, PluginError> { + let socket_path = "/tmp/relayer-plugin-shared.sock"; + + let result = SHARED_SOCKET.get_or_init(|| { + // Remove existing socket file if it exists (from previous runs) + let _ = std::fs::remove_file(socket_path); + + match SharedSocketService::new(socket_path) { + Ok(service) => Ok(Arc::new(service)), + Err(e) => Err(e.to_string()), + } + }); + + match result { + Ok(service) => Ok(service.clone()), + Err(e) => Err(PluginError::SocketError(format!( + "Failed to create shared socket service: {e}" + ))), + } +} + +/// Ensure the shared socket service is started +#[allow(clippy::type_complexity)] +pub async fn ensure_shared_socket_started( + state: Arc>, +) -> Result<(), PluginError> +where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + Repository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, +{ + let service = get_shared_socket_service()?; + service.start(state).await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::mocks::mockutils::create_mock_app_state; + use actix_web::web; + use tempfile::tempdir; + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + use tokio::net::UnixStream; + + #[tokio::test] + async fn test_unified_protocol_register_and_api_request() { + let temp_dir = tempdir().unwrap(); + let socket_path = temp_dir.path().join("shared.sock"); + + let service = Arc::new(SharedSocketService::new(socket_path.to_str().unwrap()).unwrap()); + let state = create_mock_app_state(None, None, None, None, None, None).await; + + // Start the service + service + .clone() + .start(Arc::new(web::ThinData(state))) + .await + .unwrap(); + + // Register execution + let execution_id = "test-exec-123".to_string(); + let _guard = service.register_execution(execution_id.clone()).await; + + // Give the listener time to start + tokio::time::sleep(Duration::from_millis(50)).await; + + // Connect as plugin + let mut client = UnixStream::connect(socket_path.to_str().unwrap()) + .await + .unwrap(); + + // Send Register message + let register_msg = PluginMessage::Register { + execution_id: execution_id.clone(), + }; + let msg_json = serde_json::to_string(®ister_msg).unwrap() + "\n"; + client.write_all(msg_json.as_bytes()).await.unwrap(); + + // Send ApiRequest + let api_request = PluginMessage::ApiRequest { + request_id: "req-1".to_string(), + relayer_id: "relayer-1".to_string(), + method: crate::services::plugins::relayer_api::PluginMethod::GetRelayerStatus, + payload: serde_json::json!({}), + }; + let req_json = serde_json::to_string(&api_request).unwrap() + "\n"; + client.write_all(req_json.as_bytes()).await.unwrap(); + client.flush().await.unwrap(); + + // Read ApiResponse + let (r, _w) = client.into_split(); + let mut reader = BufReader::new(r); + let mut response_line = String::new(); + reader.read_line(&mut response_line).await.unwrap(); + + let response: PluginMessage = serde_json::from_str(&response_line).unwrap(); + match response { + PluginMessage::ApiResponse { request_id, .. } => { + assert_eq!(request_id, "req-1"); + } + _ => panic!("Expected ApiResponse, got {:?}", response), + } + + service.shutdown().await; + } + + #[tokio::test] + async fn test_connection_tagging_prevents_spoofing() { + let temp_dir = tempdir().unwrap(); + let socket_path = temp_dir.path().join("shared2.sock"); + + let service = Arc::new(SharedSocketService::new(socket_path.to_str().unwrap()).unwrap()); + let state = create_mock_app_state(None, None, None, None, None, None).await; + + service + .clone() + .start(Arc::new(web::ThinData(state))) + .await + .unwrap(); + + let execution_id = "test-exec-456".to_string(); + let _guard = service.register_execution(execution_id.clone()).await; + + tokio::time::sleep(Duration::from_millis(50)).await; + + let mut client = UnixStream::connect(socket_path.to_str().unwrap()) + .await + .unwrap(); + + // Register with execution_id + let register_msg = PluginMessage::Register { + execution_id: execution_id.clone(), + }; + let msg_json = serde_json::to_string(®ister_msg).unwrap() + "\n"; + client.write_all(msg_json.as_bytes()).await.unwrap(); + + // Try to re-register with different execution_id (security violation) + let spoofed_register = PluginMessage::Register { + execution_id: "different-exec-id".to_string(), + }; + let spoofed_json = serde_json::to_string(&spoofed_register).unwrap() + "\n"; + client.write_all(spoofed_json.as_bytes()).await.unwrap(); + client.flush().await.unwrap(); + + // Connection should be closed by server + tokio::time::sleep(Duration::from_millis(100)).await; + + // Try to read - should get EOF since connection was closed + let (r, _w) = client.into_split(); + let mut reader = BufReader::new(r); + let mut line = String::new(); + let result = reader.read_line(&mut line).await; + + // Should either get an error or EOF (0 bytes) + assert!(result.is_err() || result.unwrap() == 0); + + service.shutdown().await; + } + + #[tokio::test] + async fn test_backward_compatibility_with_legacy_format() { + let temp_dir = tempdir().unwrap(); + let socket_path = temp_dir.path().join("shared3.sock"); + + let service = Arc::new(SharedSocketService::new(socket_path.to_str().unwrap()).unwrap()); + let state = create_mock_app_state(None, None, None, None, None, None).await; + + service + .clone() + .start(Arc::new(web::ThinData(state))) + .await + .unwrap(); + + let execution_id = "test-exec-789".to_string(); + let _guard = service.register_execution(execution_id.clone()).await; + + tokio::time::sleep(Duration::from_millis(50)).await; + + let mut client = UnixStream::connect(socket_path.to_str().unwrap()) + .await + .unwrap(); + + // Send legacy Request format (without PluginMessage wrapper) + let legacy_request = crate::services::plugins::relayer_api::Request { + request_id: "legacy-1".to_string(), + relayer_id: "relayer-1".to_string(), + method: crate::services::plugins::relayer_api::PluginMethod::GetRelayerStatus, + payload: serde_json::json!({}), + http_request_id: Some(execution_id.clone()), + }; + let legacy_json = serde_json::to_string(&legacy_request).unwrap() + "\n"; + client.write_all(legacy_json.as_bytes()).await.unwrap(); + client.flush().await.unwrap(); + + // Read legacy Response format + let (r, _w) = client.into_split(); + let mut reader = BufReader::new(r); + let mut response_line = String::new(); + reader.read_line(&mut response_line).await.unwrap(); + + let response: crate::services::plugins::relayer_api::Response = + serde_json::from_str(&response_line).unwrap(); + + assert_eq!(response.request_id, "legacy-1"); + // Note: GetRelayerStatus might return an error if relayer doesn't exist + // The important thing is we got a response in the correct format + assert!(response.result.is_some() || response.error.is_some()); + + service.shutdown().await; + } + + #[tokio::test] + async fn test_trace_collection() { + let temp_dir = tempdir().unwrap(); + let socket_path = temp_dir.path().join("shared4.sock"); + + let service = Arc::new(SharedSocketService::new(socket_path.to_str().unwrap()).unwrap()); + let state = create_mock_app_state(None, None, None, None, None, None).await; + + service + .clone() + .start(Arc::new(web::ThinData(state))) + .await + .unwrap(); + + let execution_id = "test-exec-trace".to_string(); + let guard = service.register_execution(execution_id.clone()).await; + + tokio::time::sleep(Duration::from_millis(50)).await; + + let mut client = UnixStream::connect(socket_path.to_str().unwrap()) + .await + .unwrap(); + + // Register + let register_msg = PluginMessage::Register { + execution_id: execution_id.clone(), + }; + client + .write_all((serde_json::to_string(®ister_msg).unwrap() + "\n").as_bytes()) + .await + .unwrap(); + + // Send trace events + let trace1 = PluginMessage::Trace { + trace: serde_json::json!({"event": "start", "timestamp": 1000}), + }; + client + .write_all((serde_json::to_string(&trace1).unwrap() + "\n").as_bytes()) + .await + .unwrap(); + + let trace2 = PluginMessage::Trace { + trace: serde_json::json!({"event": "processing", "timestamp": 2000}), + }; + client + .write_all((serde_json::to_string(&trace2).unwrap() + "\n").as_bytes()) + .await + .unwrap(); + + // Shutdown + let shutdown_msg = PluginMessage::Shutdown; + client + .write_all((serde_json::to_string(&shutdown_msg).unwrap() + "\n").as_bytes()) + .await + .unwrap(); + client.flush().await.unwrap(); + + drop(client); + + // Wait for connection to close and traces to be sent + tokio::time::sleep(Duration::from_millis(100)).await; + + // Collect traces + let mut traces_rx = guard.into_receiver(); + let traces = traces_rx.recv().await.unwrap(); + + // Should have collected 2 trace events + assert_eq!(traces.len(), 2); + assert_eq!(traces[0]["event"], "start"); + assert_eq!(traces[1]["event"], "processing"); + + service.shutdown().await; + } + + #[tokio::test] + async fn test_execution_guard_auto_unregister() { + let temp_dir = tempdir().unwrap(); + let socket_path = temp_dir.path().join("shared_guard.sock"); + + let service = Arc::new(SharedSocketService::new(socket_path.to_str().unwrap()).unwrap()); + let execution_id = "test-exec-guard".to_string(); + + { + let _guard = service.register_execution(execution_id.clone()).await; + + // Verify execution is registered + let map = service.executions.read().await; + assert!(map.contains_key(&execution_id)); + } + // Guard dropped here + + // Give tokio task time to run + tokio::time::sleep(Duration::from_millis(50)).await; + + // Verify execution was auto-unregistered + let map = service.executions.read().await; + assert!(!map.contains_key(&execution_id)); + } + + #[tokio::test] + async fn test_api_request_without_register_rejected() { + let temp_dir = tempdir().unwrap(); + let socket_path = temp_dir.path().join("shared_no_register.sock"); + + let service = Arc::new(SharedSocketService::new(socket_path.to_str().unwrap()).unwrap()); + let state = create_mock_app_state(None, None, None, None, None, None).await; + + service + .clone() + .start(Arc::new(web::ThinData(state))) + .await + .unwrap(); + + tokio::time::sleep(Duration::from_millis(50)).await; + + let mut client = UnixStream::connect(socket_path.to_str().unwrap()) + .await + .unwrap(); + + // Send ApiRequest WITHOUT registering first (security violation) + let api_request = PluginMessage::ApiRequest { + request_id: "req-1".to_string(), + relayer_id: "relayer-1".to_string(), + method: crate::services::plugins::relayer_api::PluginMethod::GetRelayerStatus, + payload: serde_json::json!({}), + }; + let req_json = serde_json::to_string(&api_request).unwrap() + "\n"; + client.write_all(req_json.as_bytes()).await.unwrap(); + client.flush().await.unwrap(); + + // Connection should be closed by server + tokio::time::sleep(Duration::from_millis(100)).await; + + let (r, _w) = client.into_split(); + let mut reader = BufReader::new(r); + let mut line = String::new(); + let result = reader.read_line(&mut line).await; + + // Should get EOF (connection closed) + assert!(result.is_err() || result.unwrap() == 0); + + service.shutdown().await; + } + + #[tokio::test] + async fn test_register_with_unknown_execution_id_rejected() { + let temp_dir = tempdir().unwrap(); + let socket_path = temp_dir.path().join("shared_unknown_exec.sock"); + + let service = Arc::new(SharedSocketService::new(socket_path.to_str().unwrap()).unwrap()); + let state = create_mock_app_state(None, None, None, None, None, None).await; + + service + .clone() + .start(Arc::new(web::ThinData(state))) + .await + .unwrap(); + + tokio::time::sleep(Duration::from_millis(50)).await; + + let mut client = UnixStream::connect(socket_path.to_str().unwrap()) + .await + .unwrap(); + + // Try to register with an execution_id that doesn't exist in registry + let register_msg = PluginMessage::Register { + execution_id: "unknown-exec-id".to_string(), + }; + let msg_json = serde_json::to_string(®ister_msg).unwrap() + "\n"; + client.write_all(msg_json.as_bytes()).await.unwrap(); + client.flush().await.unwrap(); + + // Connection should be closed + tokio::time::sleep(Duration::from_millis(100)).await; + + let (r, _w) = client.into_split(); + let mut reader = BufReader::new(r); + let mut line = String::new(); + let result = reader.read_line(&mut line).await; + + assert!(result.is_err() || result.unwrap() == 0); + + service.shutdown().await; + } + + #[tokio::test] + async fn test_connection_limit_enforcement() { + let temp_dir = tempdir().unwrap(); + let socket_path = temp_dir.path().join("shared_connection_limit.sock"); + + let service = Arc::new(SharedSocketService::new(socket_path.to_str().unwrap()).unwrap()); + let state = create_mock_app_state(None, None, None, None, None, None).await; + + service + .clone() + .start(Arc::new(web::ThinData(state))) + .await + .unwrap(); + + tokio::time::sleep(Duration::from_millis(50)).await; + + // Check initial connection count + let initial_permits = service.connection_semaphore.available_permits(); + let max_connections = get_config().socket_max_connections; + assert_eq!(initial_permits, max_connections); + + // Create a connection (should reduce available permits) + let _client = UnixStream::connect(socket_path.to_str().unwrap()) + .await + .unwrap(); + + tokio::time::sleep(Duration::from_millis(50)).await; + + // Available permits should be reduced + let after_connect = service.connection_semaphore.available_permits(); + assert!(after_connect < initial_permits); + + service.shutdown().await; + } + + #[tokio::test] + async fn test_idle_timeout() { + let temp_dir = tempdir().unwrap(); + let socket_path = temp_dir.path().join("shared_idle_timeout.sock"); + + // Create service with short idle timeout for testing + let service = Arc::new(SharedSocketService::new(socket_path.to_str().unwrap()).unwrap()); + let state = create_mock_app_state(None, None, None, None, None, None).await; + + service + .clone() + .start(Arc::new(web::ThinData(state))) + .await + .unwrap(); + + let execution_id = "test-exec-idle".to_string(); + let _guard = service.register_execution(execution_id.clone()).await; + + tokio::time::sleep(Duration::from_millis(50)).await; + + let mut client = UnixStream::connect(socket_path.to_str().unwrap()) + .await + .unwrap(); + + // Register + let register_msg = PluginMessage::Register { execution_id }; + client + .write_all((serde_json::to_string(®ister_msg).unwrap() + "\n").as_bytes()) + .await + .unwrap(); + client.flush().await.unwrap(); + + // Wait longer than idle timeout (configured in service) + // Note: idle_timeout is from config, but we can test that connection stays alive + // within a reasonable time + tokio::time::sleep(Duration::from_millis(100)).await; + + // Connection should still be alive if we're within timeout + // Send a Shutdown message to verify connection is still up + let shutdown_msg = PluginMessage::Shutdown; + let write_result = client + .write_all((serde_json::to_string(&shutdown_msg).unwrap() + "\n").as_bytes()) + .await; + + assert!(write_result.is_ok(), "Connection should still be alive"); + + service.shutdown().await; + } + + #[tokio::test] + async fn test_read_timeout_handling() { + let temp_dir = tempdir().unwrap(); + let socket_path = temp_dir.path().join("shared_read_timeout.sock"); + + let service = Arc::new(SharedSocketService::new(socket_path.to_str().unwrap()).unwrap()); + let state = create_mock_app_state(None, None, None, None, None, None).await; + + service + .clone() + .start(Arc::new(web::ThinData(state))) + .await + .unwrap(); + + let execution_id = "test-exec-read-timeout".to_string(); + let _guard = service.register_execution(execution_id.clone()).await; + + tokio::time::sleep(Duration::from_millis(50)).await; + + let mut client = UnixStream::connect(socket_path.to_str().unwrap()) + .await + .unwrap(); + + // Register + let register_msg = PluginMessage::Register { execution_id }; + client + .write_all((serde_json::to_string(®ister_msg).unwrap() + "\n").as_bytes()) + .await + .unwrap(); + client.flush().await.unwrap(); + + // Don't send anything else - connection should be cleaned up after read timeout + // Read timeout is configured in service (from config) + + // Wait a bit (but not as long as full timeout) + tokio::time::sleep(Duration::from_millis(200)).await; + + // Connection should still be valid for a short time + drop(client); + + service.shutdown().await; + } + + #[tokio::test] + async fn test_multiple_api_requests_same_connection() { + let temp_dir = tempdir().unwrap(); + let socket_path = temp_dir.path().join("shared_multiple_requests.sock"); + + let service = Arc::new(SharedSocketService::new(socket_path.to_str().unwrap()).unwrap()); + let state = create_mock_app_state(None, None, None, None, None, None).await; + + service + .clone() + .start(Arc::new(web::ThinData(state))) + .await + .unwrap(); + + let execution_id = "test-exec-multi".to_string(); + let _guard = service.register_execution(execution_id.clone()).await; + + tokio::time::sleep(Duration::from_millis(50)).await; + + let mut client = UnixStream::connect(socket_path.to_str().unwrap()) + .await + .unwrap(); + + // Register + let register_msg = PluginMessage::Register { + execution_id: execution_id.clone(), + }; + client + .write_all((serde_json::to_string(®ister_msg).unwrap() + "\n").as_bytes()) + .await + .unwrap(); + + let (r, mut w) = client.into_split(); + let mut reader = BufReader::new(r); + + // Send multiple API requests + for i in 1..=3 { + let api_request = PluginMessage::ApiRequest { + request_id: format!("req-{}", i), + relayer_id: "relayer-1".to_string(), + method: crate::services::plugins::relayer_api::PluginMethod::GetRelayerStatus, + payload: serde_json::json!({}), + }; + w.write_all((serde_json::to_string(&api_request).unwrap() + "\n").as_bytes()) + .await + .unwrap(); + w.flush().await.unwrap(); + + // Read response + let mut response_line = String::new(); + reader.read_line(&mut response_line).await.unwrap(); + + let response: PluginMessage = serde_json::from_str(&response_line).unwrap(); + match response { + PluginMessage::ApiResponse { request_id, .. } => { + assert_eq!(request_id, format!("req-{}", i)); + } + _ => panic!("Expected ApiResponse"), + } + } + + service.shutdown().await; + } + + #[tokio::test] + async fn test_shutdown_signal() { + let temp_dir = tempdir().unwrap(); + let socket_path = temp_dir.path().join("shared_shutdown_signal.sock"); + + let service = Arc::new(SharedSocketService::new(socket_path.to_str().unwrap()).unwrap()); + let state = create_mock_app_state(None, None, None, None, None, None).await; + + service + .clone() + .start(Arc::new(web::ThinData(state))) + .await + .unwrap(); + + tokio::time::sleep(Duration::from_millis(50)).await; + + // Verify socket file exists + assert!(std::path::Path::new(socket_path.to_str().unwrap()).exists()); + + // Shutdown the service + service.shutdown().await; + + // Give time for cleanup + tokio::time::sleep(Duration::from_millis(100)).await; + + // Socket file should be removed + assert!(!std::path::Path::new(socket_path.to_str().unwrap()).exists()); + } + + #[tokio::test] + async fn test_malformed_json_handling() { + let temp_dir = tempdir().unwrap(); + let socket_path = temp_dir.path().join("shared_malformed.sock"); + + let service = Arc::new(SharedSocketService::new(socket_path.to_str().unwrap()).unwrap()); + let state = create_mock_app_state(None, None, None, None, None, None).await; + + service + .clone() + .start(Arc::new(web::ThinData(state))) + .await + .unwrap(); + + let execution_id = "test-exec-malformed".to_string(); + let _guard = service.register_execution(execution_id.clone()).await; + + tokio::time::sleep(Duration::from_millis(50)).await; + + let mut client = UnixStream::connect(socket_path.to_str().unwrap()) + .await + .unwrap(); + + // Register first + let register_msg = PluginMessage::Register { + execution_id: execution_id.clone(), + }; + client + .write_all((serde_json::to_string(®ister_msg).unwrap() + "\n").as_bytes()) + .await + .unwrap(); + + // Send malformed JSON + client + .write_all(b"{ this is not valid json }\n") + .await + .unwrap(); + client.flush().await.unwrap(); + + // Connection should remain open (malformed messages are logged and skipped) + tokio::time::sleep(Duration::from_millis(100)).await; + + // Send valid shutdown message to verify connection is still up + let shutdown_msg = PluginMessage::Shutdown; + let write_result = client + .write_all((serde_json::to_string(&shutdown_msg).unwrap() + "\n").as_bytes()) + .await; + + assert!( + write_result.is_ok(), + "Connection should still be alive after malformed JSON" + ); + + service.shutdown().await; + } + + #[tokio::test] + async fn test_invalid_message_direction() { + let temp_dir = tempdir().unwrap(); + let socket_path = temp_dir.path().join("shared_invalid_direction.sock"); + + let service = Arc::new(SharedSocketService::new(socket_path.to_str().unwrap()).unwrap()); + let state = create_mock_app_state(None, None, None, None, None, None).await; + + service + .clone() + .start(Arc::new(web::ThinData(state))) + .await + .unwrap(); + + let execution_id = "test-exec-invalid-dir".to_string(); + let _guard = service.register_execution(execution_id.clone()).await; + + tokio::time::sleep(Duration::from_millis(50)).await; + + let mut client = UnixStream::connect(socket_path.to_str().unwrap()) + .await + .unwrap(); + + // Register + let register_msg = PluginMessage::Register { + execution_id: execution_id.clone(), + }; + client + .write_all((serde_json::to_string(®ister_msg).unwrap() + "\n").as_bytes()) + .await + .unwrap(); + + // Plugin tries to send ApiResponse (invalid direction - only Host sends ApiResponse) + let invalid_msg = PluginMessage::ApiResponse { + request_id: "invalid".to_string(), + result: Some(serde_json::json!({})), + error: None, + }; + client + .write_all((serde_json::to_string(&invalid_msg).unwrap() + "\n").as_bytes()) + .await + .unwrap(); + client.flush().await.unwrap(); + + // Connection should remain open (invalid messages are logged and skipped) + tokio::time::sleep(Duration::from_millis(100)).await; + + // Verify connection is still alive + let shutdown_msg = PluginMessage::Shutdown; + let write_result = client + .write_all((serde_json::to_string(&shutdown_msg).unwrap() + "\n").as_bytes()) + .await; + + assert!(write_result.is_ok()); + + service.shutdown().await; + } + + #[tokio::test] + async fn test_stale_execution_cleanup() { + let temp_dir = tempdir().unwrap(); + let socket_path = temp_dir.path().join("shared_stale_cleanup.sock"); + + let service = Arc::new(SharedSocketService::new(socket_path.to_str().unwrap()).unwrap()); + + // Register an execution manually with old timestamp + let execution_id = "stale-exec".to_string(); + let (tx, _rx) = mpsc::channel(1); + { + let mut map = service.executions.write().await; + map.insert( + execution_id.clone(), + ExecutionContext { + traces_tx: tx, + created_at: Instant::now() - Duration::from_secs(400), // 6+ minutes old + bound_execution_id: execution_id.clone(), + }, + ); + } + + // Verify it's registered + { + let map = service.executions.read().await; + assert!(map.contains_key(&execution_id)); + } + + // Wait for cleanup task to run (it runs every 60 seconds, but we can't wait that long) + // Instead, we verify the cleanup logic by checking the code in new() + // The actual cleanup test would require mocking time or waiting 60+ seconds + + // For this test, we just verify the logic exists and doesn't panic + drop(service); + } + + #[tokio::test] + async fn test_socket_path_getter() { + let temp_dir = tempdir().unwrap(); + let socket_path = temp_dir.path().join("shared_path.sock"); + + let service = SharedSocketService::new(socket_path.to_str().unwrap()).unwrap(); + + assert_eq!(service.socket_path(), socket_path.to_str().unwrap()); + } + + #[tokio::test] + async fn test_trace_send_timeout() { + let temp_dir = tempdir().unwrap(); + let socket_path = temp_dir.path().join("shared_trace_timeout.sock"); + + let service = Arc::new(SharedSocketService::new(socket_path.to_str().unwrap()).unwrap()); + let state = create_mock_app_state(None, None, None, None, None, None).await; + + service + .clone() + .start(Arc::new(web::ThinData(state))) + .await + .unwrap(); + + let execution_id = "test-exec-trace-timeout".to_string(); + let guard = service.register_execution(execution_id.clone()).await; + + // Don't consume the receiver - this will cause the channel to fill up + drop(guard); + + tokio::time::sleep(Duration::from_millis(50)).await; + + let mut client = UnixStream::connect(socket_path.to_str().unwrap()) + .await + .unwrap(); + + // Register + let register_msg = PluginMessage::Register { + execution_id: execution_id.clone(), + }; + client + .write_all((serde_json::to_string(®ister_msg).unwrap() + "\n").as_bytes()) + .await + .unwrap(); + + // Send trace + let trace = PluginMessage::Trace { + trace: serde_json::json!({"event": "test"}), + }; + client + .write_all((serde_json::to_string(&trace).unwrap() + "\n").as_bytes()) + .await + .unwrap(); + + // Shutdown + let shutdown_msg = PluginMessage::Shutdown; + client + .write_all((serde_json::to_string(&shutdown_msg).unwrap() + "\n").as_bytes()) + .await + .unwrap(); + client.flush().await.unwrap(); + + drop(client); + + // Wait for connection to close - should handle timeout gracefully + tokio::time::sleep(Duration::from_millis(200)).await; + + service.shutdown().await; + } + + #[tokio::test] + async fn test_get_shared_socket_service() { + // Test the global singleton + let service1 = get_shared_socket_service(); + assert!(service1.is_ok()); + + let service2 = get_shared_socket_service(); + assert!(service2.is_ok()); + + // Should return the same instance + let svc1 = service1.unwrap(); + let svc2 = service2.unwrap(); + let path1 = svc1.socket_path(); + let path2 = svc2.socket_path(); + assert_eq!(path1, path2); + } +} diff --git a/src/services/plugins/socket.rs b/src/services/plugins/socket.rs deleted file mode 100644 index 8e7c7356a..000000000 --- a/src/services/plugins/socket.rs +++ /dev/null @@ -1,351 +0,0 @@ -//! This module is responsible for creating a socket connection to the relayer server. -//! It is used to send requests to the relayer server and processing the responses. -//! It also intercepts the logs, errors and return values. -//! -//! The socket connection is created using the `UnixListener`. -//! -//! 1. Creates a socket connection using the `UnixListener`. -//! 2. Each request payload is stringified by the client and added as a new line to the socket. -//! 3. The server reads the requests from the socket and processes them. -//! 4. The server sends the responses back to the client in the same format. By writing a new line in the socket -//! 5. When the client sends the socket shutdown signal, the server closes the socket connection. -//! -//! Example: -//! 1. Create a new socket connection using `/tmp/socket.sock` -//! 2. Client sends request (writes in `/tmp/socket.sock`): -//! ```json -//! { -//! "request_id": "123", -//! "relayer_id": "relayer1", -//! "method": "sendTransaction", -//! "payload": { -//! "to": "0x1234567890123456789012345678901234567890", -//! "value": "1000000000000000000" -//! } -//! } -//! ``` -//! 3. Server process the requests, calls the relayer API and sends back the response (writes in `/tmp/socket.sock`): -//! ```json -//! { -//! "request_id": "123", -//! "result": { -//! "id": "123", -//! "status": "success" -//! } -//! } -//! ``` -//! 4. Client reads the response (reads from `/tmp/socket.sock`): -//! ```json -//! { -//! "request_id": "123", -//! "result": { -//! "id": "123", -//! "status": "success" -//! } -//! } -//! ``` -//! 5. Once the client finishes the execution, it sends a shutdown signal to the server. -//! 6. The server closes the socket connection. -//! - -use crate::jobs::JobProducerTrait; -use crate::models::{ - NetworkRepoModel, NotificationRepoModel, RelayerRepoModel, SignerRepoModel, ThinDataAppState, - TransactionRepoModel, -}; -use crate::repositories::{ - ApiKeyRepositoryTrait, NetworkRepository, PluginRepositoryTrait, RelayerRepository, Repository, - TransactionCounterTrait, TransactionRepository, -}; -use std::sync::Arc; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::net::{UnixListener, UnixStream}; -use tokio::sync::oneshot; -use tracing::debug; - -use super::{ - relayer_api::{RelayerApiTrait, Request}, - PluginError, -}; - -pub struct SocketService { - socket_path: String, - listener: UnixListener, -} - -impl SocketService { - /// Creates a new socket service. - /// - /// # Arguments - /// - /// * `socket_path` - The path to the socket file. - pub fn new(socket_path: &str) -> Result { - // Remove existing socket file if it exists - let _ = std::fs::remove_file(socket_path); - - let listener = - UnixListener::bind(socket_path).map_err(|e| PluginError::SocketError(e.to_string()))?; - - Ok(Self { - socket_path: socket_path.to_string(), - listener, - }) - } - - pub fn socket_path(&self) -> &str { - &self.socket_path - } - - /// Listens for incoming connections and processes the requests. - /// - /// # Arguments - /// - /// * `shutdown_rx` - A receiver for the shutdown signal. - /// * `state` - The application state. - /// * `relayer_api` - The relayer API. - /// - /// # Returns - /// - /// A vector of traces. - #[allow(clippy::type_complexity)] - pub async fn listen( - self, - shutdown_rx: oneshot::Receiver<()>, - state: Arc>, - relayer_api: Arc, - ) -> Result, PluginError> - where - RA: RelayerApiTrait + 'static + Send + Sync, - J: JobProducerTrait + Send + Sync + 'static, - RR: RelayerRepository + Repository + Send + Sync + 'static, - TR: TransactionRepository - + Repository - + Send - + Sync - + 'static, - NR: NetworkRepository + Repository + Send + Sync + 'static, - NFR: Repository + Send + Sync + 'static, - SR: Repository + Send + Sync + 'static, - TCR: TransactionCounterTrait + Send + Sync + 'static, - PR: PluginRepositoryTrait + Send + Sync + 'static, - AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, - { - let mut shutdown = shutdown_rx; - - let mut traces = Vec::new(); - - loop { - let state = Arc::clone(&state); - let relayer_api = Arc::clone(&relayer_api); - tokio::select! { - Ok((stream, _)) = self.listener.accept() => { - let result = tokio::spawn(Self::handle_connection::(stream, state, relayer_api)) - .await - .map_err(|e| PluginError::SocketError(e.to_string()))?; - - match result { - Ok(trace) => traces.extend(trace), - Err(e) => return Err(e), - } - } - _ = &mut shutdown => { - debug!("Shutdown signal received. Closing listener."); - break; - } - } - } - - Ok(traces) - } - - /// Handles a new connection. - /// - /// # Arguments - /// - /// * `stream` - The stream to the client. - /// * `state` - The application state. - /// * `relayer_api` - The relayer API. - /// - /// # Returns - /// - /// A vector of traces. - #[allow(clippy::type_complexity)] - async fn handle_connection( - stream: UnixStream, - state: Arc>, - relayer_api: Arc, - ) -> Result, PluginError> - where - RA: RelayerApiTrait + 'static + Send + Sync, - J: JobProducerTrait + 'static, - RR: RelayerRepository + Repository + Send + Sync + 'static, - TR: TransactionRepository - + Repository - + Send - + Sync - + 'static, - NR: NetworkRepository + Repository + Send + Sync + 'static, - NFR: Repository + Send + Sync + 'static, - SR: Repository + Send + Sync + 'static, - TCR: TransactionCounterTrait + Send + Sync + 'static, - PR: PluginRepositoryTrait + Send + Sync + 'static, - AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, - { - let (r, mut w) = stream.into_split(); - let mut reader = BufReader::new(r).lines(); - let mut traces = Vec::new(); - - while let Ok(Some(line)) = reader.next_line().await { - let trace: serde_json::Value = serde_json::from_str(&line) - .map_err(|e| PluginError::PluginError(format!("Failed to parse trace: {e}")))?; - traces.push(trace); - - let request: Request = - serde_json::from_str(&line).map_err(|e| PluginError::PluginError(e.to_string()))?; - - let response = relayer_api.handle_request(request, &state).await; - - let response_str = serde_json::to_string(&response) - .map_err(|e| PluginError::PluginError(e.to_string()))? - + "\n"; - - let _ = w.write_all(response_str.as_bytes()).await; - } - - Ok(traces) - } -} - -#[cfg(test)] -mod tests { - use crate::{ - services::plugins::{MockRelayerApiTrait, PluginMethod, Response}, - utils::mocks::mockutils::{create_mock_app_state, create_mock_evm_transaction_request}, - }; - use actix_web::web; - use std::time::Duration; - - use super::*; - - use tempfile::tempdir; - use tokio::{ - io::{AsyncBufReadExt, BufReader}, - time::timeout, - }; - - #[tokio::test] - async fn test_socket_service_listen_and_shutdown() { - let temp_dir = tempdir().unwrap(); - let socket_path = temp_dir.path().join("test.sock"); - - let mock_relayer = MockRelayerApiTrait::default(); - - let service = SocketService::new(socket_path.to_str().unwrap()).unwrap(); - - let state = create_mock_app_state(None, None, None, None, None, None).await; - let (shutdown_tx, shutdown_rx) = oneshot::channel(); - - let listen_handle = tokio::spawn(async move { - service - .listen( - shutdown_rx, - Arc::new(web::ThinData(state)), - Arc::new(mock_relayer), - ) - .await - }); - - shutdown_tx.send(()).unwrap(); - - let result = timeout(Duration::from_millis(100), listen_handle).await; - assert!(result.is_ok(), "Listen handle timed out"); - assert!(result.unwrap().is_ok(), "Listen handle returned error"); - } - - #[tokio::test] - async fn test_socket_service_handle_connection() { - let temp_dir = tempdir().unwrap(); - let socket_path = temp_dir.path().join("test.sock"); - - let mut mock_relayer = MockRelayerApiTrait::default(); - - mock_relayer.expect_handle_request().returning(|_, _| { - Box::pin(async move { - Response { - request_id: "test".to_string(), - result: Some(serde_json::json!("test")), - error: None, - } - }) - }); - - let service = SocketService::new(socket_path.to_str().unwrap()).unwrap(); - - let state = create_mock_app_state(None, None, None, None, None, None).await; - let (shutdown_tx, shutdown_rx) = oneshot::channel(); - - let listen_handle = tokio::spawn(async move { - service - .listen( - shutdown_rx, - Arc::new(web::ThinData(state)), - Arc::new(mock_relayer), - ) - .await - }); - - tokio::time::sleep(Duration::from_millis(50)).await; - - let mut client = UnixStream::connect(socket_path.to_str().unwrap()) - .await - .unwrap(); - - let request = Request { - request_id: "test".to_string(), - relayer_id: "test".to_string(), - method: PluginMethod::SendTransaction, - payload: serde_json::json!(create_mock_evm_transaction_request()), - http_request_id: None, - }; - - let request_json = serde_json::to_string(&request).unwrap() + "\n"; - - client.write_all(request_json.as_bytes()).await.unwrap(); - - let mut reader = BufReader::new(&mut client); - let mut response_str = String::new(); - let read_result = timeout( - Duration::from_millis(1000), - reader.read_line(&mut response_str), - ) - .await; - - assert!( - read_result.is_ok(), - "Reading response timed out: {:?}", - read_result - ); - let bytes_read = read_result.unwrap().unwrap(); - assert!(bytes_read > 0, "No data received"); - shutdown_tx.send(()).unwrap(); - - let response: Response = serde_json::from_str(&response_str).unwrap(); - - assert!(response.error.is_none(), "Error should be none"); - assert!(response.result.is_some(), "Result should be some"); - assert_eq!( - response.request_id, request.request_id, - "Request id mismatch" - ); - - client.shutdown().await.unwrap(); - - let traces = listen_handle.await.unwrap().unwrap(); - - assert_eq!(traces.len(), 1); - let expected: serde_json::Value = serde_json::from_str(&request_json).unwrap(); - let actual: serde_json::Value = - serde_json::from_str(&serde_json::to_string(&traces[0]).unwrap()).unwrap(); - assert_eq!(expected, actual, "Request json mismatch with trace"); - } -} diff --git a/src/services/provider/retry.rs b/src/services/provider/retry.rs index 014fbac43..a87ab9188 100644 --- a/src/services/provider/retry.rs +++ b/src/services/provider/retry.rs @@ -1278,9 +1278,17 @@ mod tests { let attempt_count = Arc::new(AtomicU8::new(0)); let attempt_count_clone = attempt_count.clone(); + // Track which URLs were attempted for verification + let attempted_urls = Arc::new(std::sync::Mutex::new(Vec::new())); + let attempted_urls_clone = attempted_urls.clone(); + + // Fail the FIRST initialization attempt regardless of which URL is selected. + // This makes the test deterministic - it doesn't depend on URL selection order. let provider_initializer = move |url: &str| -> Result { let count = attempt_count_clone.fetch_add(1, AtomicOrdering::SeqCst); - if count == 0 && url.contains("9988") { + attempted_urls_clone.lock().unwrap().push(url.to_string()); + if count == 0 { + // First attempt always fails, forcing failover to second provider Err(TestError("First provider init failed".to_string())) } else { Ok(url.to_string()) @@ -1304,7 +1312,23 @@ mod tests { assert!(result.is_ok()); assert_eq!(result.unwrap(), 42); - assert!(attempt_count.load(AtomicOrdering::SeqCst) >= 2); // Should have tried multiple providers + + // Verify: exactly 2 provider initialization attempts were made + let final_count = attempt_count.load(AtomicOrdering::SeqCst); + assert_eq!( + final_count, 2, + "Expected exactly 2 provider init attempts, got {}", + final_count + ); + + // Verify: two different URLs were attempted (failover occurred) + let urls = attempted_urls.lock().unwrap(); + assert_eq!(urls.len(), 2, "Expected 2 URLs attempted, got {:?}", urls); + assert_ne!( + urls[0], urls[1], + "Expected different URLs to be tried, got {:?}", + urls + ); } #[test] diff --git a/stress.js b/stress.js new file mode 100644 index 000000000..1d28de686 --- /dev/null +++ b/stress.js @@ -0,0 +1,19 @@ +import http from "k6/http"; +import { check, sleep } from "k6"; + +export const options = { + vus: 2000, // virtual users + duration: "90s", // test duration +}; + +export default function () { + const params = { + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer 2cc7ec17-45ca-4498-ba86-517ef0788b8c", + }, + }; + const res = http.get("http://localhost:8080/api/v1/relayers", params); + check(res, { "status is 200": (r) => r.status === 200 }); + sleep(1); +}