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.
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.
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.
[0x7FFFC000, 0x80000000) that is disjoint from the server’s normal handle allocation (low-to-mid) and from the NTSync client-handle range. Any caller that does nspa_local_file_is_local_handle(h) can cheaply tell whether a handle is ours.FILE_SHARE_NONE we must honour that. A server-published shmem region carries (dev, inode) -> (aggregate-access, aggregate-sharing, refcount) so client-side arbitration matches what server-side check_sharing would enforce.nspa_create_file_from_unix_fd RPC that hands the unix fd to the server and gets back a real server handle. Subsequent calls on the same local handle reuse the cached promoted handle. Eligible file-backed sections are no longer part of that automatic promote set; they can stay local on their own section table.stat() + linked-list-walk-under-lock + open() + list insert – no syscall other than the two that are inherent to the work. No lazy-init remains on the hot path.STATUS_NOT_SUPPORTED and the caller falls through to the normal server_create_file path. Anything the bypass doesn’t handle is handled by vanilla Wine unchanged.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:
0x4, grows up)~0..~5)INPROC_SYNC_CACHE_TOTAL)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.
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.
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).
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] │
└───────────────────────────────────────────────┘
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.
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:
access to agg_access and intersect sharing with agg_sharing.(agg_access & ~my_sharing) == 0 AND (my_access & ~agg_sharing) == 0.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.
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:
NtDuplicateObject – dup goes through SERVER_START_REQ(dup_handle)NtQueryInformationFile for classes the server handles (e.g. FileNameInformation)NtQueryObject – ObjectName / ObjectBasic / ObjectType all server-sideNtQuerySecurityObject, NtSetSecurityObjectNtMakePermanentObject, NtMakeTemporaryObjectNtCompareObjectsFor 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:
wine_server_send_fd(unix_fd) – SCM_RIGHTS transfers a dup of the fd to the serverstruct fd + struct file_obj + stores the NT pathalloc_handle and returns a normal server-range handleserver_handle fieldSubsequent 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.
attributes plumbingThe 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.
Three small follow-ons closed correctness gaps in the local-file path without changing the architecture:
fd->nt_name, which fixed the start.exe NULL-Name crashFILE_NON_DIRECTORY_FILE instead of accepting a shape the server would rejectcheck_sharing path arbitrates FILE_MAPPING_WRITE, matching server/fd.c::check_sharingThese 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.
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.
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 |
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:
dlls/ntdll/unix/file.c: eligibility gate plus one-line intercept calls for the local-file surfacedlls/ntdll/unix/server.c: nspa_local_file_try_get_unix_fd(), promote logic in NtDuplicateObject, LF close path, and local section close supportdlls/ntdll/unix/sync.c: local-file and local-section NtCreateSection handling, plus two promote lines in NtMake{Permanent,Temporary}Objectdlls/ntdll/unix/virtual.c: local section map / unmap / query hooksdlls/ntdll/unix/security.c: two promote linesdlls/ntdll/unix/process.c: alloc_handle_list was extended (prong A, currently deferred – see §14) + nspa_local_file_promote_inheritable() call before new_process RPCserver/file.c: one call to nspa_lf_trace_promote() inside the existing nspa_create_file_from_unix_fd handlerTrace 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
getenv(); subsequent calls are a relaxed atomic load + not-taken branch when the env is unset (production default).nspa/*.c – upstream Wine files have zero NSPA_TRACE calls. Trace-worthy hooks in upstream code (e.g. the LF fast path in server_get_unix_fd) have been extracted into helpers (nspa_local_file_try_get_unix_fd, nspa_promote_if_local_traced) that the upstream file calls, and all trace logic lives inside those helpers.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 |
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.
DuplicateHandle of a local-range sourceThe 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.
STARTUPINFOEX PROC_THREAD_ATTRIBUTE_HANDLE_LIST local-range inheritanceThe 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):
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.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.
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:
FILE_DELETE_ON_CLOSENtQueryDirectoryFile)FILE_DELETE_ON_CLOSE (temp-file semantics)None of these has been worth forcing client-side yet; keep them on the server path until a workload demonstrates otherwise.
| 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 |