# Design notes: win-sync-sys (Tier 1)
Current canonical decisions for this crate. See [README.md](README.md) for what it is and
[CHECKLIST.md](CHECKLIST.md) for what is planned.
Repository-level decisions this crate sits under: the `win-` prefix and what `-sys` promises, both in
the workspace [DESIGN-NOTES.md](../../DESIGN-NOTES.md#new-crates-take-the-win-prefix). The crate was
proposed on 2026-10-07 when durable-ioring's readiness event
([DI-D-28](../durable-ioring/DESIGN-NOTES.md#di-d-28)) needed an event it could create and set
without `unsafe`, and the engineer chose a new crate over adding one to `windows-threadpool-sys`,
which exists to wrap the thread pool and nothing else.
## Decision index
| ID | Decision |
|---|---|
| <a id="ws-d-1"></a>WS-D-1 | **The crate's area is Win32's "Synchronization" API, and it starts with the kernel objects -- handle-based objects any thread or process can wait on.** Microsoft Learn's [Synchronization Functions](https://learn.microsoft.com/en-us/windows/win32/sync/synchronization-functions) divides the area into fourteen groups, which fall into three kinds. **Kernel objects and waiting on them** -- events, semaphores, mutexes, waitable timers, the wait functions, and `QueueUserAPC` as what ends an alertable wait -- are this crate's core. **In-process primitives** -- SRW locks and condition variables, critical sections, one-time initialization, barriers, `WaitOnAddress` -- are structures in the caller's memory rather than handles, cannot move once in use, and so pose a different design problem (pinning); the engineer chose to **omit them to start with** (2026-10-07), which parks them rather than rejecting them. **Outside the crate:** interlocked functions (Rust's atomics compile to the same instructions, so a wrapper adds nothing); timer-queue timers (the legacy API the thread pool's timers replaced, which `windows-threadpool-sys` wraps); `GetOverlappedResult` and `GetOverlappedResultEx` (overlapped I/O's, so `windows-overlapped-io-sys`'); and singly-linked lists (a lock-free stack with its own alignment rules, closer to `windows-waitable-queues`' subject). **Not decided:** named objects, `OpenEvent`, and private namespaces, which cross process boundaries -- raised with the engineer and not yet answered, so every constructor here creates an unnamed object and nothing binds to that being permanent. If they are taken up, this crate is their home: Learn lists "Private namespace functions" among the Synchronization groups, and `windows-namespace-request-sys` is about the file-system namespace, not the kernel object namespace. **Nothing needs to change in advance** (checked 2026-10-07): a named event is still an event, so `Event` holds it unchanged; creating and opening by name are new constructors beside `Event::new`, with an outcome saying whether the event was created or opened; WS-D-4 survives because Windows refuses to create or open an event under a name another kind of object holds; and boundary descriptors and private namespaces are new types with their own close routines. Two questions would come with the work: whether opening always adds the `SYNCHRONIZE` access WS-D-4 requires, and where an owned security-attributes type lives -- `windows-namespace-request-sys` has one (`SecurityDescriptor`, `SecurityAttributes`), and a named object needs one, but this crate depending on a file-request crate for it would be the wrong coupling. Moving it to a lower crate and re-exporting it from the old one would not break that crate's users. |
| <a id="ws-d-2"></a>WS-D-2 | **`Event` comes first, alone.** **Amended by [WS-D-6](#ws-d-6): the single-object wait was the first of the wait functions taken up.** The engineer's sequencing, 2026-10-07. The other kernel objects and the wait functions follow as their own items in [CHECKLIST.md](CHECKLIST.md) -- sequenced after, not dropped. `Event` has no `wait` of its own in the first item for the same reason: waiting is the wait functions' item, and an event's wait is one case of it. |
| <a id="ws-d-3"></a>WS-D-3 | **`PulseEvent` is not offered, because it gives a wrong answer rather than a slow one.** Its own documentation says it "is unreliable and should not be used" and "exists mainly for backward compatibility" ([PulseEvent](https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-pulseevent)). The mechanism it gives: a thread waiting on the event can be momentarily removed from the wait by a kernel-mode APC, and a pulse that lands in that window releases nothing, because `PulseEvent` releases only the threads waiting at the instant it is called. A waiter can therefore miss a signal it was waiting for, and nothing reports that it did. The same page notes that a wait-for-all on several objects can see the pulse come and go without returning. Microsoft's replacement is a condition variable. This is a removal on analytic grounds -- an operation that silently does the wrong thing -- not on a measurement, which is the ground the repository accepts for foreclosing an option. |
| <a id="ws-d-4"></a>WS-D-4 | **An `Event` owns a handle to an event object, and the load-bearing half of that is "an event", because a safe conversion into the thread pool's wait target depends on it.** Ownership alone -- what std's `OwnedHandle` already gives -- is what makes `set` and `reset` memory-safe: the handle is valid, and `SetEvent` on a valid handle to some other kind of object simply fails. Event-ness makes them *correct* rather than safe. It becomes a safety property one layer up: the thread pool treats a wait on a **mutex** as undefined behaviour, so `windows-threadpool-sys` can accept an `Event` without `unsafe` only because an `Event` is never a mutex ([WS-D-5](#ws-d-5)). Therefore **adopting an existing handle is `unsafe`** (`Event::from_owned_handle`): the caller vouches that it is an event, and that it carries `SYNCHRONIZE` access. The second clause is conservative: what the pool does with a handle it cannot wait on is not documented, and an `Event` must be safe to hand it. A handle lacking `EVENT_MODIFY_STATE` is permitted -- `set` and `reset` then fail with an error, which is the error path their tests traverse. No checked adoption is offered: telling whether a handle is an event means asking the kernel for the object's type, a native-API question this crate has not taken up; the option stays open. |
| <a id="ws-d-5"></a>WS-D-5 | **`windows-threadpool-sys` depends on this crate, never the reverse; the conversion into the pool's wait target lives there.** The pool waits on synchronization objects, so the arrow runs from the pool to the objects. `windows-threadpool-sys` provides `From<Event> for WaitableHandle`, and `WaitableHandle::event` is built on `Event`, so the crate creates no event of its own. A pool-owned event is signalled through a clone the caller keeps (`Event::try_clone`, a duplicate handle to the same object), which needs no `unsafe` -- unlike signalling through the borrowed handle the wait hands back, which is what its examples did before. When `Mutex` is added it gets **no** such conversion, so the pool's one undefined case stays unrepresentable in safe code. |
| <a id="ws-d-6"></a>WS-D-6 | **The wait functions begin with `wait_for_single_object`, over any `AsHandle`, and `Event` still has no `wait` method of its own.** **Extended by [WS-D-8](#ws-d-8): the rest of the wait functions, the alertable ending and `QueueUserAPC` are built.** The assistant's choice, 2026-10-12, confirmed by the engineer on 2026-10-13 under durable-ioring's `DI-9.7.7`. Raised by that crate's `DI-7.3`, whose pumped delivery has the consumer's own thread wait on a backend's notification, a handle, in a crate that forbids `unsafe`; [WS-D-2](#ws-d-2) sequenced the wait functions after `Event`, and this is the first of them taken up, the rest of `WS-1+.1` staying where the engineer parked it. **Over any handle**, because the wait is what `WaitForSingleObject` is, and the ring's completion event reaches durable-ioring as an `OwnedHandle`, not an `Event`; an `Event::wait` would be one more door onto the same call, and a `Waitable` trait over every object is the parked item's design, which one function does not pre-empt. **`WaitOutcome` has `Abandoned`**, though no event is ever abandoned: the function is over any handle, and answering `Signalled` for a mutex whose owner died would be a wrong answer, not a slow one. It is non-exhaustive so the alertable wait's third ending can join it. **A satisfied wait changes the object** -- an auto-reset event is consumed -- and the documentation says so, since a poll with `Duration::ZERO` is also a wait. **A timeout rounds up** to whole milliseconds so a nonzero wait is never a poll, and **one the call cannot express is refused**: `INFINITE` is `u32::MAX`, so a finite wait may not be that many milliseconds, and shortening it would end a wait early without saying so. **Not alertable**, so a queued APC does not end it; `WaitForSingleObjectEx` is the parked item's. |
| <a id="ws-d-7"></a>WS-D-7 | **A handle with a chosen access is `duplicate_handle_with_access` over any handle, with a typed `Access` mask; `Event::try_clone_wait_only` gives a `WaitOnlyEvent`, a type with no `set` and no `reset`.** The assistant's choice, 2026-10-12, confirmed by the engineer on 2026-10-13 under durable-ioring's `DI-9.7.9`. Raised by that crate's `DI-7.5`, whose read-only handle on the ring's completion event ([DI-D-55](../durable-ioring/DESIGN-NOTES.md#di-d-55)) is `DI-7+.1`; the item here is [WS-1+.5](COMPLETED-CHECKLIST.md#ws-1+5). **Over any handle**, returning an `OwnedHandle`, because that consumer holds the ring's event as an `OwnedHandle` and not an `Event`, and whether a handle is an event cannot be asked here ([WS-D-4](#ws-d-4)); the function is memory-safe on any open handle, so it is not `unsafe`. **`Access`** is a newtype over the Win32 bits with `SYNCHRONIZE` and `EVENT_MODIFY_STATE` named, combined with `|`, and `from_bits` for every other right, so a consumer that forbids `unsafe` and does not depend on `windows-sys` need not take a constant from it. **`WaitOnlyEvent` is a type, not an `Event` that fails when set.** An `Event` adopted without `EVENT_MODIFY_STATE` is permitted ([WS-D-4](#ws-d-4)) and reports access denied from `set`; that is an `Event` whose methods fail, where this has nothing to fail, and the failure is not something a caller can discover too late. It keeps what [WS-D-4](#ws-d-4) and [WS-D-5](#ws-d-5) rely on, being known to be an event, and nothing adopts a handle as one, so that holds without `unsafe`. It lends its handle, so `wait_for_single_object` waits on it. **What the guarantee is, measured on this host and written as a test:** Windows does **not** confine a duplicate to the source handle's access. A duplicate of a `SYNCHRONIZE`-only handle asking for `EVENT_MODIFY_STATE` succeeds and can set the event. So a wait-only handle cannot be used to signal, by this crate or by a raw call, and a holder that sets out to can duplicate it into one that can; the narrowing guards against a handle used for what it was not handed out for, and is not a boundary within the process, which no process has. The item as first written said the function "can only narrow"; a test expecting the refusal found it false, and the documentation and the item's consumers were corrected before anything bound to it. A mask Windows refuses, every bit set, is reported as access denied, which gives the error path a deterministic test. **Rejected:** making the function narrow-only by reading the source's granted access, which is a native-API query this crate has not taken up and would not stop a raw `DuplicateHandle`. **Same process only**: the two process arguments are the current process, and duplicating into another process crosses the boundary [WS-D-1](#ws-d-1) leaves undecided; it belongs with [WS-inf.2](CHECKLIST.md). |
| <a id="ws-d-8"></a>WS-D-8 | **The rest of the wait functions take alertability as an `Alertable` argument, wait for any and wait for all are two functions, `SignalObjectAndWait` takes a `Signalable`, and `QueueUserAPC` takes a Rust closure.** The assistant's choices, 2026-10-12, confirmed by the engineer on 2026-10-13 under durable-ioring's `DI-9.7.9`; the item is [WS-1+.1](COMPLETED-CHECKLIST.md#ws-1+1). **Alertability** is a two-state enum on `wait_for_single_object_ex`, `wait_for_any`, `wait_for_all` and `signal_object_and_wait`, not a `bool`, so a call site reads `Alertable::Yes`; `wait_for_single_object` stays as the non-alertable form, being what Win32 names `WaitForSingleObject`, and `WaitForMultipleObjects` is the `Alertable::No` case of the `Ex` form, not a second function. **`WaitOutcome::Alerted`** is the ending [WS-D-6](#ws-d-6) left room for by making the enum non-exhaustive. **The wait stays over `AsHandle`, and there is no `Waitable` trait**, which the item proposed: every handle can be passed to a wait, so a trait would restrict nothing and would exclude the `OwnedHandle` and `BorrowedHandle` a consumer such as durable-ioring holds; the option stays open, since a trait with a blanket implementation over `AsHandle` could be added without breaking a caller. **Any and all are two functions** because their results differ: a wait for any reports which object, as `WaitAnyOutcome::{Signalled(i), Abandoned(i)}`, and a wait for all reports `WaitOutcome`, since the index that Win32 returns with it is not one a caller can use. **`MAXIMUM_OBJECTS`** (64) is public and a larger set is refused, not split, because a wait on the first sixty-four is not a wait on all of them; the zero and over-maximum checks also keep the handle array from being read past its length, which the kernel's own refusal makes unobservable, so their two sabotages are declared `survives`. **`Signalable`** is the trait for what `SignalObjectAndWait` can signal, so that an object which cannot be signalled is refused when it is passed. `Event` implements it, the semaphore and mutex items will, and `WaitOnlyEvent` does not, which a `compile_fail` doctest states; it is not sealed, since a handle claimed as signalable that is not gets the call's error and no undefined behaviour. **`queue_user_apc`** takes any handle that lends itself and a `FnOnce() + Send + 'static`, boxed twice because a fat pointer does not fit the call's one word, with a form for the calling thread, which is what makes an alertable wait reachable in a test without a second thread. A failed call drops the routine; a call that succeeds for a thread that never waits alertably leaks it, which is documented and tested. **Observed on this host, and each written as a test so a Windows that changed it would show:** a pending APC ends an alertable wait and runs even when an object is already signalled, leaving the object for the next wait; a wait for any tolerates the same handle twice and reports the first index, and a wait for all refuses it with `ERROR_INVALID_PARAMETER`; and **an APC queued to a thread that has not finished starting runs as the thread starts, before the Rust runtime has set the thread up, and a routine that calls `std::thread::current` there aborts the process**. The last was found when the first run of the tests aborted, and is on the function's documentation. **Not built, and not decided:** `QueueUserAPC2`, whose special user-mode APC runs without the target being in an alertable wait; it is queued as [WS-inf.4](CHECKLIST.md). |
| <a id="ws-d-9"></a>WS-D-9 | **`queue_user_apc` refuses a thread that is not this process's, because it is safe and the routine is an address in this process.** Found 2026-10-14 by the review of PR 126: the function took any `AsHandle`, and a thread handle of another process with `THREAD_SET_CONTEXT` -- one `OpenThread` can make -- would have had this process's callback address and boxed closure delivered where they mean nothing, running whatever is at that address. **The check** is `GetProcessIdOfThread` against `GetCurrentProcessId` before anything is queued, as [`InvalidInput`] for another process, and the call's own error for a handle that is not a thread or that lacks access; it asks of the handle `THREAD_QUERY_LIMITED_INFORMATION` besides `THREAD_SET_CONTEXT`, which the handle of a `std::thread::JoinHandle` carries. A thread's process cannot change, so the answer holds for as long as the handle is borrowed. **Rejected**: making the function `unsafe` with a same-process requirement, which burdens every caller with an obligation the crate can discharge itself. **Tested** against a suspended child process's main thread handle, which carries every access and belongs to another process; the sabotage that removes the check is caught. **No work is queued behind this**: nothing about it is deferred. |