Contract-driven flat Win32

September 16, 2026 ยท View on GitHub

Flat Win32 bindings dynamically invoke DLL exports described by Windows metadata. They use a Win32-local ABI model and explicit semantic contracts, separate from WinRT activation and Classic COM vtables.

Status: experimental. Flat Win32 support is under active development and intended for evaluation and prototyping. Its JavaScript/TypeScript APIs, generated bindings, and Win32 runtime/codegen contracts may change between releases; backward compatibility is not yet guaranteed. Do not treat this projection as a stable production dependency. Use matching runtime and codegen versions, regenerate Win32 bindings when upgrading, and validate the APIs you rely on in your target environment.

Metadata and supported capabilities

Built-in contracts are validated against Microsoft.Windows.SDK.Win32Metadata 71.0.14-preview with SHA-256 B64EE4818A7ED9F9D135038D58C51BD08369184D4D5ED428F20E9DE55DF8121D.

The measured inventory contains 18,321 eligible exports and 8,936 complete projections: 8,934 synchronous functions and two asynchronous helpers. There are 182 supported containers and 10,680 public exports including aliases, enums and helper functions. These are generation counts, not a claim that every API has been exercised against a live system.

AreaSupported behavior
Native ABIAll admitted scalars, enums, GUID/scalar pointers, native structures/unions, pointer and by-value aggregate shapes; system/cdecl calls and target availability.
Return contractsDirect, void, status/HRESULT and LastError behavior, including expected failure results.
Strings and buffersUTF-16/ANSI storage, double-NUL lists, reserved inputs, counted buffers, capacity/actual-size relationships and caller-owned output semantics.
ResourcesExact HKEY, HANDLE, HLOCAL, HGLOBAL, HMODULE, SC_HANDLE, CoTaskMem and credential cleanup; synchronized leases and consuming calls.
Native buildersSECURITY_ATTRIBUTES, STARTUPINFOA/W, PROCESS_INFORMATION and POD helpers, including retained pointer fields and owned outputs.
COM inputsExact-IID, borrowed managed interface inputs without changing the WinRT model.
Asynchronous I/OIOCP-backed ReadFile/WriteFile Promises, cancellation, EOF, retained storage, detachment checks and bounded pending work.
SubsystemsWinsock, GDI+, Media Foundation and explicit unsafe MAPI utility context behavior.
ToolingSafe/unsafe entrypoints, declarations, CLI/census, manifests, examples and stock-Windows scenarios.

The examples under samples/js/win32 demonstrate system information, Registry queries and asynchronous file I/O with the namespace layout.

Contract boundary

Windows.Win32.winmd facts
    + independent Win32 JSON semantic evidence
    -> validated Win32 model
    -> typed language projection
    -> generic renderer and immutable native call plans

tools/dynwinrt-codegen/contracts/win32/ owns Win32-specific manual evidence. COM retains its own interface/IID/vtable contracts. Both follow the contract evidence principles; their selectors and ABI models are not interchangeable.

Generate-command routing uses one lazily loaded flat-function index for the metadata set, rather than rereading and hashing WinMD for every requested COM interface. Presence-only lookup preserves the existing DllImport-based routing order and does not compute provenance hashes. Actual flat Win32 parsing still performs the unchanged metadata-hash and contract-fingerprint validation.

Metadata-complete shapes use generic projection. Manual facts such as missing count relationships, ownership, cleanup, special layouts and lifecycle requirements belong in typed, closed contracts with exact selectors, fingerprints, metadata provenance and authoritative evidence. Contracts must not contain JavaScript, declaration fragments, arbitrary cleanup functions or unconstrained native adapter names. Unsupported facts remain explicit errors.

Refactoring must not weaken an existing safety boundary to increase coverage. If a behavior needs a correctness fix, implement and document that fix and exercise its nearest failure case rather than silently dropping the API or relabeling it as supported.

Shared protocol and result model

crates/dynwinrt-win32-contracts is the single source of the Win32 wire types, version handling and structural validation. It depends on neither Windows, libffi nor a language binding. Metadata validation still proves pointer depth, encoding, type identity and provenance against the selected WinMD; core still checks the executing ABI and its supported capabilities. Sharing the protocol does not merge these responsibilities or the WinRT/COM models.

