Skip to content

file: Dispose is idempotent and ends Reload, and the live reference counts as 2^48 - #494

Open
MDA2AV wants to merge 3 commits into
mainfrom
fix/file-assets-dispose
Open

MDA2AV wants to merge 3 commits into
mainfrom
fix/file-assets-dispose

Conversation

@MDA2AV

@MDA2AV MDA2AV commented Nov 4, 2026 •

Copy link
Copy Markdown
Owner

Follow-up to #483, from a review of it after merge. Both problems below are reproduced on main 58620b5.

What was wrong

Reload after Dispose brought the assets back, and leaked the old snapshot

  • StaticAssets.Dispose ran outside the reload lock and left _cache pointing at the snapshot it dropped, and Reload never checked for disposal.
  • A reload signalled during shutdown (the documented SIGHUP wiring, say) therefore swapped in a fresh snapshot, and dropped the old one's live reference a second time.
  • Afterwards, Acquire handed out leases again, and the snapshot a request's lease still held was never freed.
  • Repro: hold a lease, Dispose, Reload, release the lease. The old descriptor stays open, and Acquire returns a lease.
  • Dispose was not idempotent either. Each call subtracted the live reference again, the 32-bit count wrapped on the fourth call, and Acquire then leased the freed snapshot.

The 2^30 cushion ran out

The fix

  • Dispose swaps the snapshot out under the reload lock (_cache becomes null) and drops its live reference once.
    • A second Dispose finds nothing to drop.
    • A reload already in progress finishes first, and its snapshot is the one dropped.
    • Reload does nothing once the assets are disposed.
    • Acquire throws ObjectDisposedException. Its null check takes the place of the null check the JIT already did.
  • The count is a long, and the live reference counts as 2^48. A double dispose on every request, or leaked leases, would take years at a million a second to reach either end. The Interlocked operations are the same single instructions on x64 and arm64.
  • Acquire keeps file: a lease disposed twice no longer frees the live snapshot, and Acquire after Dispose throws instead of spinning (#481) #483's check, and a refusal from the snapshot that is still live still throws rather than spins.
  • The docs and XML comments say "with each lease disposed once" where the guarantee needs it.

Behaviour

  • Acquire throws as soon as the assets are disposed, even while leases are out. Before, it kept leasing the dropped snapshot until those leases drained. The leases already out keep their snapshot open until they are released.
  • Reload after Dispose does nothing, where before it revived the assets.
  • Count and RootDir throw ObjectDisposedException after Dispose. Before, they returned the dropped snapshot's values.
  • Dispose can be called any number of times.

API

None. Behaviour only, as above.

Tests

New in the File suite (LeaseTests.cs):

  • file: after Dispose, Reload does nothing and Acquire throws, and a lease still out keeps its snapshot
  • file: Dispose can be called any number of times
    • A lease is held across one to six calls. Releasing it has to free the snapshot, and Acquire has to throw.
    • On main the second call already takes the live reference off again, so the lease's release never frees the snapshot.
  • file: Acquire racing Reload always gets a lease on a whole snapshot
  • file: Dispose racing Reload leaves no descriptor open and nothing to lease
    • 300 rounds of a thread reloading while the test disposes. No reload may throw, Acquire has to throw, and no descriptor may stay open on the file.
    • With Dispose outside the reload lock, the reload throws NullReferenceException in the first round.

file: after Dispose, Reload does nothing... also checks that Count and RootDir throw.

Against main's ioxide.file (the tests only):

FAIL  file: after Dispose, Reload does nothing and Acquire throws, and a lease still out keeps its snapshot: expected ObjectDisposedException (StaticAssets), but nothing was thrown
FAIL  file: Dispose can be called any number of times: after 2 Dispose calls the snapshot kept fd 71 open past its last lease
10 passed, 2 failed, 0 skipped

With the fix:

  • File: 13 passed.
  • E2E: 453 passed.
  • Http: 95 passed.

The 2^48 cushion has no test, since it would take 2^48 iterations to reach. The repro above (2^30 + 1 double disposes) shows the old limit.

Bench

None. Acquire, TryAddRef and Release run the same instructions on a long. The only addition is a null check that replaces the JIT's own.

…ounts as 2^48, so neither a stray Reload nor a recurring double dispose brings back the old failures

Follow-up to #483, from review:
- Reload after Dispose (a SIGHUP during shutdown, say) swapped in a new snapshot and dropped the
  live reference a second time: StaticAssets served again, and the snapshot a lease still held was
  never freed. Dispose now swaps the snapshot out under the reload lock; Reload does nothing once it
  is gone, Acquire throws, and a second Dispose finds nothing to drop. A 32-bit count used to wrap
  on the fourth Dispose and hand out leases on the freed snapshot.
- The live reference counted as 2^30, so a double dispose on every request still freed the live
  snapshot after 2^30 of them (about 18 minutes at a million a second), under leases still out,
  and 2^30 leaked leases overflowed the count. As a long counting 2^48, either takes years.
- Acquire throws as soon as the assets are disposed, even while leases are out; those keep their
  snapshot open until they are released.
- Docs and the XML comments say "with each lease disposed once" where the guarantee needs it.
- Tests: Reload after Dispose with a lease out; one to six Disposes; Acquire racing 5,000 Reloads,
  which fails if the refusal check throws unconditionally.
… a Dispose that is not idempotent

The test disposed one to six times and checked that Acquire threw, which a long count with a
Dispose that still subtracted per call passed too. It now holds a lease across the calls and checks
that releasing it frees the snapshot; main and that mutation both keep the descriptor open after
two calls.

Also from review: Acquire's summary and the files guide add "each lease disposed once" to the
promise about closed descriptors, and the comments this change had made untrue are fixed and cut to
one line.
…r throw, and the comments and docs say what the count can do

A double disposed lease is the case that drains the live reference, not a second Dispose of the assets, which Dispose no longer repeats.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant