diff --git a/docs/learn/dev-file.html b/docs/learn/dev-file.html index a9b68b20..f86f49b5 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;   // 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()
     {
    -    int r;
    +    long r;
         do
         {
             r = Volatile.Read(ref _refs);
    @@ -139,28 +140,32 @@ 

    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: an in-flight read on the old snapshot can never hit 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.

    • 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 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

    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/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 8ccf5b2a..aed165e8 100644
    --- a/src/clients/ioxide.file/AssetCache.cs
    +++ b/src/clients/ioxide.file/AssetCache.cs
    @@ -19,9 +19,8 @@ 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
    +    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.
         public string RootDir { get; }
    @@ -97,13 +96,13 @@ public bool TryGet(ReadOnlySpan urlPath, out Asset asset)
         /// 
         internal bool TryAddRef()
         {
    -        int r;
    +        long r;
             do
             {
                 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 fb074873..20e0efc3 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 , ,  and 
    +/// throw 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,49 @@ 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.
    +    /// (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 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.
    +            // A Reload swaps before it releases, so only the snapshot still live can refuse for good.
                 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 +79,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..cd94be1d 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());
    @@ -87,6 +86,138 @@ 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 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());
    +            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));
    +            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();
    +            Assert.True(ExecInheritance.Target(asset.Fd) != asset.Path,
    +                $"the snapshot kept fd {asset.Fd} open after its last lease was released");
    +        });
    +
    +        // 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));
    +            }
    +        });
    +
    +        // 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());
    +            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
    @@ -112,7 +243,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));
             }