Skip to main content

napi_async_runtime/
adapter.rs

1//! napi adapter: registers the shared scheduler as the addon's
2//! [`AsyncRuntime`] backend and exports the JavaScript-facing host protocol
3//! (CurrentThread task host, JS timer host + relay, config/metrics).
4//!
5//! Hosts call [`install`] from their own `#[napi_derive::module_init]` hook.
6//! This module deliberately ships no `module_init` of its own: the host must
7//! resolve its runtime configuration (environment variables, defaults) first
8//! and stay in charge of that resolution order.
9
10use std::{future::Future, pin::Pin, ptr};
11
12use napi::bindgen_prelude::{
13  AsyncRuntime, AsyncRuntimeRejection, AsyncRuntimeTask, FnArgs, Promise, Unknown,
14  register_async_runtime,
15};
16use napi::threadsafe_function::ThreadsafeFunctionCallMode;
17use napi_derive::napi;
18
19use crate::MAX_ASYNC_RUNTIME_WORKER_THREADS;
20use crate::async_runtime::{
21  CurrentThreadTaskDelivery, CurrentThreadTaskDriver, CurrentThreadTaskDriverId, RuntimeFlavor,
22  RuntimeMetricsSnapshot, RuntimeOptions, RuntimeOptionsPatch, TimerDriver, TimerDriverId, TimerId,
23  acknowledge_current_thread_task_delivery, configure, configure_partial, configured_options,
24  drive_current_thread_tasks, fail_current_thread_task_delivery, metrics,
25  register_current_thread_task_driver, register_timer_driver, request_current_thread_task_drain,
26  reset_metrics, shutdown, start, try_block_on_dyn, try_spawn, try_spawn_blocking,
27  try_spawn_detached, unregister_current_thread_task_driver, unregister_timer_driver,
28};
29use crate::js_callback::{InvalidReturnValue, JsCallback};
30
31struct SharedAsyncRuntime;
32
33// SAFETY: See the implementation notes in this crate's README. Shutdown closes
34// admission, waits for the scheduler generation to quiesce, joins native
35// workers, and releases active resources. Independently, napi-rs permanently
36// retains the native image after a module that registered this backend exports
37// successfully, so externally cloned wakers cannot call into unmapped code.
38unsafe impl AsyncRuntime for SharedAsyncRuntime {
39  fn spawn(
40    &self,
41    task: AsyncRuntimeTask,
42  ) -> std::result::Result<(), AsyncRuntimeRejection<AsyncRuntimeTask>> {
43    match try_spawn(task) {
44      Ok(handle) => {
45        handle.detach();
46        Ok(())
47      }
48      Err((error, task)) => Err(AsyncRuntimeRejection::new(
49        task,
50        napi::Error::from_reason(error.to_string()),
51      )),
52    }
53  }
54
55  fn block_on(&self, future: Pin<&mut dyn Future<Output = ()>>) -> napi::Result<()> {
56    try_block_on_dyn(future).map_err(|error| napi::Error::from_reason(error.to_string()))
57  }
58
59  fn spawn_blocking(
60    &self,
61    work: Box<dyn FnOnce() + Send + 'static>,
62  ) -> std::result::Result<(), AsyncRuntimeRejection<Box<dyn FnOnce() + Send + 'static>>> {
63    // Route blocking work submitted through this SPI to the same bounded lane
64    // as the scheduler's spawn_blocking facade. See the implementation notes in this crate's README.
65    match try_spawn_blocking(work) {
66      Ok(handle) => {
67        handle.detach();
68        Ok(())
69      }
70      Err((error, work)) => Err(AsyncRuntimeRejection::new(
71        work,
72        napi::Error::from_reason(error.to_string()),
73      )),
74    }
75  }
76
77  fn start(&self) -> napi::Result<()> {
78    start().map_err(|error| napi::Error::from_reason(error.to_string()))
79  }
80
81  fn shutdown(&self) -> napi::Result<()> {
82    shutdown().map_err(|error| napi::Error::from_reason(error.to_string()))
83  }
84}
85
86#[napi(string_enum)]
87#[derive(Clone, Copy)]
88pub enum BindingRuntimeFlavor {
89  CurrentThread,
90  MultiThread,
91}
92
93impl From<BindingRuntimeFlavor> for RuntimeFlavor {
94  fn from(value: BindingRuntimeFlavor) -> Self {
95    match value {
96      BindingRuntimeFlavor::CurrentThread => Self::CurrentThread,
97      BindingRuntimeFlavor::MultiThread => Self::MultiThread,
98    }
99  }
100}
101
102impl From<RuntimeFlavor> for BindingRuntimeFlavor {
103  fn from(value: RuntimeFlavor) -> Self {
104    match value {
105      RuntimeFlavor::CurrentThread => Self::CurrentThread,
106      RuntimeFlavor::MultiThread => Self::MultiThread,
107    }
108  }
109}
110
111#[napi(object)]
112pub struct BindingRuntimeOptions {
113  pub flavor: Option<BindingRuntimeFlavor>,
114  /// Positive integer worker count. Values above 256 are rejected.
115  pub worker_threads: Option<f64>,
116  /// Positive integer blocking-task limit. Values above 256 are rejected.
117  pub max_blocking_tasks: Option<f64>,
118}
119
120impl TryFrom<BindingRuntimeOptions> for RuntimeOptionsPatch {
121  type Error = napi::Error;
122
123  fn try_from(value: BindingRuntimeOptions) -> Result<Self, Self::Error> {
124    Ok(Self {
125      flavor: value.flavor.map(Into::into),
126      worker_threads: validate_binding_thread_count(
127        "workerThreads",
128        value.worker_threads,
129        MAX_ASYNC_RUNTIME_WORKER_THREADS,
130      )?,
131      max_blocking_tasks: validate_binding_thread_count(
132        "maxBlockingTasks",
133        value.max_blocking_tasks,
134        MAX_ASYNC_RUNTIME_WORKER_THREADS,
135      )?,
136      // Not exposed through the JavaScript configure surface; the addon-load
137      // resolution (`install` + `DRAIN_LINGER_ENV`) owns this knob.
138      drain_linger: None,
139    })
140  }
141}
142
143fn validate_binding_thread_count(
144  field: &str,
145  value: Option<f64>,
146  maximum: usize,
147) -> napi::Result<Option<usize>> {
148  value
149    .map(|count| {
150      #[expect(
151        clippy::cast_precision_loss,
152        reason = "the small production thread limit is exactly representable as f64"
153      )]
154      let maximum_as_f64 = maximum as f64;
155      if !count.is_finite() || count < 1.0 || count.fract() != 0.0 || count > maximum_as_f64 {
156        return Err(napi::Error::from_reason(format!(
157          "`{field}` must be a positive integer no greater than {maximum}"
158        )));
159      }
160
161      #[expect(
162        clippy::cast_possible_truncation,
163        clippy::cast_sign_loss,
164        reason = "the finite positive integer was range-checked against the usize-backed limit"
165      )]
166      let count = count as usize;
167      Ok(count)
168    })
169    .transpose()
170}
171
172#[napi(object)]
173pub struct BindingRuntimeConfig {
174  pub flavor: BindingRuntimeFlavor,
175  pub worker_threads: u32,
176  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,
180}
181
182impl From<RuntimeOptions> for BindingRuntimeConfig {
183  fn from(value: RuntimeOptions) -> Self {
184    Self {
185      flavor: value.flavor.into(),
186      worker_threads: saturating_u32(value.worker_threads as u64),
187      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      ),
191    }
192  }
193}
194
195#[napi(object)]
196pub struct BindingRuntimeMetrics {
197  pub flavor: BindingRuntimeFlavor,
198  pub worker_threads: u32,
199  pub max_blocking_tasks: u32,
200  pub tasks_spawned: f64,
201  pub tasks_completed: f64,
202  pub tasks_panicked: f64,
203  pub runnable_schedules: f64,
204  pub runnable_polls: f64,
205  pub queued_runnables: f64,
206  pub max_queued_runnables: f64,
207  pub active_runnables: f64,
208  pub max_active_runnables: f64,
209  pub blocking_tasks_started: f64,
210  pub blocking_tasks_completed: f64,
211  pub active_blocking_tasks: f64,
212  pub max_active_blocking_tasks: f64,
213}
214
215impl From<RuntimeMetricsSnapshot> for BindingRuntimeMetrics {
216  fn from(value: RuntimeMetricsSnapshot) -> Self {
217    Self {
218      flavor: value.flavor.into(),
219      worker_threads: saturating_u32(value.worker_threads as u64),
220      max_blocking_tasks: saturating_u32(value.max_blocking_tasks as u64),
221      tasks_spawned: safe_js_number(value.tasks_spawned),
222      tasks_completed: safe_js_number(value.tasks_completed),
223      tasks_panicked: safe_js_number(value.tasks_panicked),
224      runnable_schedules: safe_js_number(value.runnable_schedules),
225      runnable_polls: safe_js_number(value.runnable_polls),
226      queued_runnables: safe_js_number(value.queued_runnables),
227      max_queued_runnables: safe_js_number(value.max_queued_runnables),
228      active_runnables: safe_js_number(value.active_runnables),
229      max_active_runnables: safe_js_number(value.max_active_runnables),
230      blocking_tasks_started: safe_js_number(value.blocking_tasks_started),
231      blocking_tasks_completed: safe_js_number(value.blocking_tasks_completed),
232      active_blocking_tasks: safe_js_number(value.active_blocking_tasks),
233      max_active_blocking_tasks: safe_js_number(value.max_active_blocking_tasks),
234    }
235  }
236}
237
238fn saturating_u32(value: u64) -> u32 {
239  u32::try_from(value).unwrap_or(u32::MAX)
240}
241
242const MAX_SAFE_JS_INTEGER: u64 = (1_u64 << 53) - 1;
243
244#[expect(
245  clippy::cast_precision_loss,
246  reason = "the value is clamped to JavaScript's exactly representable integer range"
247)]
248fn safe_js_number(value: u64) -> f64 {
249  value.min(MAX_SAFE_JS_INTEGER) as f64
250}
251
252#[napi]
253/// Override the shared async runtime's flavor and thread counts.
254///
255/// Must be called before the first async binding call.
256pub fn configure_async_runtime(options: BindingRuntimeOptions) -> napi::Result<()> {
257  configure_partial(options.try_into()?)
258    .map_err(|error| napi::Error::from_reason(error.to_string()))
259}
260
261#[napi]
262/// Return the effective async runtime configuration.
263///
264/// Reports the runtime controller's validated options, including a
265/// pre-first-use `configureAsyncRuntime` override. The environment is never
266/// re-read.
267pub fn get_async_runtime_config() -> BindingRuntimeConfig {
268  configured_options().into()
269}
270
271#[napi]
272/// Return a snapshot of the shared async runtime's task and scheduler counters.
273pub fn get_async_runtime_metrics() -> BindingRuntimeMetrics {
274  metrics().into()
275}
276
277#[napi]
278/// Reset cumulative async runtime event counters to zero.
279///
280/// Live gauges and their lifetime high-water marks are preserved so active
281/// task guards can complete without corrupting concurrent observations.
282pub fn reset_async_runtime_metrics() {
283  reset_metrics();
284}
285
286#[napi(object)]
287pub struct BindingHostRegistration {
288  pub high: u32,
289  pub low: u32,
290}
291
292impl BindingHostRegistration {
293  fn from_id(id: u64) -> Self {
294    Self {
295      high: (id >> 32) as u32,
296      low: u32::try_from(id & u64::from(u32::MAX))
297        .expect("the masked host registration low word must fit in u32"),
298    }
299  }
300
301  fn id(high: u32, low: u32) -> u64 {
302    (u64::from(high) << 32) | u64::from(low)
303  }
304}
305
306static NEXT_HOST_REGISTRATION_ID: std::sync::atomic::AtomicU64 =
307  std::sync::atomic::AtomicU64::new(1);
308
309static RESERVED_HOST_REGISTRATIONS: std::sync::LazyLock<
310  std::sync::Mutex<rustc_hash::FxHashSet<u64>>,
311> = std::sync::LazyLock::new(|| std::sync::Mutex::new(rustc_hash::FxHashSet::default()));
312
313fn reserve_host_registration_id() -> napi::Result<u64> {
314  let id = NEXT_HOST_REGISTRATION_ID
315    .fetch_update(
316      std::sync::atomic::Ordering::SeqCst,
317      std::sync::atomic::Ordering::SeqCst,
318      |current| current.checked_add(1),
319    )
320    .map_err(|_| {
321      napi::Error::new(
322        napi::Status::GenericFailure,
323        "JavaScript host registration id space exhausted",
324      )
325    })?;
326  RESERVED_HOST_REGISTRATIONS
327    .lock()
328    .unwrap_or_else(std::sync::PoisonError::into_inner)
329    .insert(id);
330  Ok(id)
331}
332
333fn claim_host_registration_id(registration_high: u32, registration_low: u32) -> napi::Result<u64> {
334  let id = BindingHostRegistration::id(registration_high, registration_low);
335  if RESERVED_HOST_REGISTRATIONS
336    .lock()
337    .unwrap_or_else(std::sync::PoisonError::into_inner)
338    .remove(&id)
339  {
340    Ok(id)
341  } else {
342    Err(napi::Error::new(
343      napi::Status::InvalidArg,
344      "CurrentThread host registration was not reserved or was already consumed",
345    ))
346  }
347}
348
349fn release_host_registration_id(id: u64) {
350  RESERVED_HOST_REGISTRATIONS
351    .lock()
352    .unwrap_or_else(std::sync::PoisonError::into_inner)
353    .remove(&id);
354}
355
356#[napi]
357/// Reserve an exact CurrentThread host registration capability before either
358/// task or timer installation performs side effects. The JavaScript package
359/// validates the returned words and passes them back to one registration call.
360pub fn reserve_current_thread_host_registration() -> napi::Result<BindingHostRegistration> {
361  reserve_host_registration_id().map(BindingHostRegistration::from_id)
362}
363
364fn current_thread_task_host_napi_result(
365  status: napi::sys::napi_status,
366  context: &'static str,
367) -> napi::Result<()> {
368  if status == napi::sys::Status::napi_ok {
369    Ok(())
370  } else {
371    Err(napi::Error::new(napi::Status::from(status), context))
372  }
373}
374
375fn contain_current_thread_task_host_unwind<T>(operation: impl FnOnce() -> T) -> Option<T> {
376  match std::panic::catch_unwind(std::panic::AssertUnwindSafe(operation)) {
377    Ok(value) => Some(value),
378    Err(payload) => {
379      if let Err(nested) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| drop(payload)))
380      {
381        std::mem::forget(nested);
382      }
383      None
384    }
385  }
386}
387
388#[cfg(test)]
389thread_local! {
390  static NATIVE_TASK_HOST_AFTER_DRIVE_TEST_HOOK:
391    std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
392      const { std::cell::RefCell::new(None) };
393  static NATIVE_TASK_HOST_AFTER_PAYLOAD_DROP_TEST_HOOK:
394    std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
395      const { std::cell::RefCell::new(None) };
396}
397
398#[cfg(test)]
399fn run_native_task_host_after_drive_test_hook() {
400  if let Some(hook) = NATIVE_TASK_HOST_AFTER_DRIVE_TEST_HOOK.with(|slot| slot.borrow_mut().take()) {
401    hook();
402  }
403}
404
405#[cfg(test)]
406fn run_native_task_host_after_payload_drop_test_hook() {
407  if let Some(hook) =
408    NATIVE_TASK_HOST_AFTER_PAYLOAD_DROP_TEST_HOOK.with(|slot| slot.borrow_mut().take())
409  {
410    hook();
411  }
412}
413
414unsafe extern "C" fn finalize_native_current_thread_task_host(
415  _env: napi::sys::napi_env,
416  finalize_data: *mut std::ffi::c_void,
417  _finalize_hint: *mut std::ffi::c_void,
418) {
419  if finalize_data.is_null() {
420    return;
421  }
422  let _ = contain_current_thread_task_host_unwind(|| {
423    let weak = unsafe {
424      std::sync::Weak::<NativeCurrentThreadTaskHostInner>::from_raw(finalize_data.cast())
425    };
426    if let Some(inner) = weak.upgrade() {
427      inner.finalized();
428    }
429  });
430}
431
432unsafe extern "C" fn call_native_current_thread_task_host(
433  env: napi::sys::napi_env,
434  _js_callback: napi::sys::napi_value,
435  _context: *mut std::ffi::c_void,
436  data: *mut std::ffi::c_void,
437) {
438  if data.is_null() {
439    return;
440  }
441
442  let payload = unsafe { Box::<NativeCurrentThreadTaskHostPayload>::from_raw(data.cast()) };
443  let delivery = payload.delivery;
444  let callback_lease = if env.is_null() {
445    None
446  } else {
447    contain_current_thread_task_host_unwind(|| {
448      let lease = drive_current_thread_tasks(delivery.capability());
449      #[cfg(test)]
450      if lease.is_some() {
451        run_native_task_host_after_drive_test_hook();
452      }
453      lease
454    })
455    .flatten()
456  };
457  let claimed = callback_lease.is_some();
458  let completed = contain_current_thread_task_host_unwind(|| {
459    if claimed {
460      acknowledge_current_thread_task_delivery(delivery);
461    } else {
462      fail_current_thread_task_delivery(delivery);
463    }
464  });
465  if completed.is_none() {
466    let _ = contain_current_thread_task_host_unwind(|| {
467      fail_current_thread_task_delivery(delivery);
468    });
469  }
470  let _ = contain_current_thread_task_host_unwind(|| drop(payload));
471  #[cfg(test)]
472  run_native_task_host_after_payload_drop_test_hook();
473  let _ = contain_current_thread_task_host_unwind(|| drop(callback_lease));
474}
475
476struct NativeCurrentThreadTaskHostPayload {
477  delivery: CurrentThreadTaskDelivery,
478  #[cfg(test)]
479  drop_observer: Option<std::sync::Arc<std::sync::atomic::AtomicUsize>>,
480}
481
482impl NativeCurrentThreadTaskHostPayload {
483  fn new(delivery: CurrentThreadTaskDelivery) -> Self {
484    Self {
485      delivery,
486      #[cfg(test)]
487      drop_observer: None,
488    }
489  }
490
491  #[cfg(test)]
492  fn with_drop_observer(
493    delivery: CurrentThreadTaskDelivery,
494    drop_observer: std::sync::Arc<std::sync::atomic::AtomicUsize>,
495  ) -> Self {
496    Self {
497      delivery,
498      drop_observer: Some(drop_observer),
499    }
500  }
501}
502
503#[cfg(test)]
504impl Drop for NativeCurrentThreadTaskHostPayload {
505  fn drop(&mut self) {
506    if let Some(observer) = &self.drop_observer {
507      observer.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
508    }
509  }
510}
511
512// See the implementation notes in this crate's README.
513struct NativeCurrentThreadTaskHost {
514  inner: std::sync::Arc<NativeCurrentThreadTaskHostInner>,
515}
516
517struct NativeCurrentThreadTaskHostInner {
518  threadsafe_function: std::sync::Mutex<Option<usize>>,
519  dead: std::sync::atomic::AtomicBool,
520  environment_closing: std::sync::atomic::AtomicBool,
521  host_registration: u64,
522  registration: std::sync::Mutex<Option<CurrentThreadTaskDriverId>>,
523}
524
525static NATIVE_CURRENT_THREAD_TASK_HOSTS: std::sync::LazyLock<
526  std::sync::Mutex<rustc_hash::FxHashMap<u64, std::sync::Weak<NativeCurrentThreadTaskHostInner>>>,
527> = std::sync::LazyLock::new(|| std::sync::Mutex::new(rustc_hash::FxHashMap::default()));
528
529fn registered_current_thread_task_host(
530  id: u64,
531) -> Option<std::sync::Arc<NativeCurrentThreadTaskHostInner>> {
532  let mut registrations = NATIVE_CURRENT_THREAD_TASK_HOSTS
533    .lock()
534    .unwrap_or_else(std::sync::PoisonError::into_inner);
535  let inner = registrations.get(&id).and_then(std::sync::Weak::upgrade);
536  if inner.is_none() {
537    registrations.remove(&id);
538  }
539  inner
540}
541
542impl NativeCurrentThreadTaskHostInner {
543  fn new(env: &napi::Env, host_registration: u64) -> napi::Result<std::sync::Arc<Self>> {
544    const ASYNC_RESOURCE_NAME: &[u8] = b"napi_async_runtime_current_thread_task_host";
545    let name_len = isize::try_from(ASYNC_RESOURCE_NAME.len())
546      .expect("the CurrentThread task-host resource name length must fit");
547    let mut async_resource_name = ptr::null_mut();
548    current_thread_task_host_napi_result(
549      unsafe {
550        napi::sys::napi_create_string_utf8(
551          env.raw(),
552          ASYNC_RESOURCE_NAME.as_ptr().cast(),
553          name_len,
554          &raw mut async_resource_name,
555        )
556      },
557      "Failed to create the CurrentThread task-host async resource name",
558    )?;
559
560    let inner = std::sync::Arc::new(Self {
561      threadsafe_function: std::sync::Mutex::default(),
562      dead: std::sync::atomic::AtomicBool::new(false),
563      environment_closing: std::sync::atomic::AtomicBool::new(false),
564      host_registration,
565      registration: std::sync::Mutex::default(),
566    });
567    let finalize_data = std::sync::Weak::into_raw(std::sync::Arc::downgrade(&inner))
568      .cast_mut()
569      .cast();
570    let mut threadsafe_function = ptr::null_mut();
571    let create_status = unsafe {
572      napi::sys::napi_create_threadsafe_function(
573        env.raw(),
574        ptr::null_mut(),
575        ptr::null_mut(),
576        async_resource_name,
577        1,
578        1,
579        finalize_data,
580        Some(finalize_native_current_thread_task_host),
581        ptr::null_mut(),
582        Some(call_native_current_thread_task_host),
583        &raw mut threadsafe_function,
584      )
585    };
586    if create_status != napi::sys::Status::napi_ok {
587      unsafe {
588        drop(std::sync::Weak::<Self>::from_raw(finalize_data.cast()));
589      }
590      return Err(napi::Error::new(
591        napi::Status::from(create_status),
592        "Failed to create the native CurrentThread task host",
593      ));
594    }
595    *inner
596      .threadsafe_function
597      .lock()
598      .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(threadsafe_function as usize);
599
600    if let Err(error) = current_thread_task_host_napi_result(
601      unsafe { napi::sys::napi_unref_threadsafe_function(env.raw(), threadsafe_function) },
602      "Failed to unref the native CurrentThread task host",
603    ) {
604      inner.release_threadsafe_function(napi::sys::ThreadsafeFunctionReleaseMode::abort);
605      return Err(error);
606    }
607    Ok(inner)
608  }
609
610  fn is_live(&self) -> bool {
611    !self.dead.load(std::sync::atomic::Ordering::SeqCst)
612      && !self
613        .environment_closing
614        .load(std::sync::atomic::Ordering::SeqCst)
615      && self
616        .threadsafe_function
617        .lock()
618        .unwrap_or_else(std::sync::PoisonError::into_inner)
619        .is_some()
620  }
621
622  fn call_threadsafe_function_with(
623    &self,
624    data: *mut std::ffi::c_void,
625    call: impl FnOnce(
626      napi::sys::napi_threadsafe_function,
627      *mut std::ffi::c_void,
628    ) -> napi::sys::napi_status,
629  ) -> Option<napi::sys::napi_status> {
630    let mut slot = self
631      .threadsafe_function
632      .lock()
633      .unwrap_or_else(std::sync::PoisonError::into_inner);
634    if self.dead.load(std::sync::atomic::Ordering::SeqCst)
635      || self
636        .environment_closing
637        .load(std::sync::atomic::Ordering::SeqCst)
638    {
639      return None;
640    }
641    let threadsafe_function = (*slot)? as napi::sys::napi_threadsafe_function;
642    let status = call(threadsafe_function, data);
643    if status == napi::sys::Status::napi_closing {
644      // Node-API decrements this caller's acquisition before returning
645      // `napi_closing`; the pointer is no longer safe for any later API call.
646      self
647        .environment_closing
648        .store(true, std::sync::atomic::Ordering::SeqCst);
649      slot.take();
650    }
651    Some(status)
652  }
653
654  fn dispatch(&self, delivery: CurrentThreadTaskDelivery) -> bool {
655    let data: *mut std::ffi::c_void =
656      Box::into_raw(Box::new(NativeCurrentThreadTaskHostPayload::new(delivery))).cast();
657    let Some(status) =
658      self.call_threadsafe_function_with(data, |threadsafe_function, data| unsafe {
659        napi::sys::napi_call_threadsafe_function(
660          threadsafe_function,
661          data,
662          napi::sys::ThreadsafeFunctionCallMode::nonblocking,
663        )
664      })
665    else {
666      unsafe {
667        drop(Box::<NativeCurrentThreadTaskHostPayload>::from_raw(
668          data.cast(),
669        ));
670      }
671      return false;
672    };
673    if status != napi::sys::Status::napi_ok {
674      unsafe {
675        drop(Box::<NativeCurrentThreadTaskHostPayload>::from_raw(
676          data.cast(),
677        ));
678      }
679      return false;
680    }
681    true
682  }
683
684  fn finalized(&self) {
685    self
686      .environment_closing
687      .store(true, std::sync::atomic::Ordering::SeqCst);
688    // Finalization means Node is already destroying the TSFN. Invalidate the
689    // pointer without calling back into Node from its own finalizer.
690    self
691      .threadsafe_function
692      .lock()
693      .unwrap_or_else(std::sync::PoisonError::into_inner)
694      .take();
695    self.evict_inner(true, false);
696  }
697
698  fn environment_cleanup(&self) {
699    self
700      .environment_closing
701      .store(true, std::sync::atomic::Ordering::SeqCst);
702    // Cleanup is registered after the TSFN's own cleanup hook, so it normally
703    // runs first. Keep owner retirement independent from registry eviction:
704    // even a contained eviction panic must not retain the initial acquisition.
705    let _ = contain_current_thread_task_host_unwind(|| self.evict_inner(true, false));
706    self.release_threadsafe_function(napi::sys::ThreadsafeFunctionReleaseMode::release);
707  }
708
709  fn evict_after_sweep(&self) {
710    let abort = !self
711      .environment_closing
712      .load(std::sync::atomic::Ordering::SeqCst);
713    self.evict_inner(false, abort);
714  }
715
716  fn rollback(&self) {
717    self.evict_inner(true, true);
718  }
719
720  fn evict_inner(&self, request_redispatch: bool, abort: bool) {
721    self.dead.store(true, std::sync::atomic::Ordering::SeqCst);
722    NATIVE_CURRENT_THREAD_TASK_HOSTS
723      .lock()
724      .unwrap_or_else(std::sync::PoisonError::into_inner)
725      .remove(&self.host_registration);
726    let registration = self
727      .registration
728      .lock()
729      .unwrap_or_else(std::sync::PoisonError::into_inner)
730      .take();
731    if let Some(id) = registration {
732      unregister_current_thread_task_driver(id);
733      if request_redispatch {
734        // The dead host may have accepted a weak-TSFN callback that its env
735        // discarded before delivery. Republish the same internal capability to
736        // remaining live hosts through their exact delivery identities.
737        request_current_thread_task_drain();
738      }
739    }
740    if abort {
741      self.release_threadsafe_function(napi::sys::ThreadsafeFunctionReleaseMode::abort);
742    }
743  }
744
745  fn release_threadsafe_function(&self, mode: napi::sys::napi_threadsafe_function_release_mode) {
746    self.release_threadsafe_function_with(mode, |threadsafe_function, mode| unsafe {
747      napi::sys::napi_release_threadsafe_function(threadsafe_function, mode)
748    });
749  }
750
751  fn release_threadsafe_function_with(
752    &self,
753    mode: napi::sys::napi_threadsafe_function_release_mode,
754    release: impl FnOnce(
755      napi::sys::napi_threadsafe_function,
756      napi::sys::napi_threadsafe_function_release_mode,
757    ) -> napi::sys::napi_status,
758  ) {
759    let threadsafe_function = self
760      .threadsafe_function
761      .lock()
762      .unwrap_or_else(std::sync::PoisonError::into_inner)
763      .take();
764    let Some(threadsafe_function) = threadsafe_function else {
765      return;
766    };
767    let status = release(
768      threadsafe_function as napi::sys::napi_threadsafe_function,
769      mode,
770    );
771    if status == napi::sys::Status::napi_closing {
772      self
773        .environment_closing
774        .store(true, std::sync::atomic::Ordering::SeqCst);
775    }
776  }
777}
778
779impl CurrentThreadTaskDriver for NativeCurrentThreadTaskHost {
780  fn dispatch(&self, delivery: CurrentThreadTaskDelivery) -> bool {
781    self.inner.dispatch(delivery)
782  }
783
784  fn is_live(&self) -> bool {
785    self.inner.is_live()
786  }
787
788  fn on_swept(&self) {
789    // The registry is already selecting/dispatching a fallback. Avoid
790    // recursively starting another selection pass from its sweep callback.
791    self.inner.evict_after_sweep();
792  }
793}
794
795fn reject_current_thread_task_host_callback(dispatch: Option<Unknown<'_>>) -> napi::Result<()> {
796  if dispatch.is_none() {
797    Ok(())
798  } else {
799    Err(napi::Error::new(
800      napi::Status::InvalidArg,
801      "registerCurrentThreadTaskHost does not accept a JavaScript callback",
802    ))
803  }
804}
805
806const CURRENT_THREAD_TASK_HOST_CONTRACT_VERSION: u32 = 4;
807
808#[napi]
809/// Return the native CurrentThread task-host ABI expected by the JavaScript
810/// package before it invokes either async-runtime host registration. Version 4
811/// reserves and validates an exact registration capability before host
812/// installation performs side effects.
813pub fn get_current_thread_task_host_contract_version() -> u32 {
814  CURRENT_THREAD_TASK_HOST_CONTRACT_VERSION
815}
816
817#[napi]
818/// Return whether one exact CurrentThread task- or timer-host registration is
819/// still live. The JavaScript package revalidates its process-global marker on
820/// every module evaluation so native eviction cannot leave a stale installed
821/// bit that permanently suppresses replacement registration.
822pub fn is_current_thread_host_registration_active(
823  registration_high: u32,
824  registration_low: u32,
825) -> bool {
826  let id = BindingHostRegistration::id(registration_high, registration_low);
827  registered_current_thread_task_host(id).is_some_and(|inner| inner.is_live())
828    || registered_timer_host(id).is_some_and(|inner| inner.is_live())
829}
830
831fn install_cleanup_hook_or_rollback<T>(
832  install: impl FnOnce() -> napi::Result<T>,
833  rollback: impl FnOnce(),
834) -> napi::Result<()> {
835  match install() {
836    Ok(_) => Ok(()),
837    Err(error) => {
838      rollback();
839      Err(error)
840    }
841  }
842}
843
844fn install_host_driver_registration<T>(
845  dead: &std::sync::atomic::AtomicBool,
846  registration: &std::sync::Mutex<Option<T>>,
847  install: impl FnOnce() -> T,
848  publish: impl FnOnce(),
849  rollback: impl FnOnce(T),
850) -> bool {
851  // `install` may synchronously wake arbitrary task wakers. Keep it outside
852  // the exact-registration mutex so a reentrant liveness sweep can evict this
853  // host instead of deadlocking on the half-published registration.
854  let mut installed = Some(install());
855  {
856    let mut slot = registration
857      .lock()
858      .unwrap_or_else(std::sync::PoisonError::into_inner);
859    if !dead.load(std::sync::atomic::Ordering::SeqCst) {
860      *slot = installed.take();
861      // Publication is serialized with eviction by `registration`. This
862      // callback must only update binding-owned indexes.
863      publish();
864    }
865  }
866  if let Some(installed) = installed {
867    rollback(installed);
868    false
869  } else {
870    true
871  }
872}
873
874#[napi(ts_args_type = "registrationHigh: number, registrationLow: number, dispatch?: never")]
875/// Install a native-owned host turn used to poll CurrentThread runnables
876/// without re-entering arbitrary future waker locks. Called once per importing
877/// environment. JavaScript callbacks are rejected synchronously.
878pub fn register_current_thread_task_host(
879  env: &napi::Env,
880  registration_high: u32,
881  registration_low: u32,
882  dispatch: Option<Unknown<'_>>,
883) -> napi::Result<()> {
884  reject_current_thread_task_host_callback(dispatch)?;
885  let host_registration = claim_host_registration_id(registration_high, registration_low)?;
886  let inner = NativeCurrentThreadTaskHostInner::new(env, host_registration)?;
887  {
888    let mut slot = inner
889      .registration
890      .lock()
891      .unwrap_or_else(std::sync::PoisonError::into_inner);
892    *slot = Some(register_current_thread_task_driver(std::sync::Arc::new(
893      NativeCurrentThreadTaskHost {
894        inner: std::sync::Arc::clone(&inner),
895      },
896    )));
897  }
898  NATIVE_CURRENT_THREAD_TASK_HOSTS
899    .lock()
900    .unwrap_or_else(std::sync::PoisonError::into_inner)
901    .insert(host_registration, std::sync::Arc::downgrade(&inner));
902  request_current_thread_task_drain();
903  let hook_inner = std::sync::Arc::clone(&inner);
904  install_cleanup_hook_or_rollback(
905    || {
906      env.add_env_cleanup_hook(hook_inner, |inner| {
907        let _ = contain_current_thread_task_host_unwind(|| inner.environment_cleanup());
908      })
909    },
910    || inner.rollback(),
911  )?;
912  Ok(())
913}
914
915#[napi]
916/// Evict exactly one native host installed by `registerCurrentThreadTaskHost`.
917/// Managed workerd disposal uses this before environment cleanup so a later
918/// throwing cleanup hook cannot leave the process-global driver selected.
919pub fn unregister_current_thread_task_host(registration_high: u32, registration_low: u32) {
920  let id = BindingHostRegistration::id(registration_high, registration_low);
921  release_host_registration_id(id);
922  if let Some(inner) = registered_current_thread_task_host(id) {
923    inner.rollback();
924  }
925}
926
927/// Host timer driver for the shared runtime's CurrentThread flavor:
928/// `sleep_until` on the single-thread executor cannot park a
929/// helper thread (none exists on threadless wasm), so it delegates each timer
930/// to the host event loop through the JS callback registered at import --
931/// `(id, ms) => new Promise((resolve) => setTimeout(resolve, ms))`, paired
932/// with a cancellation callback that clears the timeout and resolves the
933/// relay promise.
934///
935/// Per timer id: the FIRST `register` arms one host timeout via a detached
936/// relay task; re-registers (`Sleep` re-polls) only refresh the stored waker.
937/// `cancel` removes the pending waker and either invokes the host cancellation
938/// callback or leaves a pre-arm tombstone for the accepted TSFN delivery. The
939/// JS side clears the timeout and resolves its promise, so a dropped sleep
940/// leaves neither a live timeout nor a detached relay task.
941///
942/// LIFETIME: each importing napi env registers its own
943/// host, and a host dies WITH its env -- the weak threadsafe function does
944/// not keep a worker's event loop alive, so a worker that imported the
945/// binding can exit at any time and orphan its host. A dead host must never
946/// keep timer duty (the registry would busy-fail every debounce against it),
947/// so it is EVICTED -- proactively by the env-cleanup hook installed at
948/// registration, and reactively by the `is_live` probe (the threadsafe
949/// function's `aborted` flag) and by relay-call failure. Eviction wakes every
950/// sleep armed here so each re-polls onto the registry's next live registrant
951/// (see `TimerDriverRegistry`).
952struct JsTimerHost {
953  inner: std::sync::Arc<JsTimerHostInner>,
954}
955
956#[derive(Default)]
957struct RelayIdAllocator {
958  next: std::sync::atomic::AtomicU64,
959}
960
961impl RelayIdAllocator {
962  fn reserve(&self) -> Result<u32, RelayIdExhausted> {
963    self
964      .next
965      .fetch_update(
966        std::sync::atomic::Ordering::Relaxed,
967        std::sync::atomic::Ordering::Relaxed,
968        |next| u32::try_from(next).is_ok().then_some(next + 1),
969      )
970      .map(|id| u32::try_from(id).expect("the checked relay id must fit in u32"))
971      .map_err(|_| RelayIdExhausted)
972  }
973}
974
975#[derive(Clone, Copy, Debug, PartialEq, Eq)]
976struct RelayIdExhausted;
977
978#[derive(Clone, Copy, Debug, PartialEq, Eq)]
979#[repr(u8)]
980enum RelayCancellationAccounting {
981  HostHealth = 0,
982  CleanupOnly = 1,
983}
984
985struct HostTimerRelayHealth {
986  cancellation_accounting: std::sync::atomic::AtomicU8,
987  failure_recorded: std::sync::atomic::AtomicBool,
988}
989
990impl Default for HostTimerRelayHealth {
991  fn default() -> Self {
992    Self {
993      cancellation_accounting: std::sync::atomic::AtomicU8::new(
994        RelayCancellationAccounting::HostHealth as u8,
995      ),
996      failure_recorded: std::sync::atomic::AtomicBool::new(false),
997    }
998  }
999}
1000
1001impl HostTimerRelayHealth {
1002  fn set_cancellation_accounting(&self, accounting: RelayCancellationAccounting) {
1003    self
1004      .cancellation_accounting
1005      .store(accounting as u8, std::sync::atomic::Ordering::Release);
1006  }
1007
1008  fn cancellation_accounting(&self) -> RelayCancellationAccounting {
1009    match self
1010      .cancellation_accounting
1011      .load(std::sync::atomic::Ordering::Acquire)
1012    {
1013      value if value == RelayCancellationAccounting::CleanupOnly as u8 => {
1014        RelayCancellationAccounting::CleanupOnly
1015      }
1016      _ => RelayCancellationAccounting::HostHealth,
1017    }
1018  }
1019
1020  fn claim_failure(&self) -> bool {
1021    self
1022      .failure_recorded
1023      .compare_exchange(
1024        false,
1025        true,
1026        std::sync::atomic::Ordering::AcqRel,
1027        std::sync::atomic::Ordering::Acquire,
1028      )
1029      .is_ok()
1030  }
1031
1032  fn failure_recorded(&self) -> bool {
1033    self
1034      .failure_recorded
1035      .load(std::sync::atomic::Ordering::Acquire)
1036  }
1037}
1038
1039/// Consecutive NON-LIFETIME relay failures tolerated on one live host before
1040/// eviction. A transient failure (a one-off JS rejection, a queueing hiccup)
1041/// must not poison a live driver -- on a
1042/// main-only process that would leave NO driver and every later CT sleep
1043/// would hit the loud no-driver panic. But a PERSISTENTLY failing live
1044/// callback can never fire a timer either, so after this many consecutive
1045/// failures the host is evicted anyway (announced in the log). Reset on any
1046/// successful relay. Small on purpose: each strike costs one wasted arm/wake
1047/// round-trip for the affected sleep.
1048const HOST_TIMER_MAX_TRANSIENT_FAILURES: u32 = 3;
1049
1050/// Does this relay error mean the HOST IS GONE (evict immediately), as
1051/// opposed to a callback failure on a live host (strike-counted)?
1052///
1053/// ERROR CLASSIFICATION: no message strings. A rejected JS promise is
1054/// coerced into `GenericFailure` CARRYING THE JS REJECTION STRING (pinned
1055/// napi 3.10, error.rs `From<Unknown> for Error`: native coerces the value
1056/// to a string, wasm reads `.message` -- both always `GenericFailure`), so
1057/// any message match is forgeable by a live callback rejecting with a
1058/// colliding string (e.g. `Error('oneshot canceled')`) and would evict a
1059/// live host, bypassing the strike budget. Two string-free authorities
1060/// instead:
1061/// - `Status::Closing` is LIFETIME: it originates only from the TSFN layer
1062///   (aborted pre-check, raw `napi_closing`), and the JS coercion above can
1063///   never produce it -- unforgeable.
1064/// - Everything else defers to the LIVENESS PROBE (`is_live` = the dead
1065///   latch + the threadsafe function's own `aborted` flag): the genuine
1066///   teardown shapes (queue drained at env teardown, env died before the JS
1067///   promise settled) all coincide with the env being torn down, which the
1068///   probe observes directly.
1069///
1070/// Race walk: env dies between the error and the probe read -> the probe
1071/// reads dead -> evict: correct. Env alive at the probe but dying a
1072/// microsecond later -> strike now (the affected sleep is re-woken); the
1073/// death is then caught by the env-cleanup hook, by the aborted-probe sweep
1074/// at the next selection, or by the next relay failure (which will probe
1075/// dead) -> bounded, correct. A LIVE host's failure -- whatever its message
1076/// says -- takes the strike path.
1077fn should_evict_for_relay_error(status: napi::Status, host_is_live: bool) -> bool {
1078  status == napi::Status::Closing || !host_is_live
1079}
1080
1081#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1082enum HostTimerFailureAction {
1083  Duplicate,
1084  EvictHost,
1085  EvictHostAfterStrikes(u32),
1086  Retry(u32),
1087}
1088
1089fn record_host_timer_failure(
1090  transient_failures: &std::sync::atomic::AtomicU32,
1091  relay_health: &HostTimerRelayHealth,
1092  status: napi::Status,
1093  host_is_live: bool,
1094) -> HostTimerFailureAction {
1095  if !relay_health.claim_failure() {
1096    return HostTimerFailureAction::Duplicate;
1097  }
1098  if should_evict_for_relay_error(status, host_is_live) {
1099    return HostTimerFailureAction::EvictHost;
1100  }
1101  let strikes = transient_failures.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
1102  if strikes >= HOST_TIMER_MAX_TRANSIENT_FAILURES {
1103    HostTimerFailureAction::EvictHostAfterStrikes(strikes)
1104  } else {
1105    HostTimerFailureAction::Retry(strikes)
1106  }
1107}
1108
1109fn reset_host_timer_failures_after_success(
1110  transient_failures: &std::sync::atomic::AtomicU32,
1111  relay_health: &HostTimerRelayHealth,
1112) {
1113  if !relay_health.failure_recorded() {
1114    transient_failures.store(0, std::sync::atomic::Ordering::SeqCst);
1115  }
1116}
1117
1118struct JsTimerHostInner {
1119  callback: JsCallback<FnArgs<(u32, f64)>, Promise<()>>,
1120  cancel_callback: JsCallback<FnArgs<(u32,)>, ()>,
1121  pending: std::sync::Mutex<rustc_hash::FxHashMap<TimerId, PendingHostTimer>>,
1122  relay_ids: RelayIdAllocator,
1123  /// Latched by [`JsTimerHostInner::evict`]: this host's env is gone (or its
1124  /// callback failed) and it must never serve another timer.
1125  dead: std::sync::atomic::AtomicBool,
1126  /// Exact JavaScript-facing capability used to unregister this host before
1127  /// emnapi starts running fallible environment cleanup hooks.
1128  host_registration: u64,
1129  /// This host's registration in the global driver registry. Installation and
1130  /// eviction use this mutex as their publication boundary, but the core
1131  /// registration call itself runs outside it because that call may wake
1132  /// arbitrary task wakers.
1133  registration: std::sync::Mutex<Option<TimerDriverId>>,
1134  /// Consecutive non-lifetime relay failures (see
1135  /// [`HOST_TIMER_MAX_TRANSIENT_FAILURES`]); reset on success.
1136  transient_failures: std::sync::atomic::AtomicU32,
1137}
1138
1139static JS_TIMER_HOSTS: std::sync::LazyLock<
1140  std::sync::Mutex<rustc_hash::FxHashMap<u64, std::sync::Weak<JsTimerHostInner>>>,
1141> = std::sync::LazyLock::new(|| std::sync::Mutex::new(rustc_hash::FxHashMap::default()));
1142
1143fn registered_timer_host(id: u64) -> Option<std::sync::Arc<JsTimerHostInner>> {
1144  let mut registrations = JS_TIMER_HOSTS
1145    .lock()
1146    .unwrap_or_else(std::sync::PoisonError::into_inner);
1147  let inner = registrations.get(&id).and_then(std::sync::Weak::upgrade);
1148  if inner.is_none() {
1149    registrations.remove(&id);
1150  }
1151  inner
1152}
1153
1154struct PendingHostTimer {
1155  cancellation: Option<futures::channel::oneshot::Sender<()>>,
1156  relay_health: std::sync::Arc<HostTimerRelayHealth>,
1157  relay_id: u32,
1158  waker: std::task::Waker,
1159  schedule_state: RelayScheduleState,
1160}
1161
1162impl PendingHostTimer {
1163  fn signal_native_cancellation(&mut self) {
1164    if let Some(sender) = self.cancellation.take() {
1165      let _ = sender.send(());
1166    }
1167  }
1168}
1169
1170#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1171enum RelayScheduleState {
1172  AwaitingCallback,
1173  CallbackComplete,
1174}
1175
1176trait PendingRelayState: Send + Sync + 'static {
1177  fn take_pending_relay(
1178    &self,
1179    id: TimerId,
1180    relay_id: u32,
1181    accounting: RelayCancellationAccounting,
1182  ) -> Option<PendingHostTimer>;
1183  /// Record that the JavaScript schedule callback has returned. `true` means
1184  /// Rust cleanup already removed this exact relay, so cancellation must be
1185  /// submitted now that it cannot overtake timer creation.
1186  fn mark_relay_callback_complete(&self, id: TimerId, relay_id: u32) -> bool;
1187  fn cancel_relay(
1188    self: &std::sync::Arc<Self>,
1189    relay_id: u32,
1190    relay_health: std::sync::Arc<HostTimerRelayHealth>,
1191  );
1192}
1193
1194struct PendingRelayDropGuard<T: PendingRelayState> {
1195  state: std::sync::Arc<T>,
1196  id: TimerId,
1197  relay_id: u32,
1198  cleanup_on_drop: bool,
1199}
1200
1201impl<T: PendingRelayState> PendingRelayDropGuard<T> {
1202  fn new(state: std::sync::Arc<T>, id: TimerId, relay_id: u32) -> Self {
1203    Self {
1204      state,
1205      id,
1206      relay_id,
1207      cleanup_on_drop: true,
1208    }
1209  }
1210
1211  fn disarm(&mut self) {
1212    self.cleanup_on_drop = false;
1213  }
1214}
1215
1216impl<T: PendingRelayState> Drop for PendingRelayDropGuard<T> {
1217  fn drop(&mut self) {
1218    if !self.cleanup_on_drop {
1219      return;
1220    }
1221    let Some(pending) = self.state.take_pending_relay(
1222      self.id,
1223      self.relay_id,
1224      RelayCancellationAccounting::HostHealth,
1225    ) else {
1226      return;
1227    };
1228    // An awaiting callback owns its exact, never-reused relay id and will
1229    // submit cancellation after JavaScript returns. Waking may re-enter timer
1230    // registration, but a later timer can never receive this id.
1231    retire_pending_relay(&self.state, pending);
1232  }
1233}
1234
1235enum PendingHostTimerRegistration {
1236  Refreshed(std::task::Waker),
1237  Armed {
1238    relay_id: u32,
1239    cancellation: futures::channel::oneshot::Receiver<()>,
1240    relay_health: std::sync::Arc<HostTimerRelayHealth>,
1241  },
1242  Exhausted(std::task::Waker),
1243}
1244
1245fn register_pending_host_timer_locked(
1246  pending: &mut rustc_hash::FxHashMap<TimerId, PendingHostTimer>,
1247  relay_ids: &RelayIdAllocator,
1248  id: TimerId,
1249  waker: std::task::Waker,
1250) -> PendingHostTimerRegistration {
1251  if let Some(slot) = pending.get_mut(&id) {
1252    return PendingHostTimerRegistration::Refreshed(std::mem::replace(&mut slot.waker, waker));
1253  }
1254  let Ok(relay_id) = relay_ids.reserve() else {
1255    return PendingHostTimerRegistration::Exhausted(waker);
1256  };
1257  let (cancellation_sender, cancellation) = futures::channel::oneshot::channel();
1258  let relay_health = std::sync::Arc::new(HostTimerRelayHealth::default());
1259  pending.insert(
1260    id,
1261    PendingHostTimer {
1262      cancellation: Some(cancellation_sender),
1263      relay_health: std::sync::Arc::clone(&relay_health),
1264      relay_id,
1265      waker,
1266      schedule_state: RelayScheduleState::AwaitingCallback,
1267    },
1268  );
1269  PendingHostTimerRegistration::Armed {
1270    relay_id,
1271    cancellation,
1272    relay_health,
1273  }
1274}
1275
1276#[cfg(test)]
1277fn register_pending_host_timer(
1278  pending: &std::sync::Mutex<rustc_hash::FxHashMap<TimerId, PendingHostTimer>>,
1279  relay_ids: &RelayIdAllocator,
1280  id: TimerId,
1281  waker: std::task::Waker,
1282) -> PendingHostTimerRegistration {
1283  let mut pending = pending
1284    .lock()
1285    .unwrap_or_else(std::sync::PoisonError::into_inner);
1286  register_pending_host_timer_locked(&mut pending, relay_ids, id, waker)
1287}
1288
1289fn register_pending_host_timer_if_live(
1290  pending: &std::sync::Mutex<rustc_hash::FxHashMap<TimerId, PendingHostTimer>>,
1291  relay_ids: &RelayIdAllocator,
1292  id: TimerId,
1293  waker: std::task::Waker,
1294  is_live: impl FnOnce() -> bool,
1295) -> Result<PendingHostTimerRegistration, std::task::Waker> {
1296  let mut pending = pending
1297    .lock()
1298    .unwrap_or_else(std::sync::PoisonError::into_inner);
1299  if !is_live() {
1300    return Err(waker);
1301  }
1302  Ok(register_pending_host_timer_locked(
1303    &mut pending,
1304    relay_ids,
1305    id,
1306    waker,
1307  ))
1308}
1309
1310fn take_pending_host_timers(
1311  pending: &std::sync::Mutex<rustc_hash::FxHashMap<TimerId, PendingHostTimer>>,
1312  accounting: RelayCancellationAccounting,
1313) -> rustc_hash::FxHashMap<TimerId, PendingHostTimer> {
1314  let mut pending = pending
1315    .lock()
1316    .unwrap_or_else(std::sync::PoisonError::into_inner);
1317  for timer in pending.values() {
1318    timer.relay_health.set_cancellation_accounting(accounting);
1319  }
1320  std::mem::take(&mut *pending)
1321}
1322
1323fn take_pending_host_timer(
1324  pending: &std::sync::Mutex<rustc_hash::FxHashMap<TimerId, PendingHostTimer>>,
1325  id: TimerId,
1326  relay_id: u32,
1327  accounting: RelayCancellationAccounting,
1328) -> Option<PendingHostTimer> {
1329  let mut pending = pending
1330    .lock()
1331    .unwrap_or_else(std::sync::PoisonError::into_inner);
1332  if pending
1333    .get(&id)
1334    .is_some_and(|slot| slot.relay_id == relay_id)
1335  {
1336    pending
1337      .get(&id)
1338      .expect("the matching pending relay must remain present")
1339      .relay_health
1340      .set_cancellation_accounting(accounting);
1341    pending.remove(&id)
1342  } else {
1343    None
1344  }
1345}
1346
1347fn run_host_timer_cleanup_safely(cleanup: impl FnOnce()) {
1348  if let Err(payload) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(cleanup))
1349    && let Err(nested_payload) =
1350      std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| drop(payload)))
1351  {
1352    // The original unwind is contained. A hostile payload destructor may
1353    // panic again; quarantine only that nested payload so its destructor
1354    // cannot unwind through the napi env cleanup hook either.
1355    std::mem::forget(nested_payload);
1356  }
1357}
1358
1359fn retire_pending_relay<T: PendingRelayState>(
1360  state: &std::sync::Arc<T>,
1361  mut pending: PendingHostTimer,
1362) {
1363  // The detached relay must stop depending on JavaScript before cancellation
1364  // crosses the fallible TSFN boundary. A queue rejection or callback throw can
1365  // then affect diagnostics only, never relay retirement or runtime shutdown.
1366  pending.signal_native_cancellation();
1367  if pending.schedule_state == RelayScheduleState::CallbackComplete {
1368    let relay_health = std::sync::Arc::clone(&pending.relay_health);
1369    run_host_timer_cleanup_safely(|| state.cancel_relay(pending.relay_id, relay_health));
1370  }
1371  wake_host_timer_safely(pending.waker);
1372}
1373
1374fn retire_pending_relays<T: PendingRelayState>(
1375  state: &std::sync::Arc<T>,
1376  pending: rustc_hash::FxHashMap<TimerId, PendingHostTimer>,
1377) {
1378  for (_, pending) in pending {
1379    retire_pending_relay(state, pending);
1380  }
1381}
1382
1383fn complete_relay_schedule_callback<T: PendingRelayState, R>(
1384  state: &std::sync::Arc<T>,
1385  id: TimerId,
1386  relay_id: u32,
1387  relay_health: &std::sync::Arc<HostTimerRelayHealth>,
1388  result: R,
1389  deliver: impl FnOnce(R),
1390) {
1391  // This runs from napi-rs's return-value callback on the JavaScript call
1392  // stack, after the schedule function has returned but before delivery wakes
1393  // the Rust relay future. A terminal runnable drop therefore sees
1394  // CallbackComplete, while cleanup that won earlier is completed here.
1395  run_host_timer_cleanup_safely(|| {
1396    if state.mark_relay_callback_complete(id, relay_id) {
1397      state.cancel_relay(relay_id, std::sync::Arc::clone(relay_health));
1398    }
1399  });
1400  run_host_timer_cleanup_safely(|| deliver(result));
1401}
1402
1403fn normalize_timer_schedule_result(
1404  result: napi::Result<napi::Either<Promise<()>, InvalidReturnValue>>,
1405) -> napi::Result<Promise<()>> {
1406  match result {
1407    Ok(napi::Either::A(promise)) => Ok(promise),
1408    Ok(napi::Either::B(invalid)) => Err(napi::Error::new(
1409      napi::Status::InvalidArg,
1410      format!(
1411        "The function returned `{}`, but expected `object`.",
1412        invalid.value_type.to_string().to_ascii_lowercase()
1413      ),
1414    )),
1415    Err(error) => Err(error),
1416  }
1417}
1418
1419fn wake_host_timer_safely(waker: std::task::Waker) {
1420  // Host eviction runs from a napi env cleanup hook. A custom RawWaker must
1421  // not unwind through that FFI boundary or abort the remaining timer drain.
1422  // Borrowing for the wake keeps a panicking wake and a panicking destructor
1423  // in separate containment boundaries.
1424  run_host_timer_cleanup_safely(|| waker.wake_by_ref());
1425  run_host_timer_cleanup_safely(|| drop(waker));
1426}
1427
1428fn drop_host_timer_waker_safely(waker: std::task::Waker) {
1429  run_host_timer_cleanup_safely(|| drop(waker));
1430}
1431
1432fn recover_host_timer_failure(recover: impl FnOnce(), diagnostic: std::fmt::Arguments<'_>) {
1433  recover();
1434  run_host_timer_cleanup_safely(|| {
1435    use std::io::Write as _;
1436    let _ = writeln!(std::io::stderr().lock(), "{diagnostic}");
1437  });
1438}
1439
1440impl JsTimerHostInner {
1441  fn lock_pending(
1442    &self,
1443  ) -> std::sync::MutexGuard<'_, rustc_hash::FxHashMap<TimerId, PendingHostTimer>> {
1444    self
1445      .pending
1446      .lock()
1447      .unwrap_or_else(std::sync::PoisonError::into_inner)
1448  }
1449
1450  fn register_pending(
1451    &self,
1452    id: TimerId,
1453    waker: std::task::Waker,
1454  ) -> Result<PendingHostTimerRegistration, std::task::Waker> {
1455    // Eviction publishes `dead` before taking this same pending-map mutex to
1456    // drain. Registration therefore either enters the map before that drain,
1457    // or observes the dead/aborted host afterward and returns its waker intact.
1458    register_pending_host_timer_if_live(&self.pending, &self.relay_ids, id, waker, || {
1459      self.is_live()
1460    })
1461  }
1462
1463  /// Can this host still deliver wakes? The `aborted` probe reads the
1464  /// threadsafe function's own closing flag, so a dying env is detected even
1465  /// before any eviction path ran.
1466  fn is_live(&self) -> bool {
1467    !self.dead.load(std::sync::atomic::Ordering::SeqCst)
1468      && !self.callback.aborted()
1469      && !self.cancel_callback.aborted()
1470  }
1471
1472  fn handle_cancellation_failure(
1473    self: &std::sync::Arc<Self>,
1474    relay_id: u32,
1475    relay_health: &HostTimerRelayHealth,
1476    error: napi::Error,
1477    queue_failure: bool,
1478  ) {
1479    let operation = if queue_failure {
1480      "could not be queued"
1481    } else {
1482      "failed"
1483    };
1484    if relay_health.cancellation_accounting() == RelayCancellationAccounting::CleanupOnly {
1485      recover_host_timer_failure(
1486        || {},
1487        format_args!(
1488          "napi-async-runtime: host timer cancellation callback {operation} for relay {relay_id} during \
1489           cleanup: {error}"
1490        ),
1491      );
1492      return;
1493    }
1494
1495    match record_host_timer_failure(
1496      &self.transient_failures,
1497      relay_health,
1498      error.status,
1499      self.is_live(),
1500    ) {
1501      HostTimerFailureAction::Duplicate => {
1502        recover_host_timer_failure(
1503          || {},
1504          format_args!(
1505            "napi-async-runtime: host timer cancellation callback {operation} for relay {relay_id} after \
1506             this relay failure was already accounted: {error}"
1507          ),
1508        );
1509      }
1510      HostTimerFailureAction::EvictHost => {
1511        recover_host_timer_failure(
1512          || self.evict(),
1513          format_args!(
1514            "napi-async-runtime: host timer cancellation callback {operation} for relay {relay_id} (host \
1515             gone, evicting): {error}"
1516          ),
1517        );
1518      }
1519      HostTimerFailureAction::EvictHostAfterStrikes(strikes) => {
1520        recover_host_timer_failure(
1521          || self.evict(),
1522          format_args!(
1523            "napi-async-runtime: host timer cancellation callback {operation} {strikes} times in a row, \
1524             evicting this timer host (relay {relay_id}): {error}"
1525          ),
1526        );
1527      }
1528      HostTimerFailureAction::Retry(strikes) => {
1529        recover_host_timer_failure(
1530          || {},
1531          format_args!(
1532            "napi-async-runtime: host timer cancellation callback {operation} for relay {relay_id} \
1533             ({strikes}/{HOST_TIMER_MAX_TRANSIENT_FAILURES} before eviction): {error}"
1534          ),
1535        );
1536      }
1537    }
1538  }
1539
1540  fn cancel_relay(
1541    self: &std::sync::Arc<Self>,
1542    relay_id: u32,
1543    relay_health: std::sync::Arc<HostTimerRelayHealth>,
1544  ) {
1545    let callback_inner = std::sync::Arc::clone(self);
1546    let callback_health = std::sync::Arc::clone(&relay_health);
1547    let status = self.cancel_callback.call_with_return_value(
1548      FnArgs { data: (relay_id,) },
1549      ThreadsafeFunctionCallMode::NonBlocking,
1550      move |result, _| {
1551        let error = match result {
1552          Ok(napi::Either::A(())) => {
1553            if callback_health.cancellation_accounting() == RelayCancellationAccounting::HostHealth
1554            {
1555              reset_host_timer_failures_after_success(
1556                &callback_inner.transient_failures,
1557                &callback_health,
1558              );
1559            }
1560            return Ok(());
1561          }
1562          Ok(napi::Either::B(invalid)) => napi::Error::new(
1563            napi::Status::InvalidArg,
1564            format!(
1565              "The timer cancellation callback returned `{}`, but expected `undefined`.",
1566              invalid.value_type.to_string().to_ascii_lowercase()
1567            ),
1568          ),
1569          Err(error) => error,
1570        };
1571        callback_inner.handle_cancellation_failure(relay_id, &callback_health, error, false);
1572        Ok(())
1573      },
1574    );
1575    if status != napi::Status::Ok {
1576      let error = napi::Error::new(status, "Threadsafe timer cancellation callback call failed");
1577      self.handle_cancellation_failure(relay_id, &relay_health, error, true);
1578    }
1579  }
1580
1581  fn mark_relay_callback_complete(&self, id: TimerId, relay_id: u32) -> bool {
1582    let mut pending = self.lock_pending();
1583    match pending.get_mut(&id) {
1584      Some(slot) if slot.relay_id == relay_id => {
1585        slot.schedule_state = RelayScheduleState::CallbackComplete;
1586        false
1587      }
1588      _ => true,
1589    }
1590  }
1591
1592  fn take_pending_relay(
1593    &self,
1594    id: TimerId,
1595    relay_id: u32,
1596    accounting: RelayCancellationAccounting,
1597  ) -> Option<PendingHostTimer> {
1598    take_pending_host_timer(&self.pending, id, relay_id, accounting)
1599  }
1600
1601  async fn invoke_schedule_callback(
1602    self: &std::sync::Arc<Self>,
1603    id: TimerId,
1604    relay_id: u32,
1605    ms: f64,
1606    relay_health: std::sync::Arc<HostTimerRelayHealth>,
1607  ) -> napi::Result<Promise<()>> {
1608    let (sender, receiver) = futures::channel::oneshot::channel();
1609    let callback_inner = std::sync::Arc::clone(self);
1610    let status = self.callback.call_with_return_value(
1611      FnArgs {
1612        data: (relay_id, ms),
1613      },
1614      ThreadsafeFunctionCallMode::NonBlocking,
1615      move |result, _| {
1616        complete_relay_schedule_callback(
1617          &callback_inner,
1618          id,
1619          relay_id,
1620          &relay_health,
1621          result,
1622          move |result| {
1623            let _ = sender.send(normalize_timer_schedule_result(result));
1624          },
1625        );
1626        Ok(())
1627      },
1628    );
1629    if status != napi::Status::Ok {
1630      return Err(napi::Error::new(
1631        status,
1632        "Threadsafe function call_async_catch failed",
1633      ));
1634    }
1635    receiver.await.map_err(|_| {
1636      napi::Error::new(
1637        napi::Status::GenericFailure,
1638        "Receive value from threadsafe function sender failed",
1639      )
1640    })?
1641  }
1642
1643  /// Remove this host from timer duty: latch `dead`, drop the registry
1644  /// entry, and wake every sleep armed here so each re-polls onto the next
1645  /// live registrant (absolute deadlines preserve the remaining time; with no
1646  /// live registrant left the re-poll fails LOUD in the scheduler).
1647  /// Idempotent -- the cleanup hook, the `is_live` race path, and the
1648  /// relay-failure backstop may all reach it.
1649  fn evict(self: &std::sync::Arc<Self>) {
1650    self.dead.store(true, std::sync::atomic::Ordering::SeqCst);
1651    let registration = self
1652      .registration
1653      .lock()
1654      .unwrap_or_else(std::sync::PoisonError::into_inner)
1655      .take();
1656    JS_TIMER_HOSTS
1657      .lock()
1658      .unwrap_or_else(std::sync::PoisonError::into_inner)
1659      .remove(&self.host_registration);
1660    if let Some(id) = registration {
1661      unregister_timer_driver(id);
1662    }
1663    let pending = take_pending_host_timers(&self.pending, RelayCancellationAccounting::CleanupOnly);
1664    retire_pending_relays(self, pending);
1665  }
1666}
1667
1668impl PendingRelayState for JsTimerHostInner {
1669  fn take_pending_relay(
1670    &self,
1671    id: TimerId,
1672    relay_id: u32,
1673    accounting: RelayCancellationAccounting,
1674  ) -> Option<PendingHostTimer> {
1675    JsTimerHostInner::take_pending_relay(self, id, relay_id, accounting)
1676  }
1677
1678  fn mark_relay_callback_complete(&self, id: TimerId, relay_id: u32) -> bool {
1679    JsTimerHostInner::mark_relay_callback_complete(self, id, relay_id)
1680  }
1681
1682  fn cancel_relay(
1683    self: &std::sync::Arc<Self>,
1684    relay_id: u32,
1685    relay_health: std::sync::Arc<HostTimerRelayHealth>,
1686  ) {
1687    JsTimerHostInner::cancel_relay(self, relay_id, relay_health);
1688  }
1689}
1690
1691impl TimerDriver for JsTimerHost {
1692  fn register(&self, id: TimerId, deadline: std::time::Instant, waker: std::task::Waker) {
1693    if !self.inner.is_live() {
1694      // The selecting poll raced this host's death: make sure the eviction
1695      // bookkeeping ran, then wake immediately so the sleep re-selects from
1696      // the registry (which no longer offers this host).
1697      self.inner.evict();
1698      wake_host_timer_safely(waker);
1699      return;
1700    }
1701    let (relay_id, cancellation, relay_health) = match self.inner.register_pending(id, waker) {
1702      Err(waker) => {
1703        self.inner.evict();
1704        wake_host_timer_safely(waker);
1705        return;
1706      }
1707      Ok(PendingHostTimerRegistration::Refreshed(replaced_waker)) => {
1708        // A custom RawWaker destructor may re-enter timer code. `register_pending`
1709        // returns the old waker so its destructor runs after `pending` unlocks.
1710        drop_host_timer_waker_safely(replaced_waker);
1711        return;
1712      }
1713      Ok(PendingHostTimerRegistration::Armed {
1714        relay_id,
1715        cancellation,
1716        relay_health,
1717      }) => (relay_id, cancellation, relay_health),
1718      Ok(PendingHostTimerRegistration::Exhausted(waker)) => {
1719        recover_host_timer_failure(
1720          || {
1721            self.inner.evict();
1722            wake_host_timer_safely(waker);
1723          },
1724          format_args!(
1725            "napi-async-runtime: host timer relay id space exhausted, evicting this timer host"
1726          ),
1727        );
1728        return;
1729      }
1730    };
1731    let ms = deadline
1732      .saturating_duration_since(std::time::Instant::now())
1733      .as_secs_f64()
1734      * 1000.0;
1735    let inner = std::sync::Arc::clone(&self.inner);
1736    // Build the guard outside the async body so destroying a never-polled
1737    // relay runnable still retires its pending timer.
1738    let relay_drop_guard = PendingRelayDropGuard::new(std::sync::Arc::clone(&inner), id, relay_id);
1739    let submission = try_spawn_detached(async move {
1740      let mut relay_drop_guard = relay_drop_guard;
1741      let schedule_started = std::sync::atomic::AtomicBool::new(false);
1742      let schedule = async {
1743        schedule_started.store(true, std::sync::atomic::Ordering::Release);
1744        match inner
1745          .invoke_schedule_callback(id, relay_id, ms, std::sync::Arc::clone(&relay_health))
1746          .await
1747        {
1748          Ok(promise) => promise.await,
1749          Err(error) => Err(error),
1750        }
1751      };
1752      futures::pin_mut!(cancellation);
1753      futures::pin_mut!(schedule);
1754      let result = match futures::future::select(cancellation, schedule).await {
1755        futures::future::Either::Left((_cancelled, schedule)) => {
1756          // Native cancellation won the race. The pending entry is already
1757          // gone (`cancel` removes it before signalling this oneshot), so the
1758          // drop guard has nothing left to clean up either way.
1759          relay_drop_guard.disarm();
1760          if !schedule_started.load(std::sync::atomic::Ordering::Acquire) {
1761            // The schedule callback was never invoked (the cancellation was
1762            // already pending at this task's first poll), so there is no host
1763            // result to observe -- and invoking the callback now would arm a
1764            // JS timer that nothing will ever cancel.
1765            return;
1766          }
1767          // The schedule callback is in flight. Conforming hosts settle the
1768          // schedule promise on cancellation (see `cancelTimer` in
1769          // workerd-timer-host.ts and the node host in timer-host.ts), and
1770          // that settlement shares this relay's health record with the
1771          // cancellation callback: whichever failure reaches Rust first
1772          // consumes the relay's one strike and the other is reported as
1773          // diagnostic-only (`Duplicate`, "already accounted"). Dropping the
1774          // schedule future here instead would leave the schedule-side
1775          // result unobserved and the shared accounting blind.
1776          schedule.await
1777        }
1778        futures::future::Either::Right((result, _cancellation)) => result,
1779      };
1780      match result {
1781        Ok(()) => {
1782          reset_host_timer_failures_after_success(&inner.transient_failures, &relay_health);
1783          if let Some(pending) =
1784            inner.take_pending_relay(id, relay_id, RelayCancellationAccounting::CleanupOnly)
1785          {
1786            wake_host_timer_safely(pending.waker);
1787          }
1788          relay_drop_guard.disarm();
1789        }
1790        Err(error) => {
1791          let action = record_host_timer_failure(
1792            &inner.transient_failures,
1793            &relay_health,
1794            error.status,
1795            inner.is_live(),
1796          );
1797          match action {
1798            HostTimerFailureAction::EvictHost => {
1799              recover_host_timer_failure(
1800                || inner.evict(),
1801                format_args!(
1802                  "napi-async-runtime: host timer callback failed (host gone, evicting): {error}"
1803                ),
1804              );
1805            }
1806            HostTimerFailureAction::EvictHostAfterStrikes(strikes) => {
1807              recover_host_timer_failure(
1808                || inner.evict(),
1809                format_args!(
1810                  "napi-async-runtime: host timer callback failed {strikes} times in a row, evicting this \
1811                   timer host: {error}"
1812                ),
1813              );
1814            }
1815            HostTimerFailureAction::Retry(strikes) => {
1816              recover_host_timer_failure(
1817                || {
1818                  if let Some(pending) =
1819                    inner.take_pending_relay(id, relay_id, RelayCancellationAccounting::CleanupOnly)
1820                  {
1821                    // The callback may have synchronously armed a timeout before
1822                    // throwing, returning the wrong type, or producing a
1823                    // rejected Promise. Cleanup cancellation must not add a
1824                    // second strike for this same relay failure.
1825                    retire_pending_relay(&inner, pending);
1826                  }
1827                },
1828                format_args!(
1829                  "napi-async-runtime: host timer callback failed \
1830                   ({strikes}/{HOST_TIMER_MAX_TRANSIENT_FAILURES} before eviction): {error}"
1831                ),
1832              );
1833            }
1834            HostTimerFailureAction::Duplicate => {
1835              recover_host_timer_failure(
1836                || {
1837                  if let Some(pending) =
1838                    inner.take_pending_relay(id, relay_id, RelayCancellationAccounting::CleanupOnly)
1839                  {
1840                    retire_pending_relay(&inner, pending);
1841                  }
1842                },
1843                format_args!(
1844                  "napi-async-runtime: host timer callback failed after this relay failure was already \
1845                   accounted: {error}"
1846                ),
1847              );
1848            }
1849          }
1850          relay_drop_guard.disarm();
1851        }
1852      }
1853    });
1854    if let Err(rejected_relay) = submission {
1855      // The rejected future owns the exact-match guard. Dropping it removes
1856      // and wakes the pending timer before returning to the caller.
1857      drop(rejected_relay);
1858    }
1859  }
1860
1861  fn cancel(&self, id: TimerId) {
1862    let pending = {
1863      let mut pending = self.inner.lock_pending();
1864      if let Some(timer) = pending.get(&id) {
1865        timer
1866          .relay_health
1867          .set_cancellation_accounting(RelayCancellationAccounting::HostHealth);
1868      }
1869      pending.remove(&id)
1870    };
1871    if let Some(mut pending) = pending {
1872      pending.signal_native_cancellation();
1873      if pending.schedule_state == RelayScheduleState::CallbackComplete {
1874        let relay_health = std::sync::Arc::clone(&pending.relay_health);
1875        self.inner.cancel_relay(pending.relay_id, relay_health);
1876      }
1877      drop_host_timer_waker_safely(pending.waker);
1878    }
1879  }
1880
1881  fn is_live(&self) -> bool {
1882    self.inner.is_live()
1883  }
1884
1885  fn on_swept(&self) {
1886    // The registry's selection sweep noticed this host's death (the
1887    // `aborted` probe can fire before the env-cleanup hook runs): run the
1888    // full eviction so every sleep pending here is woken into re-selection
1889    // instead of stranded. Idempotent with the hook and the relay backstop.
1890    self.inner.evict();
1891  }
1892}
1893
1894#[napi(
1895  ts_args_type = "registrationHigh: number, registrationLow: number, schedule: (id: number, ms: number) => Promise<void>, cancel: (id: number) => void"
1896)]
1897/// Install the host timer callback backing the shared async runtime's
1898/// CurrentThread timers. Called at import by every
1899/// binding-loading JS entry with paired setTimeout/clearTimeout callbacks; each
1900/// importing env (main thread and workers alike) registers its own host, and
1901/// every live host receives each timer.
1902pub fn register_timer_host(
1903  env: &napi::Env,
1904  registration_high: u32,
1905  registration_low: u32,
1906  schedule: JsCallback<FnArgs<(u32, f64)>, Promise<()>>,
1907  cancel: JsCallback<FnArgs<(u32,)>, ()>,
1908) -> napi::Result<()> {
1909  let host_registration = claim_host_registration_id(registration_high, registration_low)?;
1910  let inner = std::sync::Arc::new(JsTimerHostInner {
1911    callback: schedule,
1912    cancel_callback: cancel,
1913    pending: std::sync::Mutex::default(),
1914    relay_ids: RelayIdAllocator::default(),
1915    dead: std::sync::atomic::AtomicBool::new(false),
1916    host_registration,
1917    registration: std::sync::Mutex::default(),
1918    transient_failures: std::sync::atomic::AtomicU32::new(0),
1919  });
1920  if !install_host_driver_registration(
1921    &inner.dead,
1922    &inner.registration,
1923    || {
1924      register_timer_driver(std::sync::Arc::new(JsTimerHost {
1925        inner: std::sync::Arc::clone(&inner),
1926      }))
1927    },
1928    || {
1929      JS_TIMER_HOSTS
1930        .lock()
1931        .unwrap_or_else(std::sync::PoisonError::into_inner)
1932        .insert(host_registration, std::sync::Arc::downgrade(&inner));
1933    },
1934    unregister_timer_driver,
1935  ) {
1936    return Err(napi::Error::new(
1937      napi::Status::GenericFailure,
1938      "The CurrentThread timer host was evicted during registration",
1939    ));
1940  }
1941  // Proactive eviction at env teardown (worker exit): the primary lifetime
1942  // mechanism; the `aborted` probe and the relay-failure path in the driver
1943  // are the backstops for anything the hook cannot reach in time.
1944  let hook_inner = std::sync::Arc::clone(&inner);
1945  install_cleanup_hook_or_rollback(
1946    || {
1947      env.add_env_cleanup_hook(hook_inner, |inner| {
1948        run_host_timer_cleanup_safely(|| inner.evict());
1949      })
1950    },
1951    || run_host_timer_cleanup_safely(|| inner.evict()),
1952  )?;
1953  Ok(())
1954}
1955
1956#[napi]
1957/// Evict exactly one callback installed by `registerTimerHost`.
1958/// Pending sleeps are woken so they can reselect another live environment.
1959pub fn unregister_timer_host(registration_high: u32, registration_low: u32) {
1960  let id = BindingHostRegistration::id(registration_high, registration_low);
1961  release_host_registration_id(id);
1962  if let Some(inner) = registered_timer_host(id) {
1963    inner.evict();
1964  }
1965}
1966
1967/// Configure the shared scheduler and register it as this addon's napi
1968/// [`AsyncRuntime`] backend.
1969///
1970/// Call this from the host's own `#[napi_derive::module_init]` hook, after
1971/// resolving the process configuration (environment variables, defaults) into
1972/// [`RuntimeOptions`]. The scheduler never reads the environment itself; the
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`.
1983pub 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  }
1987  configure(options).map_err(|error| napi::Error::from_reason(error.to_string()))?;
1988  register_async_runtime(SharedAsyncRuntime);
1989  Ok(())
1990}
1991
1992/// Resolve an env-derived thread count: parse the raw value, treat a missing,
1993/// non-numeric, OR zero value as "unset" and fall back to `default`, then
1994/// clamp the result to `maximum`. A `0` must never survive here because the
1995/// host's module-init path feeds this count into a constructor that rejects
1996/// `0`: [`RuntimeOptions`] validation rejects a `0` thread count, which would
1997/// panic an `expect()` during addon load over what may be a simple typo in an
1998/// environment variable.
1999pub fn resolve_thread_count(raw: Option<String>, default: usize, maximum: usize) -> usize {
2000  assert!(default > 0, "the default thread count must be positive");
2001  assert!(maximum > 0, "the maximum thread count must be positive");
2002  raw
2003    .and_then(|value| value.parse::<usize>().ok())
2004    .filter(|&value| value != 0)
2005    .unwrap_or(default)
2006    .min(maximum)
2007}
2008
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.
2018pub 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
2030#[cfg(test)]
2031mod tests {
2032  use super::{
2033    BindingHostRegistration, BindingRuntimeFlavor, BindingRuntimeOptions,
2034    HOST_TIMER_MAX_TRANSIENT_FAILURES, HostTimerFailureAction, HostTimerRelayHealth,
2035    MAX_SAFE_JS_INTEGER, NATIVE_TASK_HOST_AFTER_DRIVE_TEST_HOOK,
2036    NATIVE_TASK_HOST_AFTER_PAYLOAD_DROP_TEST_HOOK, NativeCurrentThreadTaskHostInner,
2037    NativeCurrentThreadTaskHostPayload, PendingHostTimer, PendingHostTimerRegistration,
2038    PendingRelayDropGuard, PendingRelayState, RelayCancellationAccounting, RelayIdAllocator,
2039    RelayScheduleState, SharedAsyncRuntime, call_native_current_thread_task_host,
2040    claim_host_registration_id, complete_relay_schedule_callback,
2041    get_current_thread_task_host_contract_version, install_cleanup_hook_or_rollback,
2042    install_host_driver_registration, record_host_timer_failure, recover_host_timer_failure,
2043    register_pending_host_timer, register_pending_host_timer_if_live,
2044    reserve_current_thread_host_registration, reset_host_timer_failures_after_success,
2045    resolve_thread_count, retire_pending_relay, retire_pending_relays, safe_js_number,
2046    take_pending_host_timers, unregister_current_thread_task_host, wake_host_timer_safely,
2047  };
2048
2049  #[derive(Default)]
2050  struct TestPendingRelayState {
2051    pending:
2052      std::sync::Mutex<rustc_hash::FxHashMap<crate::async_runtime::TimerId, PendingHostTimer>>,
2053    relay_ids: RelayIdAllocator,
2054    cancelled_relays: std::sync::Mutex<Vec<u32>>,
2055    panic_on_cancel: std::sync::atomic::AtomicBool,
2056  }
2057
2058  impl PendingRelayState for TestPendingRelayState {
2059    fn take_pending_relay(
2060      &self,
2061      id: crate::async_runtime::TimerId,
2062      relay_id: u32,
2063      accounting: RelayCancellationAccounting,
2064    ) -> Option<PendingHostTimer> {
2065      let mut pending = self
2066        .pending
2067        .lock()
2068        .unwrap_or_else(std::sync::PoisonError::into_inner);
2069      if pending
2070        .get(&id)
2071        .is_some_and(|slot| slot.relay_id == relay_id)
2072      {
2073        pending
2074          .get(&id)
2075          .expect("the matching test relay must remain present")
2076          .relay_health
2077          .set_cancellation_accounting(accounting);
2078        pending.remove(&id)
2079      } else {
2080        None
2081      }
2082    }
2083
2084    fn mark_relay_callback_complete(
2085      &self,
2086      id: crate::async_runtime::TimerId,
2087      relay_id: u32,
2088    ) -> bool {
2089      let mut pending = self
2090        .pending
2091        .lock()
2092        .unwrap_or_else(std::sync::PoisonError::into_inner);
2093      match pending.get_mut(&id) {
2094        Some(slot) if slot.relay_id == relay_id => {
2095          slot.schedule_state = RelayScheduleState::CallbackComplete;
2096          false
2097        }
2098        _ => true,
2099      }
2100    }
2101
2102    fn cancel_relay(
2103      self: &std::sync::Arc<Self>,
2104      relay_id: u32,
2105      _relay_health: std::sync::Arc<HostTimerRelayHealth>,
2106    ) {
2107      self
2108        .cancelled_relays
2109        .lock()
2110        .unwrap_or_else(std::sync::PoisonError::into_inner)
2111        .push(relay_id);
2112      assert!(
2113        !self
2114          .panic_on_cancel
2115          .load(std::sync::atomic::Ordering::SeqCst),
2116        "intentional relay cancellation panic"
2117      );
2118    }
2119  }
2120
2121  fn test_pending_relay_guard(
2122    state: &std::sync::Arc<TestPendingRelayState>,
2123    id: crate::async_runtime::TimerId,
2124    waker: std::task::Waker,
2125    schedule_state: RelayScheduleState,
2126  ) -> (
2127    u32,
2128    std::sync::Arc<HostTimerRelayHealth>,
2129    PendingRelayDropGuard<TestPendingRelayState>,
2130  ) {
2131    let relay_id = state
2132      .relay_ids
2133      .reserve()
2134      .expect("the test relay id must be available");
2135    let relay_health = std::sync::Arc::new(HostTimerRelayHealth::default());
2136    let replaced = state
2137      .pending
2138      .lock()
2139      .unwrap_or_else(std::sync::PoisonError::into_inner)
2140      .insert(
2141        id,
2142        PendingHostTimer {
2143          cancellation: None,
2144          relay_health: std::sync::Arc::clone(&relay_health),
2145          relay_id,
2146          waker,
2147          schedule_state,
2148        },
2149      );
2150    assert!(replaced.is_none(), "the test timer id must be vacant");
2151    (
2152      relay_id,
2153      relay_health,
2154      PendingRelayDropGuard::new(std::sync::Arc::clone(state), id, relay_id),
2155    )
2156  }
2157
2158  struct BudgetBoundaryTimerWait {
2159    state: std::sync::Arc<TestPendingRelayState>,
2160    timer_id: crate::async_runtime::TimerId,
2161    schedule_state: RelayScheduleState,
2162    registered: bool,
2163  }
2164
2165  impl std::future::Future for BudgetBoundaryTimerWait {
2166    type Output = ();
2167
2168    fn poll(
2169      mut self: std::pin::Pin<&mut Self>,
2170      cx: &mut std::task::Context<'_>,
2171    ) -> std::task::Poll<Self::Output> {
2172      assert!(
2173        !self.registered,
2174        "the timer wait must be cancelled before a second poll"
2175      );
2176      self.registered = true;
2177      let state = std::sync::Arc::clone(&self.state);
2178      let timer_id = self.timer_id;
2179      let (_, _, relay_drop_guard) =
2180        test_pending_relay_guard(&state, timer_id, cx.waker().clone(), self.schedule_state);
2181      crate::async_runtime::spawn_detached(async move {
2182        let mut relay_drop_guard = relay_drop_guard;
2183        std::future::pending::<()>().await;
2184        relay_drop_guard.disarm();
2185      });
2186      std::task::Poll::Pending
2187    }
2188  }
2189
2190  #[test]
2191  fn current_thread_task_host_contract_version_is_stable() {
2192    assert_eq!(get_current_thread_task_host_contract_version(), 4);
2193  }
2194
2195  #[test]
2196  fn host_registration_reservations_are_exact_and_single_use() {
2197    let claimed = reserve_current_thread_host_registration().unwrap();
2198    let claimed_id = BindingHostRegistration::id(claimed.high, claimed.low);
2199    assert_eq!(
2200      claim_host_registration_id(claimed.high, claimed.low).unwrap(),
2201      claimed_id,
2202      "the exact reserved capability must be claimable once"
2203    );
2204    assert!(
2205      claim_host_registration_id(claimed.high, claimed.low).is_err(),
2206      "a consumed registration capability must not be reusable"
2207    );
2208
2209    let released = reserve_current_thread_host_registration().unwrap();
2210    unregister_current_thread_task_host(released.high, released.low);
2211    assert!(
2212      claim_host_registration_id(released.high, released.low).is_err(),
2213      "unregister must release a reservation before installation"
2214    );
2215  }
2216
2217  fn native_task_host_with_raw_owner(raw: usize) -> NativeCurrentThreadTaskHostInner {
2218    NativeCurrentThreadTaskHostInner {
2219      threadsafe_function: std::sync::Mutex::new(Some(raw)),
2220      dead: std::sync::atomic::AtomicBool::new(false),
2221      environment_closing: std::sync::atomic::AtomicBool::new(false),
2222      host_registration: u64::MAX,
2223      registration: std::sync::Mutex::default(),
2224    }
2225  }
2226
2227  #[test]
2228  fn native_task_host_competing_release_paths_retire_the_raw_owner_once() {
2229    let inner = std::sync::Arc::new(native_task_host_with_raw_owner(1));
2230    let releases = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
2231    let barrier = std::sync::Arc::new(std::sync::Barrier::new(3));
2232    let mut threads = Vec::new();
2233
2234    for mode in [
2235      napi::sys::ThreadsafeFunctionReleaseMode::release,
2236      napi::sys::ThreadsafeFunctionReleaseMode::abort,
2237    ] {
2238      let inner = std::sync::Arc::clone(&inner);
2239      let releases = std::sync::Arc::clone(&releases);
2240      let barrier = std::sync::Arc::clone(&barrier);
2241      threads.push(std::thread::spawn(move || {
2242        barrier.wait();
2243        inner.release_threadsafe_function_with(mode, |threadsafe_function, _| {
2244          assert_eq!(threadsafe_function as usize, 1);
2245          releases.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2246          napi::sys::Status::napi_ok
2247        });
2248      }));
2249    }
2250
2251    barrier.wait();
2252    for thread in threads {
2253      thread.join().unwrap();
2254    }
2255    assert_eq!(releases.load(std::sync::atomic::Ordering::SeqCst), 1);
2256    assert!(
2257      inner
2258        .threadsafe_function
2259        .lock()
2260        .unwrap_or_else(std::sync::PoisonError::into_inner)
2261        .is_none()
2262    );
2263  }
2264
2265  #[test]
2266  fn native_task_host_closing_call_retires_owner_without_a_second_release() {
2267    let inner = native_task_host_with_raw_owner(1);
2268    let status = inner
2269      .call_threadsafe_function_with(std::ptr::null_mut(), |threadsafe_function, _| {
2270        assert_eq!(threadsafe_function as usize, 1);
2271        napi::sys::Status::napi_closing
2272      })
2273      .expect("the fake TSFN owner must be callable");
2274
2275    assert_eq!(status, napi::sys::Status::napi_closing);
2276    assert!(
2277      inner
2278        .environment_closing
2279        .load(std::sync::atomic::Ordering::SeqCst)
2280    );
2281    let releases = std::sync::atomic::AtomicUsize::new(0);
2282    inner.release_threadsafe_function_with(
2283      napi::sys::ThreadsafeFunctionReleaseMode::release,
2284      |_, _| {
2285        releases.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2286        napi::sys::Status::napi_ok
2287      },
2288    );
2289    assert_eq!(
2290      releases.load(std::sync::atomic::Ordering::SeqCst),
2291      0,
2292      "napi_closing already retired the initial acquisition and invalidated the pointer"
2293    );
2294  }
2295
2296  #[test]
2297  fn host_registration_capability_round_trips_without_precision_loss() {
2298    for id in [1, u64::from(u32::MAX), u64::from(u32::MAX) + 1, u64::MAX] {
2299      let registration = BindingHostRegistration::from_id(id);
2300      assert_eq!(
2301        BindingHostRegistration::id(registration.high, registration.low),
2302        id
2303      );
2304    }
2305  }
2306
2307  #[test]
2308  fn binding_runtime_options_convert_to_a_partial_core_patch() {
2309    use crate::async_runtime::{RuntimeFlavor, RuntimeOptionsPatch};
2310
2311    let patch = RuntimeOptionsPatch::try_from(BindingRuntimeOptions {
2312      flavor: Some(BindingRuntimeFlavor::CurrentThread),
2313      worker_threads: None,
2314      max_blocking_tasks: Some(7.0),
2315    })
2316    .expect("valid binding options must convert");
2317
2318    assert!(matches!(patch.flavor, Some(RuntimeFlavor::CurrentThread)));
2319    assert_eq!(patch.worker_threads, None);
2320    assert_eq!(patch.max_blocking_tasks, Some(7));
2321  }
2322
2323  #[test]
2324  fn binding_runtime_options_reject_unsafe_thread_counts() {
2325    use crate::async_runtime::RuntimeOptionsPatch;
2326
2327    for value in [
2328      -1.0,
2329      0.0,
2330      1.5,
2331      f64::NAN,
2332      f64::INFINITY,
2333      257.0,
2334      f64::from(u32::MAX) + 1.0,
2335    ] {
2336      let error = RuntimeOptionsPatch::try_from(BindingRuntimeOptions {
2337        flavor: None,
2338        worker_threads: Some(value),
2339        max_blocking_tasks: None,
2340      })
2341      .expect_err("invalid worker thread counts must be rejected");
2342      assert!(
2343        error
2344          .reason
2345          .contains("`workerThreads` must be a positive integer"),
2346        "unexpected validation error: {}",
2347        error.reason
2348      );
2349    }
2350
2351    for value in [-1.0, 257.0] {
2352      let error = RuntimeOptionsPatch::try_from(BindingRuntimeOptions {
2353        flavor: None,
2354        worker_threads: None,
2355        max_blocking_tasks: Some(value),
2356      })
2357      .expect_err("invalid blocking task counts must be rejected");
2358      assert!(
2359        error
2360          .reason
2361          .contains("`maxBlockingTasks` must be a positive integer"),
2362        "unexpected validation error: {}",
2363        error.reason
2364      );
2365    }
2366  }
2367
2368  #[test]
2369  fn runtime_metrics_preserve_safe_javascript_integer_range() {
2370    const BEYOND_U32_JS_NUMBER: f64 = 4_294_967_296.0;
2371    const MAX_SAFE_JS_NUMBER: f64 = 9_007_199_254_740_991.0;
2372
2373    let beyond_u32 = u64::from(u32::MAX) + 1;
2374    assert_eq!(
2375      safe_js_number(beyond_u32).to_bits(),
2376      BEYOND_U32_JS_NUMBER.to_bits()
2377    );
2378    assert_eq!(
2379      safe_js_number(MAX_SAFE_JS_INTEGER).to_bits(),
2380      MAX_SAFE_JS_NUMBER.to_bits()
2381    );
2382    assert_eq!(
2383      safe_js_number(MAX_SAFE_JS_INTEGER + 1).to_bits(),
2384      MAX_SAFE_JS_NUMBER.to_bits(),
2385      "metrics must remain exact JavaScript numbers instead of silently losing integer precision"
2386    );
2387  }
2388
2389  #[test]
2390  fn relay_ids_fail_closed_without_reuse_at_u32_exhaustion() {
2391    let allocator = RelayIdAllocator::default();
2392    allocator
2393      .next
2394      .store(u64::from(u32::MAX), std::sync::atomic::Ordering::Relaxed);
2395
2396    assert_eq!(allocator.reserve(), Ok(u32::MAX));
2397    assert!(
2398      allocator.reserve().is_err(),
2399      "the allocator must fail instead of wrapping to a reusable id"
2400    );
2401    assert!(
2402      allocator.reserve().is_err(),
2403      "exhaustion must remain stable instead of spinning or advancing toward wraparound"
2404    );
2405
2406    let pending = std::sync::Mutex::new(rustc_hash::FxHashMap::default());
2407    let waker = futures::task::noop_waker();
2408    let exhausted_waker = match register_pending_host_timer(&pending, &allocator, 1, waker.clone())
2409    {
2410      PendingHostTimerRegistration::Exhausted(waker) => waker,
2411      PendingHostTimerRegistration::Refreshed(_) | PendingHostTimerRegistration::Armed { .. } => {
2412        panic!("an exhausted allocator must reject a new pending timer")
2413      }
2414    };
2415    assert!(
2416      exhausted_waker.will_wake(&waker),
2417      "allocator failure must return the attempted timer's waker intact"
2418    );
2419    assert!(
2420      pending
2421        .lock()
2422        .unwrap_or_else(std::sync::PoisonError::into_inner)
2423        .is_empty(),
2424      "an exhausted allocator must not publish a pending timer without a relay capability"
2425    );
2426  }
2427
2428  #[test]
2429  fn host_timer_wake_and_drop_panics_are_contained_separately() {
2430    struct PanickingWakeAndDrop;
2431
2432    impl std::task::Wake for PanickingWakeAndDrop {
2433      fn wake(self: std::sync::Arc<Self>) {
2434        panic!("intentional host timer waker panic");
2435      }
2436
2437      fn wake_by_ref(self: &std::sync::Arc<Self>) {
2438        panic!("intentional host timer borrowed waker panic");
2439      }
2440    }
2441
2442    impl Drop for PanickingWakeAndDrop {
2443      fn drop(&mut self) {
2444        panic!("intentional host timer waker destructor panic");
2445      }
2446    }
2447
2448    let waker = std::task::Waker::from(std::sync::Arc::new(PanickingWakeAndDrop));
2449    wake_host_timer_safely(waker);
2450  }
2451
2452  #[test]
2453  fn relay_drop_guard_cleans_only_its_matching_pending_relay() {
2454    struct CountingWake(std::sync::Arc<std::sync::atomic::AtomicUsize>);
2455
2456    impl std::task::Wake for CountingWake {
2457      fn wake(self: std::sync::Arc<Self>) {
2458        self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2459      }
2460    }
2461
2462    let state = std::sync::Arc::new(TestPendingRelayState::default());
2463    let wakes = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
2464    let (relay_id, _, guard) = test_pending_relay_guard(
2465      &state,
2466      1,
2467      std::task::Waker::from(std::sync::Arc::new(CountingWake(std::sync::Arc::clone(
2468        &wakes,
2469      )))),
2470      RelayScheduleState::CallbackComplete,
2471    );
2472    drop(guard);
2473
2474    assert!(
2475      state
2476        .pending
2477        .lock()
2478        .unwrap_or_else(std::sync::PoisonError::into_inner)
2479        .is_empty()
2480    );
2481    assert_eq!(
2482      *state
2483        .cancelled_relays
2484        .lock()
2485        .unwrap_or_else(std::sync::PoisonError::into_inner),
2486      [relay_id],
2487      "an armed relay must be cancelled before its waiter is woken"
2488    );
2489    assert_eq!(
2490      wakes.load(std::sync::atomic::Ordering::SeqCst),
2491      1,
2492      "terminal relay cleanup must wake the pending timer"
2493    );
2494
2495    let stale_wakes = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
2496    let (stale_relay_id, _, stale_guard) = test_pending_relay_guard(
2497      &state,
2498      2,
2499      std::task::Waker::from(std::sync::Arc::new(CountingWake(std::sync::Arc::clone(
2500        &stale_wakes,
2501      )))),
2502      RelayScheduleState::AwaitingCallback,
2503    );
2504    let replacement_relay_id = state
2505      .relay_ids
2506      .reserve()
2507      .expect("the replacement relay id must be available");
2508    assert_ne!(
2509      replacement_relay_id, stale_relay_id,
2510      "relay ids must never be reused within one host"
2511    );
2512    let replaced = state
2513      .pending
2514      .lock()
2515      .unwrap_or_else(std::sync::PoisonError::into_inner)
2516      .insert(
2517        2,
2518        PendingHostTimer {
2519          cancellation: None,
2520          relay_health: std::sync::Arc::new(HostTimerRelayHealth::default()),
2521          relay_id: replacement_relay_id,
2522          waker: futures::task::noop_waker(),
2523          schedule_state: RelayScheduleState::CallbackComplete,
2524        },
2525      )
2526      .expect("the original relay must still be pending");
2527    drop(replaced);
2528    drop(stale_guard);
2529
2530    let replacement = state
2531      .pending
2532      .lock()
2533      .unwrap_or_else(std::sync::PoisonError::into_inner)
2534      .remove(&2)
2535      .expect("a stale guard must preserve the replacement relay");
2536    assert_eq!(replacement.relay_id, replacement_relay_id);
2537    assert_eq!(stale_wakes.load(std::sync::atomic::Ordering::SeqCst), 0);
2538    assert_eq!(
2539      *state
2540        .cancelled_relays
2541        .lock()
2542        .unwrap_or_else(std::sync::PoisonError::into_inner),
2543      [relay_id],
2544      "a stale guard must not cancel a replacement relay"
2545    );
2546    drop(replacement);
2547
2548    let disarmed_wakes = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
2549    let (disarmed_relay_id, _, mut disarmed_guard) = test_pending_relay_guard(
2550      &state,
2551      3,
2552      std::task::Waker::from(std::sync::Arc::new(CountingWake(std::sync::Arc::clone(
2553        &disarmed_wakes,
2554      )))),
2555      RelayScheduleState::CallbackComplete,
2556    );
2557    disarmed_guard.disarm();
2558    drop(disarmed_guard);
2559    let disarmed_pending = state
2560      .pending
2561      .lock()
2562      .unwrap_or_else(std::sync::PoisonError::into_inner)
2563      .remove(&3)
2564      .expect("normal relay completion must retain ownership of pending cleanup");
2565    assert_eq!(disarmed_pending.relay_id, disarmed_relay_id);
2566    assert_eq!(disarmed_wakes.load(std::sync::atomic::Ordering::SeqCst), 0);
2567    assert_eq!(
2568      *state
2569        .cancelled_relays
2570        .lock()
2571        .unwrap_or_else(std::sync::PoisonError::into_inner),
2572      [relay_id],
2573      "a disarmed guard must not change normal relay cleanup semantics"
2574    );
2575    drop(disarmed_pending);
2576  }
2577
2578  #[test]
2579  fn relay_callback_completion_precedes_delivery_and_closes_terminal_drop_window() {
2580    struct CountingWake(std::sync::Arc<std::sync::atomic::AtomicUsize>);
2581
2582    impl std::task::Wake for CountingWake {
2583      fn wake(self: std::sync::Arc<Self>) {
2584        self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2585      }
2586    }
2587
2588    let state = std::sync::Arc::new(TestPendingRelayState::default());
2589    let wakes = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
2590    let (relay_id, relay_health, guard) = test_pending_relay_guard(
2591      &state,
2592      1,
2593      std::task::Waker::from(std::sync::Arc::new(CountingWake(std::sync::Arc::clone(
2594        &wakes,
2595      )))),
2596      RelayScheduleState::AwaitingCallback,
2597    );
2598
2599    // Model napi-rs after the JavaScript schedule function returned and armed
2600    // its timeout, but before result delivery can re-poll the Rust relay.
2601    complete_relay_schedule_callback(&state, 1, relay_id, &relay_health, guard, drop);
2602
2603    assert!(
2604      state
2605        .pending
2606        .lock()
2607        .unwrap_or_else(std::sync::PoisonError::into_inner)
2608        .is_empty()
2609    );
2610    assert_eq!(
2611      *state
2612        .cancelled_relays
2613        .lock()
2614        .unwrap_or_else(std::sync::PoisonError::into_inner),
2615      [relay_id],
2616      "terminal drop during return-value delivery must cancel the synchronously armed timeout"
2617    );
2618    assert_eq!(wakes.load(std::sync::atomic::Ordering::SeqCst), 1);
2619  }
2620
2621  #[test]
2622  fn relay_callback_completion_finishes_cleanup_that_won_before_js_returned() {
2623    struct CountingWake(std::sync::Arc<std::sync::atomic::AtomicUsize>);
2624
2625    impl std::task::Wake for CountingWake {
2626      fn wake(self: std::sync::Arc<Self>) {
2627        self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2628      }
2629    }
2630
2631    let state = std::sync::Arc::new(TestPendingRelayState::default());
2632    let wakes = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
2633    let (relay_id, relay_health, guard) = test_pending_relay_guard(
2634      &state,
2635      1,
2636      std::task::Waker::from(std::sync::Arc::new(CountingWake(std::sync::Arc::clone(
2637        &wakes,
2638      )))),
2639      RelayScheduleState::AwaitingCallback,
2640    );
2641
2642    drop(guard);
2643    assert!(
2644      state
2645        .cancelled_relays
2646        .lock()
2647        .unwrap_or_else(std::sync::PoisonError::into_inner)
2648        .is_empty(),
2649      "cancellation must not overtake a schedule callback that has not returned"
2650    );
2651
2652    complete_relay_schedule_callback(&state, 1, relay_id, &relay_health, (), drop);
2653    assert_eq!(
2654      *state
2655        .cancelled_relays
2656        .lock()
2657        .unwrap_or_else(std::sync::PoisonError::into_inner),
2658      [relay_id],
2659      "the callback return handshake must finish exact cancellation after timer creation"
2660    );
2661    assert_eq!(wakes.load(std::sync::atomic::Ordering::SeqCst), 1);
2662  }
2663
2664  #[test]
2665  fn relay_terminal_cleanup_contains_cancel_panics_and_still_wakes() {
2666    struct CountingWake(std::sync::Arc<std::sync::atomic::AtomicUsize>);
2667
2668    impl std::task::Wake for CountingWake {
2669      fn wake(self: std::sync::Arc<Self>) {
2670        self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2671      }
2672    }
2673
2674    let state = std::sync::Arc::new(TestPendingRelayState::default());
2675    state
2676      .panic_on_cancel
2677      .store(true, std::sync::atomic::Ordering::SeqCst);
2678    let wakes = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
2679    let (relay_id, _, guard) = test_pending_relay_guard(
2680      &state,
2681      1,
2682      std::task::Waker::from(std::sync::Arc::new(CountingWake(std::sync::Arc::clone(
2683        &wakes,
2684      )))),
2685      RelayScheduleState::CallbackComplete,
2686    );
2687
2688    drop(guard);
2689
2690    assert_eq!(
2691      *state
2692        .cancelled_relays
2693        .lock()
2694        .unwrap_or_else(std::sync::PoisonError::into_inner),
2695      [relay_id]
2696    );
2697    assert_eq!(
2698      wakes.load(std::sync::atomic::Ordering::SeqCst),
2699      1,
2700      "a panicking cancellation path must not suppress the pending timer wake"
2701    );
2702  }
2703
2704  #[test]
2705  fn relay_native_cancellation_settles_before_fallible_host_cancellation() {
2706    use futures::FutureExt as _;
2707
2708    let state = std::sync::Arc::new(TestPendingRelayState::default());
2709    state
2710      .panic_on_cancel
2711      .store(true, std::sync::atomic::Ordering::SeqCst);
2712    let registration = register_pending_host_timer(
2713      &state.pending,
2714      &state.relay_ids,
2715      1,
2716      futures::task::noop_waker(),
2717    );
2718    let cancellation = match registration {
2719      PendingHostTimerRegistration::Armed { cancellation, .. } => cancellation,
2720      PendingHostTimerRegistration::Refreshed(_) | PendingHostTimerRegistration::Exhausted(_) => {
2721        panic!("the first timer registration must reserve a native cancellation relay")
2722      }
2723    };
2724    let mut pending = state
2725      .pending
2726      .lock()
2727      .unwrap_or_else(std::sync::PoisonError::into_inner)
2728      .remove(&1)
2729      .expect("the registered timer must remain pending");
2730    pending.schedule_state = RelayScheduleState::CallbackComplete;
2731
2732    retire_pending_relay(&state, pending);
2733
2734    assert!(
2735      matches!(cancellation.now_or_never(), Some(Ok(()))),
2736      "native relay retirement must publish before the contained host cancellation panic"
2737    );
2738  }
2739
2740  #[test]
2741  fn relay_bulk_eviction_contains_each_cancel_panic_and_wakes_every_timer() {
2742    struct CountingWake(std::sync::Arc<std::sync::atomic::AtomicUsize>);
2743
2744    impl std::task::Wake for CountingWake {
2745      fn wake(self: std::sync::Arc<Self>) {
2746        self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2747      }
2748    }
2749
2750    let state = std::sync::Arc::new(TestPendingRelayState::default());
2751    state
2752      .panic_on_cancel
2753      .store(true, std::sync::atomic::Ordering::SeqCst);
2754    let wakes = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
2755    let pending = rustc_hash::FxHashMap::from_iter([7, 8].map(|relay_id| {
2756      (
2757        u64::from(relay_id),
2758        PendingHostTimer {
2759          cancellation: None,
2760          relay_health: std::sync::Arc::new(HostTimerRelayHealth::default()),
2761          relay_id,
2762          waker: std::task::Waker::from(std::sync::Arc::new(CountingWake(std::sync::Arc::clone(
2763            &wakes,
2764          )))),
2765          schedule_state: RelayScheduleState::CallbackComplete,
2766        },
2767      )
2768    }));
2769
2770    retire_pending_relays(&state, pending);
2771
2772    let mut cancelled = state
2773      .cancelled_relays
2774      .lock()
2775      .unwrap_or_else(std::sync::PoisonError::into_inner)
2776      .clone();
2777    cancelled.sort_unstable();
2778    assert_eq!(cancelled, [7, 8]);
2779    assert_eq!(
2780      wakes.load(std::sync::atomic::Ordering::SeqCst),
2781      2,
2782      "one panicking cancellation must not suppress any later pending timer wake"
2783    );
2784  }
2785
2786  #[test]
2787  fn failed_relay_retirement_cancels_only_after_the_schedule_callback_returns() {
2788    struct CountingWake(std::sync::Arc<std::sync::atomic::AtomicUsize>);
2789
2790    impl std::task::Wake for CountingWake {
2791      fn wake(self: std::sync::Arc<Self>) {
2792        self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2793      }
2794    }
2795
2796    let state = std::sync::Arc::new(TestPendingRelayState::default());
2797    let wakes = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
2798    retire_pending_relay(
2799      &state,
2800      PendingHostTimer {
2801        cancellation: None,
2802        relay_health: std::sync::Arc::new(HostTimerRelayHealth::default()),
2803        relay_id: 7,
2804        waker: std::task::Waker::from(std::sync::Arc::new(CountingWake(std::sync::Arc::clone(
2805          &wakes,
2806        )))),
2807        schedule_state: RelayScheduleState::CallbackComplete,
2808      },
2809    );
2810    retire_pending_relay(
2811      &state,
2812      PendingHostTimer {
2813        cancellation: None,
2814        relay_health: std::sync::Arc::new(HostTimerRelayHealth::default()),
2815        relay_id: 8,
2816        waker: std::task::Waker::from(std::sync::Arc::new(CountingWake(std::sync::Arc::clone(
2817          &wakes,
2818        )))),
2819        schedule_state: RelayScheduleState::AwaitingCallback,
2820      },
2821    );
2822
2823    assert_eq!(
2824      *state
2825        .cancelled_relays
2826        .lock()
2827        .unwrap_or_else(std::sync::PoisonError::into_inner),
2828      [7],
2829      "a callback-complete failure may have armed a timeout, while a failed TSFN submission did not"
2830    );
2831    assert_eq!(
2832      wakes.load(std::sync::atomic::Ordering::SeqCst),
2833      2,
2834      "both failed relays must wake their timer operations"
2835    );
2836  }
2837
2838  #[test]
2839  fn host_timer_cleanup_contains_panicking_payload_drop_across_ffi() {
2840    const CHILD_ENV: &str = "NAPI_RUNTIME_TEST_HOST_TIMER_PANIC_PAYLOAD_CHILD";
2841
2842    if std::env::var_os(CHILD_ENV).is_some() {
2843      struct PanicOnDrop;
2844
2845      impl Drop for PanicOnDrop {
2846        fn drop(&mut self) {
2847          panic!("intentional host timer panic payload destructor panic");
2848        }
2849      }
2850
2851      struct PanickingWake;
2852
2853      impl std::task::Wake for PanickingWake {
2854        fn wake(self: std::sync::Arc<Self>) {
2855          std::panic::panic_any(PanicOnDrop);
2856        }
2857      }
2858
2859      extern "C" fn cleanup_shim() {
2860        let waker = std::task::Waker::from(std::sync::Arc::new(PanickingWake));
2861        wake_host_timer_safely(waker);
2862      }
2863
2864      cleanup_shim();
2865      return;
2866    }
2867
2868    let output = std::process::Command::new(std::env::current_exe().unwrap())
2869      .arg("--exact")
2870      .arg("adapter::tests::host_timer_cleanup_contains_panicking_payload_drop_across_ffi")
2871      .arg("--nocapture")
2872      .env(CHILD_ENV, "1")
2873      .output()
2874      .expect("the host timer cleanup subprocess must start");
2875    assert!(
2876      output.status.success(),
2877      "host timer cleanup must contain panic-payload destruction across the C ABI; status={:?}\nstdout={}\nstderr={}",
2878      output.status.code(),
2879      String::from_utf8_lossy(&output.stdout),
2880      String::from_utf8_lossy(&output.stderr)
2881    );
2882  }
2883
2884  #[test]
2885  fn host_timer_failure_recovery_precedes_and_survives_diagnostic_panics() {
2886    struct PanickingDiagnostic;
2887
2888    impl std::fmt::Display for PanickingDiagnostic {
2889      fn fmt(&self, _formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2890        panic!("intentional host timer diagnostic panic");
2891      }
2892    }
2893
2894    let recovered = std::sync::atomic::AtomicBool::new(false);
2895    let diagnostic = PanickingDiagnostic;
2896    recover_host_timer_failure(
2897      || recovered.store(true, std::sync::atomic::Ordering::SeqCst),
2898      format_args!("{diagnostic}"),
2899    );
2900    assert!(
2901      recovered.load(std::sync::atomic::Ordering::SeqCst),
2902      "timer recovery must complete before fallible diagnostics run"
2903    );
2904  }
2905
2906  #[test]
2907  fn host_timer_registry_drops_wakers_after_unlock() {
2908    struct LockCheckingDrop {
2909      pending: std::sync::Weak<
2910        std::sync::Mutex<rustc_hash::FxHashMap<crate::async_runtime::TimerId, PendingHostTimer>>,
2911      >,
2912      dropped: std::sync::Arc<std::sync::atomic::AtomicBool>,
2913    }
2914
2915    #[expect(
2916      clippy::manual_noop_waker,
2917      reason = "the Arc-backed no-op waker exists to exercise its re-entrant destructor"
2918    )]
2919    impl std::task::Wake for LockCheckingDrop {
2920      fn wake(self: std::sync::Arc<Self>) {}
2921    }
2922
2923    impl Drop for LockCheckingDrop {
2924      fn drop(&mut self) {
2925        let pending = self
2926          .pending
2927          .upgrade()
2928          .expect("the pending registry must outlive its waker");
2929        let _guard = pending.try_lock().unwrap_or_else(|_| {
2930          panic!("a host-timer waker was dropped while the pending registry was locked")
2931        });
2932        self
2933          .dropped
2934          .store(true, std::sync::atomic::Ordering::SeqCst);
2935      }
2936    }
2937
2938    let pending = std::sync::Arc::new(std::sync::Mutex::new(rustc_hash::FxHashMap::default()));
2939    let relay_ids = RelayIdAllocator::default();
2940    let dropped = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
2941    let old_waker = std::task::Waker::from(std::sync::Arc::new(LockCheckingDrop {
2942      pending: std::sync::Arc::downgrade(&pending),
2943      dropped: std::sync::Arc::clone(&dropped),
2944    }));
2945    assert!(matches!(
2946      register_pending_host_timer(&pending, &relay_ids, 1, old_waker),
2947      PendingHostTimerRegistration::Armed { .. }
2948    ));
2949
2950    let replaced_waker =
2951      match register_pending_host_timer(&pending, &relay_ids, 1, futures::task::noop_waker()) {
2952        PendingHostTimerRegistration::Refreshed(waker) => waker,
2953        PendingHostTimerRegistration::Armed { .. } => {
2954          panic!("a re-poll must refresh the existing timer")
2955        }
2956        PendingHostTimerRegistration::Exhausted(_) => {
2957          panic!("the test allocator must not be exhausted")
2958        }
2959      };
2960    assert!(
2961      !dropped.load(std::sync::atomic::Ordering::SeqCst),
2962      "the helper must return ownership instead of dropping under its lock"
2963    );
2964    drop(replaced_waker);
2965    assert!(dropped.load(std::sync::atomic::Ordering::SeqCst));
2966
2967    let evicted_waker_dropped = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
2968    let evicted_waker = std::task::Waker::from(std::sync::Arc::new(LockCheckingDrop {
2969      pending: std::sync::Arc::downgrade(&pending),
2970      dropped: std::sync::Arc::clone(&evicted_waker_dropped),
2971    }));
2972    assert!(matches!(
2973      register_pending_host_timer(&pending, &relay_ids, 2, evicted_waker),
2974      PendingHostTimerRegistration::Armed { .. }
2975    ));
2976    let evicted = take_pending_host_timers(&pending, RelayCancellationAccounting::CleanupOnly);
2977    assert!(
2978      !evicted_waker_dropped.load(std::sync::atomic::Ordering::SeqCst),
2979      "bulk eviction must move wakers out instead of dropping under its lock"
2980    );
2981    drop(evicted);
2982    assert!(evicted_waker_dropped.load(std::sync::atomic::Ordering::SeqCst));
2983  }
2984
2985  #[test]
2986  fn host_timer_eviction_prevents_pending_registration_after_its_drain() {
2987    let pending = std::sync::Arc::new(std::sync::Mutex::new(rustc_hash::FxHashMap::default()));
2988    let relay_ids = std::sync::Arc::new(RelayIdAllocator::default());
2989    let dead = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
2990    let mut pending_guard = pending
2991      .lock()
2992      .unwrap_or_else(std::sync::PoisonError::into_inner);
2993    let (started_tx, started_rx) = std::sync::mpsc::sync_channel(1);
2994    let registration_pending = std::sync::Arc::clone(&pending);
2995    let registration_relay_ids = std::sync::Arc::clone(&relay_ids);
2996    let registration_dead = std::sync::Arc::clone(&dead);
2997    let registration = std::thread::spawn(move || {
2998      started_tx.send(()).unwrap();
2999      register_pending_host_timer_if_live(
3000        &registration_pending,
3001        &registration_relay_ids,
3002        1,
3003        futures::task::noop_waker(),
3004        || !registration_dead.load(std::sync::atomic::Ordering::SeqCst),
3005      )
3006    });
3007
3008    started_rx.recv().unwrap();
3009    dead.store(true, std::sync::atomic::Ordering::SeqCst);
3010    let drained = std::mem::take(&mut *pending_guard);
3011    drop(pending_guard);
3012    drop(drained);
3013
3014    assert!(
3015      registration.join().unwrap().is_err(),
3016      "registration arriving after death publication and the pending drain must be rejected"
3017    );
3018    assert!(
3019      pending
3020        .lock()
3021        .unwrap_or_else(std::sync::PoisonError::into_inner)
3022        .is_empty(),
3023      "a stale selected driver must not publish a new pending arm after eviction drained the map"
3024    );
3025  }
3026
3027  #[test]
3028  fn cleanup_hook_registration_failure_rolls_back_host_registration() {
3029    let rollback_calls = std::sync::atomic::AtomicUsize::new(0);
3030    let result = install_cleanup_hook_or_rollback(
3031      || -> napi::Result<()> {
3032        Err(napi::Error::new(
3033          napi::Status::GenericFailure,
3034          "intentional cleanup-hook registration failure",
3035        ))
3036      },
3037      || {
3038        rollback_calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3039      },
3040    );
3041
3042    assert!(result.is_err());
3043    assert_eq!(rollback_calls.load(std::sync::atomic::Ordering::SeqCst), 1);
3044  }
3045
3046  #[test]
3047  fn host_driver_installation_allows_reentrant_eviction_before_publication() {
3048    let dead = std::sync::atomic::AtomicBool::new(false);
3049    let registration = std::sync::Mutex::new(None);
3050    let published = std::sync::atomic::AtomicBool::new(false);
3051    let rolled_back = std::sync::atomic::AtomicUsize::new(0);
3052
3053    let installed = install_host_driver_registration(
3054      &dead,
3055      &registration,
3056      || {
3057        dead.store(true, std::sync::atomic::Ordering::SeqCst);
3058        let removed = registration
3059          .try_lock()
3060          .expect("the arbitrary installation callback must run outside the registration mutex")
3061          .take();
3062        assert!(
3063          removed.is_none(),
3064          "reentrant eviction must see no half-published registration"
3065        );
3066        17_u64
3067      },
3068      || {
3069        published.store(true, std::sync::atomic::Ordering::SeqCst);
3070      },
3071      |id| {
3072        assert_eq!(id, 17);
3073        let guard = registration
3074          .try_lock()
3075          .expect("rollback must run outside the registration mutex");
3076        drop(guard);
3077        rolled_back.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3078      },
3079    );
3080
3081    assert!(
3082      !installed,
3083      "reentrant eviction must reject the exported registration"
3084    );
3085    assert!(!published.load(std::sync::atomic::Ordering::SeqCst));
3086    assert_eq!(rolled_back.load(std::sync::atomic::Ordering::SeqCst), 1);
3087    assert!(registration.lock().unwrap().is_none());
3088  }
3089
3090  #[test]
3091  fn shared_runtime_rejects_after_shutdown_and_accepts_after_restart() {
3092    use std::{
3093      sync::{
3094        Arc,
3095        atomic::{AtomicBool, Ordering},
3096        mpsc,
3097      },
3098      time::Duration,
3099    };
3100
3101    napi::bindgen_prelude::AsyncRuntime::start(&SharedAsyncRuntime)
3102      .expect("the adapter runtime must start");
3103    crate::async_runtime::reset_metrics();
3104    let (first_tx, first_rx) = mpsc::channel();
3105    napi::bindgen_prelude::AsyncRuntime::spawn_blocking(
3106      &SharedAsyncRuntime,
3107      Box::new(move || {
3108        first_tx
3109          .send(())
3110          .expect("test receiver must still be listening");
3111      }),
3112    )
3113    .unwrap_or_else(|_| panic!("the shared blocking lane must accept napi work"));
3114    first_rx
3115      .recv_timeout(Duration::from_secs(5))
3116      .expect("the shared blocking lane must execute accepted napi work");
3117
3118    napi::bindgen_prelude::AsyncRuntime::shutdown(&SharedAsyncRuntime)
3119      .expect("the adapter runtime must shut down");
3120    let block_on_ran = Arc::new(AtomicBool::new(false));
3121    let block_on_ran_future = Arc::clone(&block_on_ran);
3122    let mut block_on_future = std::pin::pin!(async move {
3123      block_on_ran_future.store(true, Ordering::SeqCst);
3124    });
3125    let block_on_error =
3126      napi::bindgen_prelude::AsyncRuntime::block_on(&SharedAsyncRuntime, block_on_future.as_mut())
3127        .expect_err("the adapter must reject block_on while stopped");
3128    assert_eq!(
3129      block_on_error.reason,
3130      "the async runtime is stopped; call start before submitting work"
3131    );
3132    assert!(
3133      !block_on_ran.load(Ordering::SeqCst),
3134      "rejected block_on must not poll its future"
3135    );
3136
3137    let rejected_ran = Arc::new(AtomicBool::new(false));
3138    let rejected_ran_work = Arc::clone(&rejected_ran);
3139    let (rejected_retry_tx, rejected_retry_rx) = mpsc::channel();
3140    let rejected = napi::bindgen_prelude::AsyncRuntime::spawn_blocking(
3141      &SharedAsyncRuntime,
3142      Box::new(move || {
3143        rejected_ran_work.store(true, Ordering::SeqCst);
3144        rejected_retry_tx
3145          .send(())
3146          .expect("test receiver must still be listening");
3147      }),
3148    )
3149    .expect_err("the adapter must reject work while stopped");
3150    assert!(!rejected_ran.load(Ordering::SeqCst));
3151    let (rejected, error) = rejected.into_parts();
3152    assert_eq!(
3153      error.reason,
3154      "the async runtime is stopped; call start before submitting work"
3155    );
3156
3157    napi::bindgen_prelude::AsyncRuntime::start(&SharedAsyncRuntime)
3158      .expect("the adapter runtime must restart");
3159    napi::bindgen_prelude::AsyncRuntime::block_on(&SharedAsyncRuntime, block_on_future.as_mut())
3160      .expect("the restarted adapter runtime must accept the retained future");
3161    assert!(block_on_ran.load(Ordering::SeqCst));
3162
3163    napi::bindgen_prelude::AsyncRuntime::spawn_blocking(&SharedAsyncRuntime, rejected)
3164      .unwrap_or_else(|_| panic!("the restarted runtime must accept the retained napi work"));
3165    rejected_retry_rx
3166      .recv_timeout(Duration::from_secs(5))
3167      .expect("the restarted runtime must execute the retained napi work");
3168    assert!(
3169      rejected_ran.load(Ordering::SeqCst),
3170      "rejected work must be returned intact"
3171    );
3172    assert!(
3173      crate::async_runtime::metrics().blocking_tasks_started >= 2,
3174      "both accepted adapter submissions should reach the shared blocking scheduler"
3175    );
3176  }
3177
3178  #[cfg(not(target_family = "wasm"))]
3179  #[test]
3180  fn native_task_host_null_env_retires_payload_and_recovers_exact_delivery() {
3181    const CHILD_ENV: &str = "NAPI_RUNTIME_TEST_NATIVE_TASK_HOST_NULL_ENV_CHILD";
3182
3183    if std::env::var_os(CHILD_ENV).is_some() {
3184      use std::sync::{Arc, mpsc};
3185      use std::time::Duration;
3186
3187      use crate::async_runtime::{
3188        CurrentThreadTaskDelivery, CurrentThreadTaskDriver, RuntimeFlavor, RuntimeOptions,
3189        configure, register_current_thread_task_driver, shutdown, spawn_detached, start,
3190        unregister_current_thread_task_driver,
3191      };
3192
3193      struct RecordingTaskDriver {
3194        dispatches: mpsc::Sender<CurrentThreadTaskDelivery>,
3195      }
3196
3197      impl CurrentThreadTaskDriver for RecordingTaskDriver {
3198        fn dispatch(&self, delivery: CurrentThreadTaskDelivery) -> bool {
3199          self.dispatches.send(delivery).is_ok()
3200        }
3201      }
3202
3203      configure(RuntimeOptions {
3204        flavor: RuntimeFlavor::CurrentThread,
3205        worker_threads: 1,
3206        max_blocking_tasks: 1,
3207        ..RuntimeOptions::default()
3208      })
3209      .expect("the isolated runtime must accept CurrentThread configuration");
3210      start().expect("the isolated CurrentThread runtime must start");
3211
3212      let (dispatch_tx, dispatch_rx) = mpsc::channel();
3213      let driver_id = register_current_thread_task_driver(Arc::new(RecordingTaskDriver {
3214        dispatches: dispatch_tx,
3215      }));
3216      let (completed_tx, completed_rx) = mpsc::channel();
3217      spawn_detached(async move {
3218        completed_tx
3219          .send(())
3220          .expect("the completion observer must remain live");
3221      });
3222
3223      let payload_drops = Arc::new(std::sync::atomic::AtomicUsize::new(0));
3224      let first_delivery = dispatch_rx
3225        .recv_timeout(Duration::from_secs(2))
3226        .expect("the queued task must publish its first host delivery");
3227      let first_payload = Box::into_raw(Box::new(
3228        NativeCurrentThreadTaskHostPayload::with_drop_observer(
3229          first_delivery,
3230          Arc::clone(&payload_drops),
3231        ),
3232      ))
3233      .cast();
3234      unsafe {
3235        call_native_current_thread_task_host(
3236          std::ptr::null_mut(),
3237          std::ptr::null_mut(),
3238          std::ptr::null_mut(),
3239          first_payload,
3240        );
3241      }
3242      assert_eq!(
3243        payload_drops.load(std::sync::atomic::Ordering::SeqCst),
3244        1,
3245        "the null-env callback must destroy its queued payload exactly once"
3246      );
3247
3248      let replacement_delivery = dispatch_rx
3249        .recv_timeout(Duration::from_secs(2))
3250        .expect("the exact failed delivery must publish one replacement");
3251      assert_ne!(
3252        replacement_delivery, first_delivery,
3253        "recovery must use a fresh registration-scoped delivery capability"
3254      );
3255      let replacement_payload = Box::into_raw(Box::new(
3256        NativeCurrentThreadTaskHostPayload::with_drop_observer(
3257          replacement_delivery,
3258          Arc::clone(&payload_drops),
3259        ),
3260      ))
3261      .cast();
3262      let fake_env = std::ptr::NonNull::<std::ffi::c_void>::dangling()
3263        .as_ptr()
3264        .cast();
3265      unsafe {
3266        call_native_current_thread_task_host(
3267          fake_env,
3268          std::ptr::null_mut(),
3269          std::ptr::null_mut(),
3270          replacement_payload,
3271        );
3272      }
3273
3274      completed_rx
3275        .recv_timeout(Duration::from_secs(2))
3276        .expect("the replacement host delivery must run the queued task");
3277      assert_eq!(
3278        payload_drops.load(std::sync::atomic::Ordering::SeqCst),
3279        2,
3280        "successful recovery must also destroy its queued payload exactly once"
3281      );
3282      assert!(
3283        matches!(dispatch_rx.try_recv(), Err(mpsc::TryRecvError::Empty)),
3284        "successful replacement acknowledgement must not publish another delivery"
3285      );
3286
3287      unregister_current_thread_task_driver(driver_id);
3288      shutdown().expect("the isolated CurrentThread runtime must shut down cleanly");
3289      return;
3290    }
3291
3292    let output = std::process::Command::new(std::env::current_exe().unwrap())
3293      .arg("--exact")
3294      .arg("adapter::tests::native_task_host_null_env_retires_payload_and_recovers_exact_delivery")
3295      .arg("--nocapture")
3296      .env(CHILD_ENV, "1")
3297      .output()
3298      .expect("the null-env task-host subprocess must start");
3299    assert!(
3300      output.status.success(),
3301      "the null-env task-host regression failed; status={:?}\nstdout={}\nstderr={}",
3302      output.status.code(),
3303      String::from_utf8_lossy(&output.stdout),
3304      String::from_utf8_lossy(&output.stderr)
3305    );
3306  }
3307
3308  #[cfg(not(target_family = "wasm"))]
3309  #[test]
3310  fn native_task_host_callback_lease_covers_ack_payload_and_restart() {
3311    const CHILD_ENV: &str = "NAPI_RUNTIME_TEST_NATIVE_TASK_HOST_CALLBACK_LEASE_CHILD";
3312
3313    if std::env::var_os(CHILD_ENV).is_some() {
3314      use std::sync::{Arc, mpsc};
3315      use std::time::{Duration, Instant};
3316
3317      use crate::async_runtime::{
3318        CurrentThreadTaskDelivery, CurrentThreadTaskDriver, RuntimeFlavor, RuntimeOptions,
3319        configure, register_current_thread_task_driver, shutdown, spawn_detached, start,
3320        try_spawn_detached, unregister_current_thread_task_driver,
3321      };
3322
3323      struct RecordingTaskDriver {
3324        dispatches: mpsc::Sender<CurrentThreadTaskDelivery>,
3325      }
3326
3327      impl CurrentThreadTaskDriver for RecordingTaskDriver {
3328        fn dispatch(&self, delivery: CurrentThreadTaskDelivery) -> bool {
3329          self.dispatches.send(delivery).is_ok()
3330        }
3331      }
3332
3333      configure(RuntimeOptions {
3334        flavor: RuntimeFlavor::CurrentThread,
3335        worker_threads: 1,
3336        max_blocking_tasks: 1,
3337        ..RuntimeOptions::default()
3338      })
3339      .expect("the isolated runtime must accept CurrentThread configuration");
3340      start().expect("the isolated CurrentThread runtime must start");
3341
3342      let (dispatch_tx, dispatch_rx) = mpsc::channel();
3343      let driver_id = register_current_thread_task_driver(Arc::new(RecordingTaskDriver {
3344        dispatches: dispatch_tx,
3345      }));
3346      let (task_completed_tx, task_completed_rx) = mpsc::channel();
3347      spawn_detached(async move {
3348        task_completed_tx
3349          .send(())
3350          .expect("the task completion observer must remain live");
3351      });
3352      let delivery = dispatch_rx
3353        .recv_timeout(Duration::from_secs(2))
3354        .expect("the queued task must publish one native host delivery");
3355
3356      let payload_drops = Arc::new(std::sync::atomic::AtomicUsize::new(0));
3357      let payload = Box::into_raw(Box::new(
3358        NativeCurrentThreadTaskHostPayload::with_drop_observer(
3359          delivery,
3360          Arc::clone(&payload_drops),
3361        ),
3362      ))
3363      .cast::<std::ffi::c_void>() as usize;
3364      let (after_drive_tx, after_drive_rx) = mpsc::channel();
3365      let (release_after_drive_tx, release_after_drive_rx) = mpsc::channel();
3366      let (after_payload_tx, after_payload_rx) = mpsc::channel();
3367      let (release_after_payload_tx, release_after_payload_rx) = mpsc::channel();
3368      let callback = std::thread::spawn(move || {
3369        NATIVE_TASK_HOST_AFTER_DRIVE_TEST_HOOK.with(|slot| {
3370          *slot.borrow_mut() = Some(Box::new(move || {
3371            after_drive_tx.send(()).unwrap();
3372            release_after_drive_rx.recv().unwrap();
3373          }));
3374        });
3375        NATIVE_TASK_HOST_AFTER_PAYLOAD_DROP_TEST_HOOK.with(|slot| {
3376          *slot.borrow_mut() = Some(Box::new(move || {
3377            after_payload_tx.send(()).unwrap();
3378            release_after_payload_rx.recv().unwrap();
3379          }));
3380        });
3381        let fake_env = std::ptr::NonNull::<std::ffi::c_void>::dangling()
3382          .as_ptr()
3383          .cast();
3384        unsafe {
3385          call_native_current_thread_task_host(
3386            fake_env,
3387            std::ptr::null_mut(),
3388            std::ptr::null_mut(),
3389            payload as *mut std::ffi::c_void,
3390          );
3391        }
3392      });
3393
3394      after_drive_rx
3395        .recv_timeout(Duration::from_secs(2))
3396        .expect("the native callback must pause after driving the host turn");
3397      task_completed_rx
3398        .recv_timeout(Duration::from_secs(2))
3399        .expect("the host turn must run the queued task before acknowledgement");
3400
3401      let (shutdown_tx, shutdown_rx) = mpsc::channel();
3402      let shutdown_thread = std::thread::spawn(move || {
3403        shutdown_tx.send(shutdown()).unwrap();
3404      });
3405      // Hang detector: the freshly spawned shutdown thread must get CPU and
3406      // publish the stopping lifecycle -- generous for starved CI runners.
3407      let stopping_deadline = Instant::now() + Duration::from_secs(10);
3408      loop {
3409        match try_spawn_detached(async {}) {
3410          Ok(()) => {
3411            assert!(
3412              Instant::now() < stopping_deadline,
3413              "shutdown did not publish the stopping lifecycle"
3414            );
3415            std::thread::yield_now();
3416          }
3417          Err(future) => {
3418            drop(future);
3419            break;
3420          }
3421        }
3422      }
3423      assert!(
3424        shutdown_rx
3425          .recv_timeout(Duration::from_millis(200))
3426          .is_err(),
3427        "shutdown must wait after drive until delivery acknowledgement and payload destruction"
3428      );
3429
3430      let (restart_tx, restart_rx) = mpsc::channel();
3431      let restart_thread = std::thread::spawn(move || {
3432        start().expect("the runtime must restart after the old callback retires");
3433        restart_tx.send(()).unwrap();
3434      });
3435      assert!(
3436        restart_rx.recv_timeout(Duration::from_millis(200)).is_err(),
3437        "restart must not overlap the old callback before acknowledgement"
3438      );
3439
3440      release_after_drive_tx.send(()).unwrap();
3441      after_payload_rx
3442        .recv_timeout(Duration::from_secs(2))
3443        .expect("the callback must pause after acknowledging and destroying its payload");
3444      assert_eq!(
3445        payload_drops.load(std::sync::atomic::Ordering::SeqCst),
3446        1,
3447        "the acknowledged callback must destroy its payload before releasing the lease"
3448      );
3449      assert!(
3450        shutdown_rx
3451          .recv_timeout(Duration::from_millis(200))
3452          .is_err(),
3453        "shutdown must remain blocked after acknowledgement and payload destruction"
3454      );
3455      assert!(
3456        restart_rx.recv_timeout(Duration::from_millis(200)).is_err(),
3457        "restart must remain blocked until the callback lease retires"
3458      );
3459
3460      release_after_payload_tx.send(()).unwrap();
3461      callback.join().unwrap();
3462      shutdown_rx
3463        .recv_timeout(Duration::from_secs(2))
3464        .expect("shutdown must finish after the callback lease retires")
3465        .unwrap();
3466      restart_rx
3467        .recv_timeout(Duration::from_secs(2))
3468        .expect("restart must finish after old-generation shutdown");
3469      shutdown_thread.join().unwrap();
3470      restart_thread.join().unwrap();
3471
3472      unregister_current_thread_task_driver(driver_id);
3473      shutdown().expect("the replacement CurrentThread runtime must shut down cleanly");
3474      return;
3475    }
3476
3477    let output = std::process::Command::new(std::env::current_exe().unwrap())
3478      .arg("--exact")
3479      .arg("adapter::tests::native_task_host_callback_lease_covers_ack_payload_and_restart")
3480      .arg("--nocapture")
3481      .env(CHILD_ENV, "1")
3482      .output()
3483      .expect("the native task-host callback lease subprocess must start");
3484    assert!(
3485      output.status.success(),
3486      "the native task-host callback lease regression failed; status={:?}\nstdout={}\nstderr={}",
3487      output.status.code(),
3488      String::from_utf8_lossy(&output.stdout),
3489      String::from_utf8_lossy(&output.stderr)
3490    );
3491  }
3492
3493  #[cfg(not(target_family = "wasm"))]
3494  #[test]
3495  fn timer_relay_terminal_drop_at_host_turn_budget_settles_without_later_work() {
3496    const CHILD_ENV: &str = "NAPI_RUNTIME_TEST_TIMER_RELAY_TERMINAL_DROP_CHILD";
3497    const HOST_TURN_RUNNABLE_BUDGET: usize = 64;
3498
3499    if let Some(relay_state) = std::env::var_os(CHILD_ENV) {
3500      use std::sync::{Arc, mpsc};
3501      use std::time::Duration;
3502
3503      use crate::async_runtime::{
3504        CurrentThreadTaskDelivery, CurrentThreadTaskDriver, RuntimeFlavor, RuntimeOptions,
3505        acknowledge_current_thread_task_delivery, cancel_current_thread_task_dispatch, configure,
3506        drive_current_thread_tasks, metrics, register_current_thread_task_driver, reset_metrics,
3507        shutdown, spawn, spawn_detached, start, unregister_current_thread_task_driver,
3508      };
3509      use futures::FutureExt as _;
3510
3511      struct RecordingTaskDriver {
3512        dispatches: mpsc::Sender<CurrentThreadTaskDelivery>,
3513      }
3514
3515      impl CurrentThreadTaskDriver for RecordingTaskDriver {
3516        fn dispatch(&self, delivery: CurrentThreadTaskDelivery) -> bool {
3517          self.dispatches.send(delivery).is_ok()
3518        }
3519      }
3520
3521      let schedule_state = if relay_state == "complete" {
3522        RelayScheduleState::CallbackComplete
3523      } else {
3524        RelayScheduleState::AwaitingCallback
3525      };
3526
3527      let options = RuntimeOptions {
3528        flavor: RuntimeFlavor::CurrentThread,
3529        worker_threads: 1,
3530        max_blocking_tasks: 1,
3531        ..RuntimeOptions::default()
3532      };
3533      configure(options).expect("the isolated runtime must accept CurrentThread configuration");
3534      start().expect("the isolated CurrentThread runtime must start");
3535      reset_metrics();
3536
3537      let (dispatch_tx, dispatch_rx) = mpsc::channel();
3538      let driver_id = register_current_thread_task_driver(Arc::new(RecordingTaskDriver {
3539        dispatches: dispatch_tx,
3540      }));
3541
3542      for _ in 0..HOST_TURN_RUNNABLE_BUDGET - 1 {
3543        spawn_detached(async {});
3544      }
3545      let state = Arc::new(TestPendingRelayState::default());
3546      let timer_task = spawn(BudgetBoundaryTimerWait {
3547        state: Arc::clone(&state),
3548        timer_id: 1,
3549        schedule_state,
3550        registered: false,
3551      });
3552      let first_dispatch = dispatch_rx
3553        .recv_timeout(Duration::from_secs(2))
3554        .expect("the initial host turn must be published");
3555      assert!(
3556        matches!(dispatch_rx.try_recv(), Err(mpsc::TryRecvError::Empty)),
3557        "all 64 initial runnables must coalesce behind one host turn"
3558      );
3559
3560      let callback_lease = drive_current_thread_tasks(first_dispatch.capability())
3561        .expect("the exact host delivery must be admitted");
3562      acknowledge_current_thread_task_delivery(first_dispatch);
3563      drop(callback_lease);
3564      assert_eq!(
3565        metrics().runnable_polls,
3566        HOST_TURN_RUNNABLE_BUDGET as u64,
3567        "the timer operation must occupy the last poll in the host-turn budget"
3568      );
3569      assert_eq!(
3570        state
3571          .pending
3572          .lock()
3573          .unwrap_or_else(std::sync::PoisonError::into_inner)
3574          .len(),
3575        1,
3576        "the timer poll must register its pending relay"
3577      );
3578      assert_eq!(
3579        metrics().queued_runnables,
3580        1,
3581        "the unpolled relay must be the sole runnable beyond the budget boundary"
3582      );
3583
3584      let failed_dispatch = dispatch_rx
3585        .recv_timeout(Duration::from_secs(2))
3586        .expect("budget exhaustion must publish a fresh relay turn");
3587      cancel_current_thread_task_dispatch(failed_dispatch.capability());
3588      let replacement_dispatch = dispatch_rx
3589        .recv_timeout(Duration::from_secs(2))
3590        .expect("the first scheduler failure must publish one replacement");
3591      assert_ne!(replacement_dispatch, failed_dispatch);
3592      cancel_current_thread_task_dispatch(replacement_dispatch.capability());
3593
3594      assert!(
3595        state
3596          .pending
3597          .lock()
3598          .unwrap_or_else(std::sync::PoisonError::into_inner)
3599          .is_empty(),
3600        "terminal queue destruction must retire the matching pending relay"
3601      );
3602      let cancelled_relays = state
3603        .cancelled_relays
3604        .lock()
3605        .unwrap_or_else(std::sync::PoisonError::into_inner);
3606      if schedule_state == RelayScheduleState::CallbackComplete {
3607        assert_eq!(
3608          cancelled_relays.len(),
3609          1,
3610          "a callback-complete relay must queue exactly one host cancellation"
3611        );
3612      } else {
3613        assert!(
3614          cancelled_relays.is_empty(),
3615          "cleanup must not overtake a schedule callback that has not returned"
3616        );
3617      }
3618      drop(cancelled_relays);
3619      assert_eq!(metrics().queued_runnables, 0);
3620      assert!(
3621        matches!(dispatch_rx.try_recv(), Err(mpsc::TryRecvError::Empty)),
3622        "terminal recovery must stay bounded after the replacement failure"
3623      );
3624      let task_result = timer_task
3625        .now_or_never()
3626        .expect("the original timer operation must settle immediately");
3627      assert_eq!(
3628        task_result
3629          .expect_err("terminal recovery must cancel the pending timer operation")
3630          .to_string(),
3631        "async runtime stopped before the task completed"
3632      );
3633
3634      unregister_current_thread_task_driver(driver_id);
3635      shutdown().expect("the isolated CurrentThread runtime must shut down cleanly");
3636      return;
3637    }
3638
3639    for relay_state in ["awaiting", "complete"] {
3640      let output = std::process::Command::new(std::env::current_exe().unwrap())
3641        .arg("--exact")
3642        .arg(
3643          "adapter::tests::timer_relay_terminal_drop_at_host_turn_budget_settles_without_later_work",
3644        )
3645        .arg("--nocapture")
3646        .env(CHILD_ENV, relay_state)
3647        .output()
3648        .expect("the timer relay regression subprocess must start");
3649      assert!(
3650        output.status.success(),
3651        "{relay_state} timer relay terminal-drop regression failed; status={:?}\nstdout={}\nstderr={}",
3652        output.status.code(),
3653        String::from_utf8_lossy(&output.stdout),
3654        String::from_utf8_lossy(&output.stderr)
3655      );
3656    }
3657  }
3658
3659  /// Relay-eviction invariant: the relay must evict ONLY on host death, and
3660  /// the decision must be STRING-FREE. A rejected JS promise coerces to
3661  /// `GenericFailure` carrying the JS-controlled rejection string (pinned
3662  /// napi 3.10 error.rs), so message matching is forgeable by a live
3663  /// callback. The two authorities: the unforgeable `Closing` status, and
3664  /// the liveness probe.
3665  #[test]
3666  fn relay_eviction_is_decided_by_status_and_probe_never_by_message() {
3667    use napi::Status;
3668
3669    use super::should_evict_for_relay_error;
3670
3671    // `Closing` is authoritative host death: it originates only from the
3672    // TSFN layer (aborted pre-check, raw napi_closing) and JS coercion can
3673    // never produce it -- evict even if the probe still reads live (the
3674    // finalize flag can lag the raw status).
3675    assert!(
3676      should_evict_for_relay_error(Status::Closing, true),
3677      "Closing overrides a live probe"
3678    );
3679    assert!(should_evict_for_relay_error(Status::Closing, false));
3680
3681    // A DEAD probe evicts regardless of status. Queue drain during env
3682    // teardown and env death before a JS promise settles both coincide with
3683    // the env being torn down, which the probe observes directly.
3684    assert!(should_evict_for_relay_error(Status::GenericFailure, false));
3685    assert!(should_evict_for_relay_error(
3686      Status::PendingException,
3687      false
3688    ));
3689
3690    // A LIVE host's failure takes the strike path no matter what the error
3691    // says. A live callback can reject with
3692    // `Promise.reject(new Error('oneshot canceled'))`, which coerces to
3693    // GenericFailure + "Error: oneshot canceled" and must not be mistaken for
3694    // environment teardown.
3695    assert!(
3696      !should_evict_for_relay_error(Status::GenericFailure, true),
3697      "a live host's GenericFailure -- e.g. a forged 'oneshot canceled' rejection -- must strike"
3698    );
3699    assert!(
3700      !should_evict_for_relay_error(Status::PendingException, true),
3701      "a JS throw on a live host must strike"
3702    );
3703    assert!(
3704      !should_evict_for_relay_error(Status::InvalidArg, true),
3705      "a wrong return type on a live host must strike"
3706    );
3707    assert!(
3708      !should_evict_for_relay_error(Status::QueueFull, true),
3709      "queue pressure on a live host must strike"
3710    );
3711  }
3712
3713  #[test]
3714  fn cancellation_failures_share_the_three_strike_budget_without_cleanup_double_counting() {
3715    use std::sync::atomic::{AtomicU32, Ordering};
3716
3717    use napi::Status;
3718
3719    let failures = AtomicU32::new(0);
3720    for expected_strikes in 1..HOST_TIMER_MAX_TRANSIENT_FAILURES {
3721      let relay_health = HostTimerRelayHealth::default();
3722      assert_eq!(
3723        record_host_timer_failure(&failures, &relay_health, Status::GenericFailure, true),
3724        HostTimerFailureAction::Retry(expected_strikes),
3725      );
3726      assert_eq!(
3727        record_host_timer_failure(&failures, &relay_health, Status::GenericFailure, true),
3728        HostTimerFailureAction::Duplicate,
3729        "one relay failure must consume at most one strike"
3730      );
3731    }
3732
3733    let cleanup_health = HostTimerRelayHealth::default();
3734    cleanup_health.set_cancellation_accounting(RelayCancellationAccounting::CleanupOnly);
3735    assert_eq!(
3736      cleanup_health.cancellation_accounting(),
3737      RelayCancellationAccounting::CleanupOnly
3738    );
3739    assert_eq!(
3740      failures.load(Ordering::SeqCst),
3741      HOST_TIMER_MAX_TRANSIENT_FAILURES - 1,
3742      "schedule-failure and eviction cleanup must not mutate host health"
3743    );
3744
3745    let final_health = HostTimerRelayHealth::default();
3746    assert_eq!(
3747      record_host_timer_failure(&failures, &final_health, Status::GenericFailure, true),
3748      HostTimerFailureAction::EvictHostAfterStrikes(HOST_TIMER_MAX_TRANSIENT_FAILURES),
3749    );
3750  }
3751
3752  #[test]
3753  fn a_same_relay_success_cannot_erase_its_recorded_failure() {
3754    use std::sync::atomic::{AtomicU32, Ordering};
3755
3756    use napi::Status;
3757
3758    let failures = AtomicU32::new(1);
3759    let healthy_relay = HostTimerRelayHealth::default();
3760    reset_host_timer_failures_after_success(&failures, &healthy_relay);
3761    assert_eq!(failures.load(Ordering::SeqCst), 0);
3762
3763    failures.store(1, Ordering::SeqCst);
3764    let failed_relay = HostTimerRelayHealth::default();
3765    assert_eq!(
3766      record_host_timer_failure(&failures, &failed_relay, Status::GenericFailure, true),
3767      HostTimerFailureAction::Retry(2),
3768    );
3769    reset_host_timer_failures_after_success(&failures, &failed_relay);
3770    assert_eq!(
3771      failures.load(Ordering::SeqCst),
3772      2,
3773      "a later success from the same relay must not hide its cancellation failure"
3774    );
3775  }
3776
3777  #[test]
3778  fn resolve_thread_count_handles_env_inputs() {
3779    const DEFAULT: usize = 8;
3780    const MAXIMUM: usize = 16;
3781    // A `0` typo must be treated as unset, never fed to a constructor that
3782    // rejects 0 (`RuntimeOptions` validation), which would panic the host's
3783    // module_init `expect`.
3784    assert_eq!(
3785      resolve_thread_count(Some("0".to_string()), DEFAULT, MAXIMUM),
3786      DEFAULT
3787    );
3788    // Valid positive numbers pass through unchanged.
3789    assert_eq!(
3790      resolve_thread_count(Some("4".to_string()), DEFAULT, MAXIMUM),
3791      4
3792    );
3793    // Valid but unsafe values clamp to the construction limit.
3794    assert_eq!(
3795      resolve_thread_count(Some("1000".to_string()), DEFAULT, MAXIMUM),
3796      MAXIMUM
3797    );
3798    assert_eq!(
3799      resolve_thread_count(Some(usize::MAX.to_string()), DEFAULT, MAXIMUM),
3800      MAXIMUM
3801    );
3802    // Parse overflow is invalid input and falls back instead of wrapping.
3803    assert_eq!(
3804      resolve_thread_count(Some("18446744073709551616".to_string()), DEFAULT, MAXIMUM,),
3805      DEFAULT
3806    );
3807    // Non-numeric values fall back gracefully (unchanged behaviour).
3808    assert_eq!(
3809      resolve_thread_count(Some("abc".to_string()), DEFAULT, MAXIMUM),
3810      DEFAULT
3811    );
3812    // Unset falls back to the default.
3813    assert_eq!(resolve_thread_count(None, DEFAULT, MAXIMUM), DEFAULT);
3814    // Host-derived defaults are subject to the same production ceiling.
3815    assert_eq!(resolve_thread_count(None, 32, MAXIMUM), MAXIMUM);
3816  }
3817
3818  #[test]
3819  #[should_panic(expected = "the default thread count must be positive")]
3820  fn resolve_thread_count_rejects_an_invalid_default() {
3821    let _ = resolve_thread_count(None, 0, 1);
3822  }
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  }
3872}