Version 3 describes every direct return, native output slot and declared caller-aggregate result field with a ResultContract: a target, explicit success/failure policies and bounded, non-overlapping conditional overrides. The schema is derived from these Rust types rather than separately maintained protocol definitions. Unknown fields, unsupported versions, contradictory cases and incomplete slot coverage fail before native dispatch.

Resolved result policyNative behavior
UndefinedDo not decode the payload, adopt a resource or call a cleanup function on it.
Defined value or borrowed resultDeliver the value, or discard it without inventing an independent native owner.
Defined owned resultTransfer an owned value when delivered; otherwise run the exact declared cleanup even when the overall operation failed.
Defined input aliasValidate the declared input relationship and share its owner; never create a second owner by matching arbitrary handle numbers.

Delivery is independent from native validity and ownership. A valid discarded resource uses Value::Discarded, not the undefined-output state. Direct returns and out parameters have cleanup guards before any fallible conversion, so an allocation or conversion failure also retires valid, untransferred resources. Language projection chooses nullability and exception/result shape; it cannot erase cleanup obligations.

Direct-return and independent out-slot resource owners are captured before fallible result processing. An error retires untransferred results and preserves any failed cleanup in win32::CallError::cleanup_failures(), together with its result target, original cleanup error, and shared OwnedResource. This includes results converted earlier in the call but not yet delivered to the caller. retry_cleanup() attempts every retained cleanup without invoking the original function again; successful owners stay closed and failed owners remain available for another retry. Native consumption and resource effects are not rolled back by a result-processing failure. Callers can also retain or explicitly close the same resource owner through the failure record. No raw-handle re-adoption or process-global recovery queue is used.

The JavaScript call boundary retains byte/string storage independently of managed COM inputs. COM admission and leases, retained-storage validation, and aggregate owner validation precede native dispatch. After these checks, plans with owned direct returns or out slots prepare their JavaScript recovery carrier before dispatch; scalar and aggregate-only plans do not allocate that carrier.

JavaScript recovery wrapping uses null-prototype property descriptors. If an AggregateError cannot be constructed or decorated after projection failure, the original native error carrier is rethrown rather than replaced by the wrapping exception. DynWin32CallError.getCleanupFailures(error) and DynWin32CallError.retryCleanup(error) work without installing an instance prototype, including for non-extensible carriers. Keeping the thrown carrier therefore keeps the native cleanup owner and its recovery path alive.

CallPlan::invoke() returns the Win32-specific CallError on failure; its message() and source_error() preserve the original invocation error. Dropping the error releases its retained owners and performs best-effort cleanup. Deterministic recovery requires keeping the error or resource alive, resolving the reason cleanup was refused, and retrying. The runtime does not remove native close-protection flags or bypass resource leases. Aggregate-field ownership continues to reside in the caller's aggregate buffer.

Descriptors without a version are read as version 1. Their historical null resource results and cleanup behavior are isolated in an explicit compatibility adapter and lowered to the common execution plan. Version 2 slot-only contracts remain readable; aggregate field targets require version 3. New generated code emits version 3. Metadata ownership evidence alone does not prove a pointer is defined on failure: new generation leaves that failure result undefined unless reviewed result evidence establishes validity and the required cleanup/delivery policy.

An aggregate pointer remains a physical input pointer; it is not converted into a pointer-to-pointer or an owned aggregate allocation. Its pointeeDescriptor retains the exact native layout and ordered outputFields. The caller owns the structure bytes, while the native buffer's result store independently owns resources written into fields such as PROCESS_INFORMATION.hProcess/hThread. Field validity, cleanup and native completion state for every described aggregate are registered immediately after native dispatch, before any per-field conversion or discard cleanup can fail, before return/out decoding and before N-API wraps _call. Taking a field moves the existing resource owner rather than adopting its handle a second time. Failed discard cleanup preserves the original field bytes and cleanup guard for retry by prepare() or destruction; merely attempting cleanup does not release ownership or remove overlapping-write protection. Generated code does not rely on JS prepare/mark calls for this lifetime boundary. Legacy manual mark helpers cannot erase already registered native ownership.

Output-buffer reuse first locks the aggregate storage, then coordinates input owners and old owned-field results in one identity-ordered resource lock set. If retiring an old field would close an input owner in the same call, invocation fails before any old output is retired or the native function is dispatched. field_value() shares the field's owner; use take_field() to move that owner out before reusing its buffer as an output. Input-alias result policies do not retire their shared input owner. Retirement uses the already acquired owner guards rather than recursively calling a locking close() method.

