Valtron
An experimental async runtime built on iterators that do not specifically rely on the async future paradigm nor follow the current async/wait paradigm most know for a more elaborate iterator and trait syntax that both provide a unified set of principle and control.
Troubleshooting
Multi-threaded Executor: Tests Hanging or Not Receiving Values
Symptom: Integration tests using WebSocketClient, DrivenStreamIterator, or other multi-threaded valtron-based iterators hang indefinitely or return Stream::Ignore immediately without processing any values.
Cause: The PoolGuard returned by initialize_pool() must be kept alive for the duration of test execution. Dropping it (e.g., let _ = initialize_pool(...)) immediately shuts down all worker threads, preventing any task execution.
In multi-threaded mode (feature = "multi"), tasks run on worker threads managed by the pool. When PoolGuard is dropped, the pool shuts down and all worker threads exit. This means queued tasks never execute, so the concurrent queue never receives any values. The iterator then yields Stream::Ignore or loops waiting for values that will never arrive.
Fix: Keep PoolGuard alive for the test duration:
// WRONG - guard dropped immediately, worker threads shut down
let _ = initialize_pool;
// CORRECT - guard kept alive for test duration
let _pool_guard: PoolGuard = initialize_pool;
Note: An initial fix attempt added a thread::sleep wait loop in run_until_stream_has_value for multi-threaded mode, but this was unnecessary. The real issue was the dropped PoolGuard - once the guard is kept alive, worker threads remain active and tasks execute normally.
Files affected:
tests/backends/foundation_core/websocket/echo_tests.rs- Test fixtests/backends/foundation_core/websocket/subprotocol_tests.rs- Test fixtests/backends/foundation_core/websocket/reconnection_tests.rs- Test fixtests/backends/foundation_core/event_source/consumer_integration_tests.rs- Test fixbackends/foundation_core/src/valtron/executors/drivers.rs- Removed unnecessary multi-threaded wait code
Multi-threaded Executor: Parallel Test Interference
Symptom: Integration tests using initialize_pool() exhibit intermittent hangs or timeouts when running tests in parallel. Different tests fail on each run, with symptoms showing worker threads starting but never completing, or WebSocket connections established but messages never received.
Root Cause: The valtron executor uses global static registries (REGISTRY and BG_REGISTRY) to store the ThreadRegistry and BackgroundJobRegistry. When tests run in parallel:
- Test A starts, calls
initialize_pool()→ setsREGISTRY = Arc_A - Test B starts, calls
initialize_pool()→ overwritesREGISTRY = Arc_B - Test A's worker threads now reference the wrong registry
- When Test A's
PoolGuarddrops, it shuts down workers that may be serving Test B
This race condition causes intermittent failures that are non-deterministic and vary between test runs.
Fix: Tests that use initialize_pool() must be serialized using the #[serial] attribute with a named lock group. All tests within the same file share the same lock name:
use serial;
// All tests in this file use the same serial lock to prevent PoolGuard interference
// Named lock groups tests by feature area
Named lock groups by test file:
echo_tests.rs→#[serial(websocket_pool)]subprotocol_tests.rs→#[serial(subprotocol_tests)]reconnection_tests.rs→#[serial(reconnection_tests)]http_redirect_integration.rs→#[serial(http_redirect)]consumer_integration_tests.rs→#[serial(sse_consumer_tests)]
Why not fix the global state? The global registry design allows for a simple single-instance executor model. Adding per-test isolation would require significant architectural changes. The #[serial] attribute provides a pragmatic solution that:
- Keeps tests isolated with their own clean pool
- Allows parallel execution across different test groups (websocket vs http vs event_source)
- Maintains test independence without shared state
Additional safeguards:
ntest::timeoutattribute on all integration tests prevents indefinite hangsPoolGuard::drop()now clears global registries to prevent state leakage between sequential tests
Files updated:
backends/foundation_core/src/valtron/executors/threads.rs- Added cleanup callback to PoolGuardbackends/foundation_core/src/valtron/executors/multi/mod.rs- PoolGuard::with_cleanup() clears registries on drop- All integration test files in
tests/backends/foundation_core/- Added#[serial(...)]attributes
Tasks
Tasks in Valtron are a custom iterator and in another sense can also become regular interators, they specifically provide a way to represent a asynchronous task as a series of values that can be iterated over and also provide a clear distinction when they are busy or when they are ready to give you a value.
use ;
;
Executors
The crate provides two main means of driving execution:
Mulit-Threaded Executor
Provided in the multi module which allows you start a multi-threaded executor that utilizes as much threads as you allow it where work is distributed across these threads but one unique behaviour of the executor is that they never steal work, once a job has being allocated to a thread executor that job is forever owned by it till completion hence removing concerns on runtime cost to move work across threads and memory boundaries.
Example 1
use time;
use thread;
use RngCore;
use FnReady;
use ;
Example 2
use time;
use thread;
use RngCore;
use FnReady;
use ;
Example 3
use time;
use thread;
use RngCore;
use FnReady;
use ;
Single-Threaded Executor
Valtron also provides a single-threaded module that provides a more finegrained and controllable executor useful in environments like WebAssembly or embedded systems. We get great control to determine when a executor actually processes all queued up tasks to completion.
A benefit of single-threaded executor is the freedom to not be forced to think in multi-threaded contexts and require use of Send-safe smart pointers and wrappers but work with normal smart pointers like RefCell and Rc.
SingleExecutorSingleton (wasm32/wasm64 only)
On wasm32/wasm64 targets (when multi feature is off), valtron provides SingleExecutorSingleton — a convenience wrapper that combines initialization + guard ownership:
use SingleExecutorSingleton;
// Initialize executor and get guard in one call
let _guard = get_or_init;
// Later calls return the same guard (zero-sized, cheap clone)
let guard = guard;
// Schedule and run
spawn
.with_task
.with_resolver
.schedule
.expect;
run_until_complete;
The existing free functions (initialize_pool, spawn, run_until_complete) continue to work independently — SingleExecutorSingleton is optional.
Free function API (always available)
use ;
use RngCore;
use traced_test;
use ;
;
Example 1
Example 2
Example 3
StreamIterator — Send vs Non-Send Variants
Valtron provides two parallel implementations of StreamIteratorExt (the combinator methods like map_done, map_pending, map_circuit):
| Feature | StreamIterator blanket impl |
StreamIteratorExt source |
Send bound |
|---|---|---|---|
multi |
D: Send + 'static, P: Send + 'static |
extensions/streams/sendable.rs |
Required |
not(multi) |
D: 'static, P: 'static |
extensions/streams/non_sendable.rs |
Not required |
The blanket StreamIterator impl is cfg-gated to match: on multi it requires Send bounds, on non-multi (wasm32, single-threaded native) it only requires 'static. This means DrivenStreamIterator<FutureTask<F>> — whose Ready is Result<T, E> (not Send when T/E aren't) and whose Pending is () — correctly implements StreamIterator on the non-multi path and gets access to StreamIteratorExt combinators.
What this means for downstream code
You use the same API regardless of target:
use ;
let task = from_future;
let stream = execute?;
// Works on both multi (Send) and non-multi (non-Send) targets.
// The correct StreamIteratorExt implementation is selected at compile time.
let mapped = stream
.map_done
.map_pending;
No #[cfg(target_arch = "wasm32")] or feature-gated imports needed in downstream code. The executor (execute, from_future) and the combinator trait (StreamIteratorExt) are always exported from foundation_core::valtron — the cfg selection happens entirely within valtron itself.
Why two implementations
The multi executor runs tasks on background worker threads, so all task outputs (Ready, Pending) must be Send to cross thread boundaries. The single-threaded executor (used on wasm32 and native without multi) runs everything on the current thread, so no Send bound is needed. The StreamIteratorExt combinator types (MapDone, MapPending, etc.) store boxed closures — on the multi path those closures must be Send, on the non-multi path they don't need to be.
⚠️ CRITICAL: TaskIterator/Iterator Recursion Trap
Location: task.rs:311-401
Problem: The TaskIterator trait has a blanket implementation for any type that implements Iterator<Item = TaskStatus<...>>. This creates bidirectional coherence:
// Any Iterator<TaskStatus<...>> automatically implements TaskIterator
The Trap: Wrapper types that implement BOTH Iterator and TaskIterator will infinitely recurse on next_status():
// WRONG - implements both, causes infinite recursion!
The Solution: Wrapper types should:
- Implement
TaskIteratorwith explicitnext_status() - Delegate to
inner.next_status()(notIterator::next()) - Use
Box<dyn TaskIterator>to break the cycle viaas_mut().next_status()
// CORRECT - explicit TaskIterator impl
Bottom Line: Never implement both Iterator<Item = TaskStatus<...>> and TaskIterator on the same type. The blanket impl makes them mutually exclusive. Use Box<dyn TaskIterator> or explicit delegation to break the cycle.