Visitar URL original
feat(async-runtime): drain-linger surface + lock-free scheduler inter… · napi-rs/napi-rs@050d985 · GitHub
Skip to content

Commit 050d985

Browse files
Brooooooklynclaude
andauthored
feat(async-runtime): drain-linger surface + lock-free scheduler internals (#3420)
* feat(async-runtime): 0.2.0 — drain-linger surface + lock-free scheduler internals Port the napi-async-runtime 0.1.0 -> 0.2.0 delta from the rolldown integration line (rolldown/napi-3.12.0 @ 9999dad) onto main. The executor/host contract (AsyncRuntime SPI, configure/install, JsCallback, consumed API signatures) is unchanged; this is a perf release plus one new public knob. Public surface added: - RuntimeOptions.drain_linger: Duration and RuntimeOptionsPatch.drain_linger: Option<Duration> — the pool drainer's idle-linger budget (0 disables lingering), ceiling-clamped by validation. - Consts DEFAULT_DRAIN_LINGER_MICROS (500) / MAX_DRAIN_LINGER_MICROS (100_000) and env name DRAIN_LINGER_ENV ("NAPI_RUNTIME_DRAIN_LINGER_US"). - napi-feature adapter: resolve_drain_linger env resolver, install() resolves DRAIN_LINGER_ENV at addon load, and BindingRuntimeConfig.drain_linger_us reports the validated value. Perf internals (behavior-preserving for hosts): - Lock-free crossbeam-deque injector queue replacing the mutexed global queue on the pool path. - ArcSwapOption spawn path removing the spawn-side lock. - Retire-by-sequence worker retirement instead of eager teardown. - Drainer idle-linger: pool drainers linger briefly on an empty queue (bounded by a frame-residence cap) instead of respawning per wave. Deviation from the integration branch: crates/async-runtime/Cargo.toml keeps main's dependency shape (path deps with version = "3" for napi/napi-derive) instead of the branch's exact =3.12.0/=3.7.0 pins; new deps arc-swap and crossbeam-deque (non-wasm) are added. In-crate tests come with the port (327 native tests, consumed_api unchanged). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T34fMrYCf2V13Mg8BKmnda * fix(async-runtime): rustfmt drift + widen the Windows timing bound in cooperative-exit residue test Failure 1: `cargo fmt -- --check` diff at async_runtime.rs:15945 -- the ported file came from a lineage formatted with an older rustfmt; the current toolchain joins the producer loop's two-line `while` condition into one line. Reformatted (this file only; the rest of the workspace was already fmt-clean). Failure 2: `cooperative_exit_compensates_blocking_only_residue` timed out on the msys2 CLANG64 lane at `returned_rx.recv_timeout(2s)` while `blocking_rx` succeeded. Root cause is wake ROUTING, not just slowness: on a starved runner the cooperative frame can still be un-parked when the timer task is scheduled, so the schedule spawns a second drainer; that drainer then LINGERS as a registered blocking-capable parker, and `wake_one_blocking` prefers the most recently parked candidate -- the linger parker absorbs the wake meant for the frame, the drainer runs the blocking job (blocking_rx passes), and the frame sleeps in its timer-bounded park until the test's own 120s heap timer, past any sane recv bound (so merely widening the timeout could not fix this mode). Harden the test with the module's established `wait_until` parked-count idiom (as in multi_thread_cooperative_exit_hands_absorbed_wake_to_ parked_driver): require the frame to be the sole registered parker before scheduling the timer task and again before the blocking wake, so the wake deterministically targets the frame and its exit compensation -- the subject under test -- services the residue. Also widen the recv bounds 2s -> 10s per the module's hang-detector-not-latency-assertion doctrine. No scheduler changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T34fMrYCf2V13Mg8BKmnda --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent e0b8708 commit 050d985

3 files changed

Lines changed: 1528 additions & 146 deletions

File tree

‎crates/async-runtime/Cargo.toml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@ name = "napi-async-runtime"
88
readme = "README.md"
99
repository = "https://github.com/napi-rs/napi-rs"
1010
rust-version.workspace = true
11-
version = "0.1.0"
11+
version = "0.2.0"
1212

1313
[features]
1414
default = ["napi"]
1515
napi = ["dep:napi", "dep:napi-derive"]
1616

1717
[dependencies]
18+
arc-swap = "1"
1819
async-task = "4.7.1"
1920
futures = "0.3"
2021
rustc-hash = "2"
@@ -35,6 +36,7 @@ optional = true
3536

3637
[target.'cfg(not(target_family = "wasm"))'.dependencies]
3738
rayon = "1.12"
39+
crossbeam-deque = "0.8"
3840

3941
[dev-dependencies]
4042
# The in-crate test binaries are plain executables rather than Node-loaded

‎crates/async-runtime/src/adapter.rs‎

Lines changed: 93 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,9 @@ impl TryFrom<BindingRuntimeOptions> for RuntimeOptionsPatch {
133133
value.max_blocking_tasks,
134134
MAX_ASYNC_RUNTIME_WORKER_THREADS,
135135
)?,
136+
// Not exposed through the JavaScript configure surface; the addon-load
137+
// resolution (`install` + `DRAIN_LINGER_ENV`) owns this knob.
138+
drain_linger: None,
136139
})
137140
}
138141
}
@@ -171,6 +174,9 @@ pub struct BindingRuntimeConfig {
171174
pub flavor: BindingRuntimeFlavor,
172175
pub worker_threads: u32,
173176
pub max_blocking_tasks: u32,
177+
/// Drainer idle-linger budget in microseconds; `0` means lingering is
178+
/// disabled. Reported from the validated (ceiling-clamped) options.
179+
pub drain_linger_us: u32,
174180
}
175181