Once all output guards and native aggregate states are registered, the call commits successful input consumption and resource effects while input-resource locks are still held, before any field/return/out conversion or discard cleanup. The decision uses native success, not result-delivery success. A native failure leaves consuming-input ownership and success-only resource effects unchanged, even when valid failure outputs subsequently fail to decode or clean up.

Legacy raw-field adoption is explicitly unsafe in Rust, including the binding adapter entrypoint. The caller must prove that successful fields are initialized and valid, that each non-null owned resource transfers exclusive ownership, and that its declared cleanup matches the allocator/resource kind. Native writes must have completed; borrowed handles and duplicated owning fields cannot be adopted. Safe aggregate construction, reads and writes only manage bytes, not resource ownership. If field conversion fails with raw cleanup still pending, safe writes cannot overwrite any part of those cleanup targets until they are retired. Version 3's native result registration does not use this legacy adoption boundary.

Runtime and package boundary

Win32 stays outside the public WinRT model and the @microsoft/dynwinrt root. Generated Win32 code uses /win32, with manual raw ABI capabilities isolated under /win32/unsafe. Existing WinRT and COM entrypoints remain unchanged. Sharing behavior-neutral private FFI storage/execution remains allowed.

Native invocation follows completed plans; it does not infer pointer meanings from JavaScript objects. Handle values, dereferenced storage and owned resources remain distinct. Resource consumption and lease acquisition are synchronized. Buffer bounds come from validated native backing storage, not spoofable JS length properties. Unknown ownership or successful out-of-bounds lengths must not produce success-shaped fallback values.

Output relationships and validity are part of the native call contract, not renderer-specific Registry rules. Bounded input predicates are evaluated before the call; native return conditions are evaluated immediately after the call. The resulting output disposition is applied before decoding, ownership adoption or cleanup. Undefined outputs use Value::Unavailable; language projection maps that outcome to null, rather than reading a native value and hiding it afterward.

RegOpenKeyA/W with a null/empty subkey returns an alias of its input. Managed aliases share the existing owner, close state and leases; borrowed input handles stay borrowed. RegOpenKeyExA/W applies that relationship only to the documented native predefined-key case. Its predicate compares the full, pointer-sized signed handle value: on 64-bit Windows, a zero-extended 0x80000002 can produce a new owned handle, whereas the native predefined value is sign-extended. These representations must not be merged by masking away the upper bits. Aliases are authorized by exact contracts and checked against the specified input, never discovered by globally merging handle numbers. For RegQueryValueExA/W and RegGetValueA/W, performance-data queries remain supported: successful results retain their size, while HKEY_PERFORMANCE_DATA combined with ERROR_MORE_DATA exposes dataSize: null without decoding the undefined count. Callers grow a separate capacity and retry. Normal-key size queries retain their prior behavior.

Native Win32 carriers use type tags rather than mutable JavaScript prototypes. Manual aggregate descriptors and MAPI utility initialization are available on the explicit unsafe subpath; generated validated helpers call those primitives internally. Win32 runtime types are exposed through the dedicated safe and unsafe entrypoints. x86 plan invocation remains explicitly unsupported; x64 is exercised live and ARM64 is compile-validated.

Win32 optional lifecycle DLLs are loaded on explicit subsystem initialization, not on addon or generated-wrapper import. Winsock, GDI+, Media Foundation and MAPI use complete typed startup/shutdown function tables, including rollback and native error-query entrypoints. Every required export is resolved before Startup; neither a missing module/export nor a failed Startup publishes a context. Module and function-table caches retain successful values only, so later requests can retry failures. Winsock version-negotiation rollback failure is reported and retried before any new Startup.

The shared Win32 module loader accepts bare system DLL/DRV names, uses LOAD_LIBRARY_SEARCH_SYSTEM32, and owns each successful load reference. Concurrent first loads publish one module and release losing references outside the cache lock. Cached modules and tables live for the process; their lifetime is separate from subsystem activation. The last context closes the activation, not the DLL. Explicit Shutdown failure leaves the context active for retry. Ordinary export plans still bind lazily, and subsystem-exempt contracts do not gain an initialization requirement. WinRT/COM models and foundational imports remain unchanged. Normal shared-addon PE import tables must not contain these optional lifecycle DLLs; test-hook-only COM media fixtures retain their separate native test dependencies.

