Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions ImageDescriber.Test/StoreLockTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.ImageDescriber.Tests;

using ktsu.ImageDescriber.Verbs;

[TestClass]
public class StoreLockTests
{
private string lockPath = string.Empty;
private string originalLockPath = string.Empty;

[TestInitialize]
public void Initialize()
{
lockPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString(), "ImageDescriber.lock");
originalLockPath = StoreLock.StorePath;
StoreLock.StorePath = lockPath;
}

[TestCleanup]
public void Cleanup()
{
StoreLock.StorePath = originalLockPath;
string directory = Path.GetDirectoryName(lockPath)!;
if (Directory.Exists(directory))
{
Directory.Delete(directory, true);
}
}

[TestMethod]
public void SecondAcquireFailsWhileTheFirstIsHeld()
{
using (StoreLock? first = StoreLock.TryAcquire(lockPath))
{
Assert.IsNotNull(first);
Assert.IsNull(StoreLock.TryAcquire(lockPath), "A second run took the lock while the first still held it");
}

using StoreLock? again = StoreLock.TryAcquire(lockPath);
Assert.IsNotNull(again, "The lock was not released when the first run ended");
}

[TestMethod]
public void VerbsThatSaveTheStoreHoldTheLock()
{
Assert.IsTrue(new Scan().WritesStore);
Assert.IsTrue(new Import().WritesStore);
Assert.IsTrue(new Configure().WritesStore);
Assert.IsFalse(new Search().WritesStore);
Assert.IsFalse(new Stats().WritesStore);
Assert.IsFalse(new Export().WritesStore);
}

[TestMethod]
public void AWritingVerbRefusesToRunWhileAnotherRunHoldsTheLock()
{
TextWriter originalOut = Console.Out;
using StringWriter output = new();
using StoreLock? held = StoreLock.TryAcquire(lockPath);
Assert.IsNotNull(held);

try
{
Console.SetOut(output);

// The input file does not exist, so if the verb ran at all it would say so, and it
// would never reach a save.
new Import { InputPath = Path.Combine(Path.GetDirectoryName(lockPath)!, "missing.json") }.Run();
}
finally
{
Console.SetOut(originalOut);
}

string text = output.ToString();
Assert.Contains(StoreLock.BusyMessage, text);
Assert.DoesNotContain("File not found", text);
}
}
53 changes: 53 additions & 0 deletions ImageDescriber/StoreLock.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.ImageDescriber;

using System.IO;

using ktsu.AppDataStorage;

/// <summary>
/// An exclusive, cross-process hold on the description store for the length of one run.
/// </summary>
/// <remarks>
/// Every run loads the whole store and saves its whole in-memory copy back, so two runs that write
/// at the same time each overwrite what the other added. A run that writes takes this lock first
/// and reloads the store under it; a second one fails fast instead of silently losing work.
/// </remarks>
internal sealed class StoreLock : IDisposable
{
internal const string BusyMessage = "Another ImageDescriber run is updating the description store. Wait for it to finish and try again.";

/// <summary>
/// The file a run that writes to the store locks, kept beside the store itself.
/// </summary>
internal static string StorePath { get; set; } = Path.Join(AppData.Path.WeakString, "ImageDescriber.lock");

private readonly FileStream stream;

private StoreLock(FileStream stream) => this.stream = stream;

/// <summary>
/// Takes the lock held by the file at <paramref name="path"/>, creating it if needed.
/// </summary>
/// <returns>The held lock, or <see langword="null"/> when another run holds it.</returns>
internal static StoreLock? TryAcquire(string path)
{
string? directory = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}

try
{
return new(new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None));
}
catch (IOException)
{
return null;
}
}

public void Dispose() => stream.Dispose();
}
31 changes: 30 additions & 1 deletion ImageDescriber/Verbs/BaseVerb.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ internal OllamaEndpoint Endpoint

internal virtual bool ValidateArgs() => true;

/// <summary>
/// Whether the verb saves the store, and so has to hold <see cref="StoreLock"/> while it runs.
/// </summary>
internal virtual bool WritesStore => false;

public void Execute() => Run();
}

Expand All @@ -55,9 +60,33 @@ public override void Run()
}

isActive = false;
Run((T)this);

if (WritesStore)
{
RunHoldingStoreLock();
}
else
{
Run((T)this);
}

isActive = true;
}

private void RunHoldingStoreLock()
{
using StoreLock? storeLock = StoreLock.TryAcquire(StoreLock.StorePath);
if (storeLock is null)
{
Console.WriteLine(StoreLock.BusyMessage);
return;
}

// Another run may have saved since this process loaded the store, and this run's saves
// write back the whole copy, so start from what is on disk now.
Program.Settings = PersistentState.LoadOrCreate();
Run((T)this);
}

internal abstract void Run(T options);
}
2 changes: 2 additions & 0 deletions ImageDescriber/Verbs/Configure.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ namespace ktsu.ImageDescriber.Verbs;
[Verb("Configure", HelpText = "Configure the Ollama endpoint and model settings.")]
internal sealed class Configure : BaseVerb<Configure>
{
internal override bool WritesStore => true;

internal override void Run(Configure options)
{
Console.WriteLine("Current Settings:");
Expand Down
2 changes: 2 additions & 0 deletions ImageDescriber/Verbs/Import.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ namespace ktsu.ImageDescriber.Verbs;
[Verb("Import", HelpText = "Import descriptions from a JSON or CSV file.")]
internal sealed class Import : BaseVerb<Import>
{
internal override bool WritesStore => true;

private static readonly JsonSerializerOptions JsonOptions = new()
{
Converters = { new RoundTripStringJsonConverterFactory() },
Expand Down
2 changes: 2 additions & 0 deletions ImageDescriber/Verbs/Scan.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ namespace ktsu.ImageDescriber.Verbs;
[Verb("Scan", HelpText = "Scan a directory for images, describe them using Ollama, and store results.")]
internal sealed class Scan : BaseVerb<Scan>
{
internal override bool WritesStore => true;

internal override bool ValidateArgs()
{
if (PathString is "." or "")
Expand Down
Loading