Wine-NSPA – Local-File Bypass Architecture

This page covers the NtCreateFile local-fast-path itself, the local handle table and inode-sharing rules behind it, and the lazy-promotion path back into wineserver when server-owned state becomes necessary.

Table of Contents

  1. Overview
  2. Motivation
  3. Design Principles
  4. Vanilla Wine vs Wine-NSPA File Open
  5. Handle Range & Per-Process Table
  6. Shared Inode Table & Sharing Arbitration
  7. Lazy Server-Handle Promotion
  8. Dispatch Flow
  9. Eligibility Criteria
  10. NT API Coverage Matrix
  11. File Manifest (post-reorg)
  12. Debug Gating
  13. Results & Profiler Numbers
  14. Known Gaps & Roadmap
  15. History

1. Overview

Wine-NSPA’s local-file bypass services a large subset of NtCreateFile calls entirely within the client process. Every eligible open would otherwise cost a full wineserver round-trip: the client builds a create_file request, the server allocates a struct file plus inode tracking plus a handle-table entry, returns a server-visible handle, and later NtReadFile, NtQueryInformationFile, or NtSetInformationFile often pay more server traffic on top of that. For an app like Ableton Live 12 Lite that does roughly 28,500 file opens in a single startup session – DLL manifests, .pyc files, theme resources, Live Library indexes – those round-trips dominate startup profile and show up as real latency on the main thread.

The current path is broader than the earlier public draft. It covers:

The bypass routes eligible opens to a client-private handle range, maintains a per-process table that owns the unix fd, and exposes the unix fd to every Wine I/O path via a thin fast-path check inside server_get_unix_fd. When a later API genuinely needs server-owned file state, the bypass lazily promotes the local handle to a server-recognized handle on demand. Eligible file-backed sections are the main exception: they can stay local too, and are covered in detail on Local Section Bypass.

The feature is invisible to Win32 applications: same CreateFile semantics, same sharing arbitration, same IO_STATUS_BLOCK results, and the same error codes at the server boundary. Apps see identical functional behavior whether the bypass is enabled or not – the difference is measurable only in profiler output and perceived startup latency.


2. Motivation

Ableton’s startup profile exposed a large population of short-lived file opens:

Pattern Example
DLL manifest lookups C:\windows\winsxs\manifests\amd64_microsoft.windows.common-controls_*.manifest
Python bytecode loads .../Resources/Python/abl.live/**/*.pyc
Theme resources C:\windows\resources\themes\aero\aero.msstyles
Clock source probes /sys/bus/clocksource/devices/clocksource0/current_clocksource
Ableton library indexes C:\users\ninez\AppData\Local\Ableton\Live Database\Live-files-*.db
Live Packs C:\ProgramData\Ableton\Live 12 Lite\Resources\Graphics.alp

Each open is cheap on its own (a few µs) but the aggregate is hundreds of millisecond-scale server traffic during startup – and the startup is happening on the main thread, which is where paint and UI dispatch live. Eliminating the server round-trip on these opens directly reduces time-to-first-paint and reduces steady-state priority-inversion risk on the RT audio path (server’s single-threaded main loop services all requests).

Other candidate workloads with similar profiles: plugin scanners (hundreds of VST probe opens), .NET apps (thousands of assembly-manifest reads at JIT time), installers (cache-file probes), and any Windows application using Python or Lua as an embedded runtime.


3. Design Principles


4. Vanilla Wine vs Wine-NSPA File Open

Vanilla Wine: every open = server RTT Wine-NSPA: local bypass for eligible opens NtCreateFile (ntdll unix) SERVER: create_file request open(), stat(), check_sharing alloc struct fd + struct file global_lock held during sharing arbitration alloc_handle (server range) reply: server handle 0x14 NtReadFile: another server RTT get_handle_fd -> SCM_RIGHTS client mmaps + pread; close on needs_close Cost per open-read-close: 3+ server RTTs ~5-10µs each, ~15-30µs wall on an otherwise idle server under RT contention: unbounded NtCreateFile (ntdll unix) nspa_local_file_try_bypass stat() -> (dev, inode) check_and_publish via shmem table open() O_RDONLY per-bucket PI mutex, no server call alloc local handle (0x7FFF xxxx) return local handle NtReadFile(local_handle) server_get_unix_fd fast path table lookup -> pread(fd) Cost per open-read-close: 0 server RTTs stat + open + pread; everything local promotion happens only on API that needs server state

