refactor: keep the workspace lock file empty

This commit is contained in:
yyc12345 committed 2026-10-04 09:58:09 +08:00
1 parent d8a4dd6190
commit 8a7c6fe8d6
3 files changed
+11 -67

No files matched your search

@@ -5,7 +5,7 @@ using YKanBan.Tests.TestUtilities;
namespace YKanBan.Tests.Storage.Workspace;
/// <summary>
/// Tests for exclusive lock acquisition, diagnostics and release.
/// Tests for exclusive lock acquisition and release.
/// </summary>
[TestClass]
public class WorkspaceLockTests
@@ -42,7 +42,7 @@ public class WorkspaceLockTests
}
[TestMethod]
public void LockFileContainsHolderDiagnosticsAfterRelease()
public void LockFileIsEmptyAfterRelease()
{
using var directory = new TempDirectory();
WorkspaceInitializer.Initialize(directory.FullPath);
@@ -51,9 +51,7 @@ public class WorkspaceLockTests
{
}
// The file survives release and carries PID / machine name / time for humans.
string content = File.ReadAllText(WorkspacePaths.LockFile(directory.FullPath));
StringAssert.Contains(content, "pid=");
StringAssert.Contains(content, $"machine={Environment.MachineName}");
// The lock file survives release but carries no content; only the OS handle matters.
Assert.AreEqual(0L, new FileInfo(WorkspacePaths.LockFile(directory.FullPath)).Length);
}
}
+2 -26
View File
@@ -72,39 +72,15 @@ public sealed class WorkspaceLockException : YKanBanException
/// </summary>
public string LockFilePath { get; }
/// <summary>
/// Gets the best-effort holder diagnostics read from the lock file, or
/// <see langword="null"/> when they could not be read.
/// </summary>
public string? HolderDiagnostics { get; }
/// <summary>
/// Initializes the exception for a failed lock acquisition.
/// </summary>
/// <param name="lockFilePath">The lock file that is already held.</param>
/// <param name="holderDiagnostics">Diagnostics of the current holder, when readable.</param>
/// <param name="innerException">The underlying I/O error.</param>
public WorkspaceLockException(string lockFilePath, string? holderDiagnostics, Exception innerException)
: base(BuildMessage(lockFilePath, holderDiagnostics), innerException)
public WorkspaceLockException(string lockFilePath, Exception innerException)
: base($"The workspace is locked by another instance: {lockFilePath}", innerException)
{
LockFilePath = lockFilePath;
HolderDiagnostics = holderDiagnostics;
}
/// <summary>
/// Composes the diagnostic message, appending holder details when they were readable.
/// </summary>
/// <param name="lockFilePath">The lock file that is already held.</param>
/// <param name="holderDiagnostics">Diagnostics of the current holder, when readable.</param>
/// <returns>The English diagnostic message.</returns>
private static string BuildMessage(string lockFilePath, string? holderDiagnostics)
{
string message = $"The workspace is locked by another instance: {lockFilePath}";
if (!string.IsNullOrEmpty(holderDiagnostics))
{
message += $" Lock holder: {holderDiagnostics}";
}
return message;
}
}
+5 -35
View File
@@ -1,11 +1,9 @@
using System.Text;
namespace YKanBan.Storage.Workspace;
/// <summary>
/// Exclusive, OS-enforced lock over a workspace's ykanban.lock. The handle is
/// held until disposal, so crashes and power loss release it automatically.
/// The lock file also carries PID / machine name / time for human diagnostics.
/// held until disposal, so crashes and power loss release it automatically. The
/// lock file is kept empty; only its exclusive OS handle carries meaning.
/// </summary>
public sealed class WorkspaceLock : IDisposable
{
@@ -28,7 +26,7 @@ public sealed class WorkspaceLock : IDisposable
public string LockFilePath { get; }
/// <summary>
/// Acquires the workspace lock, writing this process's diagnostics into it.
/// Acquires the workspace lock, leaving the lock file empty.
/// </summary>
/// <param name="folderPath">The workspace folder to lock.</param>
/// <returns>The held lock; dispose it to release.</returns>
@@ -40,7 +38,7 @@ public sealed class WorkspaceLock : IDisposable
FileStream stream;
try
{
// FileShare.None gives the exclusive semantics; FileMode.Create truncates stale content.
// FileShare.None gives the exclusive semantics; FileMode.Create empties any stale file.
stream = new FileStream(lockFilePath, FileMode.Create, FileAccess.Write, FileShare.None);
}
catch (DirectoryNotFoundException)
@@ -49,40 +47,12 @@ public sealed class WorkspaceLock : IDisposable
}
catch (IOException ex)
{
throw new WorkspaceLockException(lockFilePath, ReadDiagnosticsBestEffort(lockFilePath), ex);
throw new WorkspaceLockException(lockFilePath, ex);
}
// Write holder diagnostics so humans can identify the owning instance from the file alone.
string diagnostics = $"pid={Environment.ProcessId};machine={Environment.MachineName};time={DateTimeOffset.UtcNow:O}";
byte[] payload = Encoding.UTF8.GetBytes(diagnostics);
stream.Write(payload, 0, payload.Length);
stream.Flush();
return new WorkspaceLock(stream, lockFilePath);
}
/// <summary>
/// Attempts to read holder diagnostics from a lock file we failed to
/// acquire. The read is best-effort: a live FileShare.None holder makes the
/// file unreadable, in which case <see langword="null"/> is returned.
/// </summary>
/// <param name="lockFilePath">The lock file to read.</param>
/// <returns>The diagnostics text, or <see langword="null"/> when unreadable.</returns>
private static string? ReadDiagnosticsBestEffort(string lockFilePath)
{
try
{
return File.ReadAllText(lockFilePath);
}
catch (IOException)
{
return null;
}
catch (UnauthorizedAccessException)
{
return null;
}
}
/// <summary>
/// Releases the exclusive handle. The lock file itself is intentionally
/// left on disk; the OS releases the handle even on a crash.