Problem
The DNP3 library calls tokio::spawn internally in all spawn_* creation functions. This prevents callers from controlling which Tokio runtime or task set the futures are placed on.
Proposed Changes
Scope: TCP and serial transports. Rust API only — no FFI changes.
1. Master: builder + single task type
Add a MasterTask builder that separates configuration from execution:
let task = MasterTask::builder(config)
.enable()
.add_association(address, assoc_config, read_handler, assoc_handler, assoc_info)
.tcp(endpoints, connect_strategy, listener)
// or .serial(path, settings, retry_delay, listener)
MasterTask is a single concrete type regardless of transport. It exposes:
channel(&self) -> &MasterChannel — get the handle for post-spawn use
enable(), add_association() — synchronous, direct state mutation (no channel messages)
async fn run(self) — consumes the task and runs the event loop
The existing spawn_* functions are refactored to delegate to this, so no behavior changes for current users.
2. Outstation: same pattern
Same approach for outstation TCP client and serial — a single task type with pre-spawn configuration and async fn run(self).
3. Outstation TCP server: accept pre-bound TcpListener
Add a bind_with_listener(self, TcpListener) method that takes an already-bound listener synchronously. Refactor bind_no_spawn() to delegate to it.
Non-goals
- FFI / language binding changes
- TLS transport
- Changes to existing public config types (semver)
Problem
The DNP3 library calls
tokio::spawninternally in allspawn_*creation functions. This prevents callers from controlling which Tokio runtime or task set the futures are placed on.Proposed Changes
Scope: TCP and serial transports. Rust API only — no FFI changes.
1. Master: builder + single task type
Add a
MasterTaskbuilder that separates configuration from execution:MasterTaskis a single concrete type regardless of transport. It exposes:channel(&self) -> &MasterChannel— get the handle for post-spawn useenable(),add_association()— synchronous, direct state mutation (no channel messages)async fn run(self)— consumes the task and runs the event loopThe existing
spawn_*functions are refactored to delegate to this, so no behavior changes for current users.2. Outstation: same pattern
Same approach for outstation TCP client and serial — a single task type with pre-spawn configuration and
async fn run(self).3. Outstation TCP server: accept pre-bound
TcpListenerAdd a
bind_with_listener(self, TcpListener)method that takes an already-bound listener synchronously. Refactorbind_no_spawn()to delegate to it.Non-goals