5. Handle Range & Per-Process Table

5.1 Handle range

Local handles are allocated from the fixed range [NSPA_LF_HANDLE_BASE, 0x80000000) where NSPA_LF_HANDLE_BASE = 0x80000000 - NSPA_LF_HANDLE_CAP*4 with NSPA_LF_HANDLE_CAP = 4096. That gives an exact 16 KiB handle window disjoint from:

nspa_local_file_is_local_handle(h) is a constant-time range check: base <= h < 0x80000000 && h != 0x7FFFFFFF (the last exclusion is for the CURRENT_PROCESS pseudo-handle which would otherwise land inside the range). The check is called from every NT-API intercept site to decide whether to take the bypass path or fall through.

5.2 Per-process table


struct nspa_local_open {
    struct list       entry;
    HANDLE            handle;         /* local-range handle returned to app */
    HANDLE            server_handle;  /* lazy-promoted; 0 until first promote */
    int               unix_fd;
    unsigned long long device;
    unsigned long long inode;
    unsigned int      access;
    unsigned int      sharing;
    unsigned int      options;        /* FILE_OPEN options: SYNC_IO_NONALERT, etc */
    unsigned int      attributes;     /* OBJ_INHERIT forwarded on promote */
    WCHAR            *nt_name;        /* original NT path for GetFinalPathNameByHandle */
    USHORT            nt_name_len;
};

Protected by a single process-wide PI mutex (nspa_lf_opens_mutex). Linear list – walk is O(N) per lookup. For Ableton’s typical workload the list reaches a few hundred entries at peak; the walk is in the noise next to a server RTT it avoids.

Table add (on mint) and remove (on close) are the only writers. Every other operation (lookup, promote lookup) is a read under the same lock. The lock is a PI mutex because RT-priority threads occasionally open files at init and we cannot have a low-priority thread holding the lock against the audio callback.


6. Shared Inode Table & Sharing Arbitration

Windows file sharing (FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) has cross-process semantics: if Process A opens foo with sharing=0, Process B’s open of foo must fail with STATUS_SHARING_VIOLATION. Any pure-local bypass has to see what other processes have done on the same (device, inode).

6.1 Shmem layout

The wineserver publishes a NSPA_INODE_BUCKETS = 1024 bucket hash table as a memfd-backed shmem region. Each bucket has 4 slots of (dev, inode, agg_access, agg_sharing, refcount) + a per-bucket PI mutex. Clients map the region read-only for arbitration lookups, read-write on the mutex word for publishing their own opens.


┌───────────────────────────────────────────────┐
│ nspa_inode_table_shm_t (~160 KB)              │
├───────────────────────────────────────────────┤
│  buckets[0..1023]                             │
│   ├─ lock_storage (pi_mutex_t, 64 B)          │
│   ├─ slot[0] (dev, ino, access, share, ref)   │
│   ├─ slot[1]                                  │
│   ├─ slot[2]                                  │
│   └─ slot[3]                                  │
└───────────────────────────────────────────────┘
Shared inode arbitration: bypass clients and wineserver publish into one table Client process A stat() -> (dev, inode) check_and_publish_open Client process B same file, different access/share mask must see A before minting local handle memfd-backed inode table bucket = hash(dev, inode) % 1024 per-bucket PI mutex slot[0..3] dev, inode agg_access, agg_sharing refcount overflow -> STATUS_NOT_SUPPORTED fallback wineserver non-bypass open server-side publish hook same compatibility rule, same bucket authoritative fallback overflow or unsupported case server create_file path remains exact the table is not a data path cache; it is a compatibility contract so local opens and server opens enforce one sharing model

Bucket index = hash(dev, inode) mod 1024. Slot selection is linear within the bucket (first free or matching). If all 4 slots are full and none match, the bypass returns STATUS_NOT_SUPPORTED and the open falls back to the server – this is an overflow-safety valve, not a correctness path.

