Skip to content

Commit 53e179f

Browse files
authored
[fix] Recursion causing stack overflow when disposing client/service (#588)
- Fix recursion causing stack overflow when disposing client/service - Add unit tests to verify disposal does not cause stack overflow - Don't dispose of client via service due to shared reference (service can be disposed of via client)
1 parent 435251a commit 53e179f

15 files changed

+197
-86
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## Next Release
44

55
- Corrects all API documentation link references to point to their new locations
6+
- Fix recursion during disposal causing stack overflow
67

78
## v6.7.2 (2024-08-16)
89

EasyPost.Tests/ClientTest.cs

+8-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using System;
2-
using System.Linq;
32
using System.Net;
43
using System.Net.Http;
54
using System.Threading;
@@ -9,6 +8,7 @@
98
using EasyPost.Tests._Utilities;
109
using EasyPost.Tests._Utilities.Attributes;
1110
using Xunit;
11+
using CustomAssertions = EasyPost.Tests._Utilities.Assertions.Assert;
1212

1313
namespace EasyPost.Tests
1414
{
@@ -70,6 +70,13 @@ public void TestThreadSafety()
7070

7171
private const string FakeApikey = "fake_api_key";
7272

73+
[Fact]
74+
public void TestClientDisposal()
75+
{
76+
Client client = new(new ClientConfiguration(FakeApikey));
77+
CustomAssertions.DoesNotThrow(() => client.Dispose());
78+
}
79+
7380
[Fact]
7481
public void TestBaseUrlOverride()
7582
{

EasyPost.Tests/HttpTests/ClientConfigurationTest.cs

+8
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
using System;
22
using EasyPost.Tests._Utilities.Attributes;
33
using Xunit;
4+
using CustomAssertions = EasyPost.Tests._Utilities.Assertions.Assert;
45

56
namespace EasyPost.Tests.HttpTests
67
{
78
public class ClientConfigurationTest
89
{
910
#region Tests
1011

12+
[Fact]
13+
public void TestClientConfigurationDisposal()
14+
{
15+
ClientConfiguration configuration = new("not_a_real_api_key");
16+
CustomAssertions.DoesNotThrow(() => configuration.Dispose());
17+
}
18+
1119
[Fact]
1220
[Testing.Function]
1321
public void TestClientConfiguration()
+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
using System.Collections.Generic;
2+
using EasyPost._base;
3+
using EasyPost.Http;
4+
using Xunit;
5+
using CustomAssertions = EasyPost.Tests._Utilities.Assertions.Assert;
6+
7+
namespace EasyPost.Tests.HttpTests;
8+
9+
public class RequestTest
10+
{
11+
#region Tests
12+
13+
[Fact]
14+
public void TestRequestDisposal()
15+
{
16+
Request request = new("https://google.com", "not_a_real_endpoint", Method.Get, ApiVersion.V2, new Dictionary<string, object> { { "key", "value" } }, new Dictionary<string, string> { { "header_key", "header_value" } });
17+
CustomAssertions.DoesNotThrow(() => request.Dispose());
18+
}
19+
20+
#endregion
21+
}

EasyPost.Tests/ServicesTests/ServiceTest.cs

+11
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55
using EasyPost.Exceptions.General;
66
using EasyPost.Http;
77
using EasyPost.Models.API;
8+
using EasyPost.Services;
89
using EasyPost.Tests._Utilities;
910
using EasyPost.Tests._Utilities.Attributes;
1011
using EasyPost.Utilities.Internal.Attributes;
1112
using Xunit;
13+
using CustomAssertions = EasyPost.Tests._Utilities.Assertions.Assert;
1214

1315
namespace EasyPost.Tests.ServicesTests
1416
{
@@ -21,6 +23,15 @@ public ServiceTests() : base("base_service")
2123
{
2224
}
2325

26+
[Fact]
27+
public void TestServiceDisposal()
28+
{
29+
Client client = new(new ClientConfiguration("not_a_real_api_key"));
30+
31+
AddressService addressService = client.Address;
32+
CustomAssertions.DoesNotThrow(() => addressService.Dispose());
33+
}
34+
2435
/// <summary>
2536
/// This test confirms that the GetNextPage method works as expected.
2637
/// This method is implemented on a per-service, but rather than testing in each service, we'll test it once here.

EasyPost.Tests/_Utilities/Assertions/AnyException.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
namespace EasyPost.Tests._Utilities.Assertions
44
{
55
/// <summary>
6-
/// Exception thrown when an Any assertion has one or more items fail an assertion.
6+
/// Exception thrown when an Any assertion has one or more items fail an assertion.
77
/// </summary>
88
public class AnyException : XunitException
99
{

EasyPost.Tests/_Utilities/Assertions/CollectionAsserts.cs

-6
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,6 @@ namespace EasyPost.Tests._Utilities.Assertions
66
// ReSharper disable once PartialTypeWithSinglePart
77
public abstract partial class Assert
88
{
9-
private static void GuardArgumentNotNull(string argName, object argValue)
10-
{
11-
if (argValue == null)
12-
throw new ArgumentNullException(argName);
13-
}
14-
159
/// <summary>
1610
/// Verifies that any items in the collection pass when executed against
1711
/// action.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
using System;
2+
3+
namespace EasyPost.Tests._Utilities.Assertions
4+
{
5+
// ReSharper disable once PartialTypeWithSinglePart
6+
public abstract partial class Assert
7+
{
8+
/// <summary>
9+
/// Verifies that an action does not throw an exception.
10+
/// </summary>
11+
/// <param name="action">The action to test</param>
12+
/// <exception cref="DoesNotThrowException">Thrown when the action throws an exception</exception>
13+
public static void DoesNotThrow(Action action)
14+
{
15+
GuardArgumentNotNull(nameof(action), action);
16+
17+
try
18+
{
19+
action();
20+
}
21+
catch (Exception ex)
22+
{
23+
throw new DoesNotThrowException(ex);
24+
}
25+
}
26+
27+
/// <summary>
28+
/// Verifies that an action does not throw a specific exception.
29+
/// </summary>
30+
/// <param name="action">The action to test</param>
31+
/// <typeparam name="T">The type of the exception to not throw</typeparam>
32+
/// <exception cref="DoesNotThrowException">Thrown when the action throws an exception of type T</exception>
33+
public static void DoesNotThrow<T>(Action action) where T : Exception
34+
{
35+
GuardArgumentNotNull(nameof(action), action);
36+
37+
try
38+
{
39+
action();
40+
}
41+
catch (Exception ex)
42+
{
43+
if (ex.GetType() == typeof(T))
44+
{
45+
throw new DoesNotThrowException(ex);
46+
}
47+
}
48+
}
49+
}
50+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using System;
2+
using Xunit.Sdk;
3+
4+
namespace EasyPost.Tests._Utilities.Assertions
5+
{
6+
/// <summary>
7+
/// Exception thrown when a DoesNotThrow assertion fails.
8+
/// </summary>
9+
public class DoesNotThrowException : XunitException
10+
{
11+
/// <summary>
12+
/// Creates a new instance of the <see cref="DoesNotThrowException"/> class.
13+
/// </summary>
14+
public DoesNotThrowException(Exception ex)
15+
: base("Assert.DoesNotThrow() Failure", ex)
16+
{
17+
}
18+
}
19+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
using System;
2+
3+
namespace EasyPost.Tests._Utilities.Assertions
4+
{
5+
// ReSharper disable once PartialTypeWithSinglePart
6+
public abstract partial class Assert
7+
{
8+
private static void GuardArgumentNotNull(string argName, object argValue)
9+
{
10+
if (argValue == null)
11+
throw new ArgumentNullException(argName);
12+
}
13+
}
14+
}

EasyPost/Client.cs

+36-40
Original file line numberDiff line numberDiff line change
@@ -190,46 +190,42 @@ public Client(ClientConfiguration configuration)
190190
/// <inheritdoc cref="EasyPostClient.Dispose(bool)"/>
191191
protected override void Dispose(bool disposing)
192192
{
193-
// ref: https://dzone.com/articles/when-and-how-to-use-dispose-and-finalize-in-c
194-
// ref: https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1063#pseudo-code-example
195-
if (disposing)
196-
{
197-
// Dispose managed state (managed objects).
198-
// "disposing" inherently true when called from Dispose(), so don't need to pass it in.
199-
200-
// Dispose of the services
201-
Address.Dispose();
202-
ApiKey.Dispose();
203-
Batch.Dispose();
204-
Billing.Dispose();
205-
CarrierAccount.Dispose();
206-
CarrierMetadata.Dispose();
207-
CarrierType.Dispose();
208-
Claim.Dispose();
209-
CustomsInfo.Dispose();
210-
CustomsItem.Dispose();
211-
EndShipper.Dispose();
212-
Event.Dispose();
213-
Insurance.Dispose();
214-
Order.Dispose();
215-
Parcel.Dispose();
216-
Pickup.Dispose();
217-
Rate.Dispose();
218-
ReferralCustomer.Dispose();
219-
Refund.Dispose();
220-
Report.Dispose();
221-
ScanForm.Dispose();
222-
Shipment.Dispose();
223-
SmartRate.Dispose();
224-
Tracker.Dispose();
225-
User.Dispose();
226-
Webhook.Dispose();
227-
228-
// Dispose of the Beta client
229-
Beta.Dispose();
230-
}
231-
232-
// Free native resources (unmanaged objects) and override a finalizer below.
193+
if (!disposing) return;
194+
195+
// Dispose managed state (managed objects)
196+
197+
// "disposing" inherently true when called from Dispose(), so don't need to pass it in.
198+
199+
// Attempt to dispose of the services (some may already be disposed)
200+
Address.Dispose();
201+
ApiKey.Dispose();
202+
Batch.Dispose();
203+
Billing.Dispose();
204+
CarrierAccount.Dispose();
205+
CarrierMetadata.Dispose();
206+
CarrierType.Dispose();
207+
Claim.Dispose();
208+
CustomsInfo.Dispose();
209+
CustomsItem.Dispose();
210+
EndShipper.Dispose();
211+
Event.Dispose();
212+
Insurance.Dispose();
213+
Order.Dispose();
214+
Parcel.Dispose();
215+
Pickup.Dispose();
216+
Rate.Dispose();
217+
ReferralCustomer.Dispose();
218+
Refund.Dispose();
219+
Report.Dispose();
220+
ScanForm.Dispose();
221+
Shipment.Dispose();
222+
SmartRate.Dispose();
223+
Tracker.Dispose();
224+
User.Dispose();
225+
Webhook.Dispose();
226+
227+
// Attempt to dispose of the Beta client (may already be disposed)
228+
Beta.Dispose();
233229

234230
// Dispose of the base client
235231
base.Dispose(disposing);

EasyPost/ClientConfiguration.cs

+8-11
Original file line numberDiff line numberDiff line change
@@ -147,21 +147,18 @@ public void Dispose()
147147
/// <inheritdoc cref="EasyPostClient.Dispose(bool)"/>
148148
protected virtual void Dispose(bool disposing)
149149
{
150-
if (_isDisposed) return;
150+
if (!disposing || _isDisposed) return;
151151

152-
if (disposing)
153-
{
154-
// Dispose managed state (managed objects).
152+
// Set the disposed flag to true before disposing of the object to avoid infinite loops
153+
_isDisposed = true;
155154

156-
// dispose of the prepared HTTP client
157-
PreparedHttpClient?.Dispose();
155+
// Dispose managed state (managed objects)
158156

159-
// dispose of the user-provided HTTP client
160-
CustomHttpClient?.Dispose();
161-
}
157+
// Attempt to dispose of the prepared HTTP client (may already be disposed)
158+
PreparedHttpClient?.Dispose();
162159

163-
// Free native resources (unmanaged objects) and override a finalizer below.
164-
_isDisposed = true;
160+
// Attempt to dispose of the user-provided HTTP client (may already be disposed)
161+
CustomHttpClient?.Dispose();
165162
}
166163

167164
/// <summary>

EasyPost/Http/Request.cs

+7-9
Original file line numberDiff line numberDiff line change
@@ -159,17 +159,15 @@ public void Dispose()
159159
/// <inheritdoc cref="EasyPostClient.Dispose(bool)"/>
160160
protected virtual void Dispose(bool disposing)
161161
{
162-
if (_isDisposed) return;
163-
if (disposing)
164-
{
165-
// Dispose managed state (managed objects).
166-
167-
// Dispose the request message
168-
_requestMessage.Dispose();
169-
}
162+
if (!disposing || _isDisposed) return;
170163

171-
// Free native resources (unmanaged objects) and override a finalizer below.
164+
// Set the disposed flag to true before disposing of the object to avoid infinite loops
172165
_isDisposed = true;
166+
167+
// Dispose managed state (managed objects)
168+
169+
// Dispose the request message
170+
_requestMessage.Dispose();
173171
}
174172

175173
/// <summary>

EasyPost/_base/EasyPostClient.cs

+7-9
Original file line numberDiff line numberDiff line change
@@ -222,17 +222,15 @@ public void Dispose()
222222
/// <param name="disposing">Whether this object is being disposed.</param>
223223
protected virtual void Dispose(bool disposing)
224224
{
225-
if (_isDisposed) return;
226-
if (disposing)
227-
{
228-
// Dispose managed state (managed objects).
229-
230-
// Dispose the configuration
231-
_configuration.Dispose();
232-
}
225+
if (!disposing || _isDisposed) return;
233226

234-
// Free native resources (unmanaged objects) and override a finalizer below.
227+
// Set the disposed flag to true before disposing of the object to avoid infinite loops
235228
_isDisposed = true;
229+
230+
// Dispose managed state (managed objects)
231+
232+
// Dispose of the configuration
233+
_configuration.Dispose();
236234
}
237235

238236
/// <summary>

0 commit comments

Comments
 (0)