VFS close/duplicate lifetime audit — 2026-09-21
Status: open. CRT termination issues stream closes (f903f56b), file
duplicates have independent identities (800a543a), and closed VFS handles
now reject ordinary I/O. Public error propagation and broader lifetime semantics
remain incomplete; do not call complete FILE/handle lifetime support finished.
Initial source findings (before the file-duplication fix)
VirtualFS.closeHandleretains file records withclosed=true, justified by a comment about an NSIS extraction thread using the installer after close.- Ordinary
readFile/writeFile/setFilePointerlook up the retained record without enforcing its closed flag.flushFileBuffersdoes check it. DuplicateHandlecreates real console/current-thread aliases but simply copies ordinary file-handle values. CRT_dupalso returns the original nonnegative value. Those are not independently closable handles.- File duplicates must share the underlying file position while retaining
distinct handle lifetimes. Separate
CreateFilecalls instead have separate positions. See Microsoft's DuplicateHandle contract and CRT duplicate-descriptor contract.
Exploratory NSIS A/B
No production source was patched. A temporary Node preload counted close and
subsequent read/write/seek calls. Its strict arm deleted the file handle on
close, rather than retaining the tombstone. The reusable version is now
tools/probe-vfs-close.js, explicitly loaded by --require; strict closure
requires the additional --vfs-close-strict argument. It is not shipping code.
| Route | Matching VFS name/size rows | Closes per arm | Read/write/seek after close |
|---|---|---|---|
Winamp 2.91 /S, 8,000 batches × 5,000 blocks |
64 | 27 | 0 / 0 / 0 |
Winamp 2.95 /S, same budget |
64 | 27 | 0 / 0 / 0 |
Winamp 2.95 /S, --threads, 800,000-batch ceiling |
184 | 159 | 0 / 0 / 0 |
All three pairs have identical sorted VFS name/size listings and no reported crash/unimplemented API. The longer 2.95 route reaches host exit code 0 after 9,717 batches with 81,075 API calls in both arms. The shorter routes stop at their budget; expected EXE/MP3/output plugin sizes match the existing regression: 2.91 = 846848 / 141312 / 13824 bytes; 2.95 = 854016 / 274944 / 13824 bytes. These are size comparisons, not byte-content hashes.
--threads configures Worker execution but does not prove the silent route
created a guest worker. The interactive 2.95 recipe was therefore also tried
with --threads --trace-thread. Both arms fail to leave License Agreement:
control 1000 appears at batch 575, Next is posted at 584, and the Installation
Options / Folder / Installing Files waits time out at 2394 / 4214 / 6034.
Both end at 25,986 batches with 12,296 API calls, one close and zero observed
post-close I/O. No extraction-worker creation was established. This is an
inconclusive negative control, not a passed interactive/Worker regression.
The probes used --no-build and the current shared worktree. Other agents had
uncommitted host/runtime changes, including VFS handle-number changes. Treat
these results as exploratory, not an isolated clean-commit A/B or a performance
benchmark. Initial default-batch-size runs were superseded by the explicit
5,000-block runs above and are not counted as completed extraction checks.
Reproduce the silent arms (omit the final strict flag for the baseline):
node --require=./tools/probe-vfs-close.js test/run.js \
--exe=test/binaries/installers/winamp295.exe --args=/S \
--max-batches=8000 --batch-size=5000 --max-seconds=60 \
--dump-vfs --quiet-api --no-build --vfs-close-strict
Longer configured-Worker arm: add --threads, change the batch ceiling to
800000 and wall guard to 30 seconds. Interactive route omits /S, uses a
45-second guard, and adds:
--threads --trace-thread
--input=1:wait-dlg-control:1000:6500,10:post-cmd:1,20:wait-title:Installation_Options:1800,30:post-cmd:1,40:wait-title:Installation_Folder:1800,50:post-cmd:1,60:wait-title:Installing_Files:1800,20000:post-cmd:1
Local exploratory logs: /private/tmp/wa-close-{291,295}-{baseline,strict}-5000.log,
/private/tmp/wa-close-295-worker-{baseline,strict}.log, and
/private/tmp/wa-close-295-interactive-{baseline,strict}.log. These temporary
artifacts are not assumed durable; the recipes and observations above are.
Next implementation order
- Separate file-handle identity from shared open-file state; wire real file
duplication through
DuplicateHandleand_dup. Test shared position, independent close, close-source, invalid handles and retained data. - Enforce close across read/write/seek/size/time/mapping paths, including the documented invalid-handle error, double close and pending provider reads. A valid duplicate or mapping must outlive closure of the source handle.
- Re-run clean CLI and browser NSIS extraction, establishing an actual guest worker before claiming the original workaround unnecessary there. Repair the wizard baseline separately if it still cannot advance.
No evidence here justifies preserving the workaround as correct Win98 behavior, nor claiming that deleting the handle alone completes the lifetime model.
File-duplication implementation checkpoint — 2026-09-21
DuplicateHandle now dispatches recognized VFS files through a real host
duplication operation; CRT _dup uses the same operation with SAME_ACCESS.
Each duplicate has its own handle, closed flag, access metadata and inheritance
flag. A shared position object is attached lazily on the first duplication,
so independent opens keep independent positions and duplicate chains all share
one cursor. File handles are allocated without colliding with existing values.
The host import signature mirror was regenerated for Worker RPC.
Recognized closed sources and fabricated high-namespace file handles fail;
unknown option bits and access escalation fail with Win32 errors. CLOSE_SOURCE
is honored on both successful duplication and recognized-file errors. _dup
returns -1 with EBADF (or EMFILE for exhausted handle space), not a fake alias.
Non-file kernel alias handling remains outside this fix.
test/test-duplicate-handle.js exercises the real WAT handlers and real VFS:
distinct identities; reads/seeks shared across Win32 and CRT duplicates;
independent positions from separate opens; source close leaves its duplicate
usable; duplicate-of-closed fails; transfer/close-source; access and option
failures; CRT errno; and cdecl/stdcall stack cleanup. Existing current-thread
pseudo-handle tests still pass. CRT stream-close and termination-callback suites
also pass. The quiet-handler census remains 250 + 22 (the old _dup body was
not part of that straight-line census).
Remaining limits are explicit: ordinary post-close VFS I/O is still accepted until the next lifetime change; this checkpoint tests independent closed state, not enforcement through every I/O API. General cross-process duplication, null-target/null-output compatibility, kernel-object aliases, descriptor-number allocation and standard CRT descriptors remain incomplete. Access metadata is preserved/restricted at duplication, but comprehensive generic/specific-rights mapping and I/O access enforcement are not established. No real Worker/browser duplication test or native Win98 differential is claimed.
Full shared-worktree build passes: canonical 1,471,730 bytes, compatibility
1,472,704 bytes, layout 54f430b349c8d55e, 246 host imports and 242
nonoverlapping data segments. These are integration results on the shared tree,
not isolated performance measurements or clean-commit artifact proofs.
Closed-file enforcement checkpoint — 2026-09-21
One getOpenFile lookup now rejects tombstones for VFS read/write/seek, truncate,
size, time and flush operations. The host bridges also reject zero-length
read/write and positional reads before guest-buffer access, and reject creation
of a new mapping through a closed file. Double close of a known file returns
failure. This removes the NSIS-specific permission to continue I/O after close,
consistent with Microsoft's CloseHandle invalidation contract.
test/test-vfs.js adds a closed-operation matrix proving no buffer, file bytes
or shared cursor changes on rejection, while a valid duplicate still reads.
test/test-crt-close.js now requires actual CRT-closed handles to reject I/O,
and verifies that a mapping created before source-handle close can still create
a view containing the original bytes. Mapping allocation/free ownership hunks
already dirty in the worktree were not changed by this work.
A current-production (no diagnostic preload) Winamp 2.95 /S extraction reaches
exit 0, 9,717 batches and 81,075 API calls under the new policy. EXE/MP3/output
plugin sizes remain 854016 / 274944 / 13824 bytes. Command:
node test/run.js --exe=test/binaries/installers/winamp295.exe --args=/S \
--max-batches=800000 --batch-size=5000 --max-seconds=30 \
--dump-vfs --quiet-api --no-build
Temporary output: /private/tmp/wa-close-enforced-295.log. The run uses the
shared tree and previously compiled artifact; this change is in host JavaScript.
It is not a browser/actual-Worker extraction or a timing benchmark.
Next: error propagation at public Win32 front doors. Several existing BOOL/
sentinel-return bridges do not carry the precise VFS error back to per-thread
GetLastError; rejection alone is not full API correctness. Known closed-file
records are still retained as tombstones, so reclamation remains open. General
kernel-handle validation, deletion/rename of mapped backing files, and closing
during outstanding provider I/O also need dedicated coverage.
Verification: VFS 32/32, lazy-provider 36/36, file-time, duplicate-handle, CRT-close and legacy-HFILE suites pass; JavaScript syntax and diff checks pass. No new full WASM build was required or claimed for these host-only changes.