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; namespace YKanBan.Tests.Storage.Workspace;
/// <summary> /// <summary>
/// Tests for exclusive lock acquisition, diagnostics and release. /// Tests for exclusive lock acquisition and release.
/// </summary> /// </summary>
[TestClass] [TestClass]
public class WorkspaceLockTests public class WorkspaceLockTests
@@ -42,7 +42,7 @@ public class WorkspaceLockTests
} }
[TestMethod] [TestMethod]
public void LockFileContainsHolderDiagnosticsAfterRelease() public void LockFileIsEmptyAfterRelease()
{ {
using var directory = new TempDirectory(); using var directory = new TempDirectory();
WorkspaceInitializer.Initialize(directory.FullPath); WorkspaceInitializer.Initialize(directory.FullPath);
@@ -51,9 +51,7 @@ public class WorkspaceLockTests
{ {
} }
// The file survives release and carries PID / machine name / time for humans. // The lock file survives release but carries no content; only the OS handle matters.
string content = File.ReadAllText(WorkspacePaths.LockFile(directory.FullPath)); Assert.AreEqual(0L, new FileInfo(WorkspacePaths.LockFile(directory.FullPath)).Length);
StringAssert.Contains(content, "pid=");
StringAssert.Contains(content, $"machine={Environment.MachineName}");
} }
} }
+2 -26
View File
@@ -72,39 +72,15 @@ public sealed class WorkspaceLockException : YKanBanException
/// </summary> /// </summary>
public string LockFilePath { get; } 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> /// <summary>
/// Initializes the exception for a failed lock acquisition. /// Initializes the exception for a failed lock acquisition.
/// </summary> /// </summary>
/// <param name="lockFilePath">The lock file that is already held.</param> /// <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> /// <param name="innerException">The underlying I/O error.</param>
public WorkspaceLockException(string lockFilePath, string? holderDiagnostics, Exception innerException) public WorkspaceLockException(string lockFilePath, Exception innerException)
: base(BuildMessage(lockFilePath, holderDiagnostics), innerException) : base($"The workspace is locked by another instance: {lockFilePath}", innerException)
{ {
LockFilePath = lockFilePath; 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; namespace YKanBan.Storage.Workspace;
/// <summary> /// <summary>
/// Exclusive, OS-enforced lock over a workspace's ykanban.lock. The handle is /// Exclusive, OS-enforced lock over a workspace's ykanban.lock. The handle is
/// held until disposal, so crashes and power loss release it automatically. /// held until disposal, so crashes and power loss release it automatically. The
/// The lock file also carries PID / machine name / time for human diagnostics. /// lock file is kept empty; only its exclusive OS handle carries meaning.
/// </summary> /// </summary>
public sealed class WorkspaceLock : IDisposable public sealed class WorkspaceLock : IDisposable
{ {
@@ -28,7 +26,7 @@ public sealed class WorkspaceLock : IDisposable
public string LockFilePath { get; } public string LockFilePath { get; }
/// <summary> /// <summary>
/// Acquires the workspace lock, writing this process's diagnostics into it. /// Acquires the workspace lock, leaving the lock file empty.
/// </summary> /// </summary>
/// <param name="folderPath">The workspace folder to lock.</param> /// <param name="folderPath">The workspace folder to lock.</param>
/// <returns>The held lock; dispose it to release.</returns> /// <returns>The held lock; dispose it to release.</returns>
@@ -40,7 +38,7 @@ public sealed class WorkspaceLock : IDisposable
FileStream stream; FileStream stream;
try 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); stream = new FileStream(lockFilePath, FileMode.Create, FileAccess.Write, FileShare.None);
} }
catch (DirectoryNotFoundException) catch (DirectoryNotFoundException)
@@ -49,40 +47,12 @@ public sealed class WorkspaceLock : IDisposable
} }
catch (IOException ex) 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); 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> /// <summary>
/// Releases the exclusive handle. The lock file itself is intentionally /// Releases the exclusive handle. The lock file itself is intentionally
/// left on disk; the OS releases the handle even on a crash. /// left on disk; the OS releases the handle even on a crash.