diff --git a/CLAUDE.md b/CLAUDE.md
index cd6b7d4..e515f07 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -55,13 +55,15 @@ Internal methods `IsAlreadyRunning()` and `WritePidFile()` are exposed to tests
JSON-serialized `ProcessInfo` with fields: `ProcessId`, `ProcessName`, `StartTime`, `MainModuleFileName`. Includes backward compatibility for legacy files that stored only a plain integer PID.
+Serialization goes through the source-generated `ProcessInfoJsonContext`, never the reflection overloads: apps published trimmed or Native AOT run with reflection-based System.Text.Json disabled. The library sets `IsAotCompatible` on net7.0+, so a reflection call is a build error (IL2026/IL3050). The test project sets `JsonSerializerIsReflectionEnabledByDefault=false` and reads the PID file through its own `TestJsonContext`, so every test runs the way those apps do.
+
### Multi-targeting
The library targets multiple frameworks via `ktsu.Sdk`. Uses `#if NET5_0_OR_GREATER` preprocessor directives (e.g., `Environment.ProcessId` vs `Process.GetCurrentProcess().Id`).
## Testing
-Tests use MSTest (`MSTest.Sdk`) targeting net10.0 only. Tests are marked `[DoNotParallelize]` because they share the PID file on disk. Each test cleans up the PID file in `[TestInitialize]`. The test project mirrors the internal `ProcessInfo` class as a private nested class for deserialization.
+Tests use MSTest (`MSTest.Sdk`) targeting net10.0 only. Tests are marked `[DoNotParallelize]` because they share the PID file on disk. Each test cleans up the PID file in `[TestInitialize]`. The test project mirrors the internal `ProcessInfo` class as a nested class, serialized through `TestJsonContext`.
## CI/CD
diff --git a/SingleAppInstance.Test/SingleAppInstance.Test.csproj b/SingleAppInstance.Test/SingleAppInstance.Test.csproj
index 0537f1d..e46a422 100644
--- a/SingleAppInstance.Test/SingleAppInstance.Test.csproj
+++ b/SingleAppInstance.Test/SingleAppInstance.Test.csproj
@@ -6,6 +6,9 @@
true
net10.0
+
+ false
diff --git a/SingleAppInstance.Test/SingleAppInstanceTests.cs b/SingleAppInstance.Test/SingleAppInstanceTests.cs
index ea5909f..0c3a934 100644
--- a/SingleAppInstance.Test/SingleAppInstanceTests.cs
+++ b/SingleAppInstance.Test/SingleAppInstanceTests.cs
@@ -42,7 +42,7 @@ public void WritePidFile_ShouldCreateFileWithCurrentProcessInfo()
// Verify file content
string fileContent = File.ReadAllText(pidFilePath);
- ProcessInfo? processInfo = JsonSerializer.Deserialize(fileContent);
+ ProcessInfo? processInfo = JsonSerializer.Deserialize(fileContent, TestJsonContext.Default.ProcessInfo);
Assert.IsNotNull(processInfo);
using Process currentProcess = Process.GetCurrentProcess();
@@ -79,7 +79,7 @@ public void WritePidFile_ShouldOverwriteExistingFile()
// Assert
string content = File.ReadAllText(pidFilePath);
Assert.AreNotEqual("old content", content);
- ProcessInfo? processInfo = JsonSerializer.Deserialize(content);
+ ProcessInfo? processInfo = JsonSerializer.Deserialize(content, TestJsonContext.Default.ProcessInfo);
Assert.IsNotNull(processInfo);
Assert.AreEqual(Environment.ProcessId, processInfo.ProcessId);
}
@@ -109,7 +109,7 @@ public void IsAlreadyRunning_WithCurrentProcessId_ShouldReturnFalse()
MainModuleFileName = currentProcess.MainModule?.FileName,
};
- string json = JsonSerializer.Serialize(processInfo);
+ string json = JsonSerializer.Serialize(processInfo, TestJsonContext.Default.ProcessInfo);
File.WriteAllText(pidFilePath, json);
// Act
@@ -198,7 +198,7 @@ public void IsAlreadyRunning_WithNonExistentPid_ShouldReturnFalse()
MainModuleFileName = "NonExistentFile.exe",
};
- string json = JsonSerializer.Serialize(processInfo);
+ string json = JsonSerializer.Serialize(processInfo, TestJsonContext.Default.ProcessInfo);
File.WriteAllText(pidFilePath, json);
// Act
@@ -283,7 +283,7 @@ public void IsAlreadyRunning_WithRunningProcessButDifferentName_ShouldReturnFals
MainModuleFileName = "C:\\nonexistent\\fake.exe",
};
- string json = JsonSerializer.Serialize(processInfo);
+ string json = JsonSerializer.Serialize(processInfo, TestJsonContext.Default.ProcessInfo);
File.WriteAllText(pidFilePath, json);
// Act
@@ -309,7 +309,7 @@ public void IsAlreadyRunning_WithMatchingProcessButDifferentStartTime_ShouldRetu
ProcessInfo processInfo = DescribeProcess(helper.Process);
processInfo.StartTime = processInfo.StartTime.AddHours(-1);
- File.WriteAllText(pidFilePath, JsonSerializer.Serialize(processInfo));
+ File.WriteAllText(pidFilePath, JsonSerializer.Serialize(processInfo, TestJsonContext.Default.ProcessInfo));
// Act
bool result = SingleAppInstance.IsAlreadyRunning();
@@ -325,7 +325,7 @@ public void IsAlreadyRunning_WithMatchingProcessAndStartTime_ShouldReturnTrue()
string pidFilePath = SingleAppInstance.PidFilePath;
using HelperProcess helper = HelperProcess.Start("StartTimeHelper");
- File.WriteAllText(pidFilePath, JsonSerializer.Serialize(DescribeProcess(helper.Process)));
+ File.WriteAllText(pidFilePath, JsonSerializer.Serialize(DescribeProcess(helper.Process), TestJsonContext.Default.ProcessInfo));
// Act
bool result = SingleAppInstance.IsAlreadyRunning();
@@ -343,7 +343,7 @@ public void IsAlreadyRunning_WithMatchingProcessAndNoStoredStartTime_ShouldRetur
ProcessInfo processInfo = DescribeProcess(helper.Process);
processInfo.StartTime = default;
- File.WriteAllText(pidFilePath, JsonSerializer.Serialize(processInfo));
+ File.WriteAllText(pidFilePath, JsonSerializer.Serialize(processInfo, TestJsonContext.Default.ProcessInfo));
// Act
bool result = SingleAppInstance.IsAlreadyRunning();
@@ -406,7 +406,7 @@ public void IsAlreadyRunning_WithHighNonExistentPid_ShouldReturnFalse()
MainModuleFileName = "fake.exe",
};
- string json = JsonSerializer.Serialize(processInfo);
+ string json = JsonSerializer.Serialize(processInfo, TestJsonContext.Default.ProcessInfo);
File.WriteAllText(pidFilePath, json);
// Act
@@ -443,7 +443,7 @@ public void IsAlreadyRunning_WithZeroPid_ShouldReturnFalse()
MainModuleFileName = "fake.exe",
};
- string json = JsonSerializer.Serialize(processInfo);
+ string json = JsonSerializer.Serialize(processInfo, TestJsonContext.Default.ProcessInfo);
File.WriteAllText(pidFilePath, json);
// Act
@@ -469,7 +469,7 @@ public void ShouldLaunch_WithNoExistingInstance_ShouldReturnTrue()
// Verify the PID file content matches the current process
string fileContent = File.ReadAllText(pidFilePath);
- ProcessInfo? processInfo = JsonSerializer.Deserialize(fileContent);
+ ProcessInfo? processInfo = JsonSerializer.Deserialize(fileContent, TestJsonContext.Default.ProcessInfo);
Assert.IsNotNull(processInfo);
Assert.AreEqual(Environment.ProcessId, processInfo.ProcessId);
}
@@ -499,7 +499,7 @@ public void IsAlreadyRunning_WithGarbageSuffixedPidFileForRunningInstance_Should
string pidFilePath = SingleAppInstance.PidFilePath;
using HelperProcess helper = HelperProcess.Start("TornPidHelper");
- File.WriteAllText(pidFilePath, JsonSerializer.Serialize(DescribeProcess(helper.Process)) + "0\"}");
+ File.WriteAllText(pidFilePath, JsonSerializer.Serialize(DescribeProcess(helper.Process), TestJsonContext.Default.ProcessInfo) + "0\"}");
// Act
bool result = SingleAppInstance.IsAlreadyRunning();
@@ -515,7 +515,7 @@ public void ShouldLaunch_WithGarbageSuffixedPidFileForRunningInstance_ShouldRetu
string pidFilePath = SingleAppInstance.PidFilePath;
using HelperProcess helper = HelperProcess.Start("TornPidHelper");
- File.WriteAllText(pidFilePath, JsonSerializer.Serialize(DescribeProcess(helper.Process)) + "0\"}");
+ File.WriteAllText(pidFilePath, JsonSerializer.Serialize(DescribeProcess(helper.Process), TestJsonContext.Default.ProcessInfo) + "0\"}");
// Act
bool result = SingleAppInstance.ShouldLaunch();
@@ -634,7 +634,7 @@ public void WritePidFile_WhileBeingRead_ReaderNeverSeesAPartialFile()
try
{
- if (JsonSerializer.Deserialize(content) is null)
+ if (JsonSerializer.Deserialize(content, TestJsonContext.Default.ProcessInfo) is null)
{
partialReads++;
lastPartialContent = content;
@@ -715,7 +715,7 @@ public void TryWritePidFile_WhenPidFileCanBeWritten_ShouldReturnTrue()
// Assert
Assert.IsTrue(result);
- ProcessInfo? processInfo = JsonSerializer.Deserialize(File.ReadAllText(SingleAppInstance.PidFilePath));
+ ProcessInfo? processInfo = JsonSerializer.Deserialize(File.ReadAllText(SingleAppInstance.PidFilePath), TestJsonContext.Default.ProcessInfo);
Assert.IsNotNull(processInfo);
Assert.AreEqual(Environment.ProcessId, processInfo.ProcessId);
}
@@ -767,7 +767,7 @@ public void WritePidFile_ContentShouldBeValidJson()
ProcessInfo? deserialized = null;
try
{
- deserialized = JsonSerializer.Deserialize(content);
+ deserialized = JsonSerializer.Deserialize(content, TestJsonContext.Default.ProcessInfo);
}
catch (JsonException)
{
@@ -789,7 +789,7 @@ public void WritePidFile_CalledTwice_ShouldSucceed()
// Assert
Assert.IsTrue(File.Exists(SingleAppInstance.PidFilePath));
string content = File.ReadAllText(SingleAppInstance.PidFilePath);
- ProcessInfo? processInfo = JsonSerializer.Deserialize(content);
+ ProcessInfo? processInfo = JsonSerializer.Deserialize(content, TestJsonContext.Default.ProcessInfo);
Assert.IsNotNull(processInfo);
Assert.AreEqual(Environment.ProcessId, processInfo.ProcessId);
}
@@ -1013,8 +1013,10 @@ public void Dispose()
}
}
- // This class needs to mirror the internal ProcessInfo class for testing
- private sealed class ProcessInfo
+ // This class needs to mirror the internal ProcessInfo class for testing. It is read and written
+ // through TestJsonContext because the test project, like a trimmed or Native AOT app, runs with
+ // reflection-based serialization disabled.
+ internal sealed class ProcessInfo
{
public int ProcessId { get; set; }
public string? ProcessName { get; set; }
diff --git a/SingleAppInstance.Test/TestJsonContext.cs b/SingleAppInstance.Test/TestJsonContext.cs
new file mode 100644
index 0000000..3249952
--- /dev/null
+++ b/SingleAppInstance.Test/TestJsonContext.cs
@@ -0,0 +1,15 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.SingleAppInstance.Test;
+
+using System.Text.Json.Serialization;
+
+///
+/// Source-generated serialization for the test's mirror of the PID file record.
+///
+///
+/// The test project disables reflection-based serialization, as a trimmed or Native AOT app does,
+/// so that every test exercises the library the way those apps run it.
+///
+[JsonSerializable(typeof(SingleAppInstanceTests.ProcessInfo))]
+internal sealed partial class TestJsonContext : JsonSerializerContext;
diff --git a/SingleAppInstance/ProcessInfoJsonContext.cs b/SingleAppInstance/ProcessInfoJsonContext.cs
new file mode 100644
index 0000000..7453144
--- /dev/null
+++ b/SingleAppInstance/ProcessInfoJsonContext.cs
@@ -0,0 +1,16 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.SingleAppInstance;
+
+using System.Text.Json.Serialization;
+
+///
+/// Source-generated serialization for the PID file record.
+///
+///
+/// An app published trimmed or as Native AOT runs with reflection-based System.Text.Json disabled,
+/// so the reflection overloads would throw on its first launch. The JSON shape is the same as the
+/// reflection serializer's, so PID files written by earlier versions still read.
+///
+[JsonSerializable(typeof(SingleAppInstance.ProcessInfo))]
+internal sealed partial class ProcessInfoJsonContext : JsonSerializerContext;
diff --git a/SingleAppInstance/SingleAppInstance.cs b/SingleAppInstance/SingleAppInstance.cs
index 2dda7d4..827bc7a 100644
--- a/SingleAppInstance/SingleAppInstance.cs
+++ b/SingleAppInstance/SingleAppInstance.cs
@@ -214,7 +214,7 @@ private static PidFileState CheckPidFileContents(string pidFileContents, int cur
try
{
Utf8JsonReader reader = new(Encoding.UTF8.GetBytes(pidFileContents));
- storedProcess = JsonSerializer.Deserialize(ref reader);
+ storedProcess = JsonSerializer.Deserialize(ref reader, ProcessInfoJsonContext.Default.ProcessInfo);
if (storedProcess == null)
{
return PidFileState.NoInstance;
@@ -469,7 +469,7 @@ internal static void WritePidFile()
MainModuleFileName = currentProcess.MainModule?.FileName
};
- string json = JsonSerializer.Serialize(processInfo);
+ string json = JsonSerializer.Serialize(processInfo, ProcessInfoJsonContext.Default.ProcessInfo);
string pidFilePath = PidFilePath;
string temporaryPath = $"{pidFilePath}.{Guid.NewGuid():N}.tmp";
diff --git a/SingleAppInstance/SingleAppInstance.csproj b/SingleAppInstance/SingleAppInstance.csproj
index 16f3437..0c17599 100644
--- a/SingleAppInstance/SingleAppInstance.csproj
+++ b/SingleAppInstance/SingleAppInstance.csproj
@@ -2,6 +2,12 @@
+
+
+ true
+
+