6.2 Arbitration logic

nspa_local_file_check_and_publish_open atomically checks the existing aggregate against the new open’s access/sharing mask, returning STATUS_SHARING_VIOLATION if they conflict. Matching the server’s algorithm exactly:

This lives in nspa_local_file_check_sharing_algorithm(). The server-side publish hooks (nspa_inode_publish_slot) mirror the same rule from the server side whenever a non-bypass open creates or clears an inode entry. Arbitration therefore sees the union of bypass and non-bypass opens.


7. Lazy Server-Handle Promotion

The LF table returns a local-range handle to the application. Most Nt-API intercepts can service the call from the local unix fd directly (NtReadFile, NtWriteFile, NtQueryInformationFile for FileBasicInformation / FilePositionInformation / etc). Some APIs still require a server-visible file handle:

For these, the bypass lazily promotes the local handle: on first call needing server state, it issues a single nspa_create_file_from_unix_fd RPC:

  1. wine_server_send_fd(unix_fd) – SCM_RIGHTS transfers a dup of the fd to the server
  2. Server’s handler wraps the fd in a struct fd + struct file_obj + stores the NT path
  3. Server calls alloc_handle and returns a normal server-range handle
  4. Client stores the server handle in the LF entry’s server_handle field

Subsequent calls on the same local handle reuse the cached server handle – no second RPC. nspa_promote_if_local(h) is the one-line helper that every intercept site calls:


HANDLE nspa_promote_if_local( HANDLE h );

// Returns `h` unchanged if not local-range.
// Returns the promoted server handle (cached if already promoted) if local-range.
// Returns `h` unchanged if promotion failed (caller falls back to server path).

This is the lazy-promotion path. The alternative – eagerly promoting at mint time – was rejected because most file opens in Ableton’s workload never touch a server-requiring API; they read, maybe query a position, and close. Eager promotion would cost an RPC per open; lazy promotion costs an RPC per distinct file that escapes the read-only happy path.

Lazy promotion: keep the read-only path local until server state is needed local handle minted `server_handle = 0`, unix fd already valid intercept site checks handle range `nspa_local_file_is_local_handle()` already promoted? reuse cached server handle if yes stays local NtReadFile / NtWriteFile server_get_unix_fd fast path basic query classes no server-visible object required promote server-only follow-on / same-process duplicate NtQueryObject / server-side info classes CreateProcess inheritance crosses into server object model one RPC only send fd via SCM_RIGHTS server allocates real handle cache `server_handle` in LF entry all later calls reuse it this is why lazy promotion wins on workloads like Ableton: most opens die on the left-hand path and never pay the server transition

7.1 attributes plumbing

The promote RPC forwards ObjectAttributes->Attributes (typically OBJ_CASE_INSENSITIVE, plus OBJ_INHERIT when bInheritHandles=TRUE is set on CreateProcess). The server’s alloc_handle_entry translates OBJ_INHERIT to RESERVED_INHERIT on the handle’s access mask, which is how Wine tracks inheritable handles for copy_handle_table during CreateProcess. Without the forwarding, inheritable local-range handles would be silently dropped by the inheritance walk.

7.2 2026-04-30 sync-parity fixes

Three small follow-ons closed correctness gaps in the local-file path without changing the architecture:

These are exactly the right kind of follow-on for this stub: preserve the fast path, preserve the fallback discipline, and close any remaining sync-parity gaps at the boundary.


8. Dispatch Flow

NtCreateFile bypass dispatch + downstream intercepts app: CreateFileA(...) eligibility gate (file.c) regular file or explicit directory, sync, bounded access/disposition fail gate server create_file RTT nspa_local_file_try_bypass stat()/lstat() + file-or-dir shape check check_and_publish via inode shmem open() + table_add SHARING_VIOLATION or NOT_SUPPORTED local handle 0x7FFFC4xx app uses the handle: NtReadFile / NtQuery* / NtSet* / NtFsCtl / NtDeviceIoCtl / ... every NT-API entry point checks nspa_local_file_is_local_handle NtReadFile / NtWriteFile server_get_unix_fd fast path -> pread(fd) query / set / section follow-ons many stay local; server promote only when state truly leaves the local envelope nspa_create_file_from_unix_fd one-time per local handle; cached NtClose (local path) close(fd) + remove entry + server close if promoted