176182
impl From<RuntimeOptions> for BindingRuntimeConfig {
@@ -179,6 +185,9 @@ impl From<RuntimeOptions> for BindingRuntimeConfig {
179185
flavor: value.flavor.into(),
180186
worker_threads: saturating_u32(value.worker_threads as u64),
181187
max_blocking_tasks: saturating_u32(value.max_blocking_tasks as u64),
188+
drain_linger_us: saturating_u32(
189+
u64::try_from(value.drain_linger.as_micros()).unwrap_or(u64::MAX),
190+
),
182191
}
183192
}
184193
}
@@ -1961,10 +1970,20 @@ pub fn unregister_timer_host(registration_high: u32, registration_low: u32) {
19611970
/// Call this from the host's own `#[napi_derive::module_init]` hook, after
19621971
/// resolving the process configuration (environment variables, defaults) into
19631972
/// [`RuntimeOptions`]. The scheduler never reads the environment itself; the
1964-
/// host owns that resolution. `configure` validates and normalizes the
1965-
/// options, and the runtime controller's options remain the reporting
1966-
/// authority for `getAsyncRuntimeConfig`.
1967-
pub fn install(options: RuntimeOptions) -> napi::Result<()> {
1973+
/// host owns that resolution -- with ONE adapter-owned convenience: `install`
1974+
/// resolves the conventional [`DRAIN_LINGER_ENV`] override into
1975+
/// [`RuntimeOptions::drain_linger`] here, at the same addon-load boundary
1976+
/// where the host resolves its variables (a present numeric value wins over
1977+
/// the passed field; absent or non-numeric keeps it -- see
1978+
/// [`resolve_drain_linger`]). Hosts that call [`configure`] directly instead
1979+
/// of `install` own that resolution too. `configure` validates and normalizes
1980+
/// the options (clamping `drain_linger` to its ceiling), and the runtime
1981+
/// controller's options remain the reporting authority for
1982+
/// `getAsyncRuntimeConfig`.
1983+
pub fn install(mut options: RuntimeOptions) -> napi::Result<()> {
1984+
if let Ok(raw) = std::env::var(crate::async_runtime::DRAIN_LINGER_ENV) {
1985+
options.drain_linger = resolve_drain_linger(Some(raw), options.drain_linger);
1986+
}
19681987
configure(options).map_err(|error| napi::Error::from_reason(error.to_string()))?;
19691988
register_async_runtime(SharedAsyncRuntime);
19701989
Ok(())
@@ -1987,6 +2006,27 @@ pub fn resolve_thread_count(raw: Option<String>, default: usize, maximum: usize)
19872006
.min(maximum)
19882007
}
19892008

2009+
/// Resolve an env-derived drainer idle-linger budget (a raw
2010+
/// [`DRAIN_LINGER_ENV`]-style value, MICROSECONDS) into
2011+
/// [`RuntimeOptions::drain_linger`]: a missing or non-numeric value keeps
2012+
/// `fallback` (the host-provided field, [`DEFAULT_DRAIN_LINGER_MICROS`] by
2013+
/// default); `0` disables lingering; anything else is clamped to
2014+
/// [`MAX_DRAIN_LINGER_MICROS`] so an extreme value (e.g. `u64::MAX`) tunes
2015+
/// the wave optimization instead of pinning pool workers on never-expiring
2016+
/// lingers. [`RuntimeOptions`] validation re-applies the same clamp, so a
2017+
/// host bypassing this resolver cannot smuggle an unbounded budget either.
2018+
pub fn resolve_drain_linger(
2019+
raw: Option<String>,
2020+
fallback: std::time::Duration,
2021+
) -> std::time::Duration {
2022+
raw
2023+
.and_then(|value| value.trim().parse::<u64>().ok())
2024+
.map_or(fallback, std::time::Duration::from_micros)
2025+
.min(std::time::Duration::from_micros(
2026+
crate::async_runtime::MAX_DRAIN_LINGER_MICROS,
2027+
))
2028+
}
2029+
19902030
#[cfg(test)]
19912031
mod tests {
19922032
use super::{
@@ -3780,4 +3820,53 @@ mod tests {
37803820
fn resolve_thread_count_rejects_an_invalid_default() {
37813821
let _ = resolve_thread_count(None, 0, 1);
37823822
}
3823+
3824+
#[test]
3825+
fn resolve_drain_linger_handles_env_inputs() {
3826+
use std::time::Duration;
3827+
3828+
use crate::async_runtime::{DEFAULT_DRAIN_LINGER_MICROS, MAX_DRAIN_LINGER_MICROS};
3829+
3830+
use super::resolve_drain_linger;
3831+
3832+
let fallback = Duration::from_micros(DEFAULT_DRAIN_LINGER_MICROS);
3833+
let ceiling = Duration::from_micros(MAX_DRAIN_LINGER_MICROS);
3834+
// Valid values pass through unchanged.
3835+
assert_eq!(
3836+
resolve_drain_linger(Some("250".to_string()), fallback),
3837+
Duration::from_micros(250)
3838+
);
3839+
// `0` disables lingering (it is a VALUE here, unlike thread counts).
3840+
assert_eq!(
3841+
resolve_drain_linger(Some("0".to_string()), fallback),
3842+
Duration::ZERO
3843+
);
3844+
// u64::MAX -- the exact value that used to wedge the suite through the
3845+
// removed first-drainer OnceLock -- clamps to the ceiling.
3846+
assert_eq!(
3847+
resolve_drain_linger(Some(u64::MAX.to_string()), fallback),
3848+
ceiling
3849+
);
3850+
// Parse overflow is invalid input and keeps the host-provided field.
3851+
assert_eq!(
3852+
resolve_drain_linger(Some("18446744073709551616".to_string()), fallback),
3853+
fallback
3854+
);
3855+
// Non-numeric and unset keep the host-provided field.
3856+
assert_eq!(
3857+
resolve_drain_linger(Some("abc".to_string()), fallback),
3858+
fallback
3859+
);
3860+
assert_eq!(resolve_drain_linger(None, fallback), fallback);
3861+
// Whitespace is tolerated, matching the removed in-scheduler parse.
3862+
assert_eq!(
3863+
resolve_drain_linger(Some(" 750 ".to_string()), fallback),
3864+
Duration::from_micros(750)
3865+
);
3866+
// An out-of-range host fallback is bounded too.
3867+
assert_eq!(
3868+
resolve_drain_linger(None, Duration::from_micros(u64::MAX)),
3869+
ceiling
3870+
);
3871+
}
37833872
}

0 commit comments

Comments
 (0)