C#: a write through a live link at the store path is refused; read-under-lock pinned (store-link-cs) (#151)

This commit is contained in:
Libby 2026-11-13 00:36:20 +00:00
commit 73e2131d9a
8 changed files with 674 additions and 32 deletions

View file

@ -710,17 +710,23 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
// Only a writer creates the records folder and the lock, so opening the store creates nothing. A reader opens an
// existing lock and returns null when there is none; that does not mean no writer committed (a store may be copied
// without its lock, and a first writer may create it mid-load), so an unleased load checks that it settled.
// A writer's lease is also where a link at the manifest path is refused (a commit would replace the link with a file): before
// the records folder or the lock is made, and again once the lease is held, because a link can be put there while it waited.
private FileStream? AcquireCommitLease(bool wait, bool create)
{
if (create)
{
CultStoreLinks.Refuse(_manifestFile.FullName);
Directory.CreateDirectory(_recordDirectory.FullName);
}
var lockPath = Path.Combine(_recordDirectory.FullName, ".commit.lock");
var started = Stopwatch.StartNew();
while (true)
{
FileStream held;
try
{
return new FileStream(
held = new FileStream(
lockPath,
create ? FileMode.OpenOrCreate : FileMode.Open,
FileAccess.ReadWrite,
@ -743,7 +749,23 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
catch (IOException) when (started.Elapsed < TimeSpan.FromSeconds(30))
{
Thread.Sleep(10);
continue;
}
if (create)
{
try
{
CultStoreLinks.Refuse(_manifestFile.FullName);
}
catch
{
held.Dispose();
throw;
}
}
return held;
}
}

View file

@ -5132,7 +5132,7 @@ namespace GameCult.Caching
var attributes = FileInfo.Attributes;
if ((int)attributes == -1)
return null;
if ((attributes & FileAttributes.Directory) != 0 && (attributes & FileAttributes.ReparsePoint) == 0)
if ((attributes & FileAttributes.Directory) != 0 && Directory.Exists(FileInfo.FullName))
throw new IOException("The store path is a directory, not a store file.");
try
{
@ -5154,25 +5154,27 @@ namespace GameCult.Caching
}
// Something is at the path even when the path cannot be opened: a link keeps the reparse-point attribute whether or not its
// target is there. A path with nothing at it reports no attributes at all (-1).
private bool PathIsALink()
{
FileInfo.Refresh();
var attributes = FileInfo.Attributes;
return (int)attributes != -1 && (attributes & FileAttributes.ReparsePoint) != 0;
}
// target is there.
private bool PathIsALink() => CultStoreLinks.IsLink(FileInfo.FullName);
// A write never goes through a link at the store path: replacing it would silently detach the store from its link.
private void RefuseLinkedStore() => CultStoreLinks.Refuse(FileInfo.FullName);
// The lock is a sidecar opened exclusively, which excludes other handles in this process and in others alike.
// Every write takes the lock first, so the link is refused here twice: before the lock file or anything else is made, and
// again once the lock is held, because a link can be put at the path while this writer waited on another's lock.
private FileStream? AcquireLock(bool wait)
{
RefuseLinkedStore();
Directory.CreateDirectory(FileInfo.DirectoryName!);
var lockPath = FileInfo.FullName + ".lock";
var started = Stopwatch.StartNew();
while (true)
{
FileStream held;
try
{
return new FileStream(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None, bufferSize: 1);
held = new FileStream(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None, bufferSize: 1);
}
catch (IOException) when (!wait)
{
@ -5181,7 +5183,20 @@ namespace GameCult.Caching
catch (IOException) when (started.Elapsed < TimeSpan.FromSeconds(30))
{
Thread.Sleep(10);
continue;
}
try
{
RefuseLinkedStore();
}
catch
{
held.Dispose();
throw;
}
return held;
}
}

View file

@ -0,0 +1,24 @@
using System.IO;
namespace GameCult.Caching
{
/// <summary>
/// A write was attempted at a store path that is a link (a symbolic link to a file or a directory, a Windows junction or any
/// other reparse-point link). The write is refused before the lock, the staging file or the replace, and again once the lock
/// is held, so a link that exists when a writer takes the lock leaves the link and its target untouched. A link created by
/// something that ignores the lock after that check, while the commit builds and writes the new image, is still replaced;
/// that window cannot be closed without native no-follow opens. Configure the link's target as the store path to write to
/// it; reads through a live link are unchanged.
/// </summary>
public sealed class CultStoreLinkedException : IOException
{
public CultStoreLinkedException(string path)
: base($"CultCache store {path} is a link, and a write at it would replace the link; configure the link's target as the store path.")
{
Path = path;
}
/// <summary>The store path that is a link.</summary>
public string Path { get; }
}
}

View file

@ -0,0 +1,22 @@
using System.IO;
namespace GameCult.Caching
{
// The one decision on whether a store-owned file is a link, shared by every store kind that replaces files.
internal static class CultStoreLinks
{
// A path with nothing at it reports no attributes at all (-1). Any reparse point counts as a link, whether or not its
// target is there.
internal static bool IsLink(string path)
{
var attributes = new FileInfo(path).Attributes;
return (int)attributes != -1 && (attributes & FileAttributes.ReparsePoint) != 0;
}
internal static void Refuse(string path)
{
if (IsLink(path))
throw new CultStoreLinkedException(path);
}
}
}