Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changes

### 10.4.2.0 06/05/2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix markdown heading level increment at Line 3.

### 10.4.2.0 ... jumps from # Changes to h3 and triggers MD001; use ## here (or add an intermediate ## section).

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 3-3: Heading levels should only increment by one level at a time
Expected: h2; Actual: h3

(MD001, heading-increment)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGES.md` at line 3, The markdown heading "### 10.4.2.0 06/05/2026"
increases from the top-level "# Changes" to an h3 and triggers MD001; change
this line to a second-level heading (replace "### 10.4.2.0 06/05/2026" with "##
10.4.2.0 06/05/2026") so headings increment properly and satisfy the MD001 rule.

Source: Linters/SAST tools

* Fix thread-unsafe request id assignment in `RequestManager` — concurrent requests on a single connection (e.g. `Task.WhenAll` over several `BookOffers`) could collide on the same id and throw `Response with id '$<guid>' is already pending` or drop a pending promise. Removed the shared `nextId` field; each call now generates its own `Guid` and registers via a single atomic `ConcurrentDictionary.TryAdd`, enabling parallel requests on one connection
* Surface exceptions thrown by stream handlers (`OnLedgerClosed`, `OnTransaction`, etc.) through the `OnError` event instead of swallowing them into a debug trace — consumer bugs are now observable, while the message loop stays alive and a throwing `OnError` handler is contained
* Clarify in XML docs that `Xrpl.Client.Exceptions.TimeoutException` is not `System.TimeoutException` (it derives from `XrplException`), to avoid mismatched `catch` clauses

### 10.4.1.0 05/28/2026
* Fix `IouValue` (IOU token amount) parsing to accept a trailing decimal point (e.g. `"128700."`), aligning with `xrpl.js` / `ripple-binary-codec` and `rippled` `STAmount` reference behavior — previously the stricter validation regex rejected a value with no digits after the dot, breaking signing of transactions (e.g. `AMMDeposit` via WalletConnect) that carried such amounts
* Relax IOU value regex fractional group from `(\.(\d+))?` to `(\.(\d*))?` while adding a `(?=\.?\d)` lookahead that still requires at least one mantissa digit — so trailing/leading dots (`"128700."`, `".5"`) parse but bare-dot inputs (`"."`, `".e10"`) are rejected, matching BigNumber; deduplicate the regex by reusing the single `IouValue.ValueRegex` constant in `AmountValue.cs` and `ExtenstionHelpers.cs`
Expand Down
22 changes: 22 additions & 0 deletions Tests/Xrpl.Tests/Client/TestSubscribe.cs
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,28 @@ public async Task TestEmitsBookChanges()
Assert.AreEqual("LESS", change.AssetB.ToString());
}

[TestMethod]
public async Task StreamHandlerException_IsSurfacedViaOnError()
{
Comment on lines +260 to +262

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Tag this unit test with TestCategory("TestU").

The new unit test should be discoverable in TestU-filtered runs.

As per coding guidelines: “Tag unit tests with TestU filter category and integration tests with TestI filter category for test organization.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Tests/Xrpl.Tests/Client/TestSubscribe.cs` around lines 260 - 262, Add the
TestU discovery tag to the new unit test by annotating the
StreamHandlerException_IsSurfacedViaOnError test method with the attribute
[TestCategory("TestU")]; locate the method named
StreamHandlerException_IsSurfacedViaOnError and insert the TestCategory
attribute directly above the [TestMethod] attribute so the test is included in
TestU-filtered runs.

Source: Coding guidelines

TaskCompletionSource<string> errorReported = new TaskCompletionSource<string>();

runner.client.connection.OnLedgerClosed += _ =>
throw new InvalidOperationException("consumer handler bug");

runner.client.connection.OnError += (error, errorMessage, message, data) =>
{
errorReported.TrySetResult(errorMessage);
return Task.CompletedTask;
};

string jsonString = "{\"fee_base\":10,\"fee_ref\":10,\"ledger_hash\":\"B3980C722D71873D6708723E71B7A28C826BC66C58712ADCEC61603415305CD1\",\"ledger_index\":66093872,\"ledger_time\":683942720,\"reserve_base\":20000000,\"reserve_inc\":5000000,\"txn_count\":70,\"type\":\"ledgerClosed\",\"validated_ledgers\":\"65201743-66093872\"}";
await runner.client.connection.OnMessage(jsonString);

