diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3a68284..5509543 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -5,13 +5,17 @@ on: pull_request: workflow_dispatch: inputs: + deploy: + description: 'Deploy to Maven Central' + type: boolean + default: false lucee-versions: description: 'JSON array of Lucee version queries (e.g. ["6.2.5.34/snapshot/jar","6.2.6.0/snapshot/jar"])' default: '["6.2/snapshot/jar","7.0/snapshot/jar"]' - dry-run: - description: 'Dry run - skip deploy to Maven' - type: boolean - default: false + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true jobs: setup: @@ -486,68 +490,58 @@ jobs: echo "CONFIG_OVERRIDE_FAILED=true" >> $GITHUB_OUTPUT fi - # ------------------------------------------------------------------ - # Reflection / Lucee-restart scenario — TEMPORARILY DISABLED. - # - # The post-restart path hits a real bug: after cfadmin action="restart", - # the new CFMLEngine's extension classloader can't load - # JakartaWebSocketEndpoint.class — `WebSocketEndpointFactory.scanWebContexts` - # and `registerEndpoint` both fail with "unable to load class path". - # The reflection fallback never gets a chance to patch the old static slot - # because the new classes themselves aren't loadable. - # - # The test CFMs (trigger-lucee-restart.cfm, test-reflection-restart.cfm) - # and this workflow block are kept in place for when the underlying bug - # is fixed — just re-enable the steps below. - # - # See LDEV-6221 and the companion catalina.out output in the earlier CI - # run logs for the stack trace. - # ------------------------------------------------------------------ - # - name: Trigger Lucee engine restart (LDEV-6221 scenario) - # id: restart-trigger - # continue-on-error: true - # run: | - # RESPONSE=$(curl -s -w "\n%{http_code}" http://localhost:8888/tests/integration/trigger-lucee-restart.cfm) - # HTTP_CODE=$(echo "$RESPONSE" | tail -1) - # BODY=$(echo "$RESPONSE" | sed '$d') - # echo "$BODY" - # if [ "$HTTP_CODE" != "200" ] || echo "$BODY" | grep -q "FAILED"; then - # echo "RESTART_TRIGGER_FAILED=true" >> $GITHUB_OUTPUT - # fi - # - # - name: Wait for Lucee to come back online - # run: | - # for i in {1..60}; do - # if curl -s -o /dev/null -w "%{http_code}" http://localhost:8888/tests/test-websocket-info.cfm | grep -q "200"; then - # echo "Lucee responding after $i attempts" - # break - # fi - # sleep 1 - # done - # - # - name: Run post-restart reflection round-trip - # id: reflection-test - # continue-on-error: true - # run: | - # RESPONSE=$(curl -s -w "\n%{http_code}" http://localhost:8888/tests/integration/test-reflection-restart.cfm) - # HTTP_CODE=$(echo "$RESPONSE" | tail -1) - # BODY=$(echo "$RESPONSE" | sed '$d') - # echo "$BODY" - # if [ "$HTTP_CODE" != "200" ] || echo "$BODY" | grep -q "FAILED"; then - # echo "REFLECTION_FAILED=true" >> $GITHUB_OUTPUT - # fi - # - # - name: Verify reflection warning in catalina.out - # id: reflection-log-check - # continue-on-error: true - # run: | - # if grep -q "calling \[.*\] via reflection" lucee-express/logs/catalina.out; then - # echo "Reflection warning found — hot re-install exercised the fallback path" - # grep "calling \[.*\] via reflection" lucee-express/logs/catalina.out - # else - # echo "REFLECTION_LOG_MISSING=true" >> $GITHUB_OUTPUT - # echo "Reflection warning NOT found in catalina.out — hot re-install did not hit the fallback path" - # fi + - name: Trigger Lucee engine restart (LDEV-6221 scenario) + id: restart-trigger + continue-on-error: true + run: | + # Calls cfadmin action="restart" — reloads the CFMLEngine while Tomcat + # stays up. Forces the fresh extension to hit its reflection fallback + # because Tomcat's endpoint registry still has the pre-restart class. + RESPONSE=$(curl -s -w "\n%{http_code}" http://localhost:8888/tests/integration/trigger-lucee-restart.cfm) + HTTP_CODE=$(echo "$RESPONSE" | tail -1) + BODY=$(echo "$RESPONSE" | sed '$d') + echo "$BODY" + if [ "$HTTP_CODE" != "200" ] || echo "$BODY" | grep -q "FAILED"; then + echo "RESTART_TRIGGER_FAILED=true" >> $GITHUB_OUTPUT + fi + + - name: Wait for Lucee to come back online + run: | + # Engine restart takes a few seconds — poll a trivial endpoint until 200 + for i in {1..60}; do + if curl -s -o /dev/null -w "%{http_code}" http://localhost:8888/tests/test-websocket-info.cfm | grep -q "200"; then + echo "Lucee responding after $i attempts" + break + fi + sleep 1 + done + + - name: Run post-restart reflection round-trip + id: reflection-test + continue-on-error: true + run: | + RESPONSE=$(curl -s -w "\n%{http_code}" http://localhost:8888/tests/integration/test-reflection-restart.cfm) + HTTP_CODE=$(echo "$RESPONSE" | tail -1) + BODY=$(echo "$RESPONSE" | sed '$d') + echo "$BODY" + if [ "$HTTP_CODE" != "200" ] || echo "$BODY" | grep -q "FAILED"; then + echo "REFLECTION_FAILED=true" >> $GITHUB_OUTPUT + fi + + - name: Verify reflection warning in catalina.out + id: reflection-log-check + continue-on-error: true + run: | + # The reflection fallback path logs "calling [onOpen] via reflection, servlet engine restart needed" + # (see BaseWebSocketEndpoint and LDEV-6221). If the hot re-install actually exercised this path, + # the warning will appear in catalina.out. If not, the round-trip test is lying. + if grep -q "calling \[.*\] via reflection" lucee-express/logs/catalina.out; then + echo "Reflection warning found — hot re-install exercised the fallback path" + grep "calling \[.*\] via reflection" lucee-express/logs/catalina.out + else + echo "REFLECTION_LOG_MISSING=true" >> $GITHUB_OUTPUT + echo "Reflection warning NOT found in catalina.out — hot re-install did not hit the fallback path" + fi - name: Dump WebSocket event logs if: always() @@ -575,13 +569,13 @@ jobs: lucee-express/lucee-server/context/logs/ - name: Fail if any sub-test failed - if: steps.config-override-test.outputs.CONFIG_OVERRIDE_FAILED == 'true' + if: steps.config-override-test.outputs.CONFIG_OVERRIDE_FAILED == 'true' || steps.reflection-test.outputs.REFLECTION_FAILED == 'true' || steps.reflection-log-check.outputs.REFLECTION_LOG_MISSING == 'true' run: exit 1 deploy: runs-on: ubuntu-latest needs: [build-extension, test, test-config-override] - if: github.event_name == 'workflow_dispatch' && needs.test.result == 'success' && needs.test-config-override.result == 'success' && !inputs.dry-run + if: github.event_name == 'workflow_dispatch' && inputs.deploy && needs.test.result == 'success' && needs.test-config-override.result == 'success' steps: - name: Checkout repository uses: actions/checkout@v6 diff --git a/source/java/src/org/lucee/extension/websocket/WebSocketEndpointFactory.java b/source/java/src/org/lucee/extension/websocket/WebSocketEndpointFactory.java index 13854b9..0a4669b 100644 --- a/source/java/src/org/lucee/extension/websocket/WebSocketEndpointFactory.java +++ b/source/java/src/org/lucee/extension/websocket/WebSocketEndpointFactory.java @@ -128,16 +128,23 @@ private void registerEndpoint(ConfigWeb cw) throws PageException { WSUtil.info(cs, msg); WSUtil.info(cw, msg); - CFMLEngine eng = CFMLEngineFactory.getInstance(); + // Use plain java.lang.reflect instead of Lucee's ClassUtil.callStaticMethod + // The old endpoint Class lives on (Tomcat holds the ref) but its owning + // bundle's classloader may be stopped after cfadmin restart / extension + // hot-reinstall — Lucee's dynamic reflector reads bytecode via ASM which + // fails with "unable to load class path". Direct reflection works off the + // Class's own metadata, no bytecode re-read needed. LDEV-6221. try { + Class oldEndpointClass = (Class) endpoint; + java.lang.reflect.Method inject = oldEndpointClass.getMethod("inject", Object.class); if (WSUtil.getContainerType(cw) == WSUtil.TYPE_JAKARTA) - eng.getClassUtil().callStaticMethod((Class) endpoint, "inject", new Object[] { new JakartaWebSocketEndpoint() }); + inject.invoke(null, new JakartaWebSocketEndpoint()); else if (WSUtil.getContainerType(cw) == WSUtil.TYPE_JAVAX) - eng.getClassUtil().callStaticMethod((Class) endpoint, "inject", new Object[] { new JavaxWebSocketEndpoint() }); + inject.invoke(null, new JavaxWebSocketEndpoint()); } - catch (PageException e) { + catch (ReflectiveOperationException e) { print.e(e); - throw e; + throw eng.getCastUtil().toPageException(e); } } // add diff --git a/source/java/src/org/lucee/extension/websocket/util/WSUtil.java b/source/java/src/org/lucee/extension/websocket/util/WSUtil.java index 488974e..fa24570 100644 --- a/source/java/src/org/lucee/extension/websocket/util/WSUtil.java +++ b/source/java/src/org/lucee/extension/websocket/util/WSUtil.java @@ -570,28 +570,26 @@ public static long getMaxIdleTimeout(ConfigWeb cw, Object session, long defaultV } /** - * Get ServletContext from ConfigWeb using reflection to avoid javax/jakarta linking issues + * Get ServletContext from ConfigWeb using plain java.lang.reflect to avoid + * javax/jakarta linking issues AND to avoid Lucee's ClassUtil which ASM-reads + * bytecode via the class's classloader — that fails after cfadmin restart / + * bundle reload because the old classloader is stopped (LDEV-6221). */ public static Object getServletContext(ConfigWeb cw) { try { - CFMLEngine eng = CFMLEngineFactory.getInstance(); - return eng.getClassUtil().callMethod(cw, eng.getCastUtil().toKey("getServletContext"), new Object[] {}); + return cw.getClass().getMethod("getServletContext").invoke(cw); } - catch (PageException e) { + catch (ReflectiveOperationException e) { throw new RuntimeException("Failed to get ServletContext from ConfigWeb", e); } } - /** - * Get real path from ServletContext using reflection - */ public static String getServletContextRealPath(ConfigWeb cw, String path) { try { Object sc = getServletContext(cw); - CFMLEngine eng = CFMLEngineFactory.getInstance(); - return (String) eng.getClassUtil().callMethod(sc, eng.getCastUtil().toKey("getRealPath"), new Object[] { path }); + return (String) sc.getClass().getMethod("getRealPath", String.class).invoke(sc, path); } - catch (PageException e) { + catch (ReflectiveOperationException e) { throw new RuntimeException("Failed to get real path from ServletContext", e); } } @@ -602,20 +600,15 @@ public static String getServletContextRealPath(ConfigWeb cw, String path) { public static boolean isCliServletContext(ConfigWeb cw) { Object sc = getServletContext(cw); if (sc == null) return true; - ClassUtil util = CFMLEngineFactory.getInstance().getClassUtil(); - return util.isInstaneOf(sc.getClass(), "lucee.cli.servlet.ServletContextImpl"); + return "lucee.cli.servlet.ServletContextImpl".equals(sc.getClass().getName()); } - /** - * Get attribute from ServletContext using reflection - */ public static Object getServletContextAttribute(ConfigWeb cw, String name) { try { Object sc = getServletContext(cw); - CFMLEngine eng = CFMLEngineFactory.getInstance(); - return eng.getClassUtil().callMethod(sc, eng.getCastUtil().toKey("getAttribute"), new Object[] { name }); + return sc.getClass().getMethod("getAttribute", String.class).invoke(sc, name); } - catch (PageException e) { + catch (ReflectiveOperationException e) { throw new RuntimeException("Failed to get attribute [" + name + "] from ServletContext", e); } } diff --git a/tests/integration/test-lifecycle-callbacks.cfm b/tests/integration/test-lifecycle-callbacks.cfm index 76ee45f..5ef93a8 100644 --- a/tests/integration/test-lifecycle-callbacks.cfm +++ b/tests/integration/test-lifecycle-callbacks.cfm @@ -75,13 +75,10 @@ try { arrayAppend( errors, "expected [#eventName#] #wanted# time(s), got #found#" ); } - // NOTE: onFirstOpen runs on an async thread (AsyncInvoker in BaseWebSocketEndpoint), - // so its ordering relative to onOpen is NOT guaranteed. Presence + count is asserted - // above; we don't check relative ordering. - - // onLastClose must be the last event (fires only after every client disconnects) - if ( arrayLen( events ) && events[ arrayLen( events ) ] != "onLastClose" ) - arrayAppend( errors, "onLastClose should be the final event, got: " & events[ arrayLen( events ) ] ); + // NOTE: both onFirstOpen and onLastClose run on async threads (AsyncInvoker in + // BaseWebSocketEndpoint). Their ordering relative to onOpen / onClose is NOT + // guaranteed — onLastClose can race with the final onClose. We assert presence + // + count above; no relative-ordering assertions. if ( arrayLen( errors ) ) { writeOutput( chr( 10 ) & "FAILED:" & chr( 10 ) );