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)

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

  1. Separate file-handle identity from shared open-file state; wire real file duplication through DuplicateHandle and _dup. Test shared position, independent close, close-source, invalid handles and retained data.
  2. 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.
  3. 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.