diff --git a/src/DynamoCore/Graph/Workspaces/Locking/GraphLockFile.cs b/src/DynamoCore/Graph/Workspaces/Locking/GraphLockFile.cs
index f642fb58405..0af0a8d3241 100644
--- a/src/DynamoCore/Graph/Workspaces/Locking/GraphLockFile.cs
+++ b/src/DynamoCore/Graph/Workspaces/Locking/GraphLockFile.cs
@@ -8,6 +8,17 @@
namespace Dynamo.Graph.Workspaces.Locking
{
+ ///
+ /// Describes the outcome of a call.
+ ///
+ internal enum GraphLockReadResult
+ {
+ NotFound,
+ Ok,
+ Corrupt,
+ TransientFailure
+ }
+
///
/// Provides file-system operations for graph lock sidecar files.
///
@@ -24,6 +35,12 @@ internal static class GraphLockFile
TypeNameHandling = TypeNameHandling.None
});
+ private static bool IsValidLockInfo(GraphLockInfo info) =>
+ info != null
+ && info.SessionId != Guid.Empty
+ && !string.IsNullOrEmpty(info.GraphPath)
+ && info.ProcessId > 0;
+
///
/// Gets the sidecar lock path for a graph file.
///
@@ -65,10 +82,13 @@ internal static bool TryCreateNewLockFile(string sidecarPath, GraphLockInfo info
/// The sidecar lock file path.
/// The parsed lock metadata when reading succeeds.
/// True if metadata was read; otherwise false.
- internal static bool TryRead(string sidecarPath, out GraphLockInfo info)
+ internal static GraphLockReadResult TryRead(string sidecarPath, out GraphLockInfo info)
{
info = null;
+ if (!File.Exists(sidecarPath))
+ return GraphLockReadResult.NotFound;
+
const int maxAttempts = 2;
for (var attempt = 0; attempt < maxAttempts; attempt++)
{
@@ -81,29 +101,25 @@ internal static bool TryRead(string sidecarPath, out GraphLockInfo info)
info = Serializer.Deserialize(jsonReader);
}
- return info != null;
+ return IsValidLockInfo(info) ? GraphLockReadResult.Ok : GraphLockReadResult.Corrupt;
}
- catch (Exception ex) when (ex is IOException || ex is JsonException)
+ catch (Exception ex) when (ex is JsonException)
{
- if (attempt == maxAttempts - 1)
- {
- return false;
- }
-
- // Wait for 50 milliseconds before retrying
- Thread.Sleep(50);
+ return GraphLockReadResult.Corrupt;
}
- catch (UnauthorizedAccessException)
+ catch (Exception ex) when (ex is IOException)
{
- return false;
+ if (attempt == maxAttempts - 1)
+ return GraphLockReadResult.TransientFailure;
+ Thread.Sleep(50);
}
- catch (SecurityException)
+ catch (Exception ex) when (ex is UnauthorizedAccessException || ex is SecurityException)
{
- return false;
+ return GraphLockReadResult.TransientFailure;
}
}
- return false;
+ return GraphLockReadResult.TransientFailure;
}
///
diff --git a/src/DynamoCore/Graph/Workspaces/Locking/GraphLockManager.cs b/src/DynamoCore/Graph/Workspaces/Locking/GraphLockManager.cs
index 7096210b41d..e543275ddd5 100644
--- a/src/DynamoCore/Graph/Workspaces/Locking/GraphLockManager.cs
+++ b/src/DynamoCore/Graph/Workspaces/Locking/GraphLockManager.cs
@@ -15,8 +15,9 @@ namespace Dynamo.Graph.Workspaces.Locking
///
internal sealed class GraphLockManager : IDisposable
{
- internal const int DefaultHeartbeatMilliseconds = 30000;
+ internal const int DefaultHeartbeatMilliseconds = 15000;
private const int StaleFactor = 3;
+ private const int MaxConsecutiveReadFailures = 3;
private readonly DynamoModel dynamoModel;
private readonly StringComparer pathComparer;
@@ -37,6 +38,7 @@ private sealed class OwnedLock
internal string SidecarPath { get; set; }
internal GraphLockInfo Info { get; set; }
internal WorkspaceModel Workspace { get; set; }
+ internal int ConsecutiveReadFailures { get; set; }
}
///
@@ -192,22 +194,28 @@ private GraphLockAcquireResult AcquireLockInternal(string normalizedPath, bool a
}
// A lock file already exists: read it to find out who owns it
- GraphLockInfo existingLock;
- var readable = GraphLockFile.TryRead(sidecarPath, out existingLock);
+ var readResult = GraphLockFile.TryRead(sidecarPath, out var existingLock);
- var lockResult = TryAcquireReadableLock(normalizedPath, sidecarPath, info, workspace, readable, existingLock);
+ if (readResult == GraphLockReadResult.NotFound)
+ {
+ // The sidecar was deleted between TryCreateNewLockFile failing and TryRead.
+ // Retry the loop — TryCreateNewLockFile will succeed on the next attempt.
+ continue;
+ }
+
+ var lockResult = TryAcquireReadableLock(normalizedPath, sidecarPath, info, workspace, readResult, existingLock);
if (lockResult != null)
{
return lockResult;
}
- // A live (or currently unreadable) lock owns the path.
+ // ExistingLock is Ok and live — a real conflict with another session.
return ResolveLockConflict(normalizedPath, allowPromptUI, workspace, existingLock);
}
catch (Exception ex) when (ex is UnauthorizedAccessException || ex is SecurityException || ex is IOException)
{
dynamoModel.Logger?.Log("GraphLock unavailable: " + ex.Message);
- }
+ }
}
return GraphLockAcquireResult.Unavailable(normalizedPath);
@@ -218,15 +226,26 @@ private GraphLockAcquireResult TryAcquireReadableLock(
string sidecarPath,
GraphLockInfo info,
WorkspaceModel workspace,
- bool readable,
+ GraphLockReadResult readResult,
GraphLockInfo existingLock)
{
- if (!readable)
+ if (readResult == GraphLockReadResult.Corrupt)
{
- return null;
+ // Definitively unreadable — not evidence of a live owner.
+ // Safe to overwrite and take ownership.
+ GraphLockFile.WriteHeartbeat(sidecarPath, info);
+ RegisterOwnedLock(normalizedPath, sidecarPath, info, workspace);
+ return GraphLockAcquireResult.Acquired(normalizedPath);
+ }
+
+ if (readResult == GraphLockReadResult.TransientFailure)
+ {
+ // Could not read due to IO/permissions — a live owner may exist.
+ // Do not steal the lock. Redirect to copy to be safe.
+ dynamoModel.Logger?.Log( "GraphLock: could not read sidecar due to transient failure, redirecting to copy: " + sidecarPath);
+ return GraphLockAcquireResult.Unavailable(normalizedPath);
}
- // It is our own lock (same machine + process): reuse it
if (IsOwnedByThisSession(existingLock))
{
RegisterOwnedLock(normalizedPath, sidecarPath, existingLock, workspace);
@@ -234,10 +253,7 @@ private GraphLockAcquireResult TryAcquireReadableLock(
}
// The lock is expired (no recent heartbeat) or owned by a process on this machine
- // that is no longer running. In these cases the previous owner is gone, so we
- // silently take the lock over. A present but unreadable lock is not reclaimed here,
- // it is treated as a real conflict below, so that a transient read failure cannot
- // make us steal a lock that a live instance still owns.
+ // that is no longer running. Silently take ownership.
if (IsStale(existingLock) || IsDeadLocalProcess(existingLock))
{
GraphLockFile.WriteHeartbeat(sidecarPath, info);
@@ -297,13 +313,13 @@ private GraphLockAcquireResult CreateAndOpenCopy( string sourcePath, string save
else
{
GraphLockInfo saveAsLock;
- var readable = GraphLockFile.TryRead(sidecarPath, out saveAsLock);
- if (readable && IsOwnedByThisSession(saveAsLock))
+ var saveAsReadResult = GraphLockFile.TryRead(sidecarPath, out saveAsLock);
+ if (saveAsReadResult == GraphLockReadResult.Ok && IsOwnedByThisSession(saveAsLock))
{
info = saveAsLock;
ownsSaveAsLock = true;
}
- else if (!readable || IsStale(saveAsLock) || IsDeadLocalProcess(saveAsLock))
+ else if (saveAsReadResult == GraphLockReadResult.Corrupt || IsStale(saveAsLock) || IsDeadLocalProcess(saveAsLock))
{
GraphLockFile.WriteHeartbeat(sidecarPath, info);
ownsSaveAsLock = true;
@@ -341,7 +357,7 @@ private void RegisterOwnedLock(string normalizedPath, string sidecarPath, GraphL
};
}
- private void RefreshHeartbeats(object state)
+ internal void RefreshHeartbeats(object state = null)
{
foreach (var pair in locks.ToList())
{
@@ -349,9 +365,34 @@ private void RefreshHeartbeats(object state)
try
{
GraphLockInfo current;
- if (!GraphLockFile.TryRead(owned.SidecarPath, out current) ||
- current.SessionId != owned.Info.SessionId)
+ if (GraphLockFile.TryRead(owned.SidecarPath, out current) != GraphLockReadResult.Ok)
+ {
+ // Transient IO failure (antivirus, NAS hiccup) — don't drop the lock immediately.
+ // Only abandon after MaxConsecutiveReadFailures consecutive misses.
+ owned.ConsecutiveReadFailures++;
+
+ if (owned.ConsecutiveReadFailures >= MaxConsecutiveReadFailures)
+ {
+ dynamoModel.Logger?.Log(
+ $"GraphLock heartbeat read failed {MaxConsecutiveReadFailures} consecutive times, " +
+ $"dropping lock: {owned.SidecarPath}");
+ locks.TryRemove(pair.Key, out _);
+ }
+ else
+ {
+ dynamoModel.Logger?.Log(
+ $"GraphLock heartbeat read failed (attempt {owned.ConsecutiveReadFailures}), " +
+ $"will retry: {owned.SidecarPath}");
+ }
+ continue;
+ }
+
+ // Successful read — reset the failure counter
+ owned.ConsecutiveReadFailures = 0;
+
+ if (current.SessionId != owned.Info.SessionId)
{
+ // Lock was genuinely stolen by another instance — stop tracking
locks.TryRemove(pair.Key, out _);
continue;
}
@@ -371,7 +412,7 @@ private void ReleaseOwnedLock(string normalizedPath, OwnedLock owned)
try
{
GraphLockInfo current;
- if (GraphLockFile.TryRead(owned.SidecarPath, out current) &&
+ if (GraphLockFile.TryRead(owned.SidecarPath, out current) == GraphLockReadResult.Ok &&
current.SessionId == owned.Info.SessionId)
{
GraphLockFile.TryDelete(owned.SidecarPath);
diff --git a/src/DynamoCoreWpf/Properties/Resources.Designer.cs b/src/DynamoCoreWpf/Properties/Resources.Designer.cs
index 9fe93cdcd4a..b94a6658674 100644
--- a/src/DynamoCoreWpf/Properties/Resources.Designer.cs
+++ b/src/DynamoCoreWpf/Properties/Resources.Designer.cs
@@ -519,6 +519,25 @@ public static string ConfigureADPButtonText {
}
}
+ ///
+ /// Looks up a localized string similar to {0} already exists.
+ ///Do you want to replace it?.
+ ///
+ public static string ConfirmReplaceFileMessage {
+ get {
+ return ResourceManager.GetString("ConfirmReplaceFileMessage", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Confirm Save As.
+ ///
+ public static string ConfirmReplaceFileTitle {
+ get {
+ return ResourceManager.GetString("ConfirmReplaceFileTitle", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Break Connection.
///
@@ -3771,6 +3790,16 @@ public static string GraphLockSaveAsButton {
}
}
+ ///
+ /// Looks up a localized string similar to This file is currently open in another instance of Dynamo.
+ ///To avoid data loss or corruption, please choose a different file name..
+ ///
+ public static string GraphLockSaveAsSameFileMessage {
+ get {
+ return ResourceManager.GetString("GraphLockSaveAsSameFileMessage", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Add Group to This Group.
///
diff --git a/src/DynamoCoreWpf/Properties/Resources.en-US.resx b/src/DynamoCoreWpf/Properties/Resources.en-US.resx
index 6ab8ca5b259..e24dca8cd4b 100644
--- a/src/DynamoCoreWpf/Properties/Resources.en-US.resx
+++ b/src/DynamoCoreWpf/Properties/Resources.en-US.resx
@@ -4313,4 +4313,15 @@ To avoid data loss or corruption, please choose one of the following actions:
Save As
+
+ This file is currently open in another instance of Dynamo.
+To avoid data loss or corruption, please choose a different file name.
+
+
+ {0} already exists.
+Do you want to replace it?
+
+
+ Confirm Save As
+
diff --git a/src/DynamoCoreWpf/Properties/Resources.resx b/src/DynamoCoreWpf/Properties/Resources.resx
index bbd5beea296..54957d694c4 100644
--- a/src/DynamoCoreWpf/Properties/Resources.resx
+++ b/src/DynamoCoreWpf/Properties/Resources.resx
@@ -4300,4 +4300,15 @@ To avoid data loss or corruption, please choose one of the following actions:
Save As
+
+ This file is currently open in another instance of Dynamo.
+To avoid data loss or corruption, please choose a different file name.
+
+
+ {0} already exists.
+Do you want to replace it?
+
+
+ Confirm Save As
+
diff --git a/src/DynamoCoreWpf/PublicAPI.Unshipped.txt b/src/DynamoCoreWpf/PublicAPI.Unshipped.txt
index 7545e42e137..5b7601e4a62 100644
--- a/src/DynamoCoreWpf/PublicAPI.Unshipped.txt
+++ b/src/DynamoCoreWpf/PublicAPI.Unshipped.txt
@@ -33,6 +33,9 @@ Dynamo.ViewModels.PreferencesViewModel.AutoSyncDocumentBrowserIsChecked.get -> b
Dynamo.ViewModels.PreferencesViewModel.AutoSyncDocumentBrowserIsChecked.set -> void
Dynamo.ViewModels.PreferencesViewModel.ShowPythonAutoMigrationNotificationIsChecked.get -> bool
Dynamo.ViewModels.PreferencesViewModel.ShowPythonAutoMigrationNotificationIsChecked.set -> void
+Dynamo.Wpf.UI.CustomSaveFileDialog.FileOk -> event System.ComponentModel.CancelEventHandler
+Dynamo.Wpf.UI.CustomSaveFileDialog.OverwritePrompt.get -> bool
+Dynamo.Wpf.UI.CustomSaveFileDialog.OverwritePrompt.set -> void
Dynamo.Wpf.UI.ToastManager
Dynamo.Wpf.UI.ToastManager.CloseRealTimeInfoWindow() -> void
Dynamo.Wpf.UI.ToastManager.CreateRealTimeInfoWindow(string content, bool stayOpen = false, string headerText = "", string hyperlinkText = "", System.Uri hyperlinkUri = null, System.Uri fileLinkUri = null) -> void
diff --git a/src/DynamoCoreWpf/Services/WpfGraphLockUserPrompt.cs b/src/DynamoCoreWpf/Services/WpfGraphLockUserPrompt.cs
index 47d5e9e259d..e0119efc6af 100644
--- a/src/DynamoCoreWpf/Services/WpfGraphLockUserPrompt.cs
+++ b/src/DynamoCoreWpf/Services/WpfGraphLockUserPrompt.cs
@@ -5,6 +5,7 @@
using System;
using System.IO;
using System.Windows;
+using System.Windows.Forms;
namespace Dynamo.Wpf.Services
{
@@ -15,16 +16,26 @@ internal sealed class WpfGraphLockUserPrompt : IGraphLockUserPrompt
{
private readonly Func ownerProvider;
private readonly string productNameProvider;
+ private readonly Func dialogFactory;
+ private readonly Func showMessageBox;
///
/// Initializes a WPF graph-lock prompt.
///
/// Provides the owner window when a prompt is shown.
- /// Provides the product name for save-dialog filters.
- internal WpfGraphLockUserPrompt(Func ownerProvider, string productNameProvider)
+ /// The product name for save-dialog filters.
+ /// Optional factory for the save dialog; defaults to .
+ /// Optional message box function used for conflict and overwrite prompts.
+ internal WpfGraphLockUserPrompt(
+ Func ownerProvider,
+ string productName,
+ Func dialogFactory = null,
+ Func showMessageBox = null)
{
this.ownerProvider = ownerProvider;
- this.productNameProvider = productNameProvider;
+ this.productNameProvider = productName;
+ this.dialogFactory = dialogFactory ?? (() => new CustomSaveFileDialog());
+ this.showMessageBox = showMessageBox ?? System.Windows.Forms.MessageBox.Show;
}
///
@@ -58,7 +69,7 @@ public GraphLockUserResponse AskUser(string graphPath, GraphLockInfo existingLoc
}
// Shows a Save As dialog for the copy path, matching the graph file extension
- private string ShowSaveAsDialog(string graphPath)
+ internal string ShowSaveAsDialog(string graphPath)
{
var extension = Path.GetExtension(graphPath);
var directory = Path.GetDirectoryName(graphPath);
@@ -69,19 +80,52 @@ private string ShowSaveAsDialog(string graphPath)
? string.Format(Resources.FileDialogDynamoCustomNode, productName, "*.dyf")
: string.Format(Resources.FileDialogDynamoWorkspace, productName, "*.dyn");
- var dialog = new CustomSaveFileDialog
- {
- AddExtension = true,
- DefaultExt = defaultExt,
- Filter = filter,
- FileName = Path.GetFileName(graphPath),
- };
+ var normalizedSourcePath = Path.GetFullPath(graphPath);
+
+ var dialog = dialogFactory();
+ dialog.AddExtension = true;
+ dialog.DefaultExt = defaultExt;
+ dialog.Filter = filter;
+ dialog.FileName = Path.GetFileName(graphPath);
+ dialog.OverwritePrompt = false;
if (Directory.Exists(directory))
{
dialog.InitialDirectory = directory;
}
+ dialog.FileOk += (sender, e) =>
+ {
+ var chosenPath = Path.GetFullPath(dialog.FileName);
+
+ // Block saving over the locked source file
+ if (string.Equals(chosenPath, normalizedSourcePath, StringComparison.OrdinalIgnoreCase))
+ {
+ showMessageBox(
+ Resources.GraphLockSaveAsSameFileMessage,
+ Resources.GraphLockFileAlreadyOpenTitle,
+ MessageBoxButtons.OK,
+ MessageBoxIcon.Warning);
+ e.Cancel = true;
+ return;
+ }
+
+ // OverwritePrompt is false so we must replicate the OS overwrite warning
+ // for any other existing file the user may have chosen.
+ if (File.Exists(chosenPath))
+ {
+ var confirm = showMessageBox(
+ string.Format(Resources.ConfirmReplaceFileMessage, Path.GetFileName(chosenPath)),
+ Resources.ConfirmReplaceFileTitle,
+ MessageBoxButtons.YesNo,
+ MessageBoxIcon.Warning);
+ if (confirm != DialogResult.Yes)
+ {
+ e.Cancel = true;
+ }
+ }
+ };
+
return dialog.ShowDialog() == true ? dialog.FileName : null;
}
}
diff --git a/src/DynamoCoreWpf/UI/CustomSaveFileDialog.cs b/src/DynamoCoreWpf/UI/CustomSaveFileDialog.cs
index 99ebc55b4c8..7dc5a56da91 100644
--- a/src/DynamoCoreWpf/UI/CustomSaveFileDialog.cs
+++ b/src/DynamoCoreWpf/UI/CustomSaveFileDialog.cs
@@ -1,3 +1,4 @@
+using System.ComponentModel;
using System.Windows.Forms;
namespace Dynamo.Wpf.UI
@@ -12,7 +13,9 @@ internal interface IFileSaver
string FileName { get; set; }
public bool AddExtension { get; set; }
public string InitialDirectory { get; set; }
- bool? ShowDialog(); // Returns true if OK, false if Cancel, null for other cases
+ event CancelEventHandler FileOk;
+ bool OverwritePrompt { get; set; }
+ bool? ShowDialog(); // Returns true if OK, false if Cancel, null for other cases
}
///
@@ -52,6 +55,18 @@ public string InitialDirectory
set => _dialog.InitialDirectory = value;
}
+ public event CancelEventHandler FileOk
+ {
+ add => _dialog.FileOk += value;
+ remove => _dialog.FileOk -= value;
+ }
+
+ public bool OverwritePrompt
+ {
+ get => _dialog.OverwritePrompt;
+ set => _dialog.OverwritePrompt = value;
+ }
+
public bool? ShowDialog()
{
DialogResult result = _dialog.ShowDialog();
diff --git a/test/DynamoCoreTests/Graph/Workspaces/GraphLockManagerTests.cs b/test/DynamoCoreTests/Graph/Workspaces/GraphLockManagerTests.cs
index 274105e0684..4e8b64f02b9 100644
--- a/test/DynamoCoreTests/Graph/Workspaces/GraphLockManagerTests.cs
+++ b/test/DynamoCoreTests/Graph/Workspaces/GraphLockManagerTests.cs
@@ -181,7 +181,8 @@ private static GraphLockInfo CreateForeignLockInfo(string graphPath, DateTime la
private static GraphLockInfo ReadLockInfo(string lockPath)
{
- Assert.IsTrue(GraphLockFile.TryRead(lockPath, out var lockInfo));
+ Assert.AreEqual(GraphLockReadResult.Ok, GraphLockFile.TryRead(lockPath, out var lockInfo),
+ "Expected lock file to be readable at: " + lockPath);
return lockInfo;
}
@@ -208,5 +209,111 @@ public GraphLockUserResponse AskUser(string graphPath, GraphLockInfo existingLoc
return response;
}
}
+
+ [Test]
+ [Category("UnitTests")]
+ public void WhenLockFileIsCorruptThenAcquireSucceedsWithoutPrompt()
+ {
+ // Arrange - write a sidecar that cannot be parsed as JSON so TryRead returns false
+ var graphPath = CreateGraphFile("corrupt-lock.dyn");
+ var lockPath = GraphLockFile.GetLockFilePath(graphPath);
+ File.WriteAllText(lockPath, "{ this is not valid json {{{{");
+ var prompt = new TestGraphLockUserPrompt(GraphLockUserResponse.Cancel());
+
+ using (var manager = CreateManager(prompt))
+ {
+ // Act
+ var result = manager.AcquireLock(graphPath, true);
+
+ // Assert - corrupt sidecar is treated as stale, not shown to the user as a conflict
+ Assert.AreEqual(GraphLockOutcome.Opened, result.Conflict);
+ Assert.IsTrue(result.ShouldOpen);
+ Assert.AreEqual(0, prompt.CallCount, "Prompt must not fire for a corrupt lock file");
+
+ manager.CompleteOpen(graphPath, true);
+ manager.Release(graphPath);
+ }
+ }
+
+ [Test]
+ [Category("UnitTests")]
+ public void WhenLockFileIsCorruptThenOwnershipIsTransferredToCurrentSession()
+ {
+ // Arrange
+ var graphPath = CreateGraphFile("corrupt-lock-ownership.dyn");
+ var lockPath = GraphLockFile.GetLockFilePath(graphPath);
+ File.WriteAllText(lockPath, "not valid json at all");
+
+ using (var manager = CreateManager())
+ {
+ // Act
+ var result = manager.AcquireLock(graphPath, true);
+ manager.CompleteOpen(graphPath, true);
+
+ // Assert - sidecar is overwritten with a valid lock owned by this session
+ Assert.AreEqual(GraphLockOutcome.Opened, result.Conflict);
+ var lockInfo = ReadLockInfo(lockPath);
+ Assert.AreEqual(Path.GetFullPath(graphPath), lockInfo.GraphPath);
+ Assert.That(lockInfo.SessionId, Is.Not.EqualTo(Guid.Empty));
+ Assert.That(lockInfo.LastHeartbeatUtc, Is.GreaterThan(DateTime.UtcNow.AddSeconds(-5)));
+
+ manager.Release(graphPath);
+ }
+ }
+
+ [Test]
+ [Category("UnitTests")]
+ public void WhenHeartbeatDetectsSessionMismatchThenForeignLockIsNotDeleted()
+ {
+ // Arrange - acquire, then simulate another instance overwriting the sidecar
+ var graphPath = CreateGraphFile("heartbeat-mismatch.dyn");
+ var lockPath = GraphLockFile.GetLockFilePath(graphPath);
+ var stolenLock = CreateForeignLockInfo(graphPath, DateTime.UtcNow);
+
+ using (var manager = CreateManager(heartbeatMilliseconds: int.MaxValue))
+ {
+ var result = manager.AcquireLock(graphPath, true);
+ manager.CompleteOpen(graphPath, true);
+ Assert.AreEqual(GraphLockOutcome.Opened, result.Conflict);
+
+ // Simulate another instance overwriting our sidecar
+ GraphLockFile.WriteHeartbeat(lockPath, stolenLock);
+
+ // Trigger one heartbeat cycle directly
+ manager.RefreshHeartbeats();
+ }
+
+ // Assert - manager must not delete a sidecar it no longer owns
+ Assert.IsTrue(File.Exists(lockPath));
+ Assert.AreEqual(GraphLockReadResult.Ok, GraphLockFile.TryRead(lockPath, out var remaining),
+ "Expected stolen lock sidecar to still be readable after session mismatch.");
+ Assert.AreEqual(stolenLock.SessionId, remaining.SessionId);
+ }
+
+ [Test]
+ [Category("UnitTests")]
+ public void WhenLiveLockExistsOnRemoteMachineThenPromptIsCalledWithNonNullLockInfo()
+ {
+ // Arrange - verifies that ResolveLockConflict only fires with a fully readable lock,
+ // guarding against the null-prompt bug from a corrupt sidecar
+ var graphPath = CreateGraphFile("live-remote-lock.dyn");
+ var lockPath = GraphLockFile.GetLockFilePath(graphPath);
+ var existingLock = CreateForeignLockInfo(graphPath, DateTime.UtcNow);
+ Assert.IsTrue(GraphLockFile.TryCreateNewLockFile(lockPath, existingLock));
+
+ var prompt = new TestGraphLockUserPrompt(GraphLockUserResponse.Cancel());
+
+ using (var manager = CreateManager(prompt))
+ {
+ // Act
+ manager.AcquireLock(graphPath, true);
+ manager.CompleteOpen(graphPath, false);
+ }
+
+ // Assert - prompt received a non-null, readable lock (not null from a corrupt file)
+ Assert.AreEqual(1, prompt.CallCount);
+ Assert.IsNotNull(prompt.ExistingLock, "Prompt must never receive a null ExistingLock");
+ Assert.AreEqual(existingLock.SessionId, prompt.ExistingLock.SessionId);
+ }
}
}
diff --git a/test/DynamoCoreWpfTests/WpfGraphLockUserPromptTests.cs b/test/DynamoCoreWpfTests/WpfGraphLockUserPromptTests.cs
new file mode 100644
index 00000000000..1746fa1636a
--- /dev/null
+++ b/test/DynamoCoreWpfTests/WpfGraphLockUserPromptTests.cs
@@ -0,0 +1,179 @@
+using Dynamo.Graph.Workspaces.Locking;
+using Dynamo.Wpf.Services;
+using Dynamo.Wpf.UI;
+using NUnit.Framework;
+using System;
+using System.IO;
+using System.Windows.Forms;
+
+namespace DynamoCoreWpfTests
+{
+ [TestFixture]
+ [Category("UnitTests")]
+ public class WpfGraphLockUserPromptTests
+ {
+ private string tempDir;
+
+ [SetUp]
+ public void SetUp()
+ {
+ tempDir = Path.Combine(Path.GetTempPath(), System.Guid.NewGuid().ToString());
+ Directory.CreateDirectory(tempDir);
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ if (Directory.Exists(tempDir))
+ Directory.Delete(tempDir, recursive: true);
+ }
+
+ private string FilePath(string name) => Path.Combine(tempDir, name);
+
+ private WpfGraphLockUserPrompt CreatePrompt(
+ IFileSaver dialog,
+ Func msgBox = null)
+ {
+ return new WpfGraphLockUserPrompt(
+ ownerProvider: () => null,
+ productName: "Dynamo",
+ dialogFactory: () => dialog,
+ showMessageBox: msgBox ?? ((_, __, ___, ____) => DialogResult.OK));
+ }
+
+ [Test]
+ public void WhenUserPicksLockedSourceFileThenSaveIsBlocked()
+ {
+ // Arrange
+ var graphPath = FilePath("locked.dyn");
+ File.WriteAllText(graphPath, "graph");
+ var dialog = new MockFileSaver { FileToSelect = graphPath };
+ var prompt = CreatePrompt(dialog);
+
+ // Act
+ var result = prompt.ShowSaveAsDialog(graphPath);
+
+ // Assert
+ Assert.IsNull(result, "Picking the locked source file must cancel the save");
+ }
+
+ [Test]
+ public void WhenUserPicksLockedSourceFileWithDifferentCasingThenSaveIsBlocked()
+ {
+ // Arrange - validates the Path.GetFullPath normalisation fix
+ var graphPath = FilePath("MyGraph.dyn");
+ File.WriteAllText(graphPath, "graph");
+ var dialog = new MockFileSaver { FileToSelect = graphPath.ToUpperInvariant() };
+ var prompt = CreatePrompt(dialog);
+
+ // Act
+ var result = prompt.ShowSaveAsDialog(graphPath);
+
+ // Assert
+ Assert.IsNull(result, "Case-insensitive path match must still block the save");
+ }
+
+ [Test]
+ public void WhenUserPicksNewNonExistingFileThenSaveProceeds()
+ {
+ // Arrange
+ var graphPath = FilePath("source.dyn");
+ var savePath = FilePath("new-copy.dyn");
+ File.WriteAllText(graphPath, "graph");
+ // savePath intentionally does not exist
+ var dialog = new MockFileSaver { FileToSelect = savePath };
+ var prompt = CreatePrompt(dialog);
+
+ // Act
+ var result = prompt.ShowSaveAsDialog(graphPath);
+
+ // Assert
+ Assert.AreEqual(savePath, result, "Picking a new path should proceed without any prompt");
+ }
+
+ [Test]
+ public void WhenUserPicksExistingFileAndConfirmsThenSaveProceeds()
+ {
+ // Arrange
+ var graphPath = FilePath("source.dyn");
+ var existingPath = FilePath("existing.dyn");
+ File.WriteAllText(graphPath, "graph");
+ File.WriteAllText(existingPath, "other graph");
+
+ var dialog = new MockFileSaver { FileToSelect = existingPath };
+ var prompt = CreatePrompt(dialog, (_, __, ___, ____) => DialogResult.Yes);
+
+ // Act
+ var result = prompt.ShowSaveAsDialog(graphPath);
+
+ // Assert
+ Assert.AreEqual(existingPath, result, "Confirming overwrite should allow the save");
+ }
+
+ [Test]
+ public void WhenUserPicksExistingFileAndDeclinesOverwriteThenSaveIsBlocked()
+ {
+ // Arrange
+ var graphPath = FilePath("source.dyn");
+ var existingPath = FilePath("existing.dyn");
+ File.WriteAllText(graphPath, "graph");
+ File.WriteAllText(existingPath, "other graph");
+
+ var dialog = new MockFileSaver { FileToSelect = existingPath };
+ var prompt = CreatePrompt(dialog, (_, __, ___, ____) => DialogResult.No);
+
+ // Act
+ var result = prompt.ShowSaveAsDialog(graphPath);
+
+ // Assert
+ Assert.IsNull(result, "Declining the overwrite confirmation must cancel the save");
+ }
+
+ [Test]
+ public void WhenUserCancelsDialogThenSaveIsBlocked()
+ {
+ // Arrange - user presses Cancel without picking any file
+ var graphPath = FilePath("source.dyn");
+ File.WriteAllText(graphPath, "graph");
+ var dialog = new MockFileSaver { SimulatedShowDialogResult = false };
+ var prompt = CreatePrompt(dialog);
+
+ // Act
+ var result = prompt.ShowSaveAsDialog(graphPath);
+
+ // Assert
+ Assert.IsNull(result, "Cancelling the dialog should return null");
+ }
+
+ private sealed class MockFileSaver : IFileSaver
+ {
+ public string Filter { get; set; }
+ public string DefaultExt { get; set; }
+ public string FileName { get; set; }
+ public bool AddExtension { get; set; }
+ public string InitialDirectory { get; set; }
+ public bool OverwritePrompt { get; set; }
+
+ internal string FileToSelect { get; set; }
+ internal bool? SimulatedShowDialogResult { get; set; } = true;
+
+ private System.ComponentModel.CancelEventHandler fileOkHandler;
+ public event System.ComponentModel.CancelEventHandler FileOk
+ {
+ add => fileOkHandler += value;
+ remove => fileOkHandler -= value;
+ }
+
+ public bool? ShowDialog()
+ {
+ if (FileToSelect != null)
+ FileName = FileToSelect;
+
+ var args = new System.ComponentModel.CancelEventArgs();
+ fileOkHandler?.Invoke(this, args);
+
+ return args.Cancel ? false : SimulatedShowDialogResult;
+ }
+ }
+ }
+}