From 62a62815bf5493043b5973f334aed3ccbd87027c Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Wed, 4 Nov 2026 19:16:52 +0000 Subject: [PATCH 1/3] file: Dispose is idempotent and ends Reload, and the live reference counts 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. --- docs/learn/dev-file.html | 40 ++++++++---- src/clients/ioxide.file/AssetCache.cs | 9 +-- src/clients/ioxide.file/StaticAssets.cs | 48 ++++++++++---- tests/Ioxide.Tests.File/LeaseTests.cs | 83 +++++++++++++++++++++++++ 4 files changed, 150 insertions(+), 30 deletions(-) diff --git a/docs/learn/dev-file.html b/docs/learn/dev-file.html index a9b68b20..f80c3066 100644 --- a/docs/learn/dev-file.html +++ b/docs/learn/dev-file.html @@ -62,10 +62,11 @@

AssetCache · AssetCache.cs

skipped too, and on Linux that is any name starting with a dot, so dot-files and a .well-known/ directory are not served.
  • Refcounting - the snapshot is reference-counted (TryAddRef / - Release). The live reference counts as 230 and each lease as 1, so lease - releases alone, even a lease disposed twice, never take the live snapshot to zero. It is actually - freed - descriptors closed - only once a reload or Dispose has dropped the live - reference and the last lease is gone. This is what makes reloads safe (below).
  • + Release). The live reference counts as 248 and each lease as 1, so lease + releases alone cannot take the live snapshot to zero: even a lease disposed twice on every request + would take years at a million requests a second. With each lease disposed once, it is freed - + descriptors closed - only once a reload or Dispose has dropped the live reference and + the last lease is gone. This is what makes reloads safe (below).

    AssetCache.cs · building the snapshot - one descriptor, path and length per file, no bytes read

    public readonly record struct Asset(int Fd, string Path, long Length);
    @@ -105,12 +106,12 @@ 

    AssetCache · AssetCache.cs

    AttributesToSkip = FileAttributes.ReparsePoint | FileAttributes.Hidden | FileAttributes.System, };

    AssetCache.cs · a lock-free refcount - the snapshot frees only when the last hold drops

    -
    private const int LiveRef = 1 << 30;   // lease releases alone never reach 0
    -private int _refs = LiveRef;           // plus one per lease
    +
    private const long LiveRef = 1L << 48;   // lease releases alone cannot reach 0
    +private long _refs = LiveRef;            // plus one per lease
     
     internal bool TryAddRef()
     {
    -    int r;
    +    long r;
         do
         {
             r = Volatile.Read(ref _refs);
    @@ -140,27 +141,31 @@ 

    StaticAssets · StaticAssets.cs

    A reloadable holder around an AssetCache. Reload builds a fresh snapshot, swaps it in atomically (Interlocked.Exchange), and drops the live reference on the old one - which is then freed only once every outstanding lease on it is released. No timer, no grace - window: an in-flight read on the old snapshot can never hit a closed fd.

    + window: with each lease disposed once, an in-flight read on the old snapshot never hits a closed fd. + Dispose drops the live snapshot the same way, under the same lock as a reload, and is + idempotent; after it Acquire throws ObjectDisposedException and + Reload does nothing, so a reload signalled during shutdown cannot bring the assets back.

    • Acquire / Lease - Acquire() returns a Lease (a refcounted hold) on the live snapshot; the handler holds it for the whole request - lookup → ring read → send - and disposes it when the response is fully sent. TryAddRef fails if the snapshot is already releasing, so Acquire simply re-reads the live one. A reload swaps the live snapshot before it releases the old one, so a refusal from the snapshot that is - still live means Dispose freed it, and Acquire throws - ObjectDisposedException instead of retrying.
    • + still live means it was freed under it, and Acquire throws + ObjectDisposedException instead of retrying, as it does once the assets are disposed.

    StaticAssets.cs · Acquire leases the live snapshot; Reload swaps a fresh one in atomically

    public Lease Acquire()
     {
         while (true)
         {
    -        AssetCache cache = Volatile.Read(ref _cache);
    +        AssetCache? cache = Volatile.Read(ref _cache);
    +        ObjectDisposedException.ThrowIf(cache is null, this);   // disposed
             if (cache.TryAddRef())
             {
                 return new Lease(cache);
             }
    -        // refused: swapped out by a reload (re-read the live one), or freed by Dispose if still live
    +        // refused: swapped out by a reload (re-read the live one), or freed under it if still live
             ObjectDisposedException.ThrowIf(cache == Volatile.Read(ref _cache), this);
         }
     }
    @@ -169,6 +174,7 @@ 

    StaticAssets · StaticAssets.cs

    { lock (_reloadLock) { + if (_cache is null) return; // disposed: nothing to reload AssetCache fresh; try { fresh = new AssetCache(_root); } catch (Exception e) @@ -176,9 +182,17 @@

    StaticAssets · StaticAssets.cs

    Console.Error.WriteLine($"[ioxide] asset reload failed, keeping current snapshot: {e.Message}"); return; } - AssetCache old = Interlocked.Exchange(ref _cache, fresh); + AssetCache old = Interlocked.Exchange(ref _cache, fresh)!; old.Dispose(); // drops the live ref; old frees once every outstanding lease releases } +} + +public void Dispose() +{ + lock (_reloadLock) + { + Interlocked.Exchange(ref _cache, null)?.Dispose(); // idempotent; a reload in progress finishes first + } }

    AssetReader · AssetReader.cs

    diff --git a/src/clients/ioxide.file/AssetCache.cs b/src/clients/ioxide.file/AssetCache.cs index 8ccf5b2a..3d4cc6fb 100644 --- a/src/clients/ioxide.file/AssetCache.cs +++ b/src/clients/ioxide.file/AssetCache.cs @@ -19,9 +19,10 @@ public sealed class AssetCache : IDisposable private readonly Dictionary _assets; private readonly SafeFileHandle[] _handles; - // The live reference counts as 2^30, so lease releases alone, even a double one, never reach 0. - private const int LiveRef = 1 << 30; - private int _refs = LiveRef; // plus one per lease + // The live reference counts as 2^48, so lease releases alone, even one disposed twice on every request, cannot + // reach 0 in any real lifetime (2^48 of them: years at a million a second), and leaks cannot overflow it. + private const long LiveRef = 1L << 48; + private long _refs = LiveRef; // plus one per lease /// The absolute root directory the cache was built over. public string RootDir { get; } @@ -97,7 +98,7 @@ public bool TryGet(ReadOnlySpan urlPath, out Asset asset) /// internal bool TryAddRef() { - int r; + long r; do { r = Volatile.Read(ref _refs); diff --git a/src/clients/ioxide.file/StaticAssets.cs b/src/clients/ioxide.file/StaticAssets.cs index fb074873..899bf81c 100644 --- a/src/clients/ioxide.file/StaticAssets.cs +++ b/src/clients/ioxide.file/StaticAssets.cs @@ -7,15 +7,17 @@ namespace ioxide.file; /// /// /// To serve safely across a reload, take a with and hold -/// it for the whole request (lookup → ring read → send): the old snapshot's descriptors are freed -/// only once every lease on it is released, so an in-flight read can never hit a closed fd. +/// it for the whole request (lookup → ring read → send), and dispose it once: the old snapshot's +/// descriptors are freed only once every lease on it is released, so an in-flight read never hits a +/// closed fd. After , throws and does +/// nothing; leases already out keep the last snapshot open until they are released. /// public sealed class StaticAssets : IDisposable { private readonly string _root; private readonly object _reloadLock = new(); - private AssetCache _cache; // swapped atomically by Reload(), read with Volatile + private AssetCache? _cache; // swapped atomically by Reload(), read with Volatile; null once disposed public StaticAssets(string rootDir) { @@ -23,41 +25,51 @@ public StaticAssets(string rootDir) _cache = new AssetCache(rootDir); } - public int Count => Volatile.Read(ref _cache).Count; + public int Count => Live.Count; - public string RootDir => Volatile.Read(ref _cache).RootDir; + public string RootDir => Live.RootDir; + + private AssetCache Live => Volatile.Read(ref _cache) ?? throw new ObjectDisposedException(nameof(StaticAssets)); /// /// Lease the live snapshot for the duration of a request. The snapshot it points at stays alive /// (descriptors open) until the lease is disposed, even across a concurrent . /// Dispose it when the response has been fully sent. /// - /// This was disposed and its snapshot freed. + /// This was disposed. public Lease Acquire() { while (true) { - AssetCache cache = Volatile.Read(ref _cache); + AssetCache? cache = Volatile.Read(ref _cache); + ObjectDisposedException.ThrowIf(cache is null, this); if (cache.TryAddRef()) { return new Lease(cache); } - // Refused. A Reload swaps the live snapshot before releasing the old one, so a refusal from the - // one still live means Dispose freed it and nothing will replace it; otherwise re-read the live one. + // Refused. A Reload swaps the live snapshot before releasing the old one, so a refusal from the one + // still live means it was freed under us (2^48 releases too many) and nothing will replace it; otherwise + // re-read the live one. ObjectDisposedException.ThrowIf(cache == Volatile.Read(ref _cache), this); } } /// /// Rebuild the snapshot from disk and swap it in atomically, then release the old one (it frees - /// once all leases on it are dropped). If the rebuild fails, the current snapshot is kept. - /// Thread-safe. + /// once all leases on it are dropped). If the rebuild fails, the current snapshot is kept. After + /// it does nothing, so a reload signalled during shutdown cannot bring the + /// assets back. Thread-safe. /// public void Reload() { lock (_reloadLock) { + if (_cache is null) + { + return; + } + AssetCache fresh; try { @@ -69,12 +81,22 @@ public void Reload() return; } - AssetCache old = Interlocked.Exchange(ref _cache, fresh); + AssetCache old = Interlocked.Exchange(ref _cache, fresh)!; old.Dispose(); // drops the live reference; old frees when outstanding leases release } } - public void Dispose() => Volatile.Read(ref _cache).Dispose(); + /// + /// Drop the live snapshot, which frees once the leases on it are released. Idempotent; under the reload lock, + /// so a reload in progress finishes first and its snapshot is the one dropped. + /// + public void Dispose() + { + lock (_reloadLock) + { + Interlocked.Exchange(ref _cache, null)?.Dispose(); + } + } /// /// A reference-counted hold on an snapshot. Look up assets through it diff --git a/tests/Ioxide.Tests.File/LeaseTests.cs b/tests/Ioxide.Tests.File/LeaseTests.cs index 35c04229..f0004d17 100644 --- a/tests/Ioxide.Tests.File/LeaseTests.cs +++ b/tests/Ioxide.Tests.File/LeaseTests.cs @@ -87,6 +87,89 @@ public static void Register(Runner runner) $"a reload with no lease out kept the old snapshot's fd {unleased.Fd} open"); assets.Dispose(); }); + + // A reload signalled during shutdown (SIGHUP, say) runs after Dispose, with a request's lease still out. It + // must not bring the assets back, and the snapshot that lease holds must still close when it is released. + runner.Test("file: after Dispose, Reload does nothing and Acquire throws, and a lease still out keeps its snapshot", () => + { + var assets = new StaticAssets(LeaseAssets()); + StaticAssets.Lease held = assets.Acquire(); + Assert.True(held.TryGet("/a.txt", out AssetCache.Asset asset), "the snapshot should hold /a.txt"); + + assets.Dispose(); + assets.Reload(); + Assert.Throws(() => assets.Acquire().Dispose(), nameof(StaticAssets)); // after Dispose and a Reload + Assert.Equal(asset.Path, ExecInheritance.Target(asset.Fd)); // the lease still reads its file + + held.Dispose(); + Assert.True(ExecInheritance.Target(asset.Fd) != asset.Path, + $"the snapshot kept fd {asset.Fd} open after its last lease was released"); + }); + + // Each Dispose used to subtract the live reference again: a 32-bit count wrapped on the fourth back to a + // live-looking value, and Acquire handed out leases on the freed snapshot. + runner.Test("file: Dispose can be called any number of times", () => + { + string root = LeaseAssets(); + for (int times = 1; times <= 6; times++) + { + var assets = new StaticAssets(root); + for (int i = 0; i < times; i++) + { + assets.Dispose(); + } + Assert.Throws(() => assets.Acquire().Dispose(), nameof(StaticAssets)); + } + }); + + // Acquire's retry is for exactly this: a snapshot that a concurrent Reload swapped out refuses the lease, + // and the next read finds the new one. None of that may surface as an exception or a failed lookup. + runner.Test("file: Acquire racing Reload always gets a lease on a whole snapshot", () => + { + var assets = new StaticAssets(LeaseAssets()); + int failures = 0; + long leases = 0; + bool stop = false; + + var acquirers = new Thread[3]; + for (int t = 0; t < acquirers.Length; t++) + { + acquirers[t] = new Thread(() => + { + while (!Volatile.Read(ref stop)) + { + try + { + using StaticAssets.Lease lease = assets.Acquire(); + if (!lease.TryGet("/a.txt", out _)) + { + Interlocked.Increment(ref failures); + } + Interlocked.Increment(ref leases); + } + catch + { + Interlocked.Increment(ref failures); + } + } + }) { IsBackground = true }; + acquirers[t].Start(); + } + + for (int i = 0; i < 5_000; i++) + { + assets.Reload(); + } + Volatile.Write(ref stop, true); + foreach (Thread acquirer in acquirers) + { + Assert.True(acquirer.Join(TimeSpan.FromSeconds(5)), "an Acquire racing Reload never returned"); + } + assets.Dispose(); + + Assert.True(leases > 0, "no lease was taken while reloading"); + Assert.Equal(0, failures); + }); } // Acquire on its own thread, so a spin fails the test instead of hanging the run: whether it returned or From 6c8c2de9db3f20a98c0f50b0f6e77f0517e95c1f Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Thu, 5 Nov 2026 00:13:05 +0000 Subject: [PATCH 2/3] file: the Dispose test holds a lease across the calls, so it fails on 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. --- docs/learn/files.html | 3 ++- src/clients/ioxide.file/AssetCache.cs | 4 +--- src/clients/ioxide.file/StaticAssets.cs | 8 +++----- tests/Ioxide.Tests.File/LeaseTests.cs | 15 ++++++++++----- 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/docs/learn/files.html b/docs/learn/files.html index 3728a779..01dd89a1 100644 --- a/docs/learn/files.html +++ b/docs/learn/files.html @@ -99,7 +99,8 @@

    Atomic reloads

    Snapshots are immutable; deploys swap them whole. StaticAssets.Reload() builds a fresh AssetCache, swaps it in atomically, and drops its reference to the old one, whose descriptors close when the last lease on it is disposed - a request always - sees one whole snapshot, and a read in flight never meets a closed descriptor. If the rebuild + sees one whole snapshot, and with each lease disposed once, a read in flight never meets a closed + descriptor. If the rebuild fails, the current snapshot stays. Wire it to SIGHUP and a deploy is kill -HUP $(pidof server):

    PosixSignalRegistration.Create(PosixSignal.SIGHUP, ctx =>
    diff --git a/src/clients/ioxide.file/AssetCache.cs b/src/clients/ioxide.file/AssetCache.cs
    index 3d4cc6fb..54e46687 100644
    --- a/src/clients/ioxide.file/AssetCache.cs
    +++ b/src/clients/ioxide.file/AssetCache.cs
    @@ -19,9 +19,7 @@ public sealed class AssetCache : IDisposable
     
         private readonly Dictionary _assets;
         private readonly SafeFileHandle[] _handles;
    -    // The live reference counts as 2^48, so lease releases alone, even one disposed twice on every request, cannot
    -    // reach 0 in any real lifetime (2^48 of them: years at a million a second), and leaks cannot overflow it.
    -    private const long LiveRef = 1L << 48;
    +    private const long LiveRef = 1L << 48;   // extra releases or leaked leases need years at 1M/s to drain or overflow it
         private long _refs = LiveRef;   // plus one per lease
     
         /// The absolute root directory the cache was built over.
    diff --git a/src/clients/ioxide.file/StaticAssets.cs b/src/clients/ioxide.file/StaticAssets.cs
    index 899bf81c..a53f6966 100644
    --- a/src/clients/ioxide.file/StaticAssets.cs
    +++ b/src/clients/ioxide.file/StaticAssets.cs
    @@ -33,8 +33,8 @@ public StaticAssets(string rootDir)
     
         /// 
         /// Lease the live snapshot for the duration of a request. The snapshot it points at stays alive
    -    /// (descriptors open) until the lease is disposed, even across a concurrent .
    -    /// Dispose it when the response has been fully sent.
    +    /// (descriptors open) until the lease is disposed, even across a concurrent ,
    +    /// as long as every lease is disposed once. Dispose it when the response has been fully sent.
         /// 
         /// This was disposed.
         public Lease Acquire()
    @@ -48,9 +48,7 @@ public Lease Acquire()
                     return new Lease(cache);
                 }
     
    -            // Refused. A Reload swaps the live snapshot before releasing the old one, so a refusal from the one
    -            // still live means it was freed under us (2^48 releases too many) and nothing will replace it; otherwise
    -            // re-read the live one.
    +            // A Reload swaps before it releases, so only the snapshot still live can refuse for good.
                 ObjectDisposedException.ThrowIf(cache == Volatile.Read(ref _cache), this);
             }
         }
    diff --git a/tests/Ioxide.Tests.File/LeaseTests.cs b/tests/Ioxide.Tests.File/LeaseTests.cs
    index f0004d17..bf9e60a7 100644
    --- a/tests/Ioxide.Tests.File/LeaseTests.cs
    +++ b/tests/Ioxide.Tests.File/LeaseTests.cs
    @@ -42,8 +42,7 @@ public static void Register(Runner runner)
                 Assert.True(found, "the lease Acquire returned should find /a.txt");
             });
     
    -        // Dispose frees the live snapshot and nothing replaces it, so a retry that waits for a Reload's swap
    -        // would re-read the freed snapshot forever. A second Dispose takes its count below zero.
    +        // After Dispose there is no snapshot left to lease, and a second Dispose changes nothing.
             runner.Test("file: Acquire after Dispose throws ObjectDisposedException, also after a second Dispose", () =>
             {
                 var assets = new StaticAssets(LeaseAssets());
    @@ -106,18 +105,24 @@ public static void Register(Runner runner)
                     $"the snapshot kept fd {asset.Fd} open after its last lease was released");
             });
     
    -        // Each Dispose used to subtract the live reference again: a 32-bit count wrapped on the fourth back to a
    -        // live-looking value, and Acquire handed out leases on the freed snapshot.
    +        // Only the first Dispose may drop the live reference, or the lease held across the rest never frees its snapshot.
             runner.Test("file: Dispose can be called any number of times", () =>
             {
                 string root = LeaseAssets();
                 for (int times = 1; times <= 6; times++)
                 {
                     var assets = new StaticAssets(root);
    +                StaticAssets.Lease held = assets.Acquire();
    +                Assert.True(held.TryGet("/a.txt", out AssetCache.Asset asset), "the snapshot should hold /a.txt");
                     for (int i = 0; i < times; i++)
                     {
                         assets.Dispose();
                     }
    +                Assert.Equal(asset.Path, ExecInheritance.Target(asset.Fd));   // the lease still reads its file
    +
    +                held.Dispose();
    +                Assert.True(ExecInheritance.Target(asset.Fd) != asset.Path,
    +                    $"after {times} Dispose calls the snapshot kept fd {asset.Fd} open past its last lease");
                     Assert.Throws(() => assets.Acquire().Dispose(), nameof(StaticAssets));
                 }
             });
    @@ -195,7 +200,7 @@ private static (bool Returned, Exception? Thrown, bool Found) AcquireOnThread(St
             bool returned = acquirer.Join(TimeSpan.FromSeconds(2));
             if (!returned)
             {
    -            assets.Reload();   // the one thing that ends the spin, so the thread doesn't keep a core for the rest of the run
    +            assets.Reload();   // ends a spin on a live instance, so the thread doesn't keep a core for the rest of the run
                 acquirer.Join(TimeSpan.FromSeconds(2));
             }
     
    
    From fe172f65da7b8e767de3f3169596729bfc44a324 Mon Sep 17 00:00:00 2001
    From: Diogo Martins 
    Date: Thu, 5 Nov 2026 20:52:18 +0000
    Subject: [PATCH 3/3] file: a test races Dispose against Reload and checks
     Count and RootDir 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.
    ---
     docs/learn/dev-file.html                |  8 ++--
     src/clients/ioxide.file/AssetCache.cs   |  2 +-
     src/clients/ioxide.file/StaticAssets.cs |  4 +-
     tests/Ioxide.Tests.File/LeaseTests.cs   | 53 ++++++++++++++++++++++---
     4 files changed, 55 insertions(+), 12 deletions(-)
    
    diff --git a/docs/learn/dev-file.html b/docs/learn/dev-file.html
    index f80c3066..f86f49b5 100644
    --- a/docs/learn/dev-file.html
    +++ b/docs/learn/dev-file.html
    @@ -106,7 +106,7 @@ 

    AssetCache · AssetCache.cs

    AttributesToSkip = FileAttributes.ReparsePoint | FileAttributes.Hidden | FileAttributes.System, };

    AssetCache.cs · a lock-free refcount - the snapshot frees only when the last hold drops

    -
    private const long LiveRef = 1L << 48;   // lease releases alone cannot reach 0
    +
    private const long LiveRef = 1L << 48;   // extra releases or leaked leases need years at 1M/s to drain or overflow it
     private long _refs = LiveRef;            // plus one per lease
     
     internal bool TryAddRef()
    @@ -140,8 +140,8 @@ 

    AssetCache · AssetCache.cs

    StaticAssets · StaticAssets.cs

    A reloadable holder around an AssetCache. Reload builds a fresh snapshot, swaps it in atomically (Interlocked.Exchange), and drops the live reference on the old - one - which is then freed only once every outstanding lease on it is released. No timer, no grace - window: with each lease disposed once, an in-flight read on the old snapshot never hits a closed fd. + one. With each lease disposed once, the old snapshot is freed only when the last lease on it is + released, so with no timer and no grace window, an in-flight read on it never hits a closed fd. Dispose drops the live snapshot the same way, under the same lock as a reload, and is idempotent; after it Acquire throws ObjectDisposedException and Reload does nothing, so a reload signalled during shutdown cannot bring the assets back.

    @@ -151,7 +151,7 @@

    StaticAssets · StaticAssets.cs

    send - and disposes it when the response is fully sent. TryAddRef fails if the snapshot is already releasing, so Acquire simply re-reads the live one. A reload swaps the live snapshot before it releases the old one, so a refusal from the snapshot that is - still live means it was freed under it, and Acquire throws + still live means extra lease releases drained it, and Acquire throws ObjectDisposedException instead of retrying, as it does once the assets are disposed.

    StaticAssets.cs · Acquire leases the live snapshot; Reload swaps a fresh one in atomically

    diff --git a/src/clients/ioxide.file/AssetCache.cs b/src/clients/ioxide.file/AssetCache.cs index 54e46687..aed165e8 100644 --- a/src/clients/ioxide.file/AssetCache.cs +++ b/src/clients/ioxide.file/AssetCache.cs @@ -102,7 +102,7 @@ internal bool TryAddRef() r = Volatile.Read(ref _refs); if (r <= 0) { - return false; // freed, or below zero after a second Dispose - don't resurrect + return false; // freed, or below zero after extra lease releases - don't resurrect } } while (Interlocked.CompareExchange(ref _refs, r + 1, r) != r); diff --git a/src/clients/ioxide.file/StaticAssets.cs b/src/clients/ioxide.file/StaticAssets.cs index a53f6966..20e0efc3 100644 --- a/src/clients/ioxide.file/StaticAssets.cs +++ b/src/clients/ioxide.file/StaticAssets.cs @@ -9,8 +9,8 @@ namespace ioxide.file; /// To serve safely across a reload, take a with and hold /// it for the whole request (lookup → ring read → send), and dispose it once: the old snapshot's /// descriptors are freed only once every lease on it is released, so an in-flight read never hits a -/// closed fd. After , throws and does -/// nothing; leases already out keep the last snapshot open until they are released. +/// closed fd. After , , and +/// throw and does nothing; leases already out keep the last snapshot open until they are released. /// public sealed class StaticAssets : IDisposable { diff --git a/tests/Ioxide.Tests.File/LeaseTests.cs b/tests/Ioxide.Tests.File/LeaseTests.cs index bf9e60a7..cd94be1d 100644 --- a/tests/Ioxide.Tests.File/LeaseTests.cs +++ b/tests/Ioxide.Tests.File/LeaseTests.cs @@ -87,8 +87,7 @@ public static void Register(Runner runner) assets.Dispose(); }); - // A reload signalled during shutdown (SIGHUP, say) runs after Dispose, with a request's lease still out. It - // must not bring the assets back, and the snapshot that lease holds must still close when it is released. + // A reload signalled during shutdown runs after Dispose, with a request's lease still out: it must revive nothing. runner.Test("file: after Dispose, Reload does nothing and Acquire throws, and a lease still out keeps its snapshot", () => { var assets = new StaticAssets(LeaseAssets()); @@ -97,7 +96,9 @@ public static void Register(Runner runner) assets.Dispose(); assets.Reload(); - Assert.Throws(() => assets.Acquire().Dispose(), nameof(StaticAssets)); // after Dispose and a Reload + Assert.Throws(() => assets.Acquire().Dispose(), nameof(StaticAssets)); + Assert.Throws(() => _ = assets.Count, nameof(StaticAssets)); + Assert.Throws(() => _ = assets.RootDir, nameof(StaticAssets)); Assert.Equal(asset.Path, ExecInheritance.Target(asset.Fd)); // the lease still reads its file held.Dispose(); @@ -127,8 +128,50 @@ public static void Register(Runner runner) } }); - // Acquire's retry is for exactly this: a snapshot that a concurrent Reload swapped out refuses the lease, - // and the next read finds the new one. None of that may surface as an exception or a failed lookup. + // They share a lock, so a reload in progress finishes first and its snapshot is the one dropped. + runner.Test("file: Dispose racing Reload leaves no descriptor open and nothing to lease", () => + { + string root = LeaseAssets(); + string file = new FileInfo(Path.Combine(root, "a.txt")).FullName; + for (int round = 0; round < 300; round++) + { + var assets = new StaticAssets(root); + int reloads = 0; + bool stop = false; + Exception? thrown = null; + var reloader = new Thread(() => + { + try + { + while (!Volatile.Read(ref stop)) + { + assets.Reload(); + Interlocked.Increment(ref reloads); + } + } + catch (Exception e) + { + thrown = e; + } + }) { IsBackground = true }; + reloader.Start(); + + while (Volatile.Read(ref reloads) == 0 && reloader.IsAlive) + { + Thread.SpinWait(16); // so the Dispose lands on a reload under way + } + assets.Dispose(); + Volatile.Write(ref stop, true); + Assert.True(reloader.Join(TimeSpan.FromSeconds(5)), "a Reload racing Dispose never returned"); + + Assert.True(thrown is null, $"Reload threw {thrown?.GetType().Name} in round {round}"); + Assert.Throws(() => assets.Acquire().Dispose(), nameof(StaticAssets)); + HashSet open = ExecInheritance.Referring(file); + Assert.True(open.Count == 0, $"round {round} left fds {string.Join(", ", open)} open on the file"); + } + }); + + // A snapshot a concurrent Reload swapped out refuses the lease, and the next read finds the new one: no exception, no failed lookup. runner.Test("file: Acquire racing Reload always gets a lease on a whole snapshot", () => { var assets = new StaticAssets(LeaseAssets());