src: load addon images on Windows and delete them again - #10
Merged
mcollina merged 1 commit intoSep 11, 2026
Merged
Conversation
Loading a shared object from bytes never worked on Windows. Materialize() kept its temporary file open with GENERIC_WRITE across the load, and the loader opens a DLL for read and execute while sharing read alone. Sharing is checked in both directions, so an open handle holding write access is refused whatever this side shares: every load failed with ERROR_SHARING_VIOLATION, "The process cannot access the file because it is being used by another process". That is every VFS-resident addon, and with it test-dlopen-binary, test-permission-dlopen-binary and test-vfs-addon. Write the image and close it again before loading, so nothing holds the file when the loader opens it. That exposes the other half. The file cannot be removed while it is loaded: Windows refuses to unlink a file backing a mapped image section, by delete-on-close, by DeleteFile() and by a POSIX-semantics disposition alike, all with ERROR_ACCESS_DENIED. The delete-on-close handle the code retained could therefore never have removed anything; it only appeared to work because the load failed first and the file was deleted on that path. Fixing the load alone leaks an image per addon into the temporary directory. An image can only go once its module is unloaded, and Node keeps addons loaded for the life of the process, so keep each image with the module it was loaded as and release both at exit. Only materialized images are unloaded -- an addon loaded from a real path is untouched. The hook is registered during static initialisation because atexit() runs handlers last-registered-first, which puts it behind every handler registered while running. At exit the delete is attempted before the unload, which doubles as the test for whether the image is still mapped, that being the only thing that can stop it. An FFI library the caller already close()d has been unloaded by uv_dlclose() and its image just goes; unloading it again through the stale module handle would be wrong. With no handle to retain, the delete-on-close file and the read-only reopen go away: Materialize() writes the image, closes it and records its path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mcollina
merged commit Sep 11, 2026
f0084c5
into
mcollina:vfs-ffi-materialize
19 of 31 checks passed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Targeting this branch rather than
main, soffi.dlopen()from a VFS works on Windows before it lands. The fix is inAddonImage::Materialize()/AfterOpen(), which this branch moves but does not otherwise change, so it applies equally tomainif you would rather it went there first.Loading from bytes has never worked on Windows
Materialize()keeps its temporary file open withGENERIC_WRITEacross the load. The loader opens a DLL for read and execute while sharing read alone, and sharing is checked in both directions, so an open handle holding write access is refused whatever this side shares. Every load fails withERROR_SHARING_VIOLATION:On
mainthat istest-dlopen-binary,test-permission-dlopen-binaryandtest-vfs-addon, all failing on Windows today. On this branch it is also everyffi.dlopen()of a library inside a mounted VFS, since it goes through the same path.The fix is to write the image and close it again before loading, so nothing holds the file when the loader opens it.
Fixing the load exposes that the cleanup never worked either
Windows will not unlink a file backing a mapped image section. Measured on Windows 11, with the DLL loaded:
DeleteFileW()ERROR_ACCESS_DENIEDFileDispositionInfoEx, POSIX semanticsERROR_ACCESS_DENIEDFreeLibrary(), then close the handleSo the retained delete-on-close handle could never have removed anything. It only looked correct because the load failed first and the file went on the failure path, where nothing had mapped it. Fixing the load alone leaks one image per addon into
%TEMP%, which is why this is one commit and not two.An image can only go once its module is unloaded, and Node keeps addons loaded for the life of the process, so each image is kept with the module it was loaded as and both are released at exit. Only materialized images are unloaded; an addon loaded from a real path is untouched. The hook is registered during static initialisation, because
atexit()runs handlers last-registered-first and registering beforemain()puts it behind every handler registered while running.At exit the delete is attempted before the unload. That doubles as the test for whether the image is still mapped, that being the only thing that can stop it: an FFI library the caller already
close()d has been unloaded byuv_dlclose()and its image just goes, where unloading it again through the stale module handle would be wrong.test-ffi-vfs.jscallslib.close(), so this path is exercised.With no handle to retain, the delete-on-close file and the read-only reopen both go away:
Materialize()writes the image, closes it, and records its path.Tests
test-dlopen-binary-image-cleanup.jsloads an addon from bytes repeatedly in a child process withTMPDIR/TMP/TEMPpointed at a private directory, then asserts after the child exits that no image is left behind. It covers each platform's contract: on Linux the image is a memfd, so it also counts/proc/self/fdacross the loads to catch a descriptor leak, which the on-disk assertion cannot see; on other POSIX and on Windows the directory must be empty afterwards.Verification
Windows 11 26200, VS 2022, clang-cl 19.1.5, x64 release:
addons,js-native-api,node-api,ffi,sea, plus the four dlopen/VFS tests: 245/245, no failuresffi/test-ffi-vfspasses, so this branch's feature works on Windowstest-dlopen-binary,test-permission-dlopen-binary,test-vfs-addonpass, having failed beforeWorth a look in review
FreeLibrary()at exit runs the addon'sDllMain(DLL_PROCESS_DETACH). Registering the hook beforemain()puts it behind everything registered at runtime and every test passes, but that is evidence rather than proof, and an addon that misbehaves at detach would do so here.close()would mean plumbing fromDynamicLibraryback intoAddonImage, which seemed out of scope.%TEMP%is per-user, unlike the/tmpthe POSIX path hardens against withmkdtemp(0700)andO_EXCL|O_NOFOLLOW.One note on the description of this PR: "Windows retains the delete-on-close handle for the process lifetime, exactly as for addons" is no longer accurate, and the addon behaviour it referred to did not work.
🤖 Generated with Claude Code