MAPI utility symbols follow the same loading and caching policy. Their x86 exports carry stdcall suffixes (ScInitMapiUtil@4, DeinitMapiUtil@0); binding them as unconditional undecorated imports would prevent unrelated WinRT/COM consumers from loading the addon. Missing utility exports produce an explicit error when that subsystem is requested. The system DLL is a dispatch stub, not a MAPI provider: utility initialization requires an installed, configured provider matching the process architecture. Without one, the stub can display a native initialization message before returning E_FAIL, as documented in MAPI stub registry settings. The unsafe entrypoint preserves that native behavior; it does not install a provider, change mail-client registration, or substitute a successful no-op.

The IOCP engine retains native state through terminal completion, including cancellation. The limits are 1,024 pending operations, 64 MiB per operation and 256 MiB of pending private buffers. These limits include completed results waiting to be consumed or discarded, not only active OS requests. A shared worker set is used, not one blocked OS/libuv worker per operation. Subsystem call guards prevent close from racing synchronous dependent calls. File I/O retains its native resource leases independently and does not require these optional subsystems. Opaque unsafe subsystem resources remain caller-managed: callers must retain their context for the required native lifetime. A future managed subsystem-owned resource or asynchronous operation requires an explicit retained subsystem lease; DLL caching alone does not provide that activation lifetime.

The native Win32 I/O module owns the operation registry, stable OVERLAPPED, private buffers, cancellation and terminal completion. It can run from Rust without loading Node. Native completion records keep their storage, resource occupancy and quota ownership until consumption or discard; failed notification or a dropped consumer cannot strand them.

Native operations hand off owning records through a native queue/channel. They do not retain an arbitrary callback closure that can capture adapter objects. The Node adapter independently owns its completion receiver and TSFN registrations; even indirectly, no native operation owns a TSFN.

The private Node bridge keeps JS references and original backing information on the owner thread. That thread revalidates the backing, performs read copy-back and delivers the callback. No Node Buffer, N-API environment/reference or TSFN type enters core I/O. Environment teardown cancels native work without freeing OS-owned storage early, while JS references are released on their owner thread. COM/WinRT carriers and their backing-storage ownership remain separate from the native Win32 I/O engine. The adapter uses the private js_storage::RetainedBuffer for JS ownership and the original view; completion, cancellation and resource coordination remain Win32-specific. Synchronous Win32 byte/string storage also uses shared storage primitives directly, separate from managed COM inputs. See JavaScript binding internals.

Generic resource coordination governs borrowing, consuming, state mutation and asynchronous occupancy. File-specific modes and association state belong to a typed file capability, not a growing collection of API-specific fields on the generic owner. Neither a string-keyed state bag nor a global handle-value owner registry is used.

File completion notification changes have a typed resource state effect. Native mode changes are serialized with managed calls and rejected while an asynchronous lease exists, including a prepared operation not yet submitted. Successful changes only add mode bits; failures do not update state. Before each IOCP submission the runtime queries the actual native notification modes, so a preconfigured handle is not assumed to use defaults. Synchronous success with FILE_SKIP_COMPLETION_PORT_ON_SUCCESS completes locally through the same retirement path; ordinary synchronous success and ERROR_IO_PENDING still wait for their IOCP packet. Buffers and leases are retired exactly once in either path. Raw/unsafe handle escape does not authorize concurrent foreign mutation or close of a managed handle.

Namespace output

Generated modules use a lowercase/kebab namespace directory mapping, for example win32/windows/win32/system/registry/Apis.js. The Win32 output manifest records namespace exports and generated file hashes. The CLI uses the shared atomic output transaction, retaining other generated namespaces on incremental runs and rejecting stale or conflicting output.

Win32-only packages do not acquire a WinRT package-root entrypoint. Mixed generation preserves existing root exports and adds explicit Win32 subpaths. Namespaces containing COM interfaces retain explicit --class-name selection; use their Apis container for flat exports. Python flat-Win32 generation remains explicitly unsupported.

