Skip to content

Commit ffb3961

Browse files
Reimplement cross-platform single-instance handling
The Avalonia port dropped ILSpy's single-instance feature, leaving three inert surfaces behind: the --newinstance / --noactivate switches, and the "Allow multiple instances" option were parsed and persisted but never read, and every launch started a new process. The former WPF implementation also encoded the executable location in the mutex name, so two ILSpy builds at different paths never shared an instance. That broke the Windows "Open with ILSpy" shell command: it launches a fixed executable and would not reuse a running instance started from elsewhere. Reimplement it with portable primitives (named Mutex + named pipes, no P/Invoke): the first launch for a user takes the mutex and listens; a later launch forwards its arguments over the pipe and exits. The namespace is derived from machine + user only -- never the location -- so any launcher reuses the running instance. --instanceid is a runtime reuse filter matched against the running instance's actual executable identity (via Environment.ProcessPath, single-file-bundle safe), not part of the mutex name, so it never re-introduces the location partitioning it replaces. The VS add-in passes its bundled exe path as --instanceid to prefer its own build while still sharing with a plain launch of that same executable. Assisted-by: Claude:claude-opus-4-8:Claude Code
1 parent 579032a commit ffb3961

7 files changed

Lines changed: 564 additions & 19 deletions

File tree

ILSpy.AddIn.VS2022/ILSpyInstance.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,16 @@ public void Start()
8080
argumentsToPass = $"@\"{filepath}\"";
8181
}
8282

