From 480ec2ee7351a9b74f52af6b995b435e8d18154e Mon Sep 17 00:00:00 2001 From: claudiamurialdo <33756655+claudiamurialdo@users.noreply.github.com> Date: Thu, 7 May 2026 15:04:12 -0300 Subject: [PATCH 1/7] Add HttpAjaxContext.Cleanup() to release the AJAX response tree After getJSONResponse() serializes the response, the underlying JArray/JObject collections (AttValues, HiddenValues, PropValues, Grids, Messages, WebComponents, ComponentObjects, StylesheetsToLoad, LoadCommands, commands, cmpContents) stay rooted on the HttpAjaxContext and only the GC + finalizer can free them when the owning GxContext is collected. Heap analysis showed thousands of JObject + Hashtable+bucket[] retained via this chain. Cleanup() resets all collections to empty instances so the previous payload becomes garbage as soon as the request finishes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../GxClasses/Core/Web/HttpAjaxContext.cs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/dotnet/src/dotnetframework/GxClasses/Core/Web/HttpAjaxContext.cs b/dotnet/src/dotnetframework/GxClasses/Core/Web/HttpAjaxContext.cs index b78405faa..724301851 100644 --- a/dotnet/src/dotnetframework/GxClasses/Core/Web/HttpAjaxContext.cs +++ b/dotnet/src/dotnetframework/GxClasses/Core/Web/HttpAjaxContext.cs @@ -465,8 +465,24 @@ public void ajax_rsp_assign_prefixed_prop(String Control, String Property, Strin } public void ajax_rsp_clear() - { + { + _PropValues = new JArray(); + } + + public void Cleanup() + { + _AttValues = new JArray(); + _HiddenValues = new JObject(); _PropValues = new JArray(); + _WebComponents = new JObject(); + _LoadCommands = new Hashtable(); + _Messages = new JObject(); + _Grids = new JArray(); + DicGrids = new Dictionary(); + _ComponentObjects = new JObject(); + _StylesheetsToLoad = new JArray(); + commands = new GXAjaxCommandCollection(); + cmpContents = new Stack(); } public void ajax_rsp_assign_prop(String CmpContext, bool IsMasterPage, String Control, String Property, String Value, bool SendAjax = true) From b44c4a8c7348926b000b2cf42551ccf58532b086 Mon Sep 17 00:00:00 2001 From: claudiamurialdo <33756655+claudiamurialdo@users.noreply.github.com> Date: Thu, 7 May 2026 15:04:37 -0300 Subject: [PATCH 2/7] Make GxContext implement IDisposable so the AJAX tree can be released eagerly GxContext only had a finalizer, so the HttpAjaxContext + JObject tree it owns waited for the GC + finalizer thread to be released. Heap analysis showed dozens of GxContext stuck on the finalizer queue with the AJAX response payload retained behind them. Implement IDisposable. Dispose() calls HttpAjaxContext.Cleanup(), runs the same handle / temp-file cleanup the finalizer did, and suppresses finalization so the instance does not pay the gen2-finalize-queue cost when callers dispose deterministically. The finalizer is kept as a safety net for paths that do not call Dispose; both paths gate on a NonSerialized _disposed flag to remain idempotent. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../GxClasses/Core/GXApplication.cs | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/dotnet/src/dotnetframework/GxClasses/Core/GXApplication.cs b/dotnet/src/dotnetframework/GxClasses/Core/GXApplication.cs index 6a92205a0..131c56fba 100644 --- a/dotnet/src/dotnetframework/GxClasses/Core/GXApplication.cs +++ b/dotnet/src/dotnetframework/GxClasses/Core/GXApplication.cs @@ -358,9 +358,11 @@ internal class GxApplication internal static GxContext MainContext { get; set; } } [Serializable] - public class GxContext : IGxContext + public class GxContext : IGxContext, IDisposable { private static IGXLogger log = null; + [NonSerialized] + private bool _disposed; internal static string GX_SPA_REQUEST_HEADER = "X-SPA-REQUEST"; internal static string GX_SPA_REDIRECT_URL = "X-SPA-REDIRECT-URL"; internal const string GXLanguage = "GXLanguage"; @@ -3441,8 +3443,25 @@ public bool ExecuteAfterConnect(String datastoreName) } ~GxContext() { + if (_disposed) return; + GxUserInfo.RemoveHandle(_handle); + GXFileWatcher.Instance.DeleteTemporaryFiles(_handle); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + if (_httpAjaxContext != null) + { + try { _httpAjaxContext.Cleanup(); } catch { } + _httpAjaxContext = null; + } + GxUserInfo.RemoveHandle(_handle); GXFileWatcher.Instance.DeleteTemporaryFiles(_handle); + GC.SuppressFinalize(this); } public void SetProperty(string key, string value) { From 1eb0e49ee0eff4ef507ccb7ec5c30ed52b27297f Mon Sep 17 00:00:00 2001 From: claudiamurialdo <33756655+claudiamurialdo@users.noreply.github.com> Date: Thu, 7 May 2026 15:04:50 -0300 Subject: [PATCH 3/7] Make GxDataReader.Dispose() actually close the reader Dispose() was an empty no-op, so any 'using (var rdr = ...)' block left the underlying IDataReader (and the Oracle native handles it owns) alive until the next GC + finalizer pass. Forward Dispose() to Close() and swallow exceptions through the standard dispose contract. Co-Authored-By: Claude Opus 4.7 (1M context) --- dotnet/src/dotnetframework/GxClasses/Data/GXDataCommon.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/dotnet/src/dotnetframework/GxClasses/Data/GXDataCommon.cs b/dotnet/src/dotnetframework/GxClasses/Data/GXDataCommon.cs index 83d239a0c..be287f441 100644 --- a/dotnet/src/dotnetframework/GxClasses/Data/GXDataCommon.cs +++ b/dotnet/src/dotnetframework/GxClasses/Data/GXDataCommon.cs @@ -2503,16 +2503,17 @@ public DataTable GetSchemaTable() { throw (new GxNotImplementedException()); } - public bool IsClosed + public bool IsClosed { get{return !open;} } - public int Depth + public int Depth { get {return 0;} } public void Dispose() { + try { Close(); } catch (Exception ex) { GXLogging.Warn(log, "GxDataReader.Dispose error", ex); } } public string GetName(int i) { From 401ada4cc3eed9edcb5e33c5ce750472a4491f16 Mon Sep 17 00:00:00 2001 From: claudiamurialdo <33756655+claudiamurialdo@users.noreply.github.com> Date: Thu, 7 May 2026 15:05:22 -0300 Subject: [PATCH 4/7] Always clear the prepared-command cache on GxConnection.Close connectionCache.Clear() was only invoked inside the 'connection is open' branch, so when the underlying ADO.NET pool had already moved the connection to Closed state (or an exception closed it) the cached IDbCommand instances were never disposed. The same hole existed on the NETCORE async path. Move the Clear() call out so it always runs and guard it against a null cache. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../GxClasses/Data/GXDataADO.cs | 37 +++++++++---------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/dotnet/src/dotnetframework/GxClasses/Data/GXDataADO.cs b/dotnet/src/dotnetframework/GxClasses/Data/GXDataADO.cs index 0568d8292..ec4cd27ec 100644 --- a/dotnet/src/dotnetframework/GxClasses/Data/GXDataADO.cs +++ b/dotnet/src/dotnetframework/GxClasses/Data/GXDataADO.cs @@ -707,20 +707,20 @@ public short FullConnect() public void Close() { - if (connection!=null) + if (connection!=null) { GXLogging.Debug(log, "GxConnection.Close Id " + " connection State '" + connection.State + "'" + " handle:" + handle + " datastore:" + DataStore.Id); } - if (connection!=null && ((connection.State & ConnectionState.Closed) == 0 )) + try + { + if (connectionCache != null) connectionCache.Clear(); + } + catch (Exception e) + { + GXLogging.Warn(log, "GxConnection.Close can't close all prepared cursors", e); + } + if (connection!=null && ((connection.State & ConnectionState.Closed) == 0 )) { - try - { - connectionCache.Clear(); - } - catch(Exception e){ - GXLogging.Warn(log, "GxConnection.Close can't close all prepared cursors" ,e); - } - GXLogging.Debug(log, "UncommitedChanges before Close:" + UncommitedChanges ); try { @@ -764,17 +764,16 @@ internal async Task CloseAsync() { GXLogging.Debug(log, "GxConnection.Close Id " + " connection State '" + connection.State + "'" + " handle:" + handle + " datastore:" + DataStore.Id); } + try + { + if (connectionCache != null) connectionCache.Clear(); + } + catch (Exception e) + { + GXLogging.Warn(log, "GxConnection.Close can't close all prepared cursors", e); + } if (connection != null && ((connection.State & ConnectionState.Closed) == 0)) { - try - { - connectionCache.Clear(); - } - catch (Exception e) - { - GXLogging.Warn(log, "GxConnection.Close can't close all prepared cursors", e); - } - GXLogging.Debug(log, "UncommitedChanges before Close:" + UncommitedChanges); try { From 55532408a1a56408e3c36a35900b02f0b39b3328 Mon Sep 17 00:00:00 2001 From: claudiamurialdo <33756655+claudiamurialdo@users.noreply.github.com> Date: Thu, 7 May 2026 15:05:37 -0300 Subject: [PATCH 5/7] Dispose prior IDbDataParameter instances when reusing a cached command When the parameter count of an incoming call did not match the cached IDbCommand, GetCommand cleared the collection and re-added cloned parameters - but the previously bound parameter objects were dropped on the floor. Providers that own native handles (ODP.NET, etc.) need their parameter objects disposed to release those handles eagerly instead of relying on the finalizer thread. The 'is IDisposable' guard keeps the deprecated System.Data.OracleClient.OracleParameter (which is not IDisposable) compiling and behaving as before. Co-Authored-By: Claude Opus 4.7 (1M context) --- dotnet/src/dotnetframework/GxClasses/Data/GXDataCommon.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dotnet/src/dotnetframework/GxClasses/Data/GXDataCommon.cs b/dotnet/src/dotnetframework/GxClasses/Data/GXDataCommon.cs index be287f441..9885ea0b1 100644 --- a/dotnet/src/dotnetframework/GxClasses/Data/GXDataCommon.cs +++ b/dotnet/src/dotnetframework/GxClasses/Data/GXDataCommon.cs @@ -837,7 +837,10 @@ public virtual IDbCommand GetCommand(IGxConnection con, string stmt, GxParameter } else { - + foreach (object oldParam in cmd.Parameters) + { + if (oldParam is IDisposable disposable) disposable.Dispose(); + } cmd.Parameters.Clear(); AddParameters(cmd, parameters); From 16c3e55821e3e5b9bed88d1bdb7bfe61c8911384 Mon Sep 17 00:00:00 2001 From: claudiamurialdo <33756655+claudiamurialdo@users.noreply.github.com> Date: Thu, 7 May 2026 15:19:44 -0300 Subject: [PATCH 6/7] Dispose GxContext at end of every web request GXHttpHandler.ProcessRequest and ProcessRequestAsync are the IHttpHandler entry points for every GeneXus web page / AJAX call. IsReusable is false, so each instance owns its GxContext for exactly one request. Wrap the existing body in try/finally and dispose the context on the finally branch, so the HttpAjaxContext payload (JObject tree, hashtables) and per-request handles are released eagerly instead of waiting for the GC + finalizer thread. The cast '(context as IDisposable)?.Dispose()' avoids changing the public IGxContext interface; only the concrete GxContext class needs to implement IDisposable for the cast to bind. The cleanup is idempotent via the _disposed flag introduced earlier, so any inner code that already disposed the context is safe. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../GxClasses/Middleware/GXHttp.cs | 224 ++++++++++-------- 1 file changed, 119 insertions(+), 105 deletions(-) diff --git a/dotnet/src/dotnetframework/GxClasses/Middleware/GXHttp.cs b/dotnet/src/dotnetframework/GxClasses/Middleware/GXHttp.cs index 9505fde13..b6d83c14f 100644 --- a/dotnet/src/dotnetframework/GxClasses/Middleware/GXHttp.cs +++ b/dotnet/src/dotnetframework/GxClasses/Middleware/GXHttp.cs @@ -1990,63 +1990,70 @@ public bool IsMain #if NETCORE internal async Task ProcessRequestAsync(HttpContext httpContext) { - localHttpContext = httpContext; - - if (IsSpaRequest() && !IsSpaSupported()) - { - this.SendResponseStatus(SPA_NOT_SUPPORTED_STATUS_CODE, "SPA not supported by the object"); - context.CloseConnections(); - await Task.CompletedTask; - } - ControlOutputWriter = new HtmlTextWriter(localHttpContext); - LoadParameters(localHttpContext.Request.QueryString.Value); - context.httpAjaxContext.GetAjaxEncryptionKey(); //Save encryption key in session - InitPrivates(); try { - SetStreaming(); - SendHeaders(); - string clientid = context.ClientID; //Send clientid cookie (before response HasStarted) if necessary, since UseResponseBuffering is not in .netcore3.0 + localHttpContext = httpContext; - bool validSession = ValidWebSession(); - if (validSession && IntegratedSecurityEnabled) - validSession = ValidSession(); - if (validSession) + if (IsSpaRequest() && !IsSpaSupported()) + { + this.SendResponseStatus(SPA_NOT_SUPPORTED_STATUS_CODE, "SPA not supported by the object"); + context.CloseConnections(); + await Task.CompletedTask; + } + ControlOutputWriter = new HtmlTextWriter(localHttpContext); + LoadParameters(localHttpContext.Request.QueryString.Value); + context.httpAjaxContext.GetAjaxEncryptionKey(); //Save encryption key in session + InitPrivates(); + try { - if (UseBigStack()) + SetStreaming(); + SendHeaders(); + string clientid = context.ClientID; //Send clientid cookie (before response HasStarted) if necessary, since UseResponseBuffering is not in .netcore3.0 + + bool validSession = ValidWebSession(); + if (validSession && IntegratedSecurityEnabled) + validSession = ValidSession(); + if (validSession) { - Thread ts = new Thread(new ParameterizedThreadStart(webExecuteWorker)); - ts.Start(httpContext); - ts.Join(); - if (workerException != null) - throw workerException; + if (UseBigStack()) + { + Thread ts = new Thread(new ParameterizedThreadStart(webExecuteWorker)); + ts.Start(httpContext); + ts.Join(); + if (workerException != null) + throw workerException; + } + else + { + await WebExecuteExAsync(httpContext); + } } else { - await WebExecuteExAsync(httpContext); + context.CloseConnections(); + if (IsGxAjaxRequest() || context.isAjaxRequest()) + context.DispatchAjaxCommands(); } + SetCompression(httpContext); + context.ResponseCommited = true; } - else + catch (Exception e) { - context.CloseConnections(); - if (IsGxAjaxRequest() || context.isAjaxRequest()) - context.DispatchAjaxCommands(); + try + { + context.CloseConnections(); + } + catch { } + { + Exception exceptionToHandle = e.InnerException ?? e; + handleException(exceptionToHandle.GetType().FullName, exceptionToHandle.Message, exceptionToHandle.StackTrace); + throw new Exception("GXApplication exception", e); + } } - SetCompression(httpContext); - context.ResponseCommited = true; } - catch (Exception e) + finally { - try - { - context.CloseConnections(); - } - catch { } - { - Exception exceptionToHandle = e.InnerException ?? e; - handleException(exceptionToHandle.GetType().FullName, exceptionToHandle.Message, exceptionToHandle.StackTrace); - throw new Exception("GXApplication exception", e); - } + try { (context as IDisposable)?.Dispose(); } catch { } } } @@ -2056,91 +2063,98 @@ internal async Task ProcessRequestAsync(HttpContext httpContext) #endif public void ProcessRequest(HttpContext httpContext) { - localHttpContext = httpContext; - - if (IsSpaRequest() && !IsSpaSupported()) + try { - this.SendResponseStatus(SPA_NOT_SUPPORTED_STATUS_CODE, "SPA not supported by the object"); + localHttpContext = httpContext; + + if (IsSpaRequest() && !IsSpaSupported()) + { + this.SendResponseStatus(SPA_NOT_SUPPORTED_STATUS_CODE, "SPA not supported by the object"); #if !NETCORE - context.HttpContext.Response.TrySkipIisCustomErrors = true; + context.HttpContext.Response.TrySkipIisCustomErrors = true; #endif - context.CloseConnections(); - return; - } + context.CloseConnections(); + return; + } #if NETCORE - ControlOutputWriter = new HtmlTextWriter(localHttpContext); - LoadParameters(localHttpContext.Request.QueryString.Value); - context.httpAjaxContext.GetAjaxEncryptionKey(); //Save encryption key in session + ControlOutputWriter = new HtmlTextWriter(localHttpContext); + LoadParameters(localHttpContext.Request.QueryString.Value); + context.httpAjaxContext.GetAjaxEncryptionKey(); //Save encryption key in session #else - ControlOutputWriter = new HtmlTextWriter(localHttpContext.Response.Output); - LoadParameters(localHttpContext.Request.Url.Query); + ControlOutputWriter = new HtmlTextWriter(localHttpContext.Response.Output); + LoadParameters(localHttpContext.Request.Url.Query); #endif - InitPrivates(); - try - { - SetStreaming(); - SendHeaders(); - string clientid = context.ClientID; //Send clientid cookie (before response HasStarted) if necessary, since UseResponseBuffering is not in .netcore3.0 + InitPrivates(); + try + { + SetStreaming(); + SendHeaders(); + string clientid = context.ClientID; //Send clientid cookie (before response HasStarted) if necessary, since UseResponseBuffering is not in .netcore3.0 #if !NETCORE - CSRFHelper.ValidateAntiforgery(httpContext); + CSRFHelper.ValidateAntiforgery(httpContext); #endif - bool validSession = ValidWebSession(); - if (validSession && IntegratedSecurityEnabled) - validSession = ValidSession(); - if (validSession) - { - if (UseBigStack()) + bool validSession = ValidWebSession(); + if (validSession && IntegratedSecurityEnabled) + validSession = ValidSession(); + if (validSession) { -#if !NETCORE - Thread ts = new Thread(new ParameterizedThreadStart(webExecuteWorker), STACKSIZE); - object currentCultureInfo = Thread.CurrentThread.CurrentUICulture.Clone(); - if (currentCultureInfo != null) + if (UseBigStack()) { - ts.CurrentUICulture = (CultureInfo)currentCultureInfo; - } +#if !NETCORE + Thread ts = new Thread(new ParameterizedThreadStart(webExecuteWorker), STACKSIZE); + object currentCultureInfo = Thread.CurrentThread.CurrentUICulture.Clone(); + if (currentCultureInfo != null) + { + ts.CurrentUICulture = (CultureInfo)currentCultureInfo; + } #else - Thread ts = new Thread(new ParameterizedThreadStart(webExecuteWorker)); + Thread ts = new Thread(new ParameterizedThreadStart(webExecuteWorker)); #endif - ts.Start(httpContext); - ts.Join(); - if (workerException != null) - throw workerException; + ts.Start(httpContext); + ts.Join(); + if (workerException != null) + throw workerException; + } + else + { + webExecuteEx(httpContext); + } } else { - webExecuteEx(httpContext); + context.CloseConnections(); + if (IsGxAjaxRequest() || context.isAjaxRequest()) + context.DispatchAjaxCommands(); } + SetCompression(httpContext); + context.ResponseCommited = true; } - else - { - context.CloseConnections(); - if (IsGxAjaxRequest() || context.isAjaxRequest()) - context.DispatchAjaxCommands(); - } - SetCompression(httpContext); - context.ResponseCommited = true; - } - catch (Exception e) - { - try + catch (Exception e) { - context.CloseConnections(); - } - catch { } + try + { + context.CloseConnections(); + } + catch { } #if !NETCORE - if (CSRFHelper.HandleException(e, httpContext)) - { - GXLogging.Error(log, $"Validation of antiforgery failed", e); - } - else + if (CSRFHelper.HandleException(e, httpContext)) + { + GXLogging.Error(log, $"Validation of antiforgery failed", e); + } + else #endif - { - Exception exceptionToHandle = e.InnerException ?? e; - handleException(exceptionToHandle.GetType().FullName, exceptionToHandle.Message, exceptionToHandle.StackTrace); - throw new Exception("GXApplication exception", e); + { + Exception exceptionToHandle = e.InnerException ?? e; + handleException(exceptionToHandle.GetType().FullName, exceptionToHandle.Message, exceptionToHandle.StackTrace); + throw new Exception("GXApplication exception", e); + } } } + finally + { + try { (context as IDisposable)?.Dispose(); } catch { } + } } protected virtual bool ChunkedStreaming() { return false; } From 1d9fa343d2c0fa604f61ccc84be8dec3d6e94798 Mon Sep 17 00:00:00 2001 From: claudiamurialdo <33756655+claudiamurialdo@users.noreply.github.com> Date: Thu, 7 May 2026 16:13:19 -0300 Subject: [PATCH 7/7] Log warning instead of swallowing errors from end-of-request Dispose A failure inside the finally-clause Dispose was being silently lost, making any future regression in HttpAjaxContext.Cleanup or GxContext.Dispose invisible. Forward the exception to GXLogging.Warn so operators can spot it in the standard log4net output. Co-Authored-By: Claude Opus 4.7 (1M context) --- dotnet/src/dotnetframework/GxClasses/Middleware/GXHttp.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/dotnet/src/dotnetframework/GxClasses/Middleware/GXHttp.cs b/dotnet/src/dotnetframework/GxClasses/Middleware/GXHttp.cs index b6d83c14f..9b00433b8 100644 --- a/dotnet/src/dotnetframework/GxClasses/Middleware/GXHttp.cs +++ b/dotnet/src/dotnetframework/GxClasses/Middleware/GXHttp.cs @@ -2053,7 +2053,8 @@ internal async Task ProcessRequestAsync(HttpContext httpContext) } finally { - try { (context as IDisposable)?.Dispose(); } catch { } + try { (context as IDisposable)?.Dispose(); } + catch (Exception disposeEx) { GXLogging.Warn(log, "Error disposing GxContext at end of request", disposeEx); } } } @@ -2153,7 +2154,8 @@ public void ProcessRequest(HttpContext httpContext) } finally { - try { (context as IDisposable)?.Dispose(); } catch { } + try { (context as IDisposable)?.Dispose(); } + catch (Exception disposeEx) { GXLogging.Warn(log, "Error disposing GxContext at end of request", disposeEx); } } } protected virtual bool ChunkedStreaming() { return false; }