From 59c7058bbf8c5544cd2fc7ff72fbf7f209f71af4 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Wed, 4 Mar 2026 13:05:51 +1100 Subject: [PATCH 1/2] LDEV-6129 ORM Regression Leak tests https://luceeserver.atlassian.net/browse/LDEV-6129 --- test/tickets/LDEV6129.cfc | 132 ++++++++++++++++++ test/tickets/LDEV6129/basic/Application.cfc | 20 +++ .../tickets/LDEV6129/basic/LDEV6129Person.cfc | 6 + test/tickets/LDEV6129/basic/flush_leak.cfm | 25 ++++ .../tickets/LDEV6129/basic/reconnect_leak.cfm | 69 +++++++++ test/tickets/LDEV6129/basic/setup.cfm | 4 + test/tickets/LDEV6129/basic/simple.cfm | 6 + test/tickets/LDEV6129/custom/Application.cfc | 26 ++++ .../LDEV6129/custom/LDEV6129Person.cfc | 6 + test/tickets/LDEV6129/custom/flush_leak.cfm | 25 ++++ .../LDEV6129/custom/reconnect_leak.cfm | 69 +++++++++ test/tickets/LDEV6129/custom/setup.cfm | 4 + test/tickets/LDEV6129/custom/simple.cfm | 6 + 13 files changed, 398 insertions(+) create mode 100644 test/tickets/LDEV6129.cfc create mode 100644 test/tickets/LDEV6129/basic/Application.cfc create mode 100644 test/tickets/LDEV6129/basic/LDEV6129Person.cfc create mode 100644 test/tickets/LDEV6129/basic/flush_leak.cfm create mode 100644 test/tickets/LDEV6129/basic/reconnect_leak.cfm create mode 100644 test/tickets/LDEV6129/basic/setup.cfm create mode 100644 test/tickets/LDEV6129/basic/simple.cfm create mode 100644 test/tickets/LDEV6129/custom/Application.cfc create mode 100644 test/tickets/LDEV6129/custom/LDEV6129Person.cfc create mode 100644 test/tickets/LDEV6129/custom/flush_leak.cfm create mode 100644 test/tickets/LDEV6129/custom/reconnect_leak.cfm create mode 100644 test/tickets/LDEV6129/custom/setup.cfm create mode 100644 test/tickets/LDEV6129/custom/simple.cfm diff --git a/test/tickets/LDEV6129.cfc b/test/tickets/LDEV6129.cfc new file mode 100644 index 00000000000..0bf54e04fb4 --- /dev/null +++ b/test/tickets/LDEV6129.cfc @@ -0,0 +1,132 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="orm" { + + function beforeAll() { + _InternalRequest( template: "#basicConfig().uri#/setup.cfm" ); + _InternalRequest( template: "#customConfig().uri#/setup.cfm" ); + } + + function run( testResults, testBox ) { + runSuite( testResults, testBox, basicConfig() ); + runSuite( testResults, testBox, customConfig() ); + } + + private void function runSuite( testResults, testBox, required struct cfg ) { + + describe( "LDEV-6129 [#cfg.label#] - ORM connection not released when flushAll() throws at request end", function() { + + /* + * Bug: PageContextImpl.releaseORM(): + * + * try { + * ormSession.flushAll(pc); // throws constraint violation + * ormSession.closeAll(pc); // skipped — same try block + * manager.releaseORM(); + * } finally { + * ormSession = null; // ref dropped, dc never returned to pool + * } + * + * Pool is maxTotal=1. If the connection leaks after the flush error, + * the subsequent simple.cfm requests will fail to get a connection. + */ + it( title="connection returned to pool even when auto-flush throws a constraint violation", body=function( currentSpec ) { + + // Trigger the potential leak: unique constraint violation at request end + try { + _InternalRequest( template: "#cfg.uri#/flush_leak.cfm" ); + } catch ( any e ) { + // _InternalRequest may propagate template exceptions — that's fine, + // the important thing is what happens to the connection afterwards + systemOutput( "flush_leak threw: #e.stacktrace#", true ); + } + + var metrics = getSystemMetrics(); + var active = getPoolActive( metrics, "LDEV6129h2" ); + var idle = getPoolIdle( metrics, "LDEV6129h2" ); + systemOutput( "[#cfg.label#] after flush error: active=#active#, idle=#idle#", true ); + + expect( active ).toBe( 0, + "Connection leaked after flush error — active=#active# (pool maxTotal=1)" + ); + + // Now verify the connection is actually usable: make N simple requests. + // If the connection was leaked (active but not in pool), these will fail. + var N = 5; + for ( var i = 1; i <= N; i++ ) { + var result = _InternalRequest( template: "#cfg.uri#/simple.cfm" ); + systemOutput( "[#cfg.label#] simple request #i#: status=#result.status#, content=#trim( result.filecontent )#", true ); + expect( result.status ).toBe( 200, + "simple request #i# failed — connection not available (pool exhausted?)" + ); + expect( left( trim( result.filecontent ), 2 ) ).toBe( "ok", + "simple request #i# returned unexpected content: #trim( result.filecontent )#" + ); + } + + } ); + + } ); + + describe( "LDEV-6129 [#cfg.label#] - dead reconnect code throws when session.isConnected() returns false", function() { + + /* + * Bug: HibernateORMSession.getSessionAndConn() has a dead reconnect block: + * + * if ( !s.isOpen() || !s.isConnected() || isClosed( s ) ) { + * sac.connect( pc ); // acquires dc from pool + * s.reconnect( sac.getConnection( pc ) ); // ALWAYS throws IllegalStateException + * } // dc is leaked + * + * Session.reconnect(Connection) is not supported for factory-opened sessions + * in Hibernate 5.6 — it unconditionally throws IllegalStateException. + * + * Fix: remove the reconnect block. ConnectionProvider handles the lifecycle. + */ + it( title="entityLoad succeeds when session isConnected() is forced false via reflection", body=function( currentSpec ) { + + var result = _InternalRequest( template: "#cfg.uri#/reconnect_leak.cfm" ); + systemOutput( "[#cfg.label#] reconnect_leak result: status=#result.status#, content=#trim( result.filecontent )#", true ); + + expect( result.status ).toBe( 200 ); + expect( left( trim( result.filecontent ), 2 ) ).toBe( "ok", + "entityLoad failed after isConnected()=false — reconnect dead code is broken: #trim( result.filecontent )#" + ); + + } ); + + } ); + + } + + private struct function basicConfig() { + return { label: "basic", uri: createURI( "LDEV6129/basic" ) }; + } + + private struct function customConfig() { + return { label: "custom (after_transaction)", uri: createURI( "LDEV6129/custom" ) }; + } + + private numeric function getPoolActive( required struct metrics, required string dsName ) { + return getPoolStat( arguments.metrics, arguments.dsName, "activeDatasourceConnections" ); + } + + private numeric function getPoolIdle( required struct metrics, required string dsName ) { + return getPoolStat( arguments.metrics, arguments.dsName, "idleDatasourceConnections" ); + } + + private numeric function getPoolStat( required struct metrics, required string dsName, required string stat ) { + if ( !structKeyExists( arguments.metrics, "datasourceConnections" ) ) return 0; + for ( var key in arguments.metrics.datasourceConnections ) { + var pool = arguments.metrics.datasourceConnections[ key ]; + if ( structKeyExists( pool, "name" ) && pool.name == arguments.dsName ) { + return val( pool[ arguments.stat ] ?: 0 ); + } + } + return 0; + } + + private string function createURI( string calledName ) { + var baseURI = "/test/#listLast( getDirectoryFromPath( getCurrenttemplatepath() ), "\/" )#/"; + return baseURI & calledName; + } + +} diff --git a/test/tickets/LDEV6129/basic/Application.cfc b/test/tickets/LDEV6129/basic/Application.cfc new file mode 100644 index 00000000000..387ae455efb --- /dev/null +++ b/test/tickets/LDEV6129/basic/Application.cfc @@ -0,0 +1,20 @@ +component { + + this.name = "LDEV-6129"; + this.datasources["LDEV6129h2"] = server.getDatasource( "h2", server._getTempDir( "LDEV6129basic" ) ); + this.datasources["LDEV6129h2"]["connectionLimit"] = 1; + this.datasources["LDEV6129h2"]["maxTotal"] = 1; + this.ormEnabled = true; + this.datasource = "LDEV6129h2"; + this.ormSettings = { + dbcreate: "dropcreate", + dialect: "h2", + flushAtRequestEnd: true, + autoManageSession: true + }; + + public function onRequestStart() { + setting requesttimeout = 10; + } + +} diff --git a/test/tickets/LDEV6129/basic/LDEV6129Person.cfc b/test/tickets/LDEV6129/basic/LDEV6129Person.cfc new file mode 100644 index 00000000000..40f0e5b5d81 --- /dev/null +++ b/test/tickets/LDEV6129/basic/LDEV6129Person.cfc @@ -0,0 +1,6 @@ +component persistent="true" entityname="LDEV6129Person" { + + property name="id" fieldtype="id" type="numeric" ormtype="long" generator="increment"; + property name="name" type="string" unique="true"; + +} diff --git a/test/tickets/LDEV6129/basic/flush_leak.cfm b/test/tickets/LDEV6129/basic/flush_leak.cfm new file mode 100644 index 00000000000..d0a73592042 --- /dev/null +++ b/test/tickets/LDEV6129/basic/flush_leak.cfm @@ -0,0 +1,25 @@ + + /* + * LDEV-6129: trigger a connection leak by causing flushAll() to throw at request end. + * + * 1. entitySave(p1) + ormFlush() — commits "test" to DB + * 2. entitySave(p2) with same unique name — no error yet (Hibernate doesn't query DB) + * 3. Request ends: flushAtRequestEnd=true → releaseORM() → flushAll() throws unique violation + * + * BUG: flushAll() and closeAll() are in the same try block in PageContextImpl.releaseORM(). + * When flushAll() throws, closeAll() is skipped → DatasourceConnection dc is never returned. + */ + uniqueName = createUUID(); + + p1 = entityNew( "LDEV6129Person" ); + p1.setName( uniqueName ); + entitySave( p1 ); + ormFlush(); // commit p1 to DB — now uniqueName exists with a unique constraint + + p2 = entityNew( "LDEV6129Person" ); + p2.setName( uniqueName ); // same name — will collide at flush time + entitySave( p2 ); + systemOutput( "flush_leak.cfm: entitySave(p2) done, request ending now — expect flush error", true ); + // request ends here: auto-flush tries to INSERT p2, throws unique constraint violation + // closeAll() is skipped → dc leaked + diff --git a/test/tickets/LDEV6129/basic/reconnect_leak.cfm b/test/tickets/LDEV6129/basic/reconnect_leak.cfm new file mode 100644 index 00000000000..1b26ca6e36b --- /dev/null +++ b/test/tickets/LDEV6129/basic/reconnect_leak.cfm @@ -0,0 +1,69 @@ + + /* + * LDEV-6129: reproduce the dead reconnect code bug in HibernateORMSession.getSessionAndConn(). + * + * The condition `!s.isConnected()` can fire under load (e.g. MySQL 9.5, pool pressure). + * When it does, the current code calls: + * sac.connect(pc) -- acquires DatasourceConnection dc from pool + * s.reconnect(conn) -- ALWAYS throws IllegalStateException on Hibernate 5.6 + * factory-opened sessions; dc is leaked + * + * We force isConnected() = false via reflection on the Hibernate internals, then + * call entityLoad() to trigger the path. + * + * Expected output before fix: ERROR: ... Cannot manually reconnect ... + * Expected output after fix: ok + */ + + // 1. Load any entity to ensure the session + connection are open and dc is in place + entityLoad( "LDEV6129Person" ); + + // 2. Get the raw Hibernate SessionImpl + hibSession = ormGetSession(); + + // 3. Walk the class hierarchy to find the private jdbcCoordinator field + // (declared on AbstractSharedSessionContract, not SessionImpl itself) + coordField = javaCast( "null", "" ); + klass = hibSession.getClass(); + while ( !isNull( klass ) ) { + try { + coordField = klass.getDeclaredField( "jdbcCoordinator" ); + break; + } + catch ( any e ) { + klass = klass.getSuperclass(); + } + } + + if ( isNull( coordField ) ) { + writeOutput( "SKIP: jdbcCoordinator field not found — Hibernate internals changed" ); + return; + } + + coordField.setAccessible( true ); + jdbcCoord = coordField.get( hibSession ); + + // 4. Get logicalConnection from JdbcCoordinatorImpl + logConnField = jdbcCoord.getClass().getDeclaredField( "logicalConnection" ); + logConnField.setAccessible( true ); + logConn = logConnField.get( jdbcCoord ); + + // 5. Force closed = true → isConnected() now returns false + closedField = logConn.getClass().getDeclaredField( "closed" ); + closedField.setAccessible( true ); + closedField.set( logConn, javaCast( "boolean", true ) ); + + systemOutput( "isConnected() after force-close: #hibSession.isConnected()#", true ); + + // 6. Trigger getSessionAndConn() — enters reconnect path because isConnected() == false + // Before fix: throws IllegalStateException (s.reconnect() always throws on managed sessions) + // After fix: reconnect block removed, entityLoad succeeds normally + try { + entityLoad( "LDEV6129Person" ); + writeOutput( "ok" ); + } + catch ( any e ) { + writeOutput( "ERROR: #e.type# - #e.message#" ); + systemOutput( "reconnect_leak: caught exception: #e.type# - #e.message#", true ); + } + diff --git a/test/tickets/LDEV6129/basic/setup.cfm b/test/tickets/LDEV6129/basic/setup.cfm new file mode 100644 index 00000000000..49af802e8a7 --- /dev/null +++ b/test/tickets/LDEV6129/basic/setup.cfm @@ -0,0 +1,4 @@ + + // Drop and recreate all ORM tables — ensures no stale data from previous runs + ormReload(); + diff --git a/test/tickets/LDEV6129/basic/simple.cfm b/test/tickets/LDEV6129/basic/simple.cfm new file mode 100644 index 00000000000..67dde45379b --- /dev/null +++ b/test/tickets/LDEV6129/basic/simple.cfm @@ -0,0 +1,6 @@ + + // Simple ORM load — just proves we can get a connection from the pool. + // If a previous request leaked the only connection (maxTotal=1), this will throw. + result = entityLoad( "LDEV6129Person" ); + writeOutput( "ok:#arrayLen( result )#" ); + diff --git a/test/tickets/LDEV6129/custom/Application.cfc b/test/tickets/LDEV6129/custom/Application.cfc new file mode 100644 index 00000000000..2e439420329 --- /dev/null +++ b/test/tickets/LDEV6129/custom/Application.cfc @@ -0,0 +1,26 @@ +component { + + this.name = "LDEV-6129-custom"; + this.datasources["LDEV6129h2"] = server.getDatasource( "h2", server._getTempDir( "LDEV6129custom" ) ); + this.datasources["LDEV6129h2"]["connectionLimit"] = 1; + this.datasources["LDEV6129h2"]["maxTotal"] = 1; + this.ormEnabled = true; + this.datasource = "LDEV6129h2"; + this.ormSettings = { + dbcreate: "dropcreate", + dialect: "h2", + flushAtRequestEnd: true, + autoManageSession: true, + hibernateConfig: { + "connection.release_mode": "after_transaction", + "hibernate.connection.provider_class": extensionExists( "D062D72F-F8A2-46F0-8CBC91325B2F067B" ) + ? "ortus.extension.orm.jdbc.ConnectionProviderImpl" + : "org.lucee.extension.orm.hibernate.jdbc.ConnectionProviderImpl" + } + }; + + public function onRequestStart() { + setting requesttimeout = 10; + } + +} diff --git a/test/tickets/LDEV6129/custom/LDEV6129Person.cfc b/test/tickets/LDEV6129/custom/LDEV6129Person.cfc new file mode 100644 index 00000000000..40f0e5b5d81 --- /dev/null +++ b/test/tickets/LDEV6129/custom/LDEV6129Person.cfc @@ -0,0 +1,6 @@ +component persistent="true" entityname="LDEV6129Person" { + + property name="id" fieldtype="id" type="numeric" ormtype="long" generator="increment"; + property name="name" type="string" unique="true"; + +} diff --git a/test/tickets/LDEV6129/custom/flush_leak.cfm b/test/tickets/LDEV6129/custom/flush_leak.cfm new file mode 100644 index 00000000000..d0a73592042 --- /dev/null +++ b/test/tickets/LDEV6129/custom/flush_leak.cfm @@ -0,0 +1,25 @@ + + /* + * LDEV-6129: trigger a connection leak by causing flushAll() to throw at request end. + * + * 1. entitySave(p1) + ormFlush() — commits "test" to DB + * 2. entitySave(p2) with same unique name — no error yet (Hibernate doesn't query DB) + * 3. Request ends: flushAtRequestEnd=true → releaseORM() → flushAll() throws unique violation + * + * BUG: flushAll() and closeAll() are in the same try block in PageContextImpl.releaseORM(). + * When flushAll() throws, closeAll() is skipped → DatasourceConnection dc is never returned. + */ + uniqueName = createUUID(); + + p1 = entityNew( "LDEV6129Person" ); + p1.setName( uniqueName ); + entitySave( p1 ); + ormFlush(); // commit p1 to DB — now uniqueName exists with a unique constraint + + p2 = entityNew( "LDEV6129Person" ); + p2.setName( uniqueName ); // same name — will collide at flush time + entitySave( p2 ); + systemOutput( "flush_leak.cfm: entitySave(p2) done, request ending now — expect flush error", true ); + // request ends here: auto-flush tries to INSERT p2, throws unique constraint violation + // closeAll() is skipped → dc leaked + diff --git a/test/tickets/LDEV6129/custom/reconnect_leak.cfm b/test/tickets/LDEV6129/custom/reconnect_leak.cfm new file mode 100644 index 00000000000..1b26ca6e36b --- /dev/null +++ b/test/tickets/LDEV6129/custom/reconnect_leak.cfm @@ -0,0 +1,69 @@ + + /* + * LDEV-6129: reproduce the dead reconnect code bug in HibernateORMSession.getSessionAndConn(). + * + * The condition `!s.isConnected()` can fire under load (e.g. MySQL 9.5, pool pressure). + * When it does, the current code calls: + * sac.connect(pc) -- acquires DatasourceConnection dc from pool + * s.reconnect(conn) -- ALWAYS throws IllegalStateException on Hibernate 5.6 + * factory-opened sessions; dc is leaked + * + * We force isConnected() = false via reflection on the Hibernate internals, then + * call entityLoad() to trigger the path. + * + * Expected output before fix: ERROR: ... Cannot manually reconnect ... + * Expected output after fix: ok + */ + + // 1. Load any entity to ensure the session + connection are open and dc is in place + entityLoad( "LDEV6129Person" ); + + // 2. Get the raw Hibernate SessionImpl + hibSession = ormGetSession(); + + // 3. Walk the class hierarchy to find the private jdbcCoordinator field + // (declared on AbstractSharedSessionContract, not SessionImpl itself) + coordField = javaCast( "null", "" ); + klass = hibSession.getClass(); + while ( !isNull( klass ) ) { + try { + coordField = klass.getDeclaredField( "jdbcCoordinator" ); + break; + } + catch ( any e ) { + klass = klass.getSuperclass(); + } + } + + if ( isNull( coordField ) ) { + writeOutput( "SKIP: jdbcCoordinator field not found — Hibernate internals changed" ); + return; + } + + coordField.setAccessible( true ); + jdbcCoord = coordField.get( hibSession ); + + // 4. Get logicalConnection from JdbcCoordinatorImpl + logConnField = jdbcCoord.getClass().getDeclaredField( "logicalConnection" ); + logConnField.setAccessible( true ); + logConn = logConnField.get( jdbcCoord ); + + // 5. Force closed = true → isConnected() now returns false + closedField = logConn.getClass().getDeclaredField( "closed" ); + closedField.setAccessible( true ); + closedField.set( logConn, javaCast( "boolean", true ) ); + + systemOutput( "isConnected() after force-close: #hibSession.isConnected()#", true ); + + // 6. Trigger getSessionAndConn() — enters reconnect path because isConnected() == false + // Before fix: throws IllegalStateException (s.reconnect() always throws on managed sessions) + // After fix: reconnect block removed, entityLoad succeeds normally + try { + entityLoad( "LDEV6129Person" ); + writeOutput( "ok" ); + } + catch ( any e ) { + writeOutput( "ERROR: #e.type# - #e.message#" ); + systemOutput( "reconnect_leak: caught exception: #e.type# - #e.message#", true ); + } + diff --git a/test/tickets/LDEV6129/custom/setup.cfm b/test/tickets/LDEV6129/custom/setup.cfm new file mode 100644 index 00000000000..49af802e8a7 --- /dev/null +++ b/test/tickets/LDEV6129/custom/setup.cfm @@ -0,0 +1,4 @@ + + // Drop and recreate all ORM tables — ensures no stale data from previous runs + ormReload(); + diff --git a/test/tickets/LDEV6129/custom/simple.cfm b/test/tickets/LDEV6129/custom/simple.cfm new file mode 100644 index 00000000000..67dde45379b --- /dev/null +++ b/test/tickets/LDEV6129/custom/simple.cfm @@ -0,0 +1,6 @@ + + // Simple ORM load — just proves we can get a connection from the pool. + // If a previous request leaked the only connection (maxTotal=1), this will throw. + result = entityLoad( "LDEV6129Person" ); + writeOutput( "ok:#arrayLen( result )#" ); + From d015a9aa4529e96ba069b68b8ac34282a28452da Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Wed, 4 Mar 2026 15:09:30 +1100 Subject: [PATCH 2/2] LDEV-6129 additional tests --- test/tickets/LDEV6129.cfc | 48 +++++++++++++++---- test/tickets/LDEV6129/custom/Application.cfc | 2 +- .../LDEV6129/custom/multi_transaction.cfm | 35 ++++++++++++++ 3 files changed, 74 insertions(+), 11 deletions(-) create mode 100644 test/tickets/LDEV6129/custom/multi_transaction.cfm diff --git a/test/tickets/LDEV6129.cfc b/test/tickets/LDEV6129.cfc index 0bf54e04fb4..2872b8201b6 100644 --- a/test/tickets/LDEV6129.cfc +++ b/test/tickets/LDEV6129.cfc @@ -1,13 +1,9 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="orm" { - function beforeAll() { - _InternalRequest( template: "#basicConfig().uri#/setup.cfm" ); - _InternalRequest( template: "#customConfig().uri#/setup.cfm" ); - } - function run( testResults, testBox ) { runSuite( testResults, testBox, basicConfig() ); runSuite( testResults, testBox, customConfig() ); + runCustomSuite( testResults, testBox, customConfig() ); } private void function runSuite( testResults, testBox, required struct cfg ) { @@ -30,9 +26,11 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="orm" { */ it( title="connection returned to pool even when auto-flush throws a constraint violation", body=function( currentSpec ) { + _InternalRequest( template: "#cfg.uri#/setup.cfm", url: cfg.params ); + // Trigger the potential leak: unique constraint violation at request end try { - _InternalRequest( template: "#cfg.uri#/flush_leak.cfm" ); + _InternalRequest( template: "#cfg.uri#/flush_leak.cfm", url: cfg.params ); } catch ( any e ) { // _InternalRequest may propagate template exceptions — that's fine, // the important thing is what happens to the connection afterwards @@ -52,7 +50,7 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="orm" { // If the connection was leaked (active but not in pool), these will fail. var N = 5; for ( var i = 1; i <= N; i++ ) { - var result = _InternalRequest( template: "#cfg.uri#/simple.cfm" ); + var result = _InternalRequest( template: "#cfg.uri#/simple.cfm", url: cfg.params ); systemOutput( "[#cfg.label#] simple request #i#: status=#result.status#, content=#trim( result.filecontent )#", true ); expect( result.status ).toBe( 200, "simple request #i# failed — connection not available (pool exhausted?)" @@ -83,7 +81,9 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="orm" { */ it( title="entityLoad succeeds when session isConnected() is forced false via reflection", body=function( currentSpec ) { - var result = _InternalRequest( template: "#cfg.uri#/reconnect_leak.cfm" ); + _InternalRequest( template: "#cfg.uri#/setup.cfm", url: cfg.params ); + + var result = _InternalRequest( template: "#cfg.uri#/reconnect_leak.cfm", url: cfg.params ); systemOutput( "[#cfg.label#] reconnect_leak result: status=#result.status#, content=#trim( result.filecontent )#", true ); expect( result.status ).toBe( 200 ); @@ -97,12 +97,40 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="orm" { } + private void function runCustomSuite( testResults, testBox, required struct cfg ) { + + describe( "LDEV-6129 [#cfg.label#] - dead reconnect code triggered naturally by after_transaction release mode", function() { + + /* + * With connection.release_mode=after_transaction, Hibernate calls afterTransaction() + * after every ormFlush(), setting physicalConnection=null → isConnected()=false. + * The next ORM call then enters the dead reconnect block in getSessionAndConn(), + * which calls s.reconnect() — always throws ResourceClosedException in Hibernate 5.6. + */ + it( title="entityLoad succeeds after ormFlush() with after_transaction release mode", body=function( currentSpec ) { + + _InternalRequest( template: "#cfg.uri#/setup.cfm", url: { flushAtRequestEnd: false } ); + + var result = _InternalRequest( template: "#cfg.uri#/multi_transaction.cfm", url: { flushAtRequestEnd: false } ); + systemOutput( "[#cfg.label#] multi_transaction result: status=#result.status#, content=#trim( result.filecontent )#", true ); + + expect( result.status ).toBe( 200 ); + expect( left( trim( result.filecontent ), 2 ) ).toBe( "ok", + "entityLoad failed after ormFlush() with after_transaction — dead reconnect code triggered: #trim( result.filecontent )#" + ); + + } ); + + } ); + + } + private struct function basicConfig() { - return { label: "basic", uri: createURI( "LDEV6129/basic" ) }; + return { label: "basic", uri: createURI( "LDEV6129/basic" ), params: {} }; } private struct function customConfig() { - return { label: "custom (after_transaction)", uri: createURI( "LDEV6129/custom" ) }; + return { label: "custom (after_transaction)", uri: createURI( "LDEV6129/custom" ), params: { flushAtRequestEnd: true } }; } private numeric function getPoolActive( required struct metrics, required string dsName ) { diff --git a/test/tickets/LDEV6129/custom/Application.cfc b/test/tickets/LDEV6129/custom/Application.cfc index 2e439420329..6555a96635b 100644 --- a/test/tickets/LDEV6129/custom/Application.cfc +++ b/test/tickets/LDEV6129/custom/Application.cfc @@ -9,7 +9,7 @@ component { this.ormSettings = { dbcreate: "dropcreate", dialect: "h2", - flushAtRequestEnd: true, + flushAtRequestEnd: url.flushAtRequestEnd, autoManageSession: true, hibernateConfig: { "connection.release_mode": "after_transaction", diff --git a/test/tickets/LDEV6129/custom/multi_transaction.cfm b/test/tickets/LDEV6129/custom/multi_transaction.cfm new file mode 100644 index 00000000000..1561c88ee8f --- /dev/null +++ b/test/tickets/LDEV6129/custom/multi_transaction.cfm @@ -0,0 +1,35 @@ + + /* + * LDEV-6129: reproduce the dead reconnect code bug naturally via after_transaction release mode. + * + * With connection.release_mode=after_transaction, Hibernate releases the physical connection + * after every ormFlush() (afterTransaction callback sets physicalConnection=null). + * + * On the next ORM call, isConnected()=false triggers the dead reconnect block in + * HibernateORMSession.getSessionAndConn() which calls s.reconnect() — always throws + * ResourceClosedException in Hibernate 5.6 — and leaks the dc acquired by sac.connect(). + * + * No reflection required: ormFlush() commits the transaction → after_transaction fires. + * + * Expected before fix: ERROR: org.hibernate.ResourceClosedException + * Expected after fix: ok + */ + + // transaction 1: save and flush + p1 = entityNew( "LDEV6129Person" ); + p1.setName( createUUID() ); + entitySave( p1 ); + ormFlush(); // commits → afterTransaction() → physicalConnection=null → isConnected()=false + + systemOutput( "multi_transaction: after first ormFlush, isConnected=#ormGetSession().isConnected()#", true ); + + // transaction 2: next ORM call should trigger getSessionAndConn() with isConnected()=false + try { + people = entityLoad( "LDEV6129Person" ); + systemOutput( "multi_transaction: entityLoad after ormFlush returned #arrayLen( people )# rows", true ); + writeOutput( "ok" ); + } catch ( any e ) { + writeOutput( "ERROR: #e.type# - #e.message#" ); + systemOutput( "multi_transaction: caught exception: #e.stacktrace#", true ); + } +