83+
// Target only this bundled ILSpy build: --instanceid names the exe we launch, so ILSpy
84+
// reuses a running instance iff it is that same executable, and otherwise opens its own
85+
// window instead of joining a different ILSpy the user may have open.
86+
string instanceId = Path.GetFullPath(ilSpyExe);
87+
8388
var process = new Process() {
8489
StartInfo = new ProcessStartInfo() {
8590
FileName = ilSpyExe,
8691
UseShellExecute = false,
87-
Arguments = argumentsToPass
92+
Arguments = $"--instanceid \"{instanceId}\" {argumentsToPass}"
8893
}
8994
};
9095
process.Start();
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Copyright (c) 2026 Siegfried Pammer
2+
//
3+
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
4+
// software and associated documentation files (the "Software"), to deal in the Software
5+
// without restriction, including without limitation the rights to use, copy, modify, merge,
6+
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
7+
// to whom the Software is furnished to do so, subject to the following conditions:
8+
//
9+
// The above copyright notice and this permission notice shall be included in all copies or
10+
// substantial portions of the Software.
11+
//
12+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
13+
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
14+
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
15+
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
16+
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
17+
// DEALINGS IN THE SOFTWARE.
18+
19+
using System;
20+
using System.IO;
21+
22+
using AwesomeAssertions;
23+
24+
using ICSharpCode.ILSpy.AppEnv;
25+
26+
using NUnit.Framework;
27+
28+
namespace ICSharpCode.ILSpy.Tests;
29+
30+
[TestFixture]
31+
public class SingleInstanceTests
32+
{
33+
[Test]
34+
public void InstanceId_Option_Is_Parsed()
35+
{
36+
CommandLineArguments.Create(new[] { "--instanceid", "Foo" }).InstanceId.Should().Be("Foo");
37+
}
38+
39+
[Test]
40+
public void InstanceId_Defaults_To_Null_When_Absent()
41+
{
42+
CommandLineArguments.Create(Array.Empty<string>()).InstanceId.Should().BeNull();
43+
}
44+
45+
[Test]
46+
public void NewInstance_Option_Forces_SingleInstance_False()
47+
{
48+
// --newinstance is what disables single-instance for a single launch; lock in the mapping
49+
// now that a consumer finally reads CommandLineArguments.SingleInstance.
50+
CommandLineArguments.Create(new[] { "--newinstance" }).SingleInstance.Should().BeFalse();
51+
CommandLineArguments.Create(Array.Empty<string>()).SingleInstance.Should().BeNull();
52+
}
53+
54+
[Test]
55+
public void GetInstanceName_Is_Deterministic_And_Contains_No_Path_Separators()
56+
{
57+
// The mutex/pipe namespace is derived from machine + user only -- no executable location --
58+
// so every launch of the same user shares one instance regardless of which exe path it came
59+
// from. The name feeds a Mutex/pipe identifier, so it must carry no path separators.
60+
var name = SingleInstance.GetInstanceName();
61+
62+
name.Should().Be(SingleInstance.GetInstanceName());
63+
name.Should().StartWith("ILSpy.");
64+
name.Should().NotContain("/");
65+
name.Should().NotContain("\\");
66+
}
67+
68+
[Test]
69+
public void ShouldReuse_When_No_Id_Requested()
70+
{
71+
// A launcher without --instanceid (Explorer "Open with", a plain CLI launch) reuses whatever
72+
// instance is running, regardless of that instance's identity.
73+
SingleInstance.ShouldReuse(null, null, "/opt/ilspy/ILSpy").Should().BeTrue();
74+
SingleInstance.ShouldReuse("", "launch-id", "/opt/ilspy/ILSpy").Should().BeTrue();
75+
}
76+
77+
[Test]
78+
public void ShouldReuse_When_Requested_Id_Matches_Running_Launch_Id()
79+
{
80+
SingleInstance.ShouldReuse("Foo", "Foo", "/opt/ilspy/ILSpy").Should().BeTrue();
81+
}
82+
83+
[Test]
84+
public void ShouldReuse_When_Requested_Id_Matches_Running_Executable_Path()
85+
{
86+
// The only-AddIn-installed case: VS requests its bundled exe path, and the running instance
87+
// was started WITHOUT an id (e.g. by a shell "Open with ILSpy" on that same exe). It must
88+
// still match, because the running instance reports the executable it actually is.
89+
SingleInstance.ShouldReuse("/opt/ilspy/ILSpy", null, "/opt/ilspy/ILSpy").Should().BeTrue();
90+
}
91+
92+
[Test]
93+
public void ShouldReuse_False_When_Requested_Id_Matches_Neither()
94+
{
95+
SingleInstance.ShouldReuse("/opt/other/ILSpy", "Foo", "/opt/ilspy/ILSpy").Should().BeFalse();
96+
}
97+
98+
[Test]
99+
public void FullyQualifyPath_Roots_An_Existing_Relative_File_Against_The_Base_Directory()
100+
{
101+
var dir = Path.Combine(Path.GetTempPath(), "ILSpySingleInstance_" + Guid.NewGuid().ToString("N"));
102+
Directory.CreateDirectory(dir);
103+
try
104+
{
105+
var file = Path.Combine(dir, "some.dll");
106+
File.WriteAllText(file, "");
107+
108+
SingleInstance.FullyQualifyPath("some.dll", dir).Should().Be(file);
109+
}
110+
finally
111+
{
112+
Directory.Delete(dir, recursive: true);
113+
}
114+
}
115+
116+
[Test]
117+
public void FullyQualifyPath_Leaves_Non_File_Tokens_Unchanged()
118+
{
119+
var dir = Path.GetTempPath();
120+
var missing = "definitely-not-a-file-" + Guid.NewGuid().ToString("N");
121+
var rooted = Path.Combine(dir, "does-not-exist-" + Guid.NewGuid().ToString("N") + ".dll");
122+
123+
// An option flag, a value that is not an existing file, and an already-rooted path all pass
124+
// through untouched so the receiving instance re-parses them exactly as typed.
125+
SingleInstance.FullyQualifyPath("--navigateto", dir).Should().Be("--navigateto");
126+
SingleInstance.FullyQualifyPath(missing, dir).Should().Be(missing);
127+
SingleInstance.FullyQualifyPath(rooted, dir).Should().Be(rooted);
128+
}
129+
}

ILSpy/App.axaml.cs

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,17 @@
2020
using System.Composition.Hosting;
2121
using System.Diagnostics;
2222
using System.IO;
23+
using System.Threading.Tasks;
2324

2425
using Avalonia;
2526
using Avalonia.Controls.ApplicationLifetimes;
2627
using Avalonia.Markup.Xaml;
28+
using Avalonia.Threading;
2729

2830
using ICSharpCode.ILSpyX.Settings;
2931

3032
using ICSharpCode.ILSpy.AppEnv;
33+
using ICSharpCode.ILSpy.AssemblyTree;
3134
using ICSharpCode.ILSpy.Themes;
3235
using ICSharpCode.ILSpy.Views;
3336

@@ -57,22 +60,7 @@ public override void OnFrameworkInitializationCompleted()
5760

5861
CommandLineArguments = CommandLineArguments.Create(Environment.GetCommandLineArgs()[1..]);
5962

