From 9e3793f373921d2161a7949604b764b787c5aa46 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 20:36:37 +0000 Subject: [PATCH] Serialize the PID file with a source-generated context so trimmed and AOT apps launch [patch] ShouldLaunch() read and wrote the PID file through reflection-based System.Text.Json, which an app published with PublishTrimmed or PublishAot runs with disabled, so the InvalidOperationException escaped and the app crashed on every launch. Both calls now use ProcessInfoJsonContext; the JSON shape is unchanged. IsAotCompatible on net7.0+ turns a future reflection call into a build error, and the test project runs with JsonSerializerIsReflectionEnabledByDefault=false so every test exercises that path. Fixes ktsu-dev/SingleAppInstance#210 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW5Mwb7UBpSmjcYVryq --- CLAUDE.md | 4 +- .../SingleAppInstance.Test.csproj | 3 ++ .../SingleAppInstanceTests.cs | 40 ++++++++++--------- SingleAppInstance.Test/TestJsonContext.cs | 15 +++++++ SingleAppInstance/ProcessInfoJsonContext.cs | 16 ++++++++ SingleAppInstance/SingleAppInstance.cs | 4 +- SingleAppInstance/SingleAppInstance.csproj | 6 +++ 7 files changed, 66 insertions(+), 22 deletions(-) create mode 100644 SingleAppInstance.Test/TestJsonContext.cs create mode 100644 SingleAppInstance/ProcessInfoJsonContext.cs 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 + +