strobengine 0.2.0__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. {strobengine-0.2.0 → strobengine-0.2.1}/.github/workflows/publish-test.yml +18 -3
  2. {strobengine-0.2.0 → strobengine-0.2.1}/.github/workflows/publish.yml +17 -3
  3. {strobengine-0.2.0 → strobengine-0.2.1}/CHANGELOG.md +28 -0
  4. {strobengine-0.2.0 → strobengine-0.2.1}/Cargo.lock +1 -1
  5. {strobengine-0.2.0 → strobengine-0.2.1}/Cargo.toml +1 -1
  6. {strobengine-0.2.0 → strobengine-0.2.1}/PKG-INFO +1 -1
  7. {strobengine-0.2.0 → strobengine-0.2.1}/docs/roadmap.md +3 -3
  8. {strobengine-0.2.0 → strobengine-0.2.1}/pyproject.toml +1 -1
  9. {strobengine-0.2.0 → strobengine-0.2.1}/src/lib.rs +95 -27
  10. {strobengine-0.2.0 → strobengine-0.2.1}/src/metrics.rs +19 -12
  11. {strobengine-0.2.0 → strobengine-0.2.1}/src/progress.rs +15 -5
  12. {strobengine-0.2.0 → strobengine-0.2.1}/src/strobengine/_strobengine.pyi +3 -3
  13. {strobengine-0.2.0 → strobengine-0.2.1}/src/strobengine/cli.py +5 -2
  14. strobengine-0.2.1/src/strobengine/constants.py +14 -0
  15. {strobengine-0.2.0 → strobengine-0.2.1}/src/strobengine/engine.py +28 -14
  16. {strobengine-0.2.0 → strobengine-0.2.1}/uv.lock +1 -1
  17. {strobengine-0.2.0 → strobengine-0.2.1}/.github/workflows/ci.yml +0 -0
  18. {strobengine-0.2.0 → strobengine-0.2.1}/.gitignore +0 -0
  19. {strobengine-0.2.0 → strobengine-0.2.1}/.python-version +0 -0
  20. {strobengine-0.2.0 → strobengine-0.2.1}/LICENSE +0 -0
  21. {strobengine-0.2.0 → strobengine-0.2.1}/README.md +0 -0
  22. {strobengine-0.2.0 → strobengine-0.2.1}/cliff.toml +0 -0
  23. {strobengine-0.2.0 → strobengine-0.2.1}/docs/benchmarks.md +0 -0
  24. {strobengine-0.2.0 → strobengine-0.2.1}/docs/http_methods.md +0 -0
  25. {strobengine-0.2.0 → strobengine-0.2.1}/docs/infrastructure.md +0 -0
  26. {strobengine-0.2.0 → strobengine-0.2.1}/examples/http_methods.py +0 -0
  27. {strobengine-0.2.0 → strobengine-0.2.1}/examples/load_test.py +0 -0
  28. {strobengine-0.2.0 → strobengine-0.2.1}/examples/spike_test.py +0 -0
  29. {strobengine-0.2.0 → strobengine-0.2.1}/examples/stress_test.py +0 -0
  30. {strobengine-0.2.0 → strobengine-0.2.1}/src/chaos.rs +0 -0
  31. {strobengine-0.2.0 → strobengine-0.2.1}/src/config.rs +0 -0
  32. {strobengine-0.2.0 → strobengine-0.2.1}/src/logging.rs +0 -0
  33. {strobengine-0.2.0 → strobengine-0.2.1}/src/strobengine/__init__.py +0 -0
  34. {strobengine-0.2.0 → strobengine-0.2.1}/src/strobengine/py.typed +0 -0
  35. {strobengine-0.2.0 → strobengine-0.2.1}/src/strobengine/reporter.py +0 -0
  36. {strobengine-0.2.0 → strobengine-0.2.1}/src/worker.rs +0 -0
  37. {strobengine-0.2.0 → strobengine-0.2.1}/tests/__init__.py +0 -0
  38. {strobengine-0.2.0 → strobengine-0.2.1}/tests/conftest.py +0 -0
  39. {strobengine-0.2.0 → strobengine-0.2.1}/tests/test_cli.py +0 -0
  40. {strobengine-0.2.0 → strobengine-0.2.1}/tests/test_engine.py +0 -0
  41. {strobengine-0.2.0 → strobengine-0.2.1}/tests/test_logging.py +0 -0
  42. {strobengine-0.2.0 → strobengine-0.2.1}/tests/test_reporter.py +0 -0
@@ -6,11 +6,24 @@ on:
6
6
 
7
7
  jobs:
8
8
  build-wheels:
9
- name: Build wheels on ${{ matrix.os }}
9
+ name: Build wheels on ${{ matrix.os }} (${{ matrix.target || 'native' }})
10
10
  runs-on: ${{ matrix.os }}
