Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions test/tickets/LDEV6129.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
component extends="org.lucee.cfml.test.LuceeTestCase" labels="orm" {

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 ) {

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 ) {

_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", url: cfg.params );
} 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", 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?)"
);
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 ) {

_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 );
expect( left( trim( result.filecontent ), 2 ) ).toBe( "ok",
"entityLoad failed after isConnected()=false — reconnect dead code is broken: #trim( result.filecontent )#"
);

} );

} );

}

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" ), params: {} };
}

private struct function customConfig() {
return { label: "custom (after_transaction)", uri: createURI( "LDEV6129/custom" ), params: { flushAtRequestEnd: true } };
}

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;
}

}
20 changes: 20 additions & 0 deletions test/tickets/LDEV6129/basic/Application.cfc
Original file line number Diff line number Diff line change
@@ -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;
}

}
6 changes: 6 additions & 0 deletions test/tickets/LDEV6129/basic/LDEV6129Person.cfc
Original file line number Diff line number Diff line change
@@ -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";

}
25 changes: 25 additions & 0 deletions test/tickets/LDEV6129/basic/flush_leak.cfm
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<cfscript>
/*
* 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
</cfscript>
69 changes: 69 additions & 0 deletions test/tickets/LDEV6129/basic/reconnect_leak.cfm
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<cfscript>
/*
* 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 );
}
</cfscript>
4 changes: 4 additions & 0 deletions test/tickets/LDEV6129/basic/setup.cfm
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<cfscript>
// Drop and recreate all ORM tables — ensures no stale data from previous runs
ormReload();
</cfscript>
6 changes: 6 additions & 0 deletions test/tickets/LDEV6129/basic/simple.cfm
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<cfscript>
// 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 )#" );
</cfscript>
26 changes: 26 additions & 0 deletions test/tickets/LDEV6129/custom/Application.cfc
Original file line number Diff line number Diff line change
@@ -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: url.flushAtRequestEnd,
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;
}

}
6 changes: 6 additions & 0 deletions test/tickets/LDEV6129/custom/LDEV6129Person.cfc
Original file line number Diff line number Diff line change
@@ -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";

}
25 changes: 25 additions & 0 deletions test/tickets/LDEV6129/custom/flush_leak.cfm
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<cfscript>
/*
* 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
</cfscript>
35 changes: 35 additions & 0 deletions test/tickets/LDEV6129/custom/multi_transaction.cfm
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<cfscript>
/*
* 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 );
}
</cfscript>
Loading
Loading