9. Eligibility Criteria

The file bypass covers a bounded but materially broader subset than the earlier public draft.

Accepted shapes today:

Shape Current behavior
regular-file opens local fast path for read-class and common write-class access
explicit directory opens local dir-mint path when the request is honestly a directory
create / overwrite dispositions bounded local coverage for FILE_OPEN, FILE_OPEN_IF, FILE_CREATE, FILE_OVERWRITE, FILE_OVERWRITE_IF, and FILE_SUPERSEDE
common follow-on file ops selected NtQueryInformationFile, NtSetInformationFile, NtFlushBuffersFileEx, and FileEndOfFileInformation cases stay local
file-backed sections eligible unnamed same-process sections can stay local instead of forcing an immediate promote

The important disqualifiers are still the same kind of boundary checks:

Condition Why rejected
loader-owned image opens Wine’s loader owns those semantics
attr->RootDirectory != 0 or custom security descriptor these still want authoritative server-side path or security handling
FILE_OPEN_BY_FILE_ID relies on server-owned name / identity machinery
FILE_DELETE_ON_CLOSE delete ordering remains a server boundary
unsupported async or server-only follow-on operation falls back rather than faking semantics
cross-process visibility requirements local handles are process-private until explicitly promoted

FILE_OPEN_REPARSE_POINT, FILE_WRITE_THROUGH, FILE_RANDOM_ACCESS, FILE_SEQUENTIAL_ONLY, FILE_EXECUTE, and FILE_DELETE_CHILD are no longer automatic disqualifiers when the surrounding open shape is otherwise within the local envelope.

When the open carries FILE_SEQUENTIAL_ONLY or FILE_RANDOM_ACCESS, the local path also applies the matching posix_fadvise() hint directly on the bypassed fd. That keeps the bypass path aligned with the server-side open finalization logic instead of silently dropping an advisory access-pattern hint.


10. NT API Coverage Matrix

Every handle-consuming NT API in ntdll/unix and server/ either:

NT API Strategy File / Line
NtCreateFile bypass dispatch dlls/ntdll/unix/file.c
NtReadFile, NtWriteFile fast path via server_get_unix_fd dlls/ntdll/unix/file.c
NtQueryInformationFile local fast path for common info classes; promote only for server-owned cases dlls/ntdll/unix/file.c
NtSetInformationFile local fast path for bounded classes like FileBasicInformation and unmapped FileEndOfFileInformation; promote otherwise dlls/ntdll/unix/file.c
NtFsControlFile intercept + promote dlls/ntdll/unix/file.c
NtDeviceIoControlFile intercept + promote dlls/ntdll/unix/file.c
NtFlushBuffersFileEx local fsync / fdatasync path where possible; promote only for server-only cases dlls/ntdll/unix/file.c
NtCancelIoFile, NtCancelSynchronousIoFile intercept + promote dlls/ntdll/unix/file.c
NtLockFile intercept + promote dlls/ntdll/unix/file.c
NtQueryVolumeInformationFile intercept + promote dlls/ntdll/unix/file.c
NtQueryObject intercept + traced promote dlls/ntdll/unix/file.c
NtSetInformationObject intercept + promote dlls/ntdll/unix/file.c
NtCreateSection local section fast path for eligible file-backed mappings; server fallback otherwise dlls/ntdll/unix/sync.c
NtMapViewOfSection / NtMapViewOfSectionEx local section fast path for local section handles dlls/ntdll/unix/virtual.c
NtUnmapViewOfSectionEx local section fast path for local section views dlls/ntdll/unix/virtual.c
NtQuerySection local basic-info path for local section handles dlls/ntdll/unix/virtual.c
NtDuplicateObject (same-process) local-file promote for file handles; one-time promote for local section handles before same-process duplicate dlls/ntdll/unix/server.c
NtCompareObjects intercept + promote (both args) dlls/ntdll/unix/server.c
NtQuerySecurityObject intercept + promote dlls/ntdll/unix/security.c
NtSetSecurityObject intercept + promote dlls/ntdll/unix/security.c
NtMakePermanentObject intercept + promote dlls/ntdll/unix/sync.c
NtMakeTemporaryObject intercept + promote dlls/ntdll/unix/sync.c
NtClose LF close path plus local section close path dlls/ntdll/unix/server.c
CreateProcess inheritance (legacy bInheritHandles=TRUE) nspa_local_file_promote_inheritable before new_process RPC dlls/ntdll/unix/process.c
CreateProcess inheritance (STARTUPINFOEX PS_ATTRIBUTE_HANDLE_LIST) deferred – synchronous promote-per-handle introduced a one-frame menu-paint delay; proper fix is batched promote RPC dlls/ntdll/unix/process.c