Task completed = await Task.WhenAny(errorReported.Task, Task.Delay(5000));
Assert.AreEqual(errorReported.Task, completed,
"Exception thrown by a stream handler was swallowed instead of surfaced via OnError");
}

[TestMethod]
public async Task TestEmitsServerStatus()
{
Expand Down
109 changes: 109 additions & 0 deletions Tests/Xrpl.Tests/Client/TestURequestManagerConcurrency.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

using Microsoft.VisualStudio.TestTools.UnitTesting;

using Xrpl.Client;
using Xrpl.Client.Exceptions;

namespace Xrpl.Tests.ClientLib
{
[TestClass]
public class TestURequestManagerConcurrency
{
Comment on lines +15 to +17

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add TestU category tagging for these unit tests.

Please tag this test class/methods with TestCategory("TestU") so they are included in category-filtered unit runs.

Proposed fix
 [TestClass]
+[TestCategory("TestU")]
 public class TestURequestManagerConcurrency

As per coding guidelines: “Tag unit tests with TestU filter category and integration tests with TestI filter category for test organization.”

Also applies to: 59-60, 84-85

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Tests/Xrpl.Tests/Client/TestURequestManagerConcurrency.cs` around lines 15 -
17, Add the TestU category to these unit tests by annotating the
TestURequestManagerConcurrency test class with [TestCategory("TestU")] and also
add [TestCategory("TestU")] to each test method in this file that already has
[TestMethod] (the ones flagged around the other diffs); update the class
declaration symbol TestURequestManagerConcurrency and each method decorated with
TestMethod to include the TestCategory attribute so the tests are included in
TestU-filtered runs.

Source: Coding guidelines

private class FakeRequest
{
public Guid? Id { get; set; }
}

private static void RunConcurrent(Action body, out IReadOnlyList<Exception> errors)
{
int threadCount = Math.Max(8, Environment.ProcessorCount * 2);
const int iterationsPerThread = 500;

ConcurrentBag<Exception> collected = new ConcurrentBag<Exception>();
Barrier barrier = new Barrier(threadCount);
Thread[] threads = new Thread[threadCount];

for (int t = 0; t < threadCount; t++)
{
threads[t] = new Thread(() =>
{
barrier.SignalAndWait();
for (int i = 0; i < iterationsPerThread; i++)
{
try
{
body();
}
catch (Exception ex)
{
collected.Add(ex);
}
}
});
}

foreach (Thread thread in threads)
thread.Start();
foreach (Thread thread in threads)
thread.Join();

errors = collected.ToArray();
}

[TestMethod]
public void CreateRequest_ConcurrentCalls_AssignUniqueIdsWithoutCollision()
{
RequestManager manager = new RequestManager();
ConcurrentBag<Guid> ids = new ConcurrentBag<Guid>();

RunConcurrent(
() =>
{
RequestManager.XrplRequest request = manager.CreateRequest(
new Dictionary<string, object>(),
System.Threading.Timeout.InfiniteTimeSpan);
ids.Add(request.Id);
},
out IReadOnlyList<Exception> errors);

Assert.AreEqual(0, errors.Count,
$"Concurrent CreateRequest threw {errors.Count} exception(s): " +
string.Join(" | ", errors.Take(3).Select(e => e.Message)));

List<Guid> all = ids.ToList();
Assert.AreEqual(all.Count, all.Distinct().Count(),
"Concurrent CreateRequest assigned duplicate ids");
}

[TestMethod]
public void CreateGRequest_ConcurrentCalls_AssignUniqueIdsWithoutCollision()
{
RequestManager manager = new RequestManager();
ConcurrentBag<Guid> ids = new ConcurrentBag<Guid>();

RunConcurrent(
() =>
{
RequestManager.XrplGRequest request = manager.CreateGRequest<object, FakeRequest>(
new FakeRequest(),
System.Threading.Timeout.InfiniteTimeSpan);
ids.Add(request.Id);
},
out IReadOnlyList<Exception> errors);

Assert.AreEqual(0, errors.Count,
$"Concurrent CreateGRequest threw {errors.Count} exception(s): " +
string.Join(" | ", errors.Take(3).Select(e => e.Message)));

List<Guid> all = ids.ToList();
Assert.AreEqual(all.Count, all.Distinct().Count(),
"Concurrent CreateGRequest assigned duplicate ids");
}
}
}
10 changes: 9 additions & 1 deletion Xrpl/Client/Exceptions/XrplException.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,15 @@ public DisconnectedException(string message) : base(message)
/// </summary>
public class RippledNotInitializedException : XrplException { }
/// <summary>
/// Exception thrown when xrpl.js times out.
/// Exception thrown when a request to rippled times out.
/// <para>
/// IMPORTANT: this is <c>Xrpl.Client.Exceptions.TimeoutException</c>, NOT
/// <see cref="System.TimeoutException"/>. It derives from <see cref="XrplException"/>, so a
/// <c>catch (System.TimeoutException)</c> will NOT catch it. Catch this type (or its base
/// <see cref="XrplException"/>) explicitly; if both <c>using System;</c> and
/// <c>using Xrpl.Client.Exceptions;</c> are in scope, fully-qualify the type to avoid catching
/// the wrong one.
/// </para>
/// </summary>
public class TimeoutException : XrplException
{
Expand Down
44 changes: 11 additions & 33 deletions Xrpl/Client/RequestManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ public class XrplGRequest
public Task<object> Promise { get; set; }
}

private Guid nextId = Guid.NewGuid();
private readonly ConcurrentDictionary<Guid, Timer> timeoutsAwaitingResponse = new ConcurrentDictionary<Guid, Timer>();
private readonly ConcurrentDictionary<Guid, TaskInfo> promisesAwaitingResponse = new ConcurrentDictionary<Guid, TaskInfo>();
private readonly JsonSerializerOptions serializerOptions = XrplJsonOptions.Default;
Expand Down Expand Up @@ -166,35 +165,25 @@ public XrplGRequest CreateGRequest<T, R>(R request, TimeSpan timeout, Cancellati
$"Timeout must be positive or Timeout.InfiniteTimeSpan, but was {timeout.TotalSeconds:F1}s");
}

Guid newId;
var info = request.GetType().GetProperty("Id");
if (info.GetValue(request) == null)
{
newId = this.nextId;
this.nextId = Guid.NewGuid();
}
else
{
newId = (Guid)info.GetValue(request);
}
object existingId = info.GetValue(request);
Guid newId = existingId == null ? Guid.NewGuid() : (Guid)existingId;
Comment on lines +169 to +170

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Normalize caller-provided ids before casting to Guid.

Line 170 and Line 242 currently assume any provided id is already a non-empty Guid. This can throw (InvalidCastException) for string/object ids and can reuse Guid.Empty, causing duplicate-pending collisions.

Proposed fix
- object existingId = info.GetValue(request);
- Guid newId = existingId == null ? Guid.NewGuid() : (Guid)existingId;
+ object? existingId = info.GetValue(request);
+ Guid newId = existingId switch
+ {
+     Guid g when g != Guid.Empty => g,
+     string s when Guid.TryParse(s, out var parsed) && parsed != Guid.Empty => parsed,
+     _ => Guid.NewGuid()
+ };

- Guid newId = hasId ? (Guid)id : Guid.NewGuid();
+ Guid newId = hasId
+     ? id switch
+     {
+         Guid g when g != Guid.Empty => g,
+         string s when Guid.TryParse(s, out var parsed) && parsed != Guid.Empty => parsed,
+         _ => Guid.NewGuid()
+     }
+     : Guid.NewGuid();

Also applies to: 242-243

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Xrpl/Client/RequestManager.cs` around lines 169 - 170, The code in
RequestManager.cs that reads object existingId = info.GetValue(request) and
immediately casts to Guid (Guid newId = existingId == null ? Guid.NewGuid() :
(Guid)existingId) must normalize caller-provided ids before casting: replace
this logic with a safe normalization routine that (1) if existingId is a Guid
and not Guid.Empty use it, (2) if existingId is a string attempt Guid.TryParse
and use the parsed value if valid and non-empty, otherwise (3) generate
Guid.NewGuid(); also ensure the normalized Guid is written back to the request
if appropriate. Apply the same normalization fix to the similar code at the
other occurrence (around lines 242-243) so no InvalidCastException or reuse of
Guid.Empty can occur.


info.SetValue(request, newId, null);

string newRequest = JsonSerializer.Serialize(request, serializerOptions);

if (this.promisesAwaitingResponse.ContainsKey(newId))
{
throw new XrplException($"Response with id '${newId}' is already pending");
}

TaskCompletionSource<object> task = new TaskCompletionSource<object>();
TaskInfo taskInfo = new TaskInfo();
taskInfo.TaskId = newId;
taskInfo.TaskCompletionResult = task;
taskInfo.RemoveUponCompletion = true;
taskInfo.Type = typeof(T);

promisesAwaitingResponse.TryAdd(newId, taskInfo);
if (!promisesAwaitingResponse.TryAdd(newId, taskInfo))
{
throw new XrplException($"Response with id '${newId}' is already pending");
}

if (cancellationToken.CanBeCanceled)
{
Expand Down Expand Up @@ -249,35 +238,24 @@ public XrplRequest CreateRequest(Dictionary<string, object> request, TimeSpan ti
$"Timeout must be positive or Timeout.InfiniteTimeSpan, but was {timeout.TotalSeconds:F1}s");
}

Guid newId;
var hasId = request.TryGetValue("id", out var id);
if (!hasId)
{
newId = this.nextId;
this.nextId = Guid.NewGuid();
}
else
{
newId = (Guid)id;
}
Guid newId = hasId ? (Guid)id : Guid.NewGuid();

request["id"] = newId;

string newRequest = JsonSerializer.Serialize(request, serializerOptions);

if (this.promisesAwaitingResponse.ContainsKey(newId))
{
throw new XrplException($"Response with id '${newId}' is already pending");
}

TaskCompletionSource<Dictionary<string, object>> task = new TaskCompletionSource<Dictionary<string, object>>();
TaskInfo taskInfo = new TaskInfo();
taskInfo.TaskId = newId;
taskInfo.TaskCompletionResult = task;
taskInfo.RemoveUponCompletion = true;
taskInfo.Type = typeof(Dictionary<string, object>);

promisesAwaitingResponse.TryAdd(newId, taskInfo);
if (!promisesAwaitingResponse.TryAdd(newId, taskInfo))
{
throw new XrplException($"Response with id '${newId}' is already pending");
}

if (cancellationToken.CanBeCanceled)
{
Expand Down
38 changes: 35 additions & 3 deletions Xrpl/Client/connection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2419,14 +2419,14 @@ private void StartMessageProcessor()
{
if (cts.Token.IsCancellationRequested)
return;

try
{
await ProcessStreamMessageAsync(message).ConfigureAwait(false);
}
catch (Exception ex)
{
Debug.WriteLine($"{DateTime.Now}Stream message processing error: {ex.Message}");
await NotifyStreamProcessingErrorAsync(ex, message).ConfigureAwait(false);
}
}
}
Expand Down Expand Up @@ -2755,7 +2755,39 @@ private async Task ProcessStreamMessageFireAndForgetAsync(string message)
}
catch (Exception ex)
{
Debug.WriteLine($"{DateTime.Now}Stream message processing error: {ex.Message}");
await NotifyStreamProcessingErrorAsync(ex, message).ConfigureAwait(false);
}
}

/// <summary>
/// Surfaces an exception raised while processing a stream message — including exceptions
/// thrown by consumer stream handlers (e.g. <see cref="OnLedgerClosed"/>, <see cref="OnTransaction"/>) —
/// through the <see cref="OnError"/> event instead of swallowing it into a debug trace, so consumer
/// bugs are observable. The message loop is always kept alive: cancellation is ignored, and an
/// exception thrown by the <see cref="OnError"/> handler itself is contained.
/// </summary>
private async Task NotifyStreamProcessingErrorAsync(Exception ex, string message)
{
Debug.WriteLine($"{DateTime.Now}Stream message processing error: {ex.Message}");

if (ex is OperationCanceledException)
{
return;
}

var handler = OnError;
if (handler is null)
{
return;
}

try
{
await handler.Invoke(error: "error", errorMessage: "streamHandlerError", message: ex.Message, data: message).ConfigureAwait(false);
}
catch (Exception notifyEx)
{
Debug.WriteLine($"{DateTime.Now}OnError handler threw while reporting stream processing error: {notifyEx.Message}");
}
}
}
2 changes: 1 addition & 1 deletion Xrpl/Xrpl.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<PackageLicenseExpression>Apache-2.0</PackageLicenseExpression>
<PackageProjectUrl>https://github.com/StaticBit-io/XrplCSharp</PackageProjectUrl>
<Title>XrplCSharp</Title>
<PackageVersion>10.4.1.0</PackageVersion>
<PackageVersion>10.4.2.0</PackageVersion>
</PropertyGroup>

<PropertyGroup>
Expand Down
Loading