windows_threadpool_sys/timer/periodic.rs
1// Copyright (c) 2026 Mike Grier
2//! Periodic thread-pool timers.
3
4use std::io;
5use std::ptr;
6use std::sync::atomic::{AtomicIsize, Ordering};
7use std::time::{Duration, SystemTime};
8
9use windows_sys::Win32::Foundation::{FALSE, TRUE};
10use windows_sys::Win32::System::Threading::{
11 CloseThreadpoolTimer, CreateThreadpoolTimer, IsThreadpoolTimerSet, PTP_CALLBACK_INSTANCE,
12 PTP_TIMER, WaitForThreadpoolTimerCallbacks,
13};
14
15use crate::callback_env::CallbackEnviron;
16use crate::timer::{absolute_filetime, arm_raw, disarm_raw, millis_u32, relative_filetime};
17
18/// Heap-allocated callback state kept alive for the lifetime of the timer.
19///
20/// `timer` is filled in after `CreateThreadpoolTimer` returns, because stopping
21/// from inside a callback needs the object the callback belongs to.
22struct PeriodicContext {
23 timer: AtomicIsize,
24 callback: Box<dyn Fn(&PeriodicTick<'_>) + Send + Sync + 'static>,
25}
26
27/// One tick of a [`ThreadpoolPeriodicTimer`], handed to its callback.
28///
29/// A tick may be running concurrently with other ticks of the same timer, so
30/// anything this callback touches must tolerate that.
31pub struct PeriodicTick<'ctx> {
32 ctx: &'ctx PeriodicContext,
33}
34
35impl std::fmt::Debug for PeriodicTick<'_> {
36 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37 f.debug_struct("PeriodicTick").finish_non_exhaustive()
38 }
39}
40
41impl PeriodicTick<'_> {
42 /// Stop the timer from inside its own callback.
43 ///
44 /// This is how a periodic timer ends itself -- "tick until some condition
45 /// holds" needs no external coordination.
46 ///
47 /// It stops *future* ticks being queued. It does not retract ticks already
48 /// queued, and it does not affect ticks already running, including other
49 /// concurrent runs of this same callback. Expect the callback to run again
50 /// after calling this, and make it idempotent accordingly.
51 pub fn stop(&self) {
52 let timer = self.ctx.timer.load(Ordering::Acquire);
53 debug_assert_ne!(timer, 0, "the timer must be published before callbacks");
54 // SAFETY: `timer` is this object's live PTP_TIMER, published before any
55 // callback could run.
56 unsafe { disarm_raw(timer) };
57 }
58}
59
60/// Trampoline from the raw `PTP_TIMER_CALLBACK` ABI into the boxed closure.
61///
62/// SAFETY: `context` must point to a live [`PeriodicContext`] for the entire
63/// duration of every callback invocation, which [`ThreadpoolPeriodicTimer`]'s `Drop`
64/// ordering guarantees.
65unsafe extern "system" fn periodic_trampoline(
66 _instance: PTP_CALLBACK_INSTANCE,
67 context: *mut core::ffi::c_void,
68 _timer: PTP_TIMER,
69) {
70 // SAFETY: context is a valid *mut PeriodicContext for the full callback duration.
71 let ctx = unsafe { &*(context as *const PeriodicContext) };
72 let tick = PeriodicTick { ctx };
73 // Not contained: see the callback contract in the crate docs.
74 (ctx.callback)(&tick);
75}
76
77/// An owned repeating thread-pool timer.
78///
79/// The period is fixed when the timer is created, so the type says what it is:
80/// this object exists to tick on a cadence, and there is no argument that can
81/// quietly turn it into a one-shot.
82///
83/// # Ticks can overlap
84///
85/// **The pool queues each tick on schedule regardless of whether the previous
86/// tick has finished.** If the callback takes longer than the period, two or
87/// more runs of it will execute concurrently on different pool threads. This is
88/// the property that makes periodic timers surprising in practice, and it
89/// follows from the cadence being fixed: the schedule cannot wait for the
90/// callback without ceasing to be a schedule.
91///
92/// So a `ThreadpoolPeriodicTimer` callback must be safe to run concurrently with itself.
93/// If that is awkward, the alternative is a one-shot [`ThreadpoolTimer`](crate::timer::ThreadpoolTimer) re-armed from
94/// inside its own callback with [`crate::timer::TimerFiring::rearm_after`]:
95/// there is never more than one firing outstanding, and the gap is measured from
96/// the end of each firing rather than from a fixed schedule.
97///
98/// | | [`ThreadpoolPeriodicTimer`] | [`ThreadpoolTimer`](crate::timer::ThreadpoolTimer) + `rearm_after` |
99/// |---|---|---|
100/// | Cadence | fixed, independent of callback duration | drifts by the callback duration |
101/// | Concurrent runs of the callback | possible | never |
102/// | Slow callback | ticks pile up and overlap | next tick simply happens later |
103///
104/// # Teardown
105///
106/// [`Drop`] stops the timer before draining callbacks, so it cannot requeue
107/// during teardown. [`ThreadpoolPeriodicTimer::stop_and_drain`] does the same thing under
108/// the caller's control, and is the ordering to copy if doing it by hand:
109/// stop first, drain second.
110///
111/// # Examples
112///
113/// ```
114/// use std::sync::Arc;
115/// use std::sync::atomic::{AtomicUsize, Ordering};
116/// use std::time::Duration;
117/// use windows_threadpool_sys::timer::ThreadpoolPeriodicTimer;
118///
119/// let ticks = Arc::new(AtomicUsize::new(0));
120/// let counter = Arc::clone(&ticks);
121///
122/// // The period belongs to the timer, not to a call.
123/// let timer = ThreadpoolPeriodicTimer::new(Duration::from_millis(1), move |_tick| {
124/// counter.fetch_add(1, Ordering::SeqCst);
125/// }, None)?;
126///
127/// timer.start();
128/// while ticks.load(Ordering::SeqCst) < 3 {
129/// std::thread::yield_now();
130/// }
131///
132/// timer.stop_and_drain();
133/// assert!(ticks.load(Ordering::SeqCst) >= 3);
134/// # Ok::<(), std::io::Error>(())
135/// ```
136///
137/// Stopping from inside the callback, for "tick until done". Note the counter
138/// may pass the threshold, because a tick already queued still runs:
139///
140/// ```
141/// use std::sync::Arc;
142/// use std::sync::atomic::{AtomicUsize, Ordering};
143/// use std::time::Duration;
144/// use windows_threadpool_sys::timer::ThreadpoolPeriodicTimer;
145///
146/// let ticks = Arc::new(AtomicUsize::new(0));
147/// let counter = Arc::clone(&ticks);
148///
149/// let timer = ThreadpoolPeriodicTimer::new(Duration::from_millis(1), move |tick| {
150/// if counter.fetch_add(1, Ordering::SeqCst) >= 2 {
151/// tick.stop();
152/// }
153/// }, None)?;
154///
155/// timer.start();
156/// while timer.is_running() {
157/// std::thread::yield_now();
158/// }
159/// timer.stop_and_drain();
160/// assert!(ticks.load(Ordering::SeqCst) >= 3);
161/// # Ok::<(), std::io::Error>(())
162/// ```
163pub struct ThreadpoolPeriodicTimer {
164 timer: PTP_TIMER,
165 period: Duration,
166 // Kept alive as a raw pointer until Drop has stopped and drained.
167 context: *mut PeriodicContext,
168}
169
170// SAFETY: PTP_TIMER is a cross-thread pool object, and the context holds a
171// callback that is Fn + Send + Sync; the pointer is only read until Drop frees
172// it after all callbacks have finished.
173unsafe impl Send for ThreadpoolPeriodicTimer {}
174unsafe impl Sync for ThreadpoolPeriodicTimer {}
175
176/// Nanoseconds in a millisecond, for checking a period divides evenly.
177const NANOS_PER_MILLI: u32 = 1_000_000;
178
179impl ThreadpoolPeriodicTimer {
180 /// The shortest period this timer can express: one millisecond.
181 ///
182 /// `SetThreadpoolTimer` takes the period as whole milliseconds, so nothing
183 /// shorter can be represented. This is a floor on what may be *asked for*,
184 /// not a promise about delivery: ticks arrive on the system timer tick,
185 /// which is far coarser (~15.6ms by default).
186 pub const MIN_PERIOD: Duration = Duration::from_millis(1);
187
188 /// The longest period this timer can express: `u32::MAX` milliseconds, or
189 /// just under 50 days.
190 ///
191 /// The pool's period field is a `u32` count of milliseconds, so a longer
192 /// period cannot be represented.
193 pub const MAX_PERIOD: Duration = Duration::from_millis(u32::MAX as u64);
194
195 /// Create a stopped timer that invokes `callback` every `period`.
196 ///
197 /// `period` must be a whole number of milliseconds between
198 /// [`MIN_PERIOD`](Self::MIN_PERIOD) and [`MAX_PERIOD`](Self::MAX_PERIOD);
199 /// anything else is rejected. The pool takes the period as a `u32` count of
200 /// milliseconds, so every other value would be quietly altered to fit:
201 ///
202 /// - a period below a millisecond rounds down to zero, and a zero period
203 /// means "do not repeat" -- the timer would fire once and stop, which is a
204 /// `ThreadpoolPeriodicTimer` silently behaving like a
205 /// [`ThreadpoolTimer`](crate::timer::ThreadpoolTimer);
206 /// - a fractional period such as 1.5ms is truncated, so the timer would tick
207 /// at 1ms while [`period`](Self::period) still reported 1.5ms;
208 /// - a period beyond `u32::MAX` milliseconds would be capped, ticking far
209 /// more often than asked.
210 ///
211 /// Rejecting rather than rounding keeps [`period`](Self::period) an accurate
212 /// report of what was scheduled.
213 ///
214 /// Pass `Some(env)` to select a private pool or callback priority; `None`
215 /// uses the process-default pool with default priority.
216 ///
217 /// The callback runs on a shared, process-managed pool thread, may run
218 /// concurrently with itself (see the type documentation), must restore any
219 /// thread state it changes, and must not terminate its thread. It must not
220 /// panic: a panic unwinds to the `extern "system"` trampoline and aborts the
221 /// process.
222 ///
223 /// # Errors
224 ///
225 /// Returns [`io::ErrorKind::InvalidInput`] if `period` is outside
226 /// [`MIN_PERIOD`](Self::MIN_PERIOD)..=[`MAX_PERIOD`](Self::MAX_PERIOD) or is
227 /// not a whole number of milliseconds, or the error from
228 /// `CreateThreadpoolTimer`.
229 pub fn new<F>(
230 period: Duration,
231 callback: F,
232 env: Option<&mut CallbackEnviron>,
233 ) -> io::Result<Self>
234 where
235 F: Fn(&PeriodicTick<'_>) + Send + Sync + 'static,
236 {
237 if period < Self::MIN_PERIOD {
238 return Err(io::Error::new(
239 io::ErrorKind::InvalidInput,
240 "a ThreadpoolPeriodicTimer needs a period of at least 1ms: the pool takes the period in whole milliseconds, so anything shorter rounds to zero and a zero period means do not repeat; use ThreadpoolTimer for a one-shot",
241 ));
242 }
243 if period > Self::MAX_PERIOD {
244 return Err(io::Error::new(
245 io::ErrorKind::InvalidInput,
246 "a ThreadpoolPeriodicTimer period must fit in u32 milliseconds (just under 50 days); a longer one would be capped and tick far more often than asked",
247 ));
248 }
249 if !period.subsec_nanos().is_multiple_of(NANOS_PER_MILLI) {
250 return Err(io::Error::new(
251 io::ErrorKind::InvalidInput,
252 "a ThreadpoolPeriodicTimer period must be a whole number of milliseconds: the pool truncates the remainder, so the timer would tick sooner than the period it reports",
253 ));
254 }
255
256 let context = Box::into_raw(Box::new(PeriodicContext {
257 timer: AtomicIsize::new(0),
258 callback: Box::new(callback),
259 }));
260 let env_ptr = env.map_or(ptr::null_mut(), |e| e.as_mut_ptr());
261
262 // SAFETY: context is a valid heap pointer that outlives every callback,
263 // and env_ptr is valid (or null) for the duration of this call.
264 let timer = unsafe {
265 CreateThreadpoolTimer(
266 Some(periodic_trampoline),
267 context.cast(),
268 env_ptr.cast_const(),
269 )
270 };
271
272 if timer == 0 {
273 let error = io::Error::last_os_error();
274 // SAFETY: the pool never saw context; reclaim it immediately.
275 unsafe { drop(Box::from_raw(context)) };
276 return Err(error);
277 }
278
279 // Publish the object before any callback can run. The timer is not
280 // started yet, so no callback can observe the unpublished value.
281 // SAFETY: context is live and exclusively ours until the first start.
282 unsafe { (*context).timer.store(timer, Ordering::Release) };
283
284 Ok(Self {
285 timer,
286 period,
287 context,
288 })
289 }
290
291 /// The period this timer ticks on.
292 #[must_use]
293 pub fn period(&self) -> Duration {
294 self.period
295 }
296
297 /// Start ticking, with the first tick one period from now.
298 pub fn start(&self) {
299 self.start_after(self.period);
300 }
301
302 /// Start ticking, with the first tick `first_delay` from now.
303 ///
304 /// Subsequent ticks follow every [`ThreadpoolPeriodicTimer::period`]. A zero
305 /// `first_delay` makes the first tick due immediately.
306 pub fn start_after(&self, first_delay: Duration) {
307 // SAFETY: timer is valid for the lifetime of self.
308 unsafe {
309 arm_raw(
310 self.timer,
311 relative_filetime(first_delay),
312 millis_u32(self.period),
313 0,
314 );
315 }
316 }
317
318 /// Start ticking, with the first tick at the wall-clock instant `when`.
319 ///
320 /// Unlike a relative first delay, an absolute one passes through sleep and
321 /// hibernation.
322 pub fn start_at(&self, when: SystemTime) {
323 // SAFETY: timer is valid for the lifetime of self.
324 unsafe {
325 arm_raw(
326 self.timer,
327 absolute_filetime(when),
328 millis_u32(self.period),
329 0,
330 );
331 }
332 }
333
334 /// Start ticking, allowing the system a coalescing `window` on each tick.
335 ///
336 /// `window` is the tolerance the system may add so it can group this timer
337 /// with other expirations and wake the processor less often, trading timing
338 /// precision for power.
339 pub fn start_with_window(&self, first_delay: Duration, window: Duration) {
340 // SAFETY: timer is valid for the lifetime of self.
341 unsafe {
342 arm_raw(
343 self.timer,
344 relative_filetime(first_delay),
345 millis_u32(self.period),
346 millis_u32(window),
347 );
348 }
349 }
350
351 /// Stop the timer.
352 ///
353 /// Future ticks stop being queued, but a tick already queued still runs and
354 /// ticks already executing are unaffected. Use
355 /// [`ThreadpoolPeriodicTimer::stop_and_drain`] to also wait for those.
356 pub fn stop(&self) {
357 // SAFETY: timer is valid for the lifetime of self.
358 unsafe { disarm_raw(self.timer) };
359 }
360
361 /// Whether the timer is currently started.
362 ///
363 /// Ticking does not clear the schedule, so this stays `true` until something
364 /// stops the timer -- [`ThreadpoolPeriodicTimer::stop`], [`PeriodicTick::stop`], or
365 /// teardown.
366 #[must_use]
367 pub fn is_running(&self) -> bool {
368 // SAFETY: timer is valid for the lifetime of self.
369 unsafe { IsThreadpoolTimerSet(self.timer) != 0 }
370 }
371
372 /// Block until all queued and executing ticks have completed.
373 ///
374 /// Stop the timer first, or this waits for a schedule that keeps producing
375 /// new ticks. [`ThreadpoolPeriodicTimer::stop_and_drain`] does both in the right order.
376 pub fn wait(&self) {
377 // SAFETY: timer is valid for the lifetime of self.
378 unsafe { WaitForThreadpoolTimerCallbacks(self.timer, FALSE) };
379 }
380
381 /// Stop the timer and wait until no tick is queued or executing.
382 ///
383 /// This is the correct teardown order -- stop first, drain second -- and is
384 /// what [`Drop`] performs. Ticks that have not started are dropped rather
385 /// than run.
386 ///
387 /// The result holds provided no other thread starts the timer during the
388 /// call. `ThreadpoolPeriodicTimer` is `Sync` and the `start*` methods take
389 /// `&self`, so a start landing between the stop and the drain is not
390 /// excluded by anything here, and would leave a schedule installed on
391 /// return. A caller needing the timer to be provably stopped must own it
392 /// exclusively or serialize access to it. Unlike the one-shot timer there is
393 /// no re-arm to suppress: [`PeriodicTick::stop`] only ever stops.
394 pub fn stop_and_drain(&self) {
395 self.stop();
396 // SAFETY: timer is valid for the lifetime of self. A cancelled timer
397 // callback owns no storage, so dropping queued ticks orphans nothing.
398 unsafe { WaitForThreadpoolTimerCallbacks(self.timer, TRUE) };
399 }
400
401 /// Give up ownership, returning the raw object, its callback context, and
402 /// the period.
403 ///
404 /// Used only by [`crate::cleanup_group::CleanupGroup`], which takes over the
405 /// first two: a group member is released by
406 /// `CloseThreadpoolCleanupGroupMembers` and must not close itself, so this
407 /// suppresses this type's `Drop`.
408 pub(crate) fn into_parts(self) -> (PTP_TIMER, *mut core::ffi::c_void, Duration) {
409 let this = std::mem::ManuallyDrop::new(self);
410 (this.timer, this.context.cast(), this.period)
411 }
412
413 /// Free a context returned by [`ThreadpoolPeriodicTimer::into_parts`].
414 ///
415 /// # Safety
416 ///
417 /// `context` must come from `into_parts` on this type, its object must
418 /// already have been released, and it must be freed exactly once.
419 pub(crate) unsafe fn drop_context(context: *mut core::ffi::c_void) {
420 // SAFETY: forwarded from this function's own contract.
421 drop(unsafe { Box::from_raw(context.cast::<PeriodicContext>()) });
422 }
423}
424
425impl Drop for ThreadpoolPeriodicTimer {
426 fn drop(&mut self) {
427 // Stop before draining, or the timer would queue a fresh tick while the
428 // drain is in progress and never settle.
429 self.stop_and_drain();
430
431 // SAFETY: no tick can be queued or executing, so the object can be
432 // closed and the context freed exactly once.
433 unsafe {
434 CloseThreadpoolTimer(self.timer);
435 drop(Box::from_raw(self.context));
436 }
437 }
438}
439
440impl std::fmt::Debug for ThreadpoolPeriodicTimer {
441 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
442 f.debug_struct("ThreadpoolPeriodicTimer")
443 .field("period", &self.period)
444 .field("is_running", &self.is_running())
445 .finish_non_exhaustive()
446 }
447}
448
449#[cfg(test)]
450mod tests;