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