11. File Manifest (post-reorg)

All NSPA-specific source lives under a nspa/ subdirectory in each module. Upstream Wine files carry only single-line intercept hook calls, keeping rebase-against-upstream conflicts minimal.


dlls/ntdll/unix/nspa/
├── local_file.c         -- LF table, bypass dispatch, promote helpers
├── local_timer.c        -- NT timer local dispatcher
└── debug.h              -- NSPA_TRACE macro, compile + runtime gated

dlls/win32u/nspa/
├── msg_ring.c           -- Message bypass (POST/SEND rings)
└── local_wm_timer.c     -- WM_TIMER local dispatcher

server/nspa/
├── local_file.c         -- inode-aggregation shmem + promote handler
├── local_file.h         -- server-side declarations
├── profile.c            -- wineserver per-request-type profiler
└── debug.h              -- server-side NSPA_TRACE macro

Upstream diffs against vanilla Wine are narrow:


12. Debug Gating

Trace emission is both compile-time gated (NSPA_DEBUG, default on; pass -DNSPA_DEBUG=0 for a release build) and runtime gated via cached env checks.


#if NSPA_DEBUG

#define NSPA_TRACE_ENABLED_FN(name) \
    static inline int nspa_trace_##name##_enabled(void) { \
        static int cache = -1; \
        int v = __atomic_load_n( &cache, __ATOMIC_RELAXED ); \
        if (v < 0) { \
            v = getenv( "NSPA_" #name ) ? 1 : 0; \
            __atomic_store_n( &cache, v, __ATOMIC_RELAXED ); \
        } \
        return v; \
    }

NSPA_TRACE_ENABLED_FN(LF_TRACE)
NSPA_TRACE_ENABLED_FN(LF_TRACE_SRV)
/* ... */