Flags declarations permit normal bitwise combinations while ordinary enums retain their member types. ABI-width validation remains separate from the TypeScript surface. CI uploads the complete dist runtime tree, matching the npm distribution boundary, rather than maintaining a second filename list. It then downloads that artifact and checks every public package entrypoint through CommonJS, ESM and native dispatch.

Tests

Behavior is protected by contract, ABI, ownership, generated-output, package, and live Windows API tests:

TestsAssertions
Shared Win32 contract testsStrict version decoding, legacy migration, complete result coverage, ownership/delivery combinations, disjoint conditions and Rust-derived schema consistency.
Codegen Win32 unit testsExact contract selectors and drift rejection, typed ABI and ownership plans, count/size relationships, native layouts, builders, return conventions and generated behavior.
Core Win32 unit testsReal FFI scalar/aggregate calls, output ordering, success/failure and cleanup, handle leases and consuming calls.
Aggregate field result testsCaller-owned storage identity, per-field validity and ownership, failed delivery before _call wrapping, partial and cross-aggregate conversion failures, unsafe legacy transfers, safe-write protection, extraction, failed discard cleanup retry and real CreateProcessW handle retirement.
Aggregate owner lock testsBounded real DuplicateHandle reuse, shared-owner rejection without mutation, moved/distinct owners, input-alias policies, failed/busy retirement, invalid-input preservation and ordered/cross-thread resource coordination.
Native side-effect testsInput consumption, alias/lease state and real file completion-mode updates across native success/failure, field/return conversion errors and discard cleanup failure.
Result cleanup recovery testsProtected-handle cleanup failures for direct returns and out slots, partial conversion, cleanup of other results, repeated and partial retries, shared owner state, lease exclusion, and best-effort error destruction.
Native I/O testsReal local file/pipe I/O without Node, synchronous/pending completion, cancellation, dropped consumers, queued-result quotas and exact lifetime retirement.
JS Win32 testsNative carrier identity, argument/descriptor validation, encoded strings and buffers, resource lifetimes, IOCP cancellation/capacity and subsystem state.
Module/lifecycle loading testsControlled System32 paths, successful-cache identity, retryable failures, concurrent load reference balance, complete function-table publication, startup/shutdown/rollback state, and the actual shared-addon PE import boundary.
Win32 CLI testsNamespace/enum files, relative runtime imports, CJS/ESM resolution, missing or malformed output, atomic failure/rollback and retry, incremental regeneration and coexistence with WinRT/COM.
Generated-module testsLoad every currently admitted JS/enum module as CJS and ESM without native dispatch, and compile its declarations with TypeScript. Expected behavior comes from actual exports and type rules, not stored implementation hashes.
Win32 E2E runnersActual Registry, aggregate, resource, IOCP, subsystem and generated declaration behavior.

The existing CI jobs run these tests together with WinRT and Classic COM regressions. The census minimum is an additional coverage signal, not proof of correctness or a substitute for explicit behavioral assertions. npm test includes Win32 consumer typechecking; the existing CI jobs run the Rust binding tests and Node suite in addition to generated E2E scenarios.

Real-metadata tests read DYNWINRT_WIN32_WINMD. CI also sets DYNWINRT_REQUIRE_WIN32_METADATA=1 so missing metadata is a failure, not an unnoticed skip. Live scenarios require stock Windows; optional-device and ARM64 execution coverage must be reported separately from compilation. Native subsystem lifecycle tests run in isolated, time-bounded processes with initialization/cleanup stage output, so a platform startup failure cannot silently block unrelated tests behind a process-global lock.

Deterministic lifecycle tests use test-only function tables to exercise the same context, counting and call-guard implementation. They cover reserved flags, first-lease initialization, last-lease cleanup, retries after startup or shutdown failure, rollback, missing exports, aliases, idempotent close, Drop and concurrent close. Separate integration cases invoke the installed Winsock, GDI+ and Media Foundation APIs; only positively identified missing optional DLLs or lifecycle exports permit a skip, not arbitrary Startup failures. Architecture-specific MAPI system export resolution is mandatory without invoking a provider.

The real MAPI lifecycle test remains available separately on a machine with a configured provider (for example, matching-bitness Outlook). It is ignored by default, fails on initialization errors/timeouts, and never treats an unavailable provider as success:

cargo test -p jswinrt_rs --lib win32_subsystem::tests::mapi_utility_contexts_use_installed_provider -- --exact --ignored --nocapture