From 5ff05d2618e17e67550a5feccd279469526ffd23 Mon Sep 17 00:00:00 2001
From: Theaux Masquelier <43664045+Theauxm@users.noreply.github.com>
Date: Fri, 3 Apr 2026 09:59:37 -0600
Subject: [PATCH] fix: preserve original exception identity through train
pipeline
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Stop mutating exception messages via reflection — store TrainExceptionData
in Exception.Data instead. Use ExceptionDispatchInfo in ResolveOrThrow to
preserve original stack traces. Callers outside Trax now see the original
exception type, message, and stack trace.
---
.../Exceptions/TrainExceptionData.cs | 7 +
src/Trax.Core/Junction/Junction.cs | 19 +-
src/Trax.Core/Monad/Monad.Resolve.cs | 3 +-
.../Exceptions/TrainExceptionDataTests.cs | 89 ++++++++
.../Junction/JunctionCancellationTests.cs | 79 ++++++++
.../Junction/StackTracePreservationTests.cs | 190 ++++++++++++++++++
.../Tests/JsonEscapingTests.cs | 124 ++++++++----
7 files changed, 455 insertions(+), 56 deletions(-)
create mode 100644 tests/Trax.Core.Tests.Unit/UnitTests/Junction/StackTracePreservationTests.cs
diff --git a/src/Trax.Core/Exceptions/TrainExceptionData.cs b/src/Trax.Core/Exceptions/TrainExceptionData.cs
index e867eab..306c9a8 100644
--- a/src/Trax.Core/Exceptions/TrainExceptionData.cs
+++ b/src/Trax.Core/Exceptions/TrainExceptionData.cs
@@ -31,4 +31,11 @@ public class TrainExceptionData
///
[JsonPropertyName("message")]
public required string Message { get; set; }
+
+ ///
+ /// The original stack trace from where the exception was thrown.
+ /// Nullable for backwards compatibility with previously serialized data.
+ ///
+ [JsonPropertyName("stackTrace")]
+ public string? StackTrace { get; set; }
}
diff --git a/src/Trax.Core/Junction/Junction.cs b/src/Trax.Core/Junction/Junction.cs
index bc9ea5e..befdd83 100644
--- a/src/Trax.Core/Junction/Junction.cs
+++ b/src/Trax.Core/Junction/Junction.cs
@@ -1,6 +1,4 @@
using System.ComponentModel;
-using System.Reflection;
-using System.Text.Json;
using LanguageExt;
using LanguageExt.UnsafeValueAccess;
using Trax.Core.Exceptions;
@@ -96,15 +94,6 @@ Train train
}
catch (Exception e)
{
- // Enrich the exception with junction information for better debugging
- var messageField = typeof(Exception).GetField(
- "_message",
- BindingFlags.Instance | BindingFlags.NonPublic
- );
-
- if (messageField is null)
- return e;
-
var exceptionData = new TrainExceptionData
{
TrainName = train.GetType().Name,
@@ -112,14 +101,16 @@ Train train
Junction = GetType().Name,
Type = e.GetType().Name,
Message = e.Message,
+ StackTrace = e.StackTrace,
};
ExceptionData = exceptionData;
- var serializedMessage = JsonSerializer.Serialize(exceptionData);
- messageField.SetValue(e, serializedMessage);
+ // Store structured data on the exception without mutating its message.
+ // This preserves the original exception for callers outside Trax,
+ // while still making junction context available to Metadata.AddException().
+ e.Data["TrainExceptionData"] = exceptionData;
- // Return the exception as Left
return e;
}
}
diff --git a/src/Trax.Core/Monad/Monad.Resolve.cs b/src/Trax.Core/Monad/Monad.Resolve.cs
index c2905ae..c4dab43 100644
--- a/src/Trax.Core/Monad/Monad.Resolve.cs
+++ b/src/Trax.Core/Monad/Monad.Resolve.cs
@@ -1,3 +1,4 @@
+using System.Runtime.ExceptionServices;
using LanguageExt;
using Trax.Core.Exceptions;
using Trax.Core.Extensions;
@@ -50,7 +51,7 @@ public Either Resolve()
internal TReturn ResolveOrThrow()
{
if (Exception is not null)
- throw Exception;
+ ExceptionDispatchInfo.Capture(Exception).Throw();
if (ShortCircuitValueSet)
return ShortCircuitValue;
diff --git a/tests/Trax.Core.Tests.Unit/UnitTests/Exceptions/TrainExceptionDataTests.cs b/tests/Trax.Core.Tests.Unit/UnitTests/Exceptions/TrainExceptionDataTests.cs
index 2bc10ce..fc6bd72 100644
--- a/tests/Trax.Core.Tests.Unit/UnitTests/Exceptions/TrainExceptionDataTests.cs
+++ b/tests/Trax.Core.Tests.Unit/UnitTests/Exceptions/TrainExceptionDataTests.cs
@@ -98,4 +98,93 @@ public async Task Deserialize_ValidJson_CreatesCorrectObject()
data.Junction.Should().Be("ParseJunction");
data.Message.Should().Be("bad arg");
}
+
+ #region StackTrace Serialization
+
+ [Theory]
+ public async Task Serialize_RoundTrip_PreservesStackTrace()
+ {
+ // Arrange
+ var data = new TrainExceptionData
+ {
+ TrainName = "MyTrain",
+ TrainExternalId = "ext-123",
+ Type = "InvalidOperationException",
+ Junction = "ValidateInput",
+ Message = "Input was null",
+ StackTrace = " at MyApp.ValidateInput.Run() in /app/Validate.cs:line 42",
+ };
+
+ // Act
+ var json = JsonSerializer.Serialize(data);
+ var deserialized = JsonSerializer.Deserialize(json);
+
+ // Assert
+ deserialized.Should().NotBeNull();
+ deserialized!.StackTrace.Should().Be(data.StackTrace);
+ }
+
+ [Theory]
+ public async Task Serialize_NullStackTrace_HandledCorrectly()
+ {
+ // Arrange
+ var data = new TrainExceptionData
+ {
+ TrainName = "MyTrain",
+ TrainExternalId = "ext-123",
+ Type = "Exception",
+ Junction = "Junction",
+ Message = "msg",
+ StackTrace = null,
+ };
+
+ // Act
+ var json = JsonSerializer.Serialize(data);
+ var deserialized = JsonSerializer.Deserialize(json);
+
+ // Assert
+ deserialized.Should().NotBeNull();
+ deserialized!.StackTrace.Should().BeNull();
+ }
+
+ [Theory]
+ public async Task Deserialize_WithoutStackTraceField_BackwardsCompatible()
+ {
+ // Arrange — JSON from an older version that doesn't include stackTrace
+ var json =
+ """{"trainName":"Test","trainExternalId":"id","type":"Exception","junction":"J","message":"msg"}""";
+
+ // Act
+ var data = JsonSerializer.Deserialize(json);
+
+ // Assert — should deserialize successfully with null StackTrace
+ data.Should().NotBeNull();
+ data!.StackTrace.Should().BeNull();
+ data.Message.Should().Be("msg");
+ }
+
+ [Theory]
+ public async Task Serialize_StackTraceWithSpecialCharacters_PreservedCorrectly()
+ {
+ // Arrange
+ var data = new TrainExceptionData
+ {
+ TrainName = "Test",
+ TrainExternalId = "id",
+ Type = "Exception",
+ Junction = "J",
+ Message = "msg",
+ StackTrace =
+ " at MyApp.Run() in C:\\Users\\dev\\src\\App.cs:line 10\n at System.Threading.Tasks.Task.Execute()",
+ };
+
+ // Act
+ var json = JsonSerializer.Serialize(data);
+ var deserialized = JsonSerializer.Deserialize(json);
+
+ // Assert
+ deserialized!.StackTrace.Should().Be(data.StackTrace);
+ }
+
+ #endregion
}
diff --git a/tests/Trax.Core.Tests.Unit/UnitTests/Junction/JunctionCancellationTests.cs b/tests/Trax.Core.Tests.Unit/UnitTests/Junction/JunctionCancellationTests.cs
index 9e44181..81c9767 100644
--- a/tests/Trax.Core.Tests.Unit/UnitTests/Junction/JunctionCancellationTests.cs
+++ b/tests/Trax.Core.Tests.Unit/UnitTests/Junction/JunctionCancellationTests.cs
@@ -1,6 +1,7 @@
using FluentAssertions;
using LanguageExt;
using LanguageExt.UnsafeValueAccess;
+using Trax.Core.Exceptions;
using Trax.Core.Junction;
using Trax.Core.Train;
@@ -105,6 +106,84 @@ public async Task RailwayStep_NonCancellationException_StillWrapsInExceptionData
junction.ExceptionData!.Junction.Should().Be(nameof(ThrowingStep));
}
+ [Theory]
+ public async Task RailwayJunction_ExceptionThrown_OriginalMessagePreserved()
+ {
+ // Arrange
+ var junction = new ThrowingStep();
+ var train = new TestTrain();
+
+ Either input = "hello";
+
+ // Act
+ var result = await junction.RailwayJunction(input, train);
+
+ // Assert — the exception message must be the original, not JSON
+ var exception = result.Swap().ValueUnsafe();
+ exception.Message.Should().Be("test error");
+ }
+
+ [Theory]
+ public async Task RailwayJunction_ExceptionThrown_ExceptionDataAttachedViaDataDictionary()
+ {
+ // Arrange
+ var junction = new ThrowingStep();
+ var train = new TestTrain();
+
+ Either input = "hello";
+
+ // Act
+ var result = await junction.RailwayJunction(input, train);
+
+ // Assert — TrainExceptionData stored in Exception.Data dictionary
+ var exception = result.Swap().ValueUnsafe();
+ exception.Data["TrainExceptionData"].Should().NotBeNull();
+ var data = exception.Data["TrainExceptionData"] as TrainExceptionData;
+ data.Should().NotBeNull();
+ data!.Junction.Should().Be(nameof(ThrowingStep));
+ data.Type.Should().Be("InvalidOperationException");
+ data.Message.Should().Be("test error");
+ data.TrainName.Should().Be(nameof(TestTrain));
+ }
+
+ [Theory]
+ public async Task RailwayJunction_ExceptionThrown_OriginalStackTraceInExceptionData()
+ {
+ // Arrange
+ var junction = new ThrowingStep();
+ var train = new TestTrain();
+
+ Either input = "hello";
+
+ // Act
+ var result = await junction.RailwayJunction(input, train);
+
+ // Assert — the captured StackTrace should contain the junction's Run method
+ var exception = result.Swap().ValueUnsafe();
+ var data = exception.Data["TrainExceptionData"] as TrainExceptionData;
+ data.Should().NotBeNull();
+ data!.StackTrace.Should().NotBeNullOrEmpty();
+ data.StackTrace.Should().Contain(nameof(ThrowingStep));
+ }
+
+ [Theory]
+ public async Task RailwayJunction_ExceptionThrown_ExceptionDataAndPropertyAreConsistent()
+ {
+ // Arrange
+ var junction = new ThrowingStep();
+ var train = new TestTrain();
+
+ Either input = "hello";
+
+ // Act
+ var result = await junction.RailwayJunction(input, train);
+
+ // Assert — Junction.ExceptionData property and Exception.Data dictionary should match
+ var exception = result.Swap().ValueUnsafe();
+ var dictionaryData = exception.Data["TrainExceptionData"] as TrainExceptionData;
+ junction.ExceptionData.Should().BeSameAs(dictionaryData);
+ }
+
#region Test Helpers
private class TokenVerifyingStep : Junction
diff --git a/tests/Trax.Core.Tests.Unit/UnitTests/Junction/StackTracePreservationTests.cs b/tests/Trax.Core.Tests.Unit/UnitTests/Junction/StackTracePreservationTests.cs
new file mode 100644
index 0000000..52489eb
--- /dev/null
+++ b/tests/Trax.Core.Tests.Unit/UnitTests/Junction/StackTracePreservationTests.cs
@@ -0,0 +1,190 @@
+using FluentAssertions;
+using LanguageExt;
+using LanguageExt.UnsafeValueAccess;
+using Trax.Core.Exceptions;
+using Trax.Core.Junction;
+using Trax.Core.Train;
+
+namespace Trax.Core.Tests.Unit.UnitTests.Step;
+
+///
+/// Verifies that original exception identity (message, stack trace, type) is preserved
+/// through the full Junction → Monad → ResolveOrThrow chain.
+///
+public class StackTracePreservationTests : TestSetup
+{
+ #region ResolveOrThrow Stack Trace Preservation
+
+ [Theory]
+ public async Task Run_ThrowingJunction_PreservesOriginalStackTrace()
+ {
+ // Arrange
+ var train = new ThrowingTrain();
+
+ // Act
+ Exception? caught = null;
+ try
+ {
+ await train.Run("input");
+ }
+ catch (Exception ex)
+ {
+ caught = ex;
+ }
+
+ // Assert — the stack trace should contain the original throw site
+ caught.Should().NotBeNull();
+ caught!.StackTrace.Should().Contain(nameof(AlwaysThrowsJunction));
+ }
+
+ [Theory]
+ public async Task Run_ThrowingJunction_StackTraceDoesNotStartAtResolveOrThrow()
+ {
+ // Arrange
+ var train = new ThrowingTrain();
+
+ // Act
+ Exception? caught = null;
+ try
+ {
+ await train.Run("input");
+ }
+ catch (Exception ex)
+ {
+ caught = ex;
+ }
+
+ // Assert — the first frame should NOT be ResolveOrThrow
+ caught.Should().NotBeNull();
+ var firstLine = caught!.StackTrace!.Split('\n')[0];
+ firstLine.Should().NotContain("ResolveOrThrow");
+ }
+
+ #endregion
+
+ #region RunEither (Either Path) Stack Trace Preservation
+
+ [Theory]
+ public async Task RunEither_ThrowingJunction_ReturnsExceptionWithOriginalStackTrace()
+ {
+ // Arrange
+ var train = new ThrowingTrain();
+
+ // Act
+ var result = await train.RunEither("input");
+
+ // Assert
+ result.IsLeft.Should().BeTrue();
+ var exception = result.Swap().ValueUnsafe();
+ exception.StackTrace.Should().Contain(nameof(AlwaysThrowsJunction));
+ }
+
+ #endregion
+
+ #region Original Message Preservation
+
+ [Theory]
+ public async Task Run_ThrowingJunction_ExceptionHasOriginalMessage()
+ {
+ // Arrange
+ var train = new ThrowingTrain();
+
+ // Act
+ Exception? caught = null;
+ try
+ {
+ await train.Run("input");
+ }
+ catch (Exception ex)
+ {
+ caught = ex;
+ }
+
+ // Assert — the message should be the original, not JSON
+ caught.Should().NotBeNull();
+ caught!.Message.Should().Be("something went wrong");
+ caught.Should().BeOfType();
+ }
+
+ [Theory]
+ public async Task Run_ThrowingJunction_ExceptionDataContainsStructuredInfo()
+ {
+ // Arrange
+ var train = new ThrowingTrain();
+
+ // Act
+ Exception? caught = null;
+ try
+ {
+ await train.Run("input");
+ }
+ catch (Exception ex)
+ {
+ caught = ex;
+ }
+
+ // Assert — structured data available via Exception.Data
+ caught.Should().NotBeNull();
+ var data = caught!.Data["TrainExceptionData"] as TrainExceptionData;
+ data.Should().NotBeNull();
+ data!.Junction.Should().Be(nameof(AlwaysThrowsJunction));
+ data.Type.Should().Be("InvalidOperationException");
+ data.Message.Should().Be("something went wrong");
+ data.StackTrace.Should().Contain(nameof(AlwaysThrowsJunction));
+ }
+
+ #endregion
+
+ #region Exception Type Preservation
+
+ [Theory]
+ public async Task Run_ThrowingJunction_OriginalExceptionTypePreserved()
+ {
+ // Arrange
+ var train = new ArgumentThrowingTrain();
+
+ // Act
+ Exception? caught = null;
+ try
+ {
+ await train.Run("input");
+ }
+ catch (Exception ex)
+ {
+ caught = ex;
+ }
+
+ // Assert — the exception type should be the original, not TrainException
+ caught.Should().NotBeNull();
+ caught.Should().BeOfType();
+ caught!.Message.Should().Be("bad argument");
+ }
+
+ #endregion
+
+ #region Test Helpers
+
+ private class AlwaysThrowsJunction : Junction
+ {
+ public override Task Run(string input) =>
+ throw new InvalidOperationException("something went wrong");
+ }
+
+ private class ArgumentThrowsJunction : Junction
+ {
+ public override Task Run(string input) =>
+ throw new ArgumentException("bad argument");
+ }
+
+ private class ThrowingTrain : Train
+ {
+ protected override string Junctions() => Chain();
+ }
+
+ private class ArgumentThrowingTrain : Train
+ {
+ protected override string Junctions() => Chain();
+ }
+
+ #endregion
+}
diff --git a/tests/Trax.Core.Tests/Tests/JsonEscapingTests.cs b/tests/Trax.Core.Tests/Tests/JsonEscapingTests.cs
index 03bfca8..9a3116e 100644
--- a/tests/Trax.Core.Tests/Tests/JsonEscapingTests.cs
+++ b/tests/Trax.Core.Tests/Tests/JsonEscapingTests.cs
@@ -9,8 +9,9 @@
namespace Trax.Core.Tests.Tests;
///
-/// Tests to verify that JSON content in exception messages is properly escaped
-/// when exceptions are enriched with junction information.
+/// Tests to verify that exception messages with special characters (including JSON content)
+/// are preserved in their original form, and that structured junction context is available
+/// via Exception.Data["TrainExceptionData"].
///
public class JsonEscapingTests
{
@@ -22,14 +23,12 @@ protected override Task> RunInternal(string input) =>
///
/// Test junction that throws an exception with JSON content in the message.
- /// This simulates the scenario described in the issue where a CybersourcePaymentsException
- /// contains JSON in its message.
+ /// This simulates the scenario where an API exception contains JSON in its message.
///
private class TestJunctionWithJsonException : Junction
{
public override Task Run(string input)
{
- // Simulate an exception with JSON content in the message (like CybersourcePaymentsException)
var jsonMessage =
"""{"success":false,"referenceId":"reference-me2","amount":null,"id":"7551812047776009403814","submitTimeUtc":null,"cardType":null,"metadata":{"cybersourceReason":"Decline - Insufficient funds in the account.","statusReason":"The credit card was declined with a reason.","processorReason":"Decline - Insufficient funds in the account.","cardVerificationReason":null,"addressVerificationReason":null},"attemptCount":2}""";
@@ -38,7 +37,7 @@ public override Task Run(string input)
}
[Test]
- public async Task RailwayJunction_WhenExceptionContainsJson_ShouldProduceValidJson()
+ public async Task RailwayJunction_WhenExceptionContainsJson_OriginalMessagePreserved()
{
// Arrange
var junction = new TestJunctionWithJsonException();
@@ -56,28 +55,36 @@ public async Task RailwayJunction_WhenExceptionContainsJson_ShouldProduceValidJs
);
var exception = result.Swap().ValueUnsafe();
- var exceptionMessage = exception.Message;
- // Verify that the exception message is valid JSON
- Assert.That(
- IsValidJson(exceptionMessage),
- Is.True,
- $"Exception message should be valid JSON, but got: {exceptionMessage}"
- );
+ // The original message should be preserved (not replaced with TrainExceptionData JSON)
+ Assert.That(exception.Message, Does.Contain("\"success\":false"));
+ Assert.That(exception.Message, Does.Contain("\"referenceId\":\"reference-me2\""));
+ }
+
+ [Test]
+ public async Task RailwayJunction_WhenExceptionContainsJson_ExceptionDataAvailable()
+ {
+ // Arrange
+ var junction = new TestJunctionWithJsonException();
+ var input = Either.Right("test input");
+ var train = new DummyTrain();
+
+ // Act
+ var result = await junction.RailwayJunction(input, train);
- // Verify that we can deserialize the exception message
- var exceptionData = JsonSerializer.Deserialize(exceptionMessage);
- Assert.That(exceptionData, Is.Not.Null);
- Assert.That(exceptionData.Junction, Is.EqualTo("TestJunctionWithJsonException"));
- Assert.That(exceptionData.Type, Is.EqualTo("InvalidOperationException"));
+ // Assert
+ var exception = result.Swap().ValueUnsafe();
- // Verify that the original JSON message is properly escaped within the message property
- Assert.That(exceptionData.Message, Does.Contain("\"success\":false"));
- Assert.That(exceptionData.Message, Does.Contain("\"referenceId\":\"reference-me2\""));
+ // Structured data should be available via Exception.Data
+ var data = exception.Data["TrainExceptionData"] as TrainExceptionData;
+ Assert.That(data, Is.Not.Null);
+ Assert.That(data!.Junction, Is.EqualTo("TestJunctionWithJsonException"));
+ Assert.That(data.Type, Is.EqualTo("InvalidOperationException"));
+ Assert.That(data.Message, Does.Contain("\"success\":false"));
}
[Test]
- public async Task RailwayJunction_WhenExceptionContainsSpecialCharacters_ShouldProduceValidJson()
+ public async Task RailwayJunction_WhenExceptionContainsSpecialCharacters_OriginalMessagePreserved()
{
// Arrange
var junction = new TestJunctionWithSpecialCharacters();
@@ -88,32 +95,67 @@ public async Task RailwayJunction_WhenExceptionContainsSpecialCharacters_ShouldP
var result = await junction.RailwayJunction(input, train);
// Assert
- Assert.That(
- result.IsLeft,
- Is.True,
- "Expected the junction to fail and return Left(Exception)"
- );
+ var exception = result.Swap().ValueUnsafe();
+
+ // The original message should be preserved
+ Assert.That(exception.Message, Does.Contain("quotes"));
+ Assert.That(exception.Message, Does.Contain("newlines"));
+ Assert.That(exception.Message, Does.Contain("backslashes"));
+ }
+ [Test]
+ public async Task RailwayJunction_WhenExceptionContainsSpecialCharacters_ExceptionDataAvailable()
+ {
+ // Arrange
+ var junction = new TestJunctionWithSpecialCharacters();
+ var input = Either.Right("test input");
+ var train = new DummyTrain();
+
+ // Act
+ var result = await junction.RailwayJunction(input, train);
+
+ // Assert
var exception = result.Swap().ValueUnsafe();
- var exceptionMessage = exception.Message;
- // Verify that the exception message is valid JSON
+ // Structured data should be available via Exception.Data
+ var data = exception.Data["TrainExceptionData"] as TrainExceptionData;
+ Assert.That(data, Is.Not.Null);
+ Assert.That(data!.Junction, Is.EqualTo("TestJunctionWithSpecialCharacters"));
+ Assert.That(data.Type, Is.EqualTo("InvalidOperationException"));
+
+ // Original message preserved in the data object
+ Assert.That(data.Message, Does.Contain("quotes"));
+ Assert.That(data.Message, Does.Contain("newlines"));
+ Assert.That(data.Message, Does.Contain("backslashes"));
+ }
+
+ [Test]
+ public async Task RailwayJunction_ExceptionDataMessage_CanBeSerializedToValidJson()
+ {
+ // Arrange
+ var junction = new TestJunctionWithJsonException();
+ var input = Either.Right("test input");
+ var train = new DummyTrain();
+
+ // Act
+ var result = await junction.RailwayJunction(input, train);
+
+ // Assert — the TrainExceptionData can be serialized to valid JSON
+ var exception = result.Swap().ValueUnsafe();
+ var data = exception.Data["TrainExceptionData"] as TrainExceptionData;
+ Assert.That(data, Is.Not.Null);
+
+ var json = JsonSerializer.Serialize(data);
Assert.That(
- IsValidJson(exceptionMessage),
+ IsValidJson(json),
Is.True,
- $"Exception message should be valid JSON, but got: {exceptionMessage}"
+ $"Serialized TrainExceptionData should be valid JSON: {json}"
);
- // Verify that we can deserialize the exception message
- var exceptionData = JsonSerializer.Deserialize(exceptionMessage);
- Assert.That(exceptionData, Is.Not.Null);
- Assert.That(exceptionData.Junction, Is.EqualTo("TestJunctionWithSpecialCharacters"));
- Assert.That(exceptionData.Type, Is.EqualTo("InvalidOperationException"));
-
- // Verify that special characters are properly escaped
- Assert.That(exceptionData.Message, Does.Contain("quotes"));
- Assert.That(exceptionData.Message, Does.Contain("newlines"));
- Assert.That(exceptionData.Message, Does.Contain("backslashes"));
+ // Round-trip: deserialize and verify the original JSON message survives
+ var roundTripped = JsonSerializer.Deserialize(json);
+ Assert.That(roundTripped, Is.Not.Null);
+ Assert.That(roundTripped!.Message, Does.Contain("\"success\":false"));
}
///