#define NSPA_TRACE(name, ...) \
    do { if (nspa_trace_##name##_enabled()) fprintf( stderr, __VA_ARGS__ ); } while (0)

#else
#define NSPA_TRACE(name, ...) ((void)0)
#endif

13. Results & Profiler Numbers

13.1 Later follow-ons

The original public numbers on this page captured the first local-file bring-up. The current feature set is broader, and the later follow-ons moved more traffic off wineserver:

Follow-on Observed effect
widened local open envelope create_file count 7,845 -> 5,658 (-28%), handler time 137 ms -> 50 ms (-64%) on the compared run
local sections default-on nspa_create_mapping_from_unix_fd count 2,664 -> ~800 (-70%), with total wineserver handler time 1,991 ms -> 1,077 ms on the cleanest run
local FileEndOfFileInformation path direct handler-time saving ~8 ms / snapshot, plus the caller no longer blocks the wineserver loop for eligible ftruncate() cases

13.2 Original full-stack playback snapshot

Ableton Live 12 Lite, 95-second playback window, NSPA_PROFILE=1 with all prod gates. Baseline is the pre-LF fullprod run (2026-04-21); “post-LF” is the 2026-04-23 run after the complete stack landed.

Request Pre-LF (baseline) Post-LF Delta
send_message 32,342 325 -99%
get_message_reply 7,557 0 -100%
send_hardware_message 1,249 0 -100%
accept_hardware_message 1,205 0 -100%
set_cursor 1,766 0 -100%
get_key_state 1,166 0 -100%
get_window_children_from_point 1,705 0 -100%
create_file 60 0 -100%
close_handle 62 0 -100%
AudioCalc thread server requests 27 mentions 0 complete audio-path offload
Server handler total CPU 686.8 ms 571.6 ms -16.8%

The 99% drop on send_message is msg-ring (documented separately) rather than LF – they compose, and the full NSPA bypass stack is what produces the aggregate numbers. LF’s direct contribution shows as the zero rows on create_file / close_handle / get_handle_fd: those are steady-state during playback, but during startup the LF bypass eats roughly 28,500 file opens that would otherwise each cost a server RTT plus a get_handle_fd return-trip.

The bottom-line metric is server handler CPU: 16.8% less server work across the board despite a 10x higher raw request count. The replacement traffic (ring wakeups, hook chain) is ~0.05 µs per request where the replaced traffic was 8+ µs per request.


14. Known Gaps & Roadmap

14.1 Cross-process DuplicateHandle of a local-range source

The same-process path is covered. Cross-process dup where the source lives in another Wine-NSPA process’s local-range is not – the server has no access to the remote’s LF table. Fix would require a cross-process LF promotion RPC. Rare in DAW workloads; parked.

14.2 STARTUPINFOEX PROC_THREAD_ATTRIBUTE_HANDLE_LIST local-range inheritance

The synchronous get_or_promote variant for explicit handle-list inheritance was deferred because the per-handle promote RPC on the CreateProcess-calling thread surfaced as a visible menu-content-paint delay (“black menu flash”). Legacy bInheritHandles=TRUE via nspa_local_file_promote_inheritable is unaffected and covers the common case.

Proper fix options (ranked):

  1. Batched promote RPC – single server round-trip that promotes an array of local handles. Caps the CreateProcess cost at one RTT regardless of list length.
  2. Async pre-promotion at mint time – if the open carried OBJ_INHERIT, fire the promote RPC off the critical path so the server handle is already cached when alloc_handle_list runs. Lower CreateProcess latency but higher complexity.

Relationship to 14.1

14.1 and 14.2 share the same underlying shape – “an LF handle must become a real server handle before it crosses a process boundary” – but the fix surfaces differ:

The two fixes compose: 14.2’s batched RPC is a prerequisite of 14.1. Once nspa_promote_local_handles exists as a handler, 14.1 can reuse it on process A, driven by a new “remote promote” request where process B asks the server to wake A and invoke it. 14.1’s complexity is then the wake mechanism, not the promote itself. Ship 14.2 first; 14.1 composes on top.

14.3 Remaining boundaries

The current envelope already covers the high-volume surfaces that justified the feature. Anything outside it still falls back cleanly. The remaining boundary cases are the ones that still want an authoritative server path today:

None of these has been worth forcing client-side yet; keep them on the server path until a workload demonstrates otherwise.


15. History

Phase Scope
1A.0 Diagnostic scaffolding
1A.1.a-c Shared inode-table shmem + publish hooks + client reader
1A.2.a-e Per-bucket PI lock + slot subentries + client publish API + NtCreateFile bypass dispatch + read/write routing
1A.3 Section-handle promotion infrastructure + audit conclusions
1A.4.a Lazy server-handle promotion + PI mutex on table
1A.4 partial Nt*File hooks (b-e)
1A.5 Final ship-stable + audit findings
1A.5+ Wider Nt*File coverage (audit-driven)
1A.6 Promoted-fd correctness (nt_name plumb, GENERIC_* access map)
1A.6 follow-up NtQueryObject + NtSetInformationObject promote
1A.7 NtDuplicateObject same-process promote (fixes Ableton .als load)
1A.8 Object-generic API audit sweep (NtCompareObjects, security, permanence)
1A.9 OVERLAPPED reject + FILE_OPEN_IF widen + CreateProcess inheritance (prong B) + attributes plumbing
1A.9 parity follow-up promote-time nt_name, directory-bypass reject parity, FILE_MAPPING_WRITE sharing parity
Menu-flash fix init nspa_lf_handle_base at declaration + defer prong A + gate QS_TIMER synth on caller’s filter
Reorg A-D file moves into nspa/ subdirs + intercept-site collapse + debug gating