Skip to content

Commit 3ab3d4d

Browse files
authored
Merge pull request #134 from Cysharp/feature/ReleaseHandleOnCompleted
Release and dispose the native handles when releasing RequestContext
2 parents 9832f00 + c5ea5d0 commit 3ab3d4d

9 files changed

+161
-11
lines changed

src/YetAnotherHttpHandler/NativeHttpHandlerCore.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -617,6 +617,10 @@ private static unsafe void OnComplete(int reqSeq, IntPtr state, CompletionReason
617617
finally
618618
{
619619
requestContext.Release();
620+
621+
// NOTE: We need to dispose the request context in the thread pool.
622+
// If we call Dispose on the native thread, we will release the native handles and crash.
623+
ThreadPool.UnsafeQueueUserWorkItem(static r => ((RequestContext)r).Dispose(), requestContext);
620624
}
621625
}
622626

src/YetAnotherHttpHandler/RequestContext.cs

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ internal class RequestContext : IDisposable
2121
private readonly bool _hasRequestContextHandleRef;
2222
private GCHandle _handle;
2323

24+
private bool _handleReleased;
2425
internal YahaContextSafeHandle _ctxHandle;
2526
internal YahaRequestContextSafeHandle _requestContextHandle;
2627

@@ -69,6 +70,9 @@ public void Allocate()
6970
}
7071
}
7172

73+
/// <summary>
74+
/// Release the managed allocation of RequestContext. This method does not release the native handle, so Dispose must be called separately.
75+
/// </summary>
7276
public void Release()
7377
{
7478
Debug.Assert(_handle.IsAllocated);
@@ -77,7 +81,7 @@ public void Release()
7781
if (YahaEventSource.Log.IsEnabled()) YahaEventSource.Log.Trace($"[ReqSeq:{_requestSequence}:State:0x{Handle:X}] Releasing state");
7882
_handle.Free();
7983
_handle = default;
80-
_fullyCompleted.Set();
84+
_fullyCompleted.Set(); // Finalizer thread can release the native handles.
8185
}
8286
}
8387

@@ -125,6 +129,7 @@ private async Task RunReadRequestLoopAsync(CancellationToken cancellationToken)
125129
finally
126130
{
127131
if (YahaEventSource.Log.IsEnabled()) YahaEventSource.Log.Trace($"[ReqSeq:{_requestSequence}:State:0x{Handle:X}] Completing RunReadRequestLoopAsync");
132+
TryReleaseNativeHandles();
128133
}
129134
}
130135

@@ -309,8 +314,34 @@ public void CompleteAsFailed(string errorMessage, uint h2ErrorCode)
309314
_cancellationTokenSource.Cancel(); // Stop reading the request body.
310315
}
311316

317+
private void TryReleaseNativeHandles()
318+
{
319+
Debug.Assert(!_handle.IsAllocated);
320+
UnsafeUtilities.RequireRunningOnManagedThread();
321+
322+
lock (_handleLock)
323+
{
324+
if (_handleReleased)
325+
{
326+
return;
327+
}
328+
329+
if (YahaEventSource.Log.IsEnabled()) YahaEventSource.Log.Trace($"[ReqSeq:{_requestSequence}:State:0x{Handle:X}] Releasing native handles");
330+
// RequestContextHandle can be released after all the processes using it are complete.
331+
if (_hasRequestContextHandleRef)
332+
{
333+
Debug.Assert(!_requestContextHandle.IsClosed);
334+
_requestContextHandle.DangerousRelease();
335+
}
336+
_requestContextHandle.Dispose();
337+
338+
_handleReleased = true;
339+
}
340+
}
341+
312342
public void Dispose()
313343
{
344+
UnsafeUtilities.RequireRunningOnManagedThread();
314345
Dispose(true);
315346
GC.SuppressFinalize(this);
316347
}
@@ -342,13 +373,7 @@ private void Dispose(bool disposing)
342373
_fullyCompleted.Wait();
343374
}
344375

345-
// RequestContextHandle can be released after all the processes using it are complete.
346-
if (_hasRequestContextHandleRef)
347-
{
348-
Debug.Assert(!_requestContextHandle.IsClosed);
349-
_requestContextHandle.DangerousRelease();
350-
}
351-
_requestContextHandle.Dispose();
376+
TryReleaseNativeHandles();
352377
}
353378
}
354379
}

src/YetAnotherHttpHandler/ResponseContext.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ public void Complete()
115115
WaitForLatestFlush();
116116
_pipe.Writer.Complete();
117117
_completed = true;
118+
_tokenRegistration.Dispose();
118119
}
119120
}
120121

@@ -150,6 +151,7 @@ public void CompleteAsFailed(string errorMessage, uint h2ErrorCode)
150151
WaitForLatestFlush();
151152
_pipe.Writer.Complete(ex);
152153
_completed = true;
154+
_tokenRegistration.Dispose();
153155
}
154156
}
155157

@@ -164,6 +166,7 @@ public void Cancel()
164166
WaitForLatestFlush();
165167
_pipe.Writer.Complete(new OperationCanceledException(_cancellationToken));
166168
_completed = true;
169+
_tokenRegistration.Dispose();
167170
}
168171
}
169172

src/YetAnotherHttpHandler/UnsafeUtilities.cs

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
using System;
22
using System.Buffers;
3-
using System.Collections.Generic;
3+
using System.Diagnostics;
44
using System.Runtime.CompilerServices;
55
using System.Runtime.InteropServices;
66
using System.Text;
@@ -59,6 +59,73 @@ public static bool EqualsIgnoreCase(ref byte left, ref byte right, uint length)
5959
[MethodImpl(MethodImplOptions.AggressiveInlining)]
6060
static bool IsAsciiCodePoint(uint value) => value <= 0x7Fu;
6161
}
62+
63+
[Conditional("DEBUG")]
64+
public static void RequireRunningOnManagedThread()
65+
{
66+
// NOTE: This check logic is working only on Windows.
67+
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
68+
{
69+
return;
70+
}
71+
72+
var threadName = GetCurrentThreadName();
73+
if (threadName == "tokio-runtime-worker")
74+
{
75+
Environment.FailFast($"The current thread is the tokio worker thread.");
76+
}
77+
78+
static string GetCurrentThreadName()
79+
{
80+
const uint THREAD_QUERY_LIMITED_INFORMATION = 0x0800;
81+
82+
var threadId = GetCurrentThreadId();
83+
var threadName = string.Empty;
84+
var threadHandle = OpenThread(THREAD_QUERY_LIMITED_INFORMATION, false, threadId);
85+
86+
if (threadHandle != IntPtr.Zero)
87+
{
88+
try
89+
{
90+
IntPtr threadDescriptionPtr;
91+
var result = GetThreadDescription(threadHandle, out threadDescriptionPtr);
92+
93+
if (result >= 0 && threadDescriptionPtr != IntPtr.Zero)
94+
{
95+
try
96+
{
97+
threadName = Marshal.PtrToStringUni(threadDescriptionPtr);
98+
}
99+
finally
100+
{
101+
LocalFree(threadDescriptionPtr);
102+
}
103+
}
104+
}
105+
finally
106+
{
107+
CloseHandle(threadHandle);
108+
}
109+
}
110+
111+
return threadName ?? string.Empty;
112+
113+
[DllImport("kernel32.dll", SetLastError = true)]
114+
static extern bool CloseHandle(IntPtr hObject);
115+
116+
[DllImport("kernel32.dll", SetLastError = true)]
117+
static extern uint GetCurrentThreadId();
118+
119+
[DllImport("kernel32.dll", SetLastError = true)]
120+
static extern IntPtr OpenThread(uint dwDesiredAccess, bool bInheritHandle, uint dwThreadId);
121+
122+
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
123+
static extern int GetThreadDescription(IntPtr hThread, out IntPtr ppszThreadDescription);
124+
125+
[DllImport("kernel32.dll")]
126+
static extern IntPtr LocalFree(IntPtr hMem);
127+
}
128+
}
62129
}
63130

64131
internal readonly ref struct TempUtf8String

test/YetAnotherHttpHandler.Test/Helpers/NativeLibraryResolver.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ public static void Initialize()
1010
{
1111
NativeLibrary.SetDllImportResolver(typeof(Cysharp.Net.Http.YetAnotherHttpHandler).Assembly, (name, assembly, path) =>
1212
{
13+
if (!name.Contains("yaha_native") && !name.Contains("Cysharp.Net.Http.YetAnotherHttpHandler.Native"))
14+
{
15+
return nint.Zero;
16+
}
17+
1318
var ext = "";
1419
var prefix = "";
1520
var platform = "";

test/YetAnotherHttpHandler.Test/Helpers/TestWebAppServer.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
using System.Collections.Concurrent;
12
using System.Diagnostics;
23
using Microsoft.AspNetCore.Builder;
4+
using Microsoft.AspNetCore.Connections;
35
using Microsoft.AspNetCore.Hosting;
46
using Microsoft.AspNetCore.Server.Kestrel.Core;
57
using Microsoft.Extensions.DependencyInjection;
@@ -14,12 +16,17 @@ public class TestWebAppServer : IAsyncDisposable
1416
private readonly Task _appTask;
1517
private readonly IHostApplicationLifetime _appLifetime;
1618
private readonly TaskCompletionSource _waitForAppStarted;
19+
private readonly ConcurrentDictionary<string, bool> _activeConnectionsById = new();
20+
private int _activeConnections;
1721

1822
public int Port { get; }
1923
public bool IsSecure { get; }
2024

2125
public string BaseUri => $"{(IsSecure ? "https" : "http")}://localhost:{Port}";
2226

27+
public int ActiveConnections => _activeConnections;
28+
public IReadOnlyList<string> ActiveConnectionIds => _activeConnectionsById.Keys.ToArray();
29+
2330
private TestWebAppServer(int port, TestWebAppServerListenMode listenMode, ITestOutputHelper? testOutputHelper, Func<WebApplicationBuilder, WebApplication> webAppBuilder, Action<WebApplicationBuilder>? configure)
2431
{
2532
Port = port;
@@ -51,6 +58,21 @@ TestWebAppServerListenMode.SecureHttp2Only or
5158
{
5259
listenOptions.UseHttps();
5360
}
61+
62+
listenOptions.Use(async (ctx, next) =>
63+
{
64+
try
65+
{
66+
Interlocked.Increment(ref _activeConnections);
67+
_activeConnectionsById.TryAdd(ctx.ConnectionId, true);
68+
await next();
69+
}
70+
finally
71+
{
72+
Interlocked.Decrement(ref _activeConnections);
73+
_activeConnectionsById.TryRemove(ctx.ConnectionId, out _);
74+
}
75+
});
5476
});
5577
});
5678
if (testOutputHelper is not null)

test/YetAnotherHttpHandler.Test/Http2TestBase.cs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,7 @@ public async Task Cancel_Post_SendingBody()
307307
public async Task Cancel_Post_SendingBody_Duplex()
308308
{
309309
// Arrange
310-
using var httpHandler = CreateHandler();
310+
/*using*/ var httpHandler = CreateHandler();
311311
var httpClient = new HttpClient(httpHandler);
312312
await using var server = await LaunchServerAsync<TestServerForHttp1AndHttp2>();
313313

@@ -320,13 +320,29 @@ public async Task Cancel_Post_SendingBody_Duplex()
320320
Version = HttpVersion.Version20,
321321
Content = content,
322322
};
323+
323324
var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead).WaitAsync(TimeoutToken);
325+
var connectionId = response.Headers.TryGetValues("x-connection-id", out var values) ? string.Join(',', values) : string.Empty;
324326
var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
325327
var ex = await Record.ExceptionAsync(async () => await response.Content.ReadAsByteArrayAsync(cts.Token).WaitAsync(TimeoutToken));
326328

329+
pipe.Writer.Complete();
330+
httpHandler.Dispose();
331+
httpClient.Dispose();
332+
response = null;
333+
httpHandler = null;
334+
httpClient = null;
335+
336+
GC.Collect();
337+
GC.WaitForPendingFinalizers();
338+
Thread.Sleep(100);
339+
GC.Collect();
340+
Thread.Sleep(100);
341+
327342
// Assert
328343
var operationCanceledException = Assert.IsAssignableFrom<OperationCanceledException>(ex);
329344
Assert.Equal(cts.Token, operationCanceledException.CancellationToken);
345+
Assert.DoesNotContain(connectionId, server.ActiveConnectionIds);
330346
}
331347
#endif
332348

test/YetAnotherHttpHandler.Test/TestServerForHttp1AndHttp2.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ public static WebApplication BuildApplication(WebApplicationBuilder builder)
2424

2525
var app = builder.Build();
2626

27+
// ConnectionId header
28+
app.Use((ctx, next) =>
29+
{
30+
ctx.Response.Headers["x-connection-id"] = ctx.Connection.Id;
31+
return next(ctx);
32+
});
33+
2734
// SessionState
2835
app.Use((ctx, next) =>
2936
{

test/YetAnotherHttpHandler.Test/YetAnotherHttpHandlerTest.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,8 @@ public async Task InitializationFailure()
110110
}
111111

112112
// NOTE: Currently, this test can only be run on Windows.
113-
[Fact(Skip = "Due to the state remaining from Http2Test.Cancel_Post_SendingBody_Duplex, this test fails. Enable it after fixing that issue.")]
113+
[Fact]
114+
[OSSkipCondition(OperatingSystems.MacOSX | OperatingSystems.Linux)]
114115
public async Task SetWorkerThreads()
115116
{
116117
GC.Collect();

0 commit comments

Comments
 (0)