60-
ILSpySettings.SettingsFilePathProvider = () => {
61-
if (App.CommandLineArguments.ConfigFile != null)
62-
return App.CommandLineArguments.ConfigFile;
63-
64-
var assemblyLocation = typeof(MainWindow).Assembly.Location;
65-
if (!String.IsNullOrWhiteSpace(assemblyLocation))
66-
{
67-
var assemblyDirectory = Path.GetDirectoryName(assemblyLocation);
68-
Debug.Assert(assemblyDirectory != null);
69-
string localPath = Path.Combine(assemblyDirectory, "ILSpy.xml");
70-
if (File.Exists(localPath))
71-
return localPath;
72-
}
73-
74-
return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ICSharpCode", "ILSpy.xml");
75-
};
63+
ConfigureSettingsFilePathProvider(CommandLineArguments);
7664

7765
try
7866
{
@@ -125,11 +113,52 @@ public override void OnFrameworkInitializationCompleted()
125113
catch { /* persistence must never block shutdown */ }
126114
Composition?.Dispose();
127115
};
116+
117+
// A second launch forwards its command-line arguments to this instance over the
118+
// single-instance pipe (see Program.Main); open them in this window rather than
119+
// letting a second process start. No-op when single-instance is not in force.
120+
SingleInstance.NewInstanceDetected += OnNewInstanceDetected;
128121
}
129122

130123
base.OnFrameworkInitializationCompleted();
131124
}
132125

126+
// Resolves where ILSpy.xml is loaded from. Shared by normal startup and the single-instance
127+
// gate in Program.Main, which reads the settings before Avalonia starts.
128+
internal static void ConfigureSettingsFilePathProvider(CommandLineArguments commandLineArguments)
129+
{
130+
ILSpySettings.SettingsFilePathProvider = () => {
131+
if (commandLineArguments.ConfigFile != null)
132+
return commandLineArguments.ConfigFile;
133+
134+
var assemblyLocation = typeof(MainWindow).Assembly.Location;
135+
if (!String.IsNullOrWhiteSpace(assemblyLocation))
136+
{
137+
var assemblyDirectory = Path.GetDirectoryName(assemblyLocation);
138+
Debug.Assert(assemblyDirectory != null);
139+
string localPath = Path.Combine(assemblyDirectory, "ILSpy.xml");
140+
if (File.Exists(localPath))
141+
return localPath;
142+
}
143+
144+
return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ICSharpCode", "ILSpy.xml");
145+
};
146+
}
147+
148+
// Raised on the single-instance listener thread; marshal to the UI thread before touching
149+
// the model or the window.
150+
static void OnNewInstanceDetected(string[] forwardedArguments)
151+
=> Dispatcher.UIThread.Post(() => HandleForwardedArgumentsAsync(forwardedArguments).HandleExceptions());
152+
153+
static async Task HandleForwardedArgumentsAsync(string[] forwardedArguments)
154+
{
155+
var args = CommandLineArguments.Create(forwardedArguments);
156+
if (Composition?.GetExport<AssemblyTreeModel>() is { } assemblyTreeModel)
157+
await assemblyTreeModel.HandleCommandLineArgumentsAsync(args);
158+
if (!args.NoActivate)
159+
UiContext.ActivateMainWindow();
160+
}
161+
133162
// Effective UI culture is process-wide. Setting it on startup means a changed CurrentCulture
134163
// only takes effect after restart, matching the WPF behaviour.
135164
static void ApplyCulture(string? culture)

ILSpy/AppEnv/CommandLineArguments.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ public sealed class CommandLineArguments
3535
public string Language;
3636
public bool NoActivate;
3737
public string ConfigFile;
38+
public string InstanceId;
3839

3940
public CommandLineApplication ArgumentsParser { get; }
4041

@@ -79,6 +80,10 @@ public static CommandLineArguments Create(IEnumerable<string> arguments)
7980
"Do not activate the existing ILSpy instance.",
8081
CommandOptionType.NoValue);
8182

83+
var oInstanceId = app.Option<string>("--instanceid <NAME>",
84+
"Only reuse a running ILSpy instance whose identity (its executable, or its own --instanceid) matches NAME; otherwise start a separate instance.",
85+
CommandOptionType.SingleValue);
86+
8287
var files = app.Argument("Assemblies", "Assemblies to load", multipleValues: true);
8388

8489
app.Parse(arguments.ToArray());
@@ -90,6 +95,7 @@ public static CommandLineArguments Create(IEnumerable<string> arguments)
9095
instance.Search = oSearch.ParsedValue;
9196
instance.Language = oLanguage.ParsedValue;
9297
instance.ConfigFile = oConfig.ParsedValue;
98+
instance.InstanceId = oInstanceId.ParsedValue;
9399

94100
if (oNoActivate.HasValue())
95101
instance.NoActivate = true;

0 commit comments

Comments
 (0)