From dafe3eac3e76b764c40e62cd05fd834cbcde6d59 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:23:07 +0000 Subject: [PATCH 1/5] =?UTF-8?q?perf:=20Phase=201=20=E2=80=94=20cache=20reg?= =?UTF-8?q?ex,=20static=20Range=20regex,=20.Count,=20Array.Empty,=20string?= =?UTF-8?q?=20interpolation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/hibri/HttpMock/sessions/0c587fbf-086a-44c4-89e8-15fc96b56d3b Co-authored-by: hibri <122442+hibri@users.noreply.github.com> --- src/HttpMock/BufferedBody.cs | 2 +- src/HttpMock/EndpointMatchingRule.cs | 5 ++++- src/HttpMock/FileResponseBody.cs | 18 +++++++++++------- src/HttpMock/HttpMockRepository.cs | 2 +- src/HttpMock/HttpServer.cs | 4 ++-- src/HttpMock/RequestProcessor.cs | 4 ++-- src/HttpMock/ResponseBuilder.cs | 4 ++-- src/HttpMock/StubNotFoundResponse.cs | 2 +- 8 files changed, 24 insertions(+), 17 deletions(-) diff --git a/src/HttpMock/BufferedBody.cs b/src/HttpMock/BufferedBody.cs index e1c67c7..acb3d40 100644 --- a/src/HttpMock/BufferedBody.cs +++ b/src/HttpMock/BufferedBody.cs @@ -29,7 +29,7 @@ public void SetRequestHeaders(IDictionary requestHeaders) { } class NoBody : IResponse { - public byte[] GetBytes() => new byte[0]; + public byte[] GetBytes() => Array.Empty(); public void SetRequestHeaders(IDictionary requestHeaders) { } } diff --git a/src/HttpMock/EndpointMatchingRule.cs b/src/HttpMock/EndpointMatchingRule.cs index f81f4d8..ea7856d 100644 --- a/src/HttpMock/EndpointMatchingRule.cs +++ b/src/HttpMock/EndpointMatchingRule.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.Specialized; using System.Linq; @@ -9,6 +10,7 @@ namespace HttpMock { public class EndpointMatchingRule : IMatchingRule { + private static readonly ConcurrentDictionary PathRegexCache = new(); private readonly HeaderMatch _headerMatch; private readonly QueryParamMatch _queryParamMatch; @@ -55,7 +57,8 @@ private static bool MatchPath(IRequestHandler requestHandler, IHttpRequestHead r { pathToMatch = request.Uri.Substring(0, positionOfQueryStart); } - var pathMatch = new Regex(string.Format(@"^{0}\/*$", Regex.Escape(requestHandler.Path))); + var pathMatch = PathRegexCache.GetOrAdd(requestHandler.Path, + static path => new Regex($@"^{Regex.Escape(path)}\/*$", RegexOptions.Compiled)); return pathMatch.IsMatch(pathToMatch); } diff --git a/src/HttpMock/FileResponseBody.cs b/src/HttpMock/FileResponseBody.cs index 370e0a1..c96d000 100644 --- a/src/HttpMock/FileResponseBody.cs +++ b/src/HttpMock/FileResponseBody.cs @@ -9,6 +9,7 @@ namespace HttpMock { class FileResponseBody : IResponse { + private static readonly Regex RangeRegex = new(@"bytes=([\d]*)-([\d]*)", RegexOptions.Compiled); private readonly string _filepath; private readonly ILogger _log; private IDictionary _requestHeaders; @@ -23,26 +24,29 @@ public byte[] GetBytes() var fileInfo = new FileInfo(_filepath); using (FileStream fileStream = fileInfo.Open(FileMode.Open, FileAccess.Read)) { - var buffer = new byte[fileInfo.Length]; - fileStream.ReadExactly(buffer, 0, (int)fileInfo.Length); int length = (int)fileInfo.Length; int offset = 0; if (_requestHeaders != null && _requestHeaders.ContainsKey(HttpRequestHeader.Range.ToString())) { string range = _requestHeaders[HttpRequestHeader.Range.ToString()]; - Regex rangeEx = new Regex(@"bytes=([\d]*)-([\d]*)"); - if (rangeEx.IsMatch(range)) + var match = RangeRegex.Match(range); + if (match.Success) { - int from = Convert.ToInt32(rangeEx.Match(range).Groups[1].Value); - int to = Convert.ToInt32(rangeEx.Match(range).Groups[2].Value); + int from = Convert.ToInt32(match.Groups[1].Value); + int to = Convert.ToInt32(match.Groups[2].Value); offset = from; length = (to - from) + 1; } } + if (offset > 0) + { + fileStream.Seek(offset, SeekOrigin.Begin); + } + var result = new byte[length]; - Array.Copy(buffer, offset, result, 0, length); + fileStream.ReadExactly(result, 0, length); _log.LogDebug("Wrote {Length} bytes to buffer", length); return result; } diff --git a/src/HttpMock/HttpMockRepository.cs b/src/HttpMock/HttpMockRepository.cs index d71f482..69fcb7d 100644 --- a/src/HttpMock/HttpMockRepository.cs +++ b/src/HttpMock/HttpMockRepository.cs @@ -10,7 +10,7 @@ public static class HttpMockRepository public static IHttpServer At(string uri, ILoggerFactory loggerFactory = null) { if (uri.Trim().EndsWith("/")) { throw new ArgumentException( - String.Format("Do not use a trailing slash for the server URI please: {0}", uri), "uri"); + $"Do not use a trailing slash for the server URI please: {uri}", "uri"); } return At(new Uri(uri), loggerFactory); } diff --git a/src/HttpMock/HttpServer.cs b/src/HttpMock/HttpServer.cs index e9cfebd..c0c8118 100644 --- a/src/HttpMock/HttpServer.cs +++ b/src/HttpMock/HttpServer.cs @@ -49,10 +49,10 @@ public void Start() if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux) && !RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && !string.IsNullOrEmpty(host) && host != "+" && host != "*" && host != "localhost") { - _listener.Prefixes.Add(string.Format("http://{0}:{1}/", host, _uri.Port)); + _listener.Prefixes.Add($"http://{host}:{_uri.Port}/"); } - _listener.Prefixes.Add(string.Format("http://+:{0}/", _uri.Port)); + _listener.Prefixes.Add($"http://+:{_uri.Port}/"); _listener.Start(); _running = true; diff --git a/src/HttpMock/RequestProcessor.cs b/src/HttpMock/RequestProcessor.cs index d4faafb..e49bdf0 100644 --- a/src/HttpMock/RequestProcessor.cs +++ b/src/HttpMock/RequestProcessor.cs @@ -108,7 +108,7 @@ private static string SanitizeForLog(string value) => value?.Replace("\r", "\\r").Replace("\n", "\\n"); private int GetHandlerCount() { - return _handlers.Count(); + return _handlers.Count; } public IRequestVerify FindHandler(string method, string path) { @@ -132,7 +132,7 @@ private static void ReturnHttpMockNotFound(Action var notFoundResponse = new HttpMockResponseHead { - Status = string.Format("{0} {1}", 404, "NotFound"), + Status = $"{404} NotFound", Headers = dictionary }; respond(notFoundResponse, null); diff --git a/src/HttpMock/ResponseBuilder.cs b/src/HttpMock/ResponseBuilder.cs index cb2ec81..faba8ba 100644 --- a/src/HttpMock/ResponseBuilder.cs +++ b/src/HttpMock/ResponseBuilder.cs @@ -50,7 +50,7 @@ public HttpMockResponseHead BuildHeaders(int? contentLength = null) { return new HttpMockResponseHead { - Status = string.Format("{0} {1}", (int)_httpStatusCode, _httpStatusCode), + Status = $"{(int)_httpStatusCode} {_httpStatusCode}", Headers = _headers }; } @@ -80,7 +80,7 @@ public void WithFileRange(string pathToFile, int from, int to) var fileInfo = new FileInfo(pathToFile); _contentLength = () => (to - from) + 1; _response = new FileResponseBody(pathToFile); - AddHeader(HttpResponseHeader.ContentRange.ToString(), string.Format("bytes={0}-{1}/{2}", from, to, fileInfo.Length)); + AddHeader(HttpResponseHeader.ContentRange.ToString(), $"bytes={from}-{to}/{fileInfo.Length}"); } else { diff --git a/src/HttpMock/StubNotFoundResponse.cs b/src/HttpMock/StubNotFoundResponse.cs index 28815e2..5e58ce8 100644 --- a/src/HttpMock/StubNotFoundResponse.cs +++ b/src/HttpMock/StubNotFoundResponse.cs @@ -6,7 +6,7 @@ public class StubNotFoundResponse : IStubResponse { public ResponseBuilder Get(IHttpRequestHead request) { var stubNotFoundResponseBuilder = new ResponseBuilder(); - stubNotFoundResponseBuilder.Return(string.Format("Stub not found for {0} : {1}", request.Method, request.Uri)); + stubNotFoundResponseBuilder.Return($"Stub not found for {request.Method} : {request.Uri}"); stubNotFoundResponseBuilder.WithStatus(HttpStatusCode.NotFound); return stubNotFoundResponseBuilder; } From fa42a4246fa975d319fc6580803291ec59c54a2f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:24:25 +0000 Subject: [PATCH 2/5] =?UTF-8?q?perf:=20Phase=202=20=E2=80=94=20O(1)=20head?= =?UTF-8?q?er=20match,=20OrdinalIgnoreCase,=20guard=20log=20calls,=20Buffe?= =?UTF-8?q?redBody.Length?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/hibri/HttpMock/sessions/0c587fbf-086a-44c4-89e8-15fc96b56d3b Co-authored-by: hibri <122442+hibri@users.noreply.github.com> --- src/HttpMock/BufferedBody.cs | 2 +- src/HttpMock/HeaderMatch.cs | 30 +++++++++++++++++++++--------- src/HttpMock/HttpServer.cs | 16 ++++++---------- src/HttpMock/RequestProcessor.cs | 10 ++++++++-- 4 files changed, 36 insertions(+), 22 deletions(-) diff --git a/src/HttpMock/BufferedBody.cs b/src/HttpMock/BufferedBody.cs index acb3d40..4fd21e8 100644 --- a/src/HttpMock/BufferedBody.cs +++ b/src/HttpMock/BufferedBody.cs @@ -17,7 +17,7 @@ public BufferedBody(Func data) _dataFunc = data; } - public Func Length => () => _dataFunc().Length; + public Func Length => () => GetBytes().Length; public byte[] GetBytes() { diff --git a/src/HttpMock/HeaderMatch.cs b/src/HttpMock/HeaderMatch.cs index f6d2b37..718858b 100644 --- a/src/HttpMock/HeaderMatch.cs +++ b/src/HttpMock/HeaderMatch.cs @@ -7,17 +7,29 @@ namespace HttpMock public class HeaderMatch { internal bool MatchHeaders(IRequestHandler requestHandler, IDictionary requestHeaders) { - return requestHandler.RequestHeaders.All( - expectedHeader => requestHeaders.Any(header => HeadersMatch(expectedHeader, header))); - } - - private static bool HeadersMatch(KeyValuePair expectedHeader, KeyValuePair header) - { - if (!string.Equals(expectedHeader.Key, header.Key, StringComparison.OrdinalIgnoreCase)) + foreach (var expectedHeader in requestHandler.RequestHeaders) { - return false; + if (!requestHeaders.TryGetValue(expectedHeader.Key, out var actualValue)) + { + // Try case-insensitive key lookup as a fallback + bool found = false; + foreach (var header in requestHeaders) + { + if (string.Equals(header.Key, expectedHeader.Key, StringComparison.OrdinalIgnoreCase) + && string.Equals(header.Value, expectedHeader.Value, StringComparison.OrdinalIgnoreCase)) + { + found = true; + break; + } + } + if (!found) return false; + } + else if (!string.Equals(actualValue, expectedHeader.Value, StringComparison.OrdinalIgnoreCase)) + { + return false; + } } - return string.Equals(expectedHeader.Value, header.Value, StringComparison.OrdinalIgnoreCase); + return true; } } } \ No newline at end of file diff --git a/src/HttpMock/HttpServer.cs b/src/HttpMock/HttpServer.cs index c0c8118..9041998 100644 --- a/src/HttpMock/HttpServer.cs +++ b/src/HttpMock/HttpServer.cs @@ -189,17 +189,13 @@ private static void WriteResponse(HttpListenerResponse response, HttpMockRespons { foreach (var header in head.Headers) { - switch (header.Key.ToLowerInvariant()) + if (string.Equals(header.Key, "Content-Type", StringComparison.OrdinalIgnoreCase)) { - case "content-type": - response.ContentType = header.Value; - break; - case "content-length": - // set below via ContentLength64 - break; - default: - response.Headers[header.Key] = header.Value; - break; + response.ContentType = header.Value; + } + else if (!string.Equals(header.Key, "Content-Length", StringComparison.OrdinalIgnoreCase)) + { + response.Headers[header.Key] = header.Value; } } } diff --git a/src/HttpMock/RequestProcessor.cs b/src/HttpMock/RequestProcessor.cs index e49bdf0..a58e21c 100644 --- a/src/HttpMock/RequestProcessor.cs +++ b/src/HttpMock/RequestProcessor.cs @@ -23,7 +23,10 @@ public RequestProcessor(IMatchingRule matchingRule, IRequestHandlerList requestH } public void OnRequest(IHttpRequestHead request, Stream requestBody, Action respond) { - _log.LogDebug("Start Processing request for : {Method}:{Uri}", SanitizeForLog(request.Method), SanitizeForLog(request.Uri)); + if (_log.IsEnabled(LogLevel.Debug)) + { + _log.LogDebug("Start Processing request for : {Method}:{Uri}", SanitizeForLog(request.Method), SanitizeForLog(request.Uri)); + } if (GetHandlerCount() < 1) { using var noHandlersActivity = HttpMockActivitySource.Source.StartActivity("httpmock.request"); noHandlersActivity?.SetTag("http.request.method", request.Method); @@ -101,7 +104,10 @@ private async Task HandleRequest(IHttpRequestHead request, string bufferedBody, var statusCode = statusParts is { Length: > 0 } ? statusParts[0] : null; activity?.SetTag("http.response.status_code", statusCode); respond(responseHead, responseBody); - _log.LogDebug("End Processing request for : {Method}:{Uri}", SanitizeForLog(request.Method), SanitizeForLog(request.Uri)); + if (_log.IsEnabled(LogLevel.Debug)) + { + _log.LogDebug("End Processing request for : {Method}:{Uri}", SanitizeForLog(request.Method), SanitizeForLog(request.Uri)); + } } private static string SanitizeForLog(string value) => From b77cdf5023bdad6e1588deba3fe4af4b2fccc18b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:26:43 +0000 Subject: [PATCH 3/5] =?UTF-8?q?perf:=20Phase=203=20=E2=80=94=20single-pass?= =?UTF-8?q?=20matcher,=20short-circuit=20matching,=20pre-sized=20dictionar?= =?UTF-8?q?y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/hibri/HttpMock/sessions/0c587fbf-086a-44c4-89e8-15fc96b56d3b Co-authored-by: hibri <122442+hibri@users.noreply.github.com> --- src/HttpMock/EndpointMatchingRule.cs | 21 +++++++++++---------- src/HttpMock/HttpServer.cs | 2 +- src/HttpMock/RequestMatcher.cs | 16 ++++++++++------ 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/HttpMock/EndpointMatchingRule.cs b/src/HttpMock/EndpointMatchingRule.cs index ea7856d..6a168d2 100644 --- a/src/HttpMock/EndpointMatchingRule.cs +++ b/src/HttpMock/EndpointMatchingRule.cs @@ -24,29 +24,30 @@ public bool IsEndpointMatch(IRequestHandler requestHandler, IHttpRequestHead req if (requestHandler.QueryParams == null) throw new ArgumentException("requestHandler QueryParams cannot be null"); - var requestQueryParams = GetQueryParams(request); - var requestHeaders = GetHeaders(request); + // Check cheapest conditions first before parsing query/headers + bool httpMethodsMatch = requestHandler.Method == request.Method; + if (!httpMethodsMatch) return false; bool uriStartsWith = MatchPath(requestHandler, request); + if (!uriStartsWith) return false; - bool httpMethodsMatch = requestHandler.Method == request.Method; - - bool queryParamMatch = true; bool shouldMatchQueryParams = (requestHandler.QueryParams.Count > 0); - if (shouldMatchQueryParams) { - queryParamMatch = _queryParamMatch.MatchQueryParams(requestHandler, requestQueryParams); + var requestQueryParams = GetQueryParams(request); + if (!_queryParamMatch.MatchQueryParams(requestHandler, requestQueryParams)) + return false; } - bool headerMatch = true; bool shouldMatchHeaders = requestHandler.RequestHeaders != null && requestHandler.RequestHeaders.Count > 0; if (shouldMatchHeaders) { - headerMatch = _headerMatch.MatchHeaders(requestHandler, requestHeaders); + var requestHeaders = GetHeaders(request); + if (!_headerMatch.MatchHeaders(requestHandler, requestHeaders)) + return false; } - return uriStartsWith && httpMethodsMatch && queryParamMatch && headerMatch; + return true; } private static bool MatchPath(IRequestHandler requestHandler, IHttpRequestHead request) diff --git a/src/HttpMock/HttpServer.cs b/src/HttpMock/HttpServer.cs index 9041998..e832c7d 100644 --- a/src/HttpMock/HttpServer.cs +++ b/src/HttpMock/HttpServer.cs @@ -149,7 +149,7 @@ private void HandleContext(HttpListenerContext context) { try { - var headers = new Dictionary(); + var headers = new Dictionary(context.Request.Headers.Count); foreach (string key in context.Request.Headers) { if (key != null) diff --git a/src/HttpMock/RequestMatcher.cs b/src/HttpMock/RequestMatcher.cs index d2e4457..a1ec956 100644 --- a/src/HttpMock/RequestMatcher.cs +++ b/src/HttpMock/RequestMatcher.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.Linq; namespace HttpMock { @@ -19,12 +18,17 @@ public RequestMatcher(IMatchingRule matchingRule) public IRequestHandler Match(IHttpRequestHead request, IEnumerable requestHandlerList, string body = null) { - var matches = requestHandlerList - .Where(handler => _matchingRule.IsEndpointMatch(handler, request)) - .Where(handler => handler.CanVerifyConstraintsFor(request.Uri)) - .Where(handler => handler.MatchesBody(body)); + foreach (var handler in requestHandlerList) + { + if (_matchingRule.IsEndpointMatch(handler, request) + && handler.CanVerifyConstraintsFor(request.Uri) + && handler.MatchesBody(body)) + { + return handler; + } + } - return matches.FirstOrDefault(); + return null; } } } \ No newline at end of file From 1d9bb1fbbbc494de7fbfbb721a4167282055bb6d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:30:12 +0000 Subject: [PATCH 4/5] =?UTF-8?q?perf:=20Phase=204=20=E2=80=94=20async=20bod?= =?UTF-8?q?y=20read,=20async=20listener=20loop,=20double-check=20lock=20in?= =?UTF-8?q?=20HttpServerFactory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/hibri/HttpMock/sessions/0c587fbf-086a-44c4-89e8-15fc96b56d3b Co-authored-by: hibri <122442+hibri@users.noreply.github.com> --- .../RequestProcessorTests.cs | 21 ++++++++-------- src/HttpMock/HttpServer.cs | 24 +++++++++---------- src/HttpMock/HttpServerFactory.cs | 13 ++++++++-- src/HttpMock/IRequestProcessor.cs | 3 ++- src/HttpMock/RequestProcessor.cs | 9 +++---- 5 files changed, 39 insertions(+), 31 deletions(-) diff --git a/src/HttpMock.Unit.Tests/RequestProcessorTests.cs b/src/HttpMock.Unit.Tests/RequestProcessorTests.cs index 295d4ad..16e0571 100644 --- a/src/HttpMock.Unit.Tests/RequestProcessorTests.cs +++ b/src/HttpMock.Unit.Tests/RequestProcessorTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using Moq; using NUnit.Framework; @@ -65,21 +66,21 @@ public void Custom_verb_should_return_handler_with_custom_method_set() { } [Test] - public void If_no_handlers_found_should_fire_onresponse_with_a_404() { + public async Task If_no_handlers_found_should_fire_onresponse_with_a_404() { _processor = new RequestProcessor(_ruleThatReturnsNoHandlers.Object, new RequestHandlerList()); _processor.Add(_requestHandlerFactory.Get("test")); HttpMockResponseHead capturedHead = null; byte[] capturedBody = null; - _processor.OnRequest(new HttpRequestHead(), null, (h, b) => { capturedHead = h; capturedBody = b; }); + await _processor.OnRequest(new HttpRequestHead(), null, (h, b) => { capturedHead = h; capturedBody = b; }); Assert.That(capturedHead.Status, Is.EqualTo("404 NotFound")); Assert.That(capturedBody, Is.Null); } [Test] - public void If_a_handler_found_should_fire_onresponse_with_that_repsonse() { + public async Task If_a_handler_found_should_fire_onresponse_with_that_repsonse() { _processor = new RequestProcessor(_ruleThatReturnsFirstHandler.Object, new RequestHandlerList()); RequestHandler requestHandler = _requestHandlerFactory.Get("test"); @@ -88,7 +89,7 @@ public void If_a_handler_found_should_fire_onresponse_with_that_repsonse() { HttpMockResponseHead capturedHead = null; byte[] capturedBody = null; - _processor.OnRequest(new HttpRequestHead { Headers = headers }, null, + await _processor.OnRequest(new HttpRequestHead { Headers = headers }, null, (h, b) => { capturedHead = h; capturedBody = b; }); Assert.That(capturedHead.Status, Is.EqualTo(requestHandler.ResponseBuilder.BuildHeaders().Status)); @@ -96,7 +97,7 @@ public void If_a_handler_found_should_fire_onresponse_with_that_repsonse() { } [Test] - public void Matching_HEAD_handler_should_output_handlers_expected_response_with_null_body() { + public async Task Matching_HEAD_handler_should_output_handlers_expected_response_with_null_body() { _processor = new RequestProcessor(_ruleThatReturnsFirstHandler.Object, new RequestHandlerList()); @@ -106,7 +107,7 @@ public void Matching_HEAD_handler_should_output_handlers_expected_response_with_ HttpMockResponseHead capturedHead = null; byte[] capturedBody = null; - _processor.OnRequest(httpRequestHead, null, (h, b) => { capturedHead = h; capturedBody = b; }); + await _processor.OnRequest(httpRequestHead, null, (h, b) => { capturedHead = h; capturedBody = b; }); Assert.That(capturedHead, Is.Not.Null); Assert.That(capturedBody, Is.Null); @@ -129,7 +130,7 @@ public void When_a_handler_is_added_should_be_able_to_find_it() { } [Test] - public void When_a_handler_is_hit_handlers_request_count_is_incremented() { + public async Task When_a_handler_is_hit_handlers_request_count_is_incremented() { string expectedPath = "/blah/test"; string expectedMethod = "GET"; @@ -138,14 +139,14 @@ public void When_a_handler_is_hit_handlers_request_count_is_incremented() { requestProcessor.Add(_requestHandlerFactory.Get(expectedPath)); var httpRequestHead = new HttpRequestHead { Headers = new Dictionary(), Uri = expectedPath, Method = expectedPath }; - requestProcessor.OnRequest(httpRequestHead, null, (h, b) => { }); + await requestProcessor.OnRequest(httpRequestHead, null, (h, b) => { }); var handler = requestProcessor.FindHandler(expectedMethod, expectedPath); Assert.That(handler.RequestCount(), Is.EqualTo(1)); } [Test] - public void Returns_mock_not_found_when_handler_constraints_cannot_be_verified() + public async Task Returns_mock_not_found_when_handler_constraints_cannot_be_verified() { var excludePhrase = "OhMyDaysssss"; @@ -160,7 +161,7 @@ public void Returns_mock_not_found_when_handler_constraints_cannot_be_verified() var p = new RequestProcessor(matchingRule.Object, new RequestHandlerList { handlerWithConstraints }); HttpMockResponseHead capturedHead = null; - p.OnRequest(new HttpRequestHead { Uri = "http://blah.com/cheese/" + excludePhrase }, null, + await p.OnRequest(new HttpRequestHead { Uri = "http://blah.com/cheese/" + excludePhrase }, null, (h, b) => capturedHead = h); Assert.That(capturedHead.Status, Is.EqualTo("404 NotFound")); diff --git a/src/HttpMock/HttpServer.cs b/src/HttpMock/HttpServer.cs index e832c7d..9cd903b 100644 --- a/src/HttpMock/HttpServer.cs +++ b/src/HttpMock/HttpServer.cs @@ -4,8 +4,8 @@ using System.Net; using System.Net.Sockets; using System.Runtime.InteropServices; -using System.Text; using System.Threading; +using System.Threading.Tasks; using Microsoft.Extensions.Logging; namespace HttpMock @@ -17,7 +17,6 @@ public class HttpServer : IHttpServer private readonly IRequestProcessor _requestProcessor; private readonly Uri _uri; private HttpListener _listener; - private Thread _listenerThread; private volatile bool _running; /// @@ -56,8 +55,7 @@ public void Start() _listener.Start(); _running = true; - _listenerThread = new Thread(ListenLoop) { IsBackground = true }; - _listenerThread.Start(); + _ = ListenLoopAsync(); } if (!IsAvailable()) { @@ -105,8 +103,6 @@ public void Dispose() try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } - if (_listenerThread != null && _listenerThread.IsAlive) - _listenerThread.Join(500); } public IRequestStub Stub(Func func) @@ -125,19 +121,23 @@ public string WhatDoIHave() return _requestProcessor.WhatDoIHave(); } - private void ListenLoop() + private async Task ListenLoopAsync() { while (_running) { try { - var context = _listener.GetContext(); - ThreadPool.QueueUserWorkItem(_ => HandleContext(context)); + var context = await _listener.GetContextAsync(); + _ = HandleContextAsync(context); } catch (HttpListenerException) { if (!_running) break; } + catch (ObjectDisposedException) + { + break; + } catch (Exception ex) { _log.LogError(ex, "Error in listen loop"); @@ -145,7 +145,7 @@ private void ListenLoop() } } - private void HandleContext(HttpListenerContext context) + private async Task HandleContextAsync(HttpListenerContext context) { try { @@ -165,7 +165,7 @@ private void HandleContext(HttpListenerContext context) }; Stream body = context.Request.HasEntityBody ? context.Request.InputStream : null; - _requestProcessor.OnRequest(requestHead, body, (responseHead, responseBody) => + await _requestProcessor.OnRequest(requestHead, body, (responseHead, responseBody) => WriteResponse(context.Response, responseHead, responseBody)); } catch (Exception ex) @@ -216,4 +216,4 @@ private static void WriteResponse(HttpListenerResponse response, HttpMockRespons } } } -} +} \ No newline at end of file diff --git a/src/HttpMock/HttpServerFactory.cs b/src/HttpMock/HttpServerFactory.cs index cb5e590..a19a437 100644 --- a/src/HttpMock/HttpServerFactory.cs +++ b/src/HttpMock/HttpServerFactory.cs @@ -15,11 +15,20 @@ public IHttpServer Get(Uri uri, ILoggerFactory loggerFactory = null) { if (_httpServers.TryGetValue(uri.Port, out var existing) && existing.IsAvailable()) return existing; + } - var server = BuildServer(uri, loggerFactory); + var server = BuildServer(uri, loggerFactory); + lock (_serverLock) + { + // Double-check: another thread may have created a server while we were building + if (_httpServers.TryGetValue(uri.Port, out var existing) && existing.IsAvailable()) + { + server.Dispose(); + return existing; + } _httpServers[uri.Port] = server; - return server; } + return server; } public IHttpServer Create(Uri uri, ILoggerFactory loggerFactory = null) diff --git a/src/HttpMock/IRequestProcessor.cs b/src/HttpMock/IRequestProcessor.cs index 9ba75d2..fbf3ec7 100644 --- a/src/HttpMock/IRequestProcessor.cs +++ b/src/HttpMock/IRequestProcessor.cs @@ -1,11 +1,12 @@ using System; using System.IO; +using System.Threading.Tasks; namespace HttpMock { public interface IRequestProcessor { - void OnRequest(IHttpRequestHead request, Stream requestBody, Action respond); + Task OnRequest(IHttpRequestHead request, Stream requestBody, Action respond); IRequestVerify FindHandler(string method, string path); void Add(RequestHandler requestHandler); void ClearHandlers(); diff --git a/src/HttpMock/RequestProcessor.cs b/src/HttpMock/RequestProcessor.cs index a58e21c..6ae22a6 100644 --- a/src/HttpMock/RequestProcessor.cs +++ b/src/HttpMock/RequestProcessor.cs @@ -22,7 +22,7 @@ public RequestProcessor(IMatchingRule matchingRule, IRequestHandlerList requestH _log = (loggerFactory ?? HttpMockLogging.GetLoggerFactory()).CreateLogger(); } - public void OnRequest(IHttpRequestHead request, Stream requestBody, Action respond) { + public async Task OnRequest(IHttpRequestHead request, Stream requestBody, Action respond) { if (_log.IsEnabled(LogLevel.Debug)) { _log.LogDebug("Start Processing request for : {Method}:{Uri}", SanitizeForLog(request.Method), SanitizeForLog(request.Uri)); @@ -43,7 +43,7 @@ public void OnRequest(IHttpRequestHead request, Stream requestBody, Action _log.LogError(t.Exception?.InnerException ?? t.Exception, "Unhandled error processing request"), - TaskContinuationOptions.OnlyOnFaulted); + await HandleRequest(request, bufferedBody, respond, handler); } private async Task HandleRequest(IHttpRequestHead request, string bufferedBody, Action respond, IRequestHandler handler) From 5553ffad14c776a817800c769fd63fef652cffcc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:32:41 +0000 Subject: [PATCH 5/5] fix: add ContinueWith error handler for fire-and-forget HandleContextAsync Agent-Logs-Url: https://github.com/hibri/HttpMock/sessions/0c587fbf-086a-44c4-89e8-15fc96b56d3b Co-authored-by: hibri <122442+hibri@users.noreply.github.com> --- src/HttpMock/HttpServer.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/HttpMock/HttpServer.cs b/src/HttpMock/HttpServer.cs index 9cd903b..82f5cce 100644 --- a/src/HttpMock/HttpServer.cs +++ b/src/HttpMock/HttpServer.cs @@ -128,7 +128,9 @@ private async Task ListenLoopAsync() try { var context = await _listener.GetContextAsync(); - _ = HandleContextAsync(context); + _ = HandleContextAsync(context).ContinueWith( + t => _log.LogError(t.Exception?.InnerException ?? t.Exception, "Unhandled error in request handler"), + TaskContinuationOptions.OnlyOnFaulted); } catch (HttpListenerException) {