11
11
  strategy:
12
+ fail-fast: false
12
13
  matrix:
13
- os: [ubuntu-latest, macos-latest, windows-latest]
14
+ include:
15
+ # Linux
16
+ - os: ubuntu-latest
17
+ target: x86_64
18
+ # Windows
19
+ - os: windows-latest
20
+ target: x64
21
+ # macOS Apple Silicon (ARM64)
22
+ - os: macos-latest
23
+ target: aarch64
24
+ # macOS Intel (x86_64) cross-compiled on macos-latest
25
+ - os: macos-latest
26
+ target: x86_64
14
27
  steps:
15
28
  - uses: actions/checkout@v7
16
29
 
@@ -22,6 +35,7 @@ jobs:
22
35
  - name: Build wheels via Maturin
23
36
  uses: PyO3/maturin-action@v1
24
37
  with:
38
+ target: ${{ matrix.target }}
25
39
  args: --release --out dist --find-interpreter
26
40
  sccache: 'true'
27
41
  sdist: false
@@ -29,7 +43,7 @@ jobs:
29
43
  - name: Upload wheel artifacts
30
44
  uses: actions/upload-artifact@v7
31
45
  with:
32
- name: wheels-${{ matrix.os }}
46
+ name: wheels-${{ matrix.os }}-${{ matrix.target }}
33
47
  path: dist/*.whl
34
48
 
35
49
  build-sdist:
@@ -71,3 +85,4 @@ jobs:
71
85
  with:
72
86
  repository-url: https://test.pypi.org/legacy/
73
87
  packages-dir: dist/
88
+ skip-existing: true # Skips 400 errors for already uploaded files
@@ -9,11 +9,24 @@ on:
9
9
 
10
10
  jobs:
11
11
  build-wheels:
12
- name: Build wheels on ${{ matrix.os }}
12
+ name: Build wheels on ${{ matrix.os }} (${{ matrix.target || 'native' }})
13
13
  runs-on: ${{ matrix.os }}
14
14
  strategy:
15
+ fail-fast: false
15
16
  matrix:
16
- os: [ubuntu-latest, macos-latest, windows-latest]
17
+ include:
18
+ # Linux
19
+ - os: ubuntu-latest
20
+ target: x86_64
21
+ # Windows
22
+ - os: windows-latest
23
+ target: x64
24
+ # macOS Apple Silicon (ARM64)
25
+ - os: macos-latest
26
+ target: aarch64
27
+ # macOS Intel (x86_64) cross-compiled on macos-latest
28
+ - os: macos-latest
29
+ target: x86_64
17
30
  steps:
18
31
  - uses: actions/checkout@v7
19
32
 
@@ -25,6 +38,7 @@ jobs:
25
38
  - name: Build wheels via Maturin
26
39
  uses: PyO3/maturin-action@v1
27
40
  with:
41
+ target: ${{ matrix.target }}
28
42
  args: --release --out dist --find-interpreter
29
43
  sccache: 'true'
30
44
  sdist: false
@@ -32,7 +46,7 @@ jobs:
32
46
  - name: Upload wheel artifacts
33
47
  uses: actions/upload-artifact@v7
34
48
  with:
35
- name: wheels-${{ matrix.os }}
49
+ name: wheels-${{ matrix.os }}-${{ matrix.target }}
36
50
  path: dist/*.whl
37
51
 
38
52
  build-sdist:
@@ -4,6 +4,31 @@ All notable changes to `strobengine` will be documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.2.1] - 2026-08-03
8
+
9
+ ### Bug Fixes
10
+
11
+ - *(types)* Correct headers type in _strobengine.pyi from dict to list of tuples (#41)
12
+ - Replace build_client panic with proper error propagation (#42)
13
+ - Replace std::process::exit with safe PyKeyboardInterrupt on SIGINT (#43)
14
+ - *(progress)* Guard against zero total_duration in progress bar calculation (#44)
15
+ - Improve worker shutdown and panic handling (#45)
16
+ - *(metrics)* Normalize counter types to AtomicU64 for 32-bit safety (#46)
17
+ - *(cli)* Add type annotation to _output_results summary parameter (#48)
18
+
19
+ ### Documentation
20
+
21
+ - Add comment explaining SystemExit re-raise pattern (#50)
22
+
23
+ ### Miscellaneous Tasks
24
+
25
+ - Remove unnecessary clippy allow on run_load_test (#47)
26
+ - Add multi-architecture matrix for macOS wheel builds (#52)
27
+
28
+ ### Refactoring
29
+
30
+ - Extract magic number 8192 to named constant METRIC_CHANNEL_BUFFER (#49)
31
+ - Extract magic numbers to named constants across Rust and Python (#51)
7
32
  ## [0.2.0] - 2026-08-02
8
33
 
9
34
  ### Bug Fixes
@@ -20,6 +45,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
20
45
  - Update roadmap to reflect changes (#34)
21
46
  - Add HTTP method, body, and header documentation
22
47
  - Add code examples (#37)
48
+ - *(changelog)* Update CHANGELOG.md for v0.2.0 release
23
49
 
24
50
  ### Features
25
51
 
@@ -43,6 +69,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
43
69
 
44
70
  - *(release)* Prepare v0.1.0 changelog and tag
45
71
  - Add PyPI publish workflow with tag-based triggering (#26)
72
+ - *(pyproject)* Add license metadata and project URLs, bump version
73
+ - *(release)* Merge v0.2.0 release prep into main (#40)
46
74
 
47
75
  ### Performance
48
76
 
@@ -1273,7 +1273,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
1273
1273
 
1274
1274
  [[package]]
1275
1275
  name = "strobengine"
1276
- version = "0.2.0"
1276
+ version = "0.2.1"
1277
1277
  dependencies = [
1278
1278
  "bytes",
1279
1279
  "fastrand",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "strobengine"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  edition = "2024"
5
5
  readme = "README.md"
6
6
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: strobengine
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Requires-Dist: rich>=13.0.0
5
5
  Requires-Dist: typer>=0.27.0
6
6
  License-File: LICENSE
@@ -15,12 +15,12 @@ This document outlines the planned trajectory and upcoming feature epics for **s
15
15
 
16
16
  ### Epic: Fault Injection & Resilience Testing (Chaos)
17
17
 
18
- - [x] Basic chaos testing support. `[Unreleased / main]`
18
+ - [x] Basic chaos testing support. `[v0.2.0]`
19
19
 
20
20
  ### Epic: Real-Time Telemetry & Progress Bars
21
21
  *Target Focus: UX & Observability*
22
22
 
23
- - [x] Interactive CLI progress indicators (live RPS, active virtual users, latency feed). `[Unreleased / main]`
23
+ - [x] Interactive CLI progress indicators (live RPS, active virtual users, latency feed). `[v0.2.0]`
24
24
  - [ ] ~~Real-time telemetry dashboard / TUI integration.~~ *(Postponed: Python scripting is primary runner; CLI is for quick drafts only)*
25
25
  - [ ] ~~Improved streaming metrics collection to minimize memory overhead during long runs.~~ *(Deferred until $O(1)$ memory metrics are required for multi-hour runs)*
26
26
 
@@ -31,7 +31,7 @@ This document outlines the planned trajectory and upcoming feature epics for **s
31
31
 
32
32
  - [ ] Custom HTTP Headers support (authentication, user-agents, metadata).
33
33
  - [ ] Dynamic & static Request Payloads (`POST`/`PUT`/`PATCH` body support with JSON/Form Data).
34
- - [ ] Support for all standard HTTP methods (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, `OPTIONS`).
34
+ - [x] Support for all standard HTTP methods (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, `OPTIONS`). `[v0.2.0]`
35
35
 
36
36
  ---
37
37
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "strobengine"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "A load testing framework with a Python UI and a bare-metal Rust engine core."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -24,6 +24,20 @@ use crate::chaos::ChaosEngine;
24
24
  use crate::config::{LoadProfile, TestConfig};
25
25
  use crate::metrics::{LiveCounters, RequestMetric};
26
26
 
27
+ /// Buffer capacity for the async MPSC channel streaming metrics from workers to the aggregator.
28
+ ///
29
+ /// 8,192 (~8k) provides enough head room to prevent worker task backpressure during high RPS bursts
30
+ /// without allocating unnecessary heap memory (8,192 * std::mem::size_of::<RequestMetric>()).
31
+ const METRIC_CHANNEL_BUFFER: usize = 8192;
32
+
33
+ /// Client network configuration defaults.
34
+ const DEFAULT_CONNECT_TIMEOUT_SECS: u64 = 5;
35
+ const DEFAULT_H2_KEEPALIVE_INTERVAL_SECS: u64 = 10;
36
+ const DEFAULT_H2_KEEPALIVE_TIMEOUT_SECS: u64 = 5;
37
+
38
+ /// Supervisor loop poll rate in milliseconds.
39
+ const SUPERVISOR_TICK_MS: u64 = 200;
40
+
27
41
  fn parse_method(method_str: &str) -> PyResult<Method> {
28
42
  method_str.to_uppercase().parse().map_err(|_| {
29
43
  pyo3::exceptions::PyValueError::new_err(format!("Invalid HTTP method: {method_str}"))
@@ -55,17 +69,20 @@ fn parse_headers(headers: Option<Vec<(String, String)>>) -> PyResult<HeaderMap>
55
69
  Ok(header_map)
56
70
  }
57
71
 
58
- fn build_client(concurrency: usize, timeout_secs: u64, header_map: HeaderMap) -> reqwest::Client {
72
+ fn build_client(
73
+ concurrency: usize,
74
+ timeout_secs: u64,
75
+ header_map: HeaderMap,
76
+ ) -> Result<reqwest::Client, reqwest::Error> {
59
77
  reqwest::Client::builder()
60
78
  .pool_max_idle_per_host(concurrency)
61
79
  .timeout(Duration::from_secs(timeout_secs))
62
- .connect_timeout(Duration::from_secs(5))
80
+ .connect_timeout(Duration::from_secs(DEFAULT_CONNECT_TIMEOUT_SECS))
63
81
  .tcp_nodelay(true)
64
- .http2_keep_alive_interval(Duration::from_secs(10))
65
- .http2_keep_alive_timeout(Duration::from_secs(5))
82
+ .http2_keep_alive_interval(Duration::from_secs(DEFAULT_H2_KEEPALIVE_INTERVAL_SECS))
83
+ .http2_keep_alive_timeout(Duration::from_secs(DEFAULT_H2_KEEPALIVE_TIMEOUT_SECS))
66
84
  .default_headers(header_map)
67
85
  .build()
68
- .expect("failed to build HTTP client")
69
86
  }
70
87
 
71
88
  #[pyfunction]
@@ -74,7 +91,6 @@ fn init_logging(level: String, log_file: Option<String>) {
74
91
  }
75
92
 
76
93
  #[pyfunction]
77
- #[allow(clippy::too_many_arguments)]
78
94
  fn run_load_test(py: Python<'_>, config: TestConfig) -> PyResult<metrics::TestSummary> {
79
95
  py.detach(move || {
80
96
  let url = config.url;
@@ -109,9 +125,15 @@ fn run_load_test(py: Python<'_>, config: TestConfig) -> PyResult<metrics::TestSu
109
125
  .build()
110
126
  .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
111
127
 
112
- rt.block_on(async move {
113
- let client = build_client(concurrency, timeout_secs, header_map);
128
+ // Build the client OUTSIDE the async closure
129
+ let client = build_client(concurrency, timeout_secs, header_map)
130
+ .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
114
131
 
132
+ // Track whether cancellation was triggered by user SIGINT
133
+ let interrupted = Arc::new(std::sync::atomic::AtomicBool::new(false));
134
+ let interrupted_check = Arc::clone(&interrupted);
135
+
136
+ let result = rt.block_on(async move {
115
137
  tracing::debug!("http client created");
116
138
 
117
139
  let counters = Arc::new(LiveCounters::new());
@@ -123,20 +145,20 @@ fn run_load_test(py: Python<'_>, config: TestConfig) -> PyResult<metrics::TestSu
123
145
 
124
146
  // Spawn SIGINT listener with double Ctrl+C safety hatch
125
147
  let token_clone = cancel_token.clone();
148
+ let interrupted_clone = interrupted.clone();
126
149
  tokio::spawn(async move {
127
150
  if tokio::signal::ctrl_c().await.is_ok() {
128
151
  tracing::info!("received SIGINT, initiating graceful shutdown");
152
+ interrupted_clone.store(true, Ordering::SeqCst);
129
153
  token_clone.cancel();
130
154
 
131
- // Second Ctrl+C forces immediate termination
132
155
  if tokio::signal::ctrl_c().await.is_ok() {
133
- tracing::warn!("received second SIGINT, terminating immediately");
134
- std::process::exit(130);
156
+ tracing::warn!("received second SIGINT, shutdown already in progress");
135
157
  }
136
158
  }
137
159
  });
138
160
 
139
- let (tx, rx) = tokio::sync::mpsc::channel::<RequestMetric>(8192);
161
+ let (tx, rx) = tokio::sync::mpsc::channel::<RequestMetric>(METRIC_CHANNEL_BUFFER);
140
162
 
141
163
  let aggregator = tokio::spawn(async move {
142
164
  let mut latencies = Vec::new();
@@ -184,12 +206,17 @@ fn run_load_test(py: Python<'_>, config: TestConfig) -> PyResult<metrics::TestSu
184
206
  drop(tx);
185
207
 
186
208
  for handle in handles {
187
- let _ = handle.await;
209
+ if let Err(e) = handle.await {
210
+ tracing::warn!(error = %e, "worker task panicked");
211
+ }
188
212
  }
189
213
 
190
214
  // Wait for render task to finish naturally
215
+ #[allow(clippy::collapsible_if)]
191
216
  if let Some(handle) = render_handle {
192
- let _ = handle.await;
217
+ if let Err(e) = handle.await {
218
+ tracing::debug!(error = %e, "render task panicked");
219
+ }
193
220
  }
194
221
 
195
222
  let latencies = aggregator
@@ -201,8 +228,19 @@ fn run_load_test(py: Python<'_>, config: TestConfig) -> PyResult<metrics::TestSu
201
228
 
202
229
  tracing::info!(total, errors, "constant load test completed");
203
230
 
204
- Ok(metrics::calculate_summary(total, errors, latencies))
205
- })
231
+ Ok::<metrics::TestSummary, pyo3::PyErr>(metrics::calculate_summary(
232
+ total, errors, latencies,
233
+ ))
234
+ })?;
235
+
236
+ // If SIGINT was received, raise KeyboardInterrupt to Python
237
+ if interrupted_check.load(Ordering::SeqCst) {
238
+ return Err(pyo3::exceptions::PyKeyboardInterrupt::new_err(
239
+ "load test interrupted by user",
240
+ ));
241
+ }
242
+
243
+ Ok(result)
206
244
  })
207
245
  }
208
246
 
@@ -261,9 +299,14 @@ fn run_load_profiles(
261
299
  .build()
262
300
  .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
263
301
 
264
- rt.block_on(async move {
265
- let client = build_client(max_concurrency, timeout_secs, header_map);
302
+ let client = build_client(max_concurrency, timeout_secs, header_map)
303
+ .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
304
+
305
+ // Track whether cancellation was triggered by user SIGINT
306
+ let interrupted = Arc::new(std::sync::atomic::AtomicBool::new(false));
307
+ let interrupted_check = Arc::clone(&interrupted);
266
308
 
309
+ let result = rt.block_on(async move {
267
310
  tracing::debug!("http client created");
268
311
 
269
312
  let counters = Arc::new(LiveCounters::new());
@@ -275,19 +318,20 @@ fn run_load_profiles(
275
318
 
276
319
  // Spawn SIGINT listener with double Ctrl+C safety hatch
277
320
  let token_clone = cancel_token.clone();
321
+ let interrupted_clone = interrupted.clone();
278
322
  tokio::spawn(async move {
279
323
  if tokio::signal::ctrl_c().await.is_ok() {
280
324
  tracing::info!("received SIGINT, initiating graceful shutdown");
325
+ interrupted_clone.store(true, Ordering::SeqCst);
281
326
  token_clone.cancel();
282
327
 
283
328
  if tokio::signal::ctrl_c().await.is_ok() {
284
- tracing::warn!("received second SIGINT, terminating immediately");
285
- std::process::exit(130);
329
+ tracing::warn!("received second SIGINT, shutdown already in progress");
286
330
  }
287
331
  }
288
332
  });
289
333
 
290
- let (tx, rx) = tokio::sync::mpsc::channel::<RequestMetric>(8192);
334
+ let (tx, rx) = tokio::sync::mpsc::channel::<RequestMetric>(METRIC_CHANNEL_BUFFER);
291
335
 
292
336
  let aggregator = tokio::spawn(async move {
293
337
  let mut latencies = Vec::new();
@@ -323,6 +367,7 @@ fn run_load_profiles(
323
367
  let supervisor = tokio::spawn(async move {
324
368
  let mut child_tokens: Vec<CancellationToken> = Vec::new();
325
369
  let mut handles: Vec<tokio::task::JoinHandle<()>> = Vec::new();
370
+ let mut reaped_handles: Vec<tokio::task::JoinHandle<()>> = Vec::new();
326
371
  let mut current_concurrency = 0usize;
327
372
  let start = Instant::now();
328
373
 
@@ -357,22 +402,31 @@ fn run_load_profiles(
357
402
  if let Some(token) = child_tokens.pop() {
358
403
  token.cancel();
359
404
  if let Some(handle) = handles.pop() {
360
- let _ = handle.await;
405
+ reaped_handles.push(handle);
361
406
  }
362
407
  current_concurrency -= 1;
363
408
  }
364
409
  }
365
410
 
411
+ // Join reaped workers concurrently
412
+ for handle in reaped_handles.drain(..) {
413
+ if let Err(e) = handle.await {
414
+ tracing::debug!(error = %e, "cancelled worker panicked during shutdown");
415
+ }
416
+ }
417
+
366
418
  tracing::debug!(current_concurrency, target, "supervisor tick");
367
419
 
368
- tokio::time::sleep(Duration::from_millis(200)).await;
420
+ tokio::time::sleep(Duration::from_millis(SUPERVISOR_TICK_MS)).await;
369
421
  }
370
422
 
371
423
  for token in child_tokens {
372
424
  token.cancel();
373
425
  }
374
426
  for handle in handles {
375
- let _ = handle.await;
427
+ if let Err(e) = handle.await {
428
+ tracing::warn!(error = %e, "worker task panicked");
429
+ }
376
430
  }
377
431
 
378
432
  drop(tx);
@@ -383,8 +437,11 @@ fn run_load_profiles(
383
437
  .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
384
438
 
385
439
  // Wait for render task to finish naturally
440
+ #[allow(clippy::collapsible_if)]
386
441
  if let Some(handle) = render_handle {
387
- let _ = handle.await;
442
+ if let Err(e) = handle.await {
443
+ tracing::debug!(error = %e, "render task panicked");
444
+ }
388
445
  }
389
446
 
390
447
  let latencies = aggregator
@@ -396,8 +453,19 @@ fn run_load_profiles(
396
453
 
397
454
  tracing::info!(total, errors, "profile load test completed");
398
455
 
399
- Ok(metrics::calculate_summary(total, errors, latencies))
400
- })
456
+ Ok::<metrics::TestSummary, pyo3::PyErr>(metrics::calculate_summary(
457
+ total, errors, latencies,
458
+ ))
459
+ })?;
460
+
461
+ // If SIGINT was received, raise KeyboardInterrupt to Python
462
+ if interrupted_check.load(Ordering::SeqCst) {
463
+ return Err(pyo3::exceptions::PyKeyboardInterrupt::new_err(
464
+ "load test interrupted by user",
465
+ ));
466
+ }
467
+
468
+ Ok(result)
401
469
  })
402
470
  }
403
471
 
@@ -2,6 +2,9 @@ use std::sync::atomic::{AtomicU64, AtomicUsize};
2
2
 
3
3
  use pyo3::prelude::*;
4
4
 
5
+ /// Conversion factor from microseconds to milliseconds (1 ms = 1,000 us).
6
+ pub const MICROS_PER_MILLI: f64 = 1_000.0;
7
+
5
8
  #[allow(dead_code)]
6
9
  pub struct RequestMetric {
7
10
  pub status_code: u16,
@@ -10,8 +13,8 @@ pub struct RequestMetric {
10
13
  }
11
14
 
12
15
  pub struct LiveCounters {
13
- pub total_requests: AtomicUsize,
14
- pub errors: AtomicUsize,
16
+ pub total_requests: AtomicU64,
17
+ pub errors: AtomicU64,
15
18
  pub active_workers: AtomicUsize,
16
19
  pub completed_requests: AtomicU64,
17
20
  pub latency_sum_micros: AtomicU64,
@@ -21,8 +24,8 @@ pub struct LiveCounters {
21
24
  impl LiveCounters {
22
25
  pub fn new() -> Self {
23
26
  Self {
24
- total_requests: AtomicUsize::new(0),
25
- errors: AtomicUsize::new(0),
27
+ total_requests: AtomicU64::new(0),
28
+ errors: AtomicU64::new(0),
26
29
  active_workers: AtomicUsize::new(0),
27
30
  completed_requests: AtomicU64::new(0),
28
31
  latency_sum_micros: AtomicU64::new(0),
@@ -46,11 +49,15 @@ pub struct TestSummary {
46
49
  pub p99_latency_ms: f64,
47
50
  }
48
51
 
49
- pub fn calculate_summary(total: usize, errors: usize, mut latencies: Vec<u128>) -> TestSummary {
52
+ pub fn calculate_summary(
53
+ total_requests: u64,
54
+ total_errors: u64,
55
+ mut latencies: Vec<u128>,
56
+ ) -> TestSummary {
50
57
  if latencies.is_empty() {
51
58
  return TestSummary {
52
- total_requests: total,
53
- total_errors: errors,
59
+ total_requests: total_requests as usize,
60
+ total_errors: total_errors as usize,
54
61
  average_latency_ms: 0.0,
55
62
  p95_latency_ms: 0.0,
56
63
  p99_latency_ms: 0.0,
@@ -61,17 +68,17 @@ pub fn calculate_summary(total: usize, errors: usize, mut latencies: Vec<u128>)
61
68
 
62
69
  let len = latencies.len();
63
70
  let sum: u128 = latencies.iter().sum();
64
- let average_latency_ms = sum as f64 / len as f64 / 1000.0;
71
+ let average_latency_ms = sum as f64 / len as f64 / MICROS_PER_MILLI;
65
72
 
66
73
  let p95_idx = (len * 95 / 100).min(len - 1);
67
74
  let p99_idx = (len * 99 / 100).min(len - 1);
68
75
 
69
76
  TestSummary {
70
- total_requests: total,
71
- total_errors: errors,
77
+ total_requests: total_requests as usize,
78
+ total_errors: total_errors as usize,
72
79
  average_latency_ms,
73
- p95_latency_ms: latencies[p95_idx] as f64 / 1000.0,
74
- p99_latency_ms: latencies[p99_idx] as f64 / 1000.0,
80
+ p95_latency_ms: latencies[p95_idx] as f64 / MICROS_PER_MILLI,
81
+ p99_latency_ms: latencies[p99_idx] as f64 / MICROS_PER_MILLI,
75
82
  }
76
83
  }
77
84
 
@@ -5,10 +5,16 @@ use std::time::{Duration, Instant};
5
5
  use indicatif::{ProgressBar, ProgressStyle};
6
6
  use tokio_util::sync::CancellationToken;
7
7
 
8
- use crate::metrics::LiveCounters;
8
+ use crate::metrics::{LiveCounters, MICROS_PER_MILLI};
9
+
10
+ /// Target update rate for the terminal UI tick loop.
11
+ const RENDER_LOOP_SLEEP_MS: u64 = 200;
12
+
13
+ /// Indicatif progress bar total resolution ticks.
14
+ const PROGRESS_BAR_MAX_TICKS: u64 = 100;
9
15
 
10
16
  pub fn create_progress_bar(_total_duration: Duration) -> ProgressBar {
11
- let pb = ProgressBar::new(100);
17
+ let pb = ProgressBar::new(PROGRESS_BAR_MAX_TICKS);
12
18
  pb.set_style(
13
19
  ProgressStyle::with_template(
14
20
  "{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/dim}] {pos}% | {msg}",
@@ -33,7 +39,7 @@ pub async fn render_loop(
33
39
 
34
40
  loop {
35
41
  tokio::select! {
36
- _ = tokio::time::sleep(Duration::from_millis(200)) => {
42
+ _ = tokio::time::sleep(Duration::from_millis(RENDER_LOOP_SLEEP_MS)) => {
37
43
  let now = Instant::now();
38
44
  let delta_sec = (now - last_tick).as_secs_f64();
39
45
  last_tick = now;
@@ -48,7 +54,11 @@ pub async fn render_loop(
48
54
  }
49
55
 
50
56
  // Progress percentage based on elapsed time
51
- let pct = (elapsed.as_secs_f64() / total_duration.as_secs_f64() * 100.0) as u64;
57
+ let pct = if total_duration.is_zero() {
58
+ 0
59
+ } else {
60
+ (elapsed.as_secs_f64() / total_duration.as_secs_f64() * 100.0) as u64
61
+ };
52
62
  pb.set_position(pct.min(99));
53
63
 
54
64
  // Sample metrics
@@ -66,7 +76,7 @@ pub async fn render_loop(
66
76
 
67
77
  // Average latency across all completed requests
68
78
  let avg_latency_ms = if latency_count > 0 {
69
- latency_sum as f64 / latency_count as f64 / 1000.0
79
+ latency_sum as f64 / latency_count as f64 / MICROS_PER_MILLI
70
80
  } else {
71
81
  0.0
72
82
  };
@@ -30,7 +30,7 @@ class TestConfig:
30
30
  no_progress: bool
31
31
  method: str
32
32
  body: str | None
33
- headers: dict[str, str] | None
33
+ headers: list[tuple[str, str]] | None
34
34
  def __init__(
35
35
  self,
36
36
  url: str,
@@ -42,7 +42,7 @@ class TestConfig:
42
42
  no_progress: bool = False,
43
43
  method: str = "GET",
44
44
  body: str | None = None,
45
- headers: dict[str, str] | None = None,
45
+ headers: list[tuple[str, str]] | None = None,
46
46
  ) -> None: ...
47
47
 
48
48
  class TestSummary:
@@ -68,5 +68,5 @@ def run_load_profiles(
68
68
  no_progress: bool = False,
69
69
  method: str = "GET",
70
70
  body: str | None = None,
71
- headers: dict[str, str] | None = None,
71
+ headers: list[tuple[str, str]] | None = None,
72
72
  ) -> TestSummary: ...
@@ -6,7 +6,7 @@ from typing import Annotated
6
6
 
7
7
  import typer
8
8
 
9
- from strobengine._strobengine import init_logging
9
+ from strobengine._strobengine import TestSummary, init_logging
10
10
  from strobengine.engine import RequestOptions, StrobEngine
11
11
  from strobengine.reporter import print_summary
12
12
 
@@ -114,7 +114,9 @@ def _global_options(
114
114
  pass
115
115
 
116
116
 
117
- def _output_results(summary, url: str, duration_secs: int, json_output: bool) -> None:
117
+ def _output_results(
118
+ summary: TestSummary, url: str, duration_secs: int, json_output: bool
119
+ ) -> None:
118
120
  if json_output:
119
121
  print(
120
122
  json.dumps(
@@ -394,6 +396,7 @@ def main(argv: list[str] | None = None) -> None:
394
396
  try:
395
397
  app(args=argv)
396
398
  except SystemExit as e:
399
+ # Re-raise to prevent KeyboardInterrupt handler from catching typer exits
397
400
  raise e
398
401
  except KeyboardInterrupt:
399
402
  typer.echo("\nInterrupted.", err=True)
@@ -0,0 +1,14 @@
1
+ """Default configuration values for strobengine execution."""
2
+
3
+ DEFAULT_CONCURRENCY: int = 10
4
+ DEFAULT_DURATION_SECS: int = 10
5
+ DEFAULT_TIMEOUT_SECS: int = 10
6
+ DEFAULT_START_CONCURRENCY: int = 10
7
+ DEFAULT_MAX_CONCURRENCY: int = 200
8
+ DEFAULT_RAMP_SECS: int = 60
9
+ DEFAULT_HOLD_SECS: int = 30
10
+ DEFAULT_BASELINE: int = 5
11
+ DEFAULT_PEAK_CONCURRENCY: int = 500
12
+ DEFAULT_PRE_SPIKE_SECS: int = 5
13
+ DEFAULT_SPIKE_SECS: int = 10
14
+ DEFAULT_POST_SPIKE_SECS: int = 5
@@ -8,13 +8,27 @@ from strobengine._strobengine import (
8
8
  run_load_profiles,
9
9
  run_load_test,
10
10
  )
11
+ from strobengine.constants import (
12
+ DEFAULT_BASELINE,
13
+ DEFAULT_CONCURRENCY,
14
+ DEFAULT_DURATION_SECS,
15
+ DEFAULT_HOLD_SECS,
16
+ DEFAULT_MAX_CONCURRENCY,
17
+ DEFAULT_PEAK_CONCURRENCY,
18
+ DEFAULT_POST_SPIKE_SECS,
19
+ DEFAULT_PRE_SPIKE_SECS,
20
+ DEFAULT_RAMP_SECS,
21
+ DEFAULT_SPIKE_SECS,
22
+ DEFAULT_START_CONCURRENCY,
23
+ DEFAULT_TIMEOUT_SECS,
24
+ )
11
25
 
12
26
 
13
27
  @dataclass
14
28
  class RequestOptions:
15
29
  """Encapsulates common HTTP and execution parameters with validation."""
16
30
 
17
- timeout: int = 10
31
+ timeout: int = DEFAULT_TIMEOUT_SECS
18
32
  chaos: bool = False
19
33
  no_progress: bool = False
20
34
  method: str = "GET"
@@ -30,8 +44,8 @@ class StrobEngine:
30
44
  def __init__(
31
45
  self,
32
46
  url: str,
33
- concurrency: int = 10,
34
- duration: int = 10,
47
+ concurrency: int = DEFAULT_CONCURRENCY,
48
+ duration: int = DEFAULT_DURATION_SECS,
35
49
  options: RequestOptions | None = None,
36
50
  profile: LoadProfile | None = None,
37
51
  ) -> None:
@@ -64,8 +78,8 @@ class StrobEngine:
64
78
  def load_test(
65
79
  cls,
66
80
  url: str,
67
- concurrency: int = 10,
68
- duration: int = 10,
81
+ concurrency: int = DEFAULT_CONCURRENCY,
82
+ duration: int = DEFAULT_DURATION_SECS,
69
83
  options: RequestOptions | None = None,
70
84
  ) -> "StrobEngine":
71
85
  return cls(
@@ -79,10 +93,10 @@ class StrobEngine:
79
93
  def stress_test(
80
94
  cls,
81
95
  url: str,
82
- start_concurrency: int = 10,
83
- max_concurrency: int = 200,
84
- ramp_duration: int = 60,
85
- hold_duration: int = 30,
96
+ start_concurrency: int = DEFAULT_START_CONCURRENCY,
97
+ max_concurrency: int = DEFAULT_MAX_CONCURRENCY,
98
+ ramp_duration: int = DEFAULT_RAMP_SECS,
99
+ hold_duration: int = DEFAULT_HOLD_SECS,
86
100
  options: RequestOptions | None = None,
87
101
  ) -> "StrobEngine":
88
102
  if start_concurrency <= 0:
@@ -112,11 +126,11 @@ class StrobEngine:
112
126
  def spike_test(
113
127
  cls,
114
128
  url: str,
115
- baseline: int = 5,
116
- peak_concurrency: int = 500,
117
- pre_spike_duration: int = 5,
118
- spike_duration: int = 10,
119
- post_spike_duration: int = 5,
129
+ baseline: int = DEFAULT_BASELINE,
130
+ peak_concurrency: int = DEFAULT_PEAK_CONCURRENCY,
131
+ pre_spike_duration: int = DEFAULT_PRE_SPIKE_SECS,
132
+ spike_duration: int = DEFAULT_SPIKE_SECS,
133
+ post_spike_duration: int = DEFAULT_POST_SPIKE_SECS,
120
134
  options: RequestOptions | None = None,
121
135
  ) -> "StrobEngine":
122
136
  if baseline <= 0:
@@ -155,7 +155,7 @@ wheels = [
155
155
 
156
156
  [[package]]
157
157
  name = "strobengine"
158
- version = "0.2.0"
158
+ version = "0.2.1"
159
159
  source = { editable = "." }
160
160
  dependencies = [
161
161
  { name = "rich" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes