Replace native turbowalk with pure TypeScript workspace package#22910
Replace native turbowalk with pure TypeScript workspace package#22910halgari wants to merge 16 commits into
Conversation
turbowalk previously wrapped winapi-bindings' WalkDir() on Windows and fell back to a JS implementation on Linux. Replace the external git dependency with a local workspace package that uses Node's built-in fs.readdir + fs.lstat for all platforms. The new implementation supports all options used in the codebase: recurse, skipHidden, skipLinks, skipInaccessible, and details. No import changes needed — the workspace package shadows the old name. Resolves APP-405
- Return Bluebird<void> instead of Promise<void> to match the type signature callers expect (fixes typecheck on Linux CI) - Add concurrent lstat batching (64 workers) to improve walk performance — roughly 2x faster than sequential on NVME - Add bluebird as devDependency for the workspace package
purgeLinks() returns PromiseBB<void> but the .then() chain ended with PromiseBB.map() which returns Bluebird<void[]>. Add .then(() => undefined) to satisfy the return type. This was masked by the old turbowalk type declarations.
Uses koffi to call Win32 directory enumeration APIs directly, which returns attributes, size, and timestamps in a single call per entry — no separate lstat() needed. This also enables proper FILE_ATTRIBUTE_HIDDEN checking instead of dot-prefix convention. Falls back to the async fs.readdir + lstat path on Linux or if koffi is unavailable. Benchmark (Samsung 970 EVO NVMe, Windows 11): | Directory | Old Native | koffi FFI | Pure TS | |--------------|-----------|-----------|----------| | src/ (1.9k) | 8.8ms | 19.1ms | 62.0ms | | ext/ (3.9k) | 22.1ms | 48.9ms | 170.8ms | | n_m/ (89k) | 341ms | 1005ms | 3426ms | koffi is ~2-3x the old C++ addon vs 7-10x for pure TS — acceptable for a zero-compile solution.
NtQueryDirectoryFile (the same NT API the old C++ addon used) returns multiple directory entries per syscall in a packed 64KB buffer, avoiding per-entry syscall overhead. Buffer parsing is done entirely in JS. Benchmark (Samsung 970 EVO NVMe, Windows 11): | Directory | Old Native | koffi NtQuery | koffi FindFirst | |--------------|-----------|---------------|-----------------| | src/ (1.9k) | 8.8ms | 13.0ms | 19.1ms | | ext/ (3.9k) | 22.1ms | 42.7ms | 48.9ms | | n_m/ (89k) | 341ms | 724ms | 1005ms | Now ~1.5-2.1x the old native addon, down from 2.2-2.9x with FindFirst.
Benchmarking revealed smaller buffers are faster because koffi marshals the entire buffer across the FFI boundary each call. A 1KB buffer minimizes that overhead while still fitting multiple entries per call. Results now match or beat the old C++ addon: src/ (1.9k): 8.8ms native → 7.0ms koffi ext/ (3.9k): 22.1ms native → 20.4ms koffi n_m/ (89k): 341ms native → 383ms koffi
- Add 9 tests covering recursive walk, skipHidden, recurse:false, non-existent dirs, file sizes, mtime, batched callbacks, and paths - Fix error handling: check GetLastError after CreateFileW, retry on sharing violations (matching C++ behavior), throw on unexpected errors when skipInaccessible is false - Implement details option: call NtQueryInformationFile with FileAllInformation to get linkCount, id, and idStr (same as C++) - Reuse buffer and ioStatus across directories to avoid per-dir allocs - Fix stale comment referencing FindFirstFileW
|
I'll be a bit pedantic, but I wouldn't say it's a pure TS solution, we still have a native dependency, it's just out of our maintenance scope now. |
|
This PR has conflicts. You need to rebase the PR before it can be merged. |
|
This PR doesn't have conflicts anymore. It can be merged after all status checks have passed and it has been reviewed. |
|
This PR has conflicts. You need to rebase the PR before it can be merged. |
|
This PR doesn't have conflicts anymore. It can be merged after all status checks have passed and it has been reviewed. |
❌ Code is not formattedTo fix this:
Then commit the formatting changes. View logs |
|
This PR has conflicts. You need to rebase the PR before it can be merged. |
|
This PR has been marked as stale due to inactivity. |
Summary
turbowalkpackage (which wrapswinapi-bindings'WalkDir()on Windows) with a local workspace package atpackages/turbowalkfs.readdir+ concurrentlstat(64 workers)FILE_ATTRIBUTE_HIDDENon Windows via NT directory info (not dot-prefix convention)Bluebird<void>to match the type signature callers expectturbowalk) so all 30+ import sites continue to work unchangedpackage.jsonfiles fromcatalog:toworkspace:*and removed the catalog entryflatpak/generated-sources.jsonand the dependency reportpnpm-workspace.yamlallowBuilds (koffi ships prebuilds for all platforms — no C++ toolchain needed)Technical approach: Win32 API selection
Why
NtQueryDirectoryFileinstead ofFindFirstFileW/FindNextFileWThe old C++ addon (
winapi-bindings) usedNtQueryDirectoryFilefromntdll.dll— the low-level NT kernel directory enumeration API. This is the same API thatFindFirstFileWcalls internally, but with a critical difference: it returns multiple directory entries per syscall in a caller-provided buffer.Each entry in the buffer is a variable-length
FILE_FULL_DIR_INFORMATIONstruct containing:FileAttributes(hidden, directory, reparse point flags)EndOfFile(file size)LastWriteTime(modification time as FILETIME)FileName(variable-length UTF-16)This means a single
NtQueryDirectoryFilecall replaces what would be N separateFindNextFileWcalls, and no separatelstat()/GetFileAttributesW()calls are needed — all metadata comes back inline.We initially implemented the koffi walk using
FindFirstFileW/FindNextFileW, which was 2.2-2.9x slower than the old native addon because each entry required its own FFI round-trip. Switching toNtQueryDirectoryFileeliminated that per-entry overhead.The 1KB buffer size
The old C++ addon used a 1KB buffer (
static const unsigned int BUFFER_SIZE = 1024inwalk.cpp). We initially used 64KB thinking "bigger buffer = fewer syscalls = faster." Benchmarking showed the opposite:Why smaller is faster: koffi marshals the entire buffer across the FFI boundary on every
NtQueryDirectoryFilecall. With a 64KB buffer, it copies 64KB of data even if the kernel only filled a few hundred bytes of entries. The 1KB buffer minimizes this marshaling overhead while still fitting multiple entries per syscall (a typical entry with a short filename is ~100 bytes, so 1KB fits ~8-10 entries per call).The sweet spot is 1-4KB. We use 1KB to match the proven C++ implementation.
Other Win32 APIs considered
FindFirstFileW/FindNextFileW: Higher-level Win32 API. One entry per call, each crossing the FFI boundary. 2.2-2.9x slower than native.GetFileInformationByHandleExwithFileIdBothDirectoryInfo: Another bulk API, but requires opening a handle first and doesn't offer meaningful advantages overNtQueryDirectoryFile.NtQueryDirectoryFilewithFileFullDirectoryInformation: What we use. Same API the old C++ addon used. Returns attributes, size, and timestamps inline — no separate stat calls needed.Benchmark (NVMe — Samsung 970 EVO 1TB, Windows 11, Node 24)
All times are median of 5-10 runs on a warm filesystem cache.
The koffi FFI path matches or beats the old C++ addon on typical directory sizes (~1k-10k entries). The small regression on the 89k-entry stress test is from residual FFI call overhead (one
CreateFileW+ NNtQueryDirectoryFile+ oneCloseHandleper directory).Resolves APP-405
Test plan
pnpm installsucceeds with workspace package resolutionIEntry/IWalkOptions/Bluebird<void>return types — API-compatibleFILE_ATTRIBUTE_HIDDENproperly checked via NT directory info