Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -822,8 +822,6 @@ public static void setSQLParam(final PreparedStatement stmt, final int idx, SQLP
stmt.setLong(idx, ((BigInteger) val).longValue());
} else if (val instanceof Byte) {
stmt.setByte(idx, (Byte) val);
} else if (val instanceof Integer) {
stmt.setInt(idx, (Integer) val);
} else if (val instanceof InputStream) {
stmt.setBinaryStream(idx, (InputStream) val, 10000);
} else if (val instanceof Blob) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
* with the fields enclosed by brackets [] replaced by
* your own identifying information:
* "Portions Copyrighted [year] [name of copyright owner]"
*
* Portions Copyright 2026 3A Systems, LLC.
*/

package org.forgerock.openicf.connectors.scriptedcrest
Expand Down Expand Up @@ -198,7 +200,6 @@ class ScriptedCRESTConfiguration extends ScriptedConfiguration {
}

private Closure init = null;
private Closure release = null;
private Closure beforeRequest = null;
private Closure onComplete = null;
private Closure onFail = null;
Expand All @@ -223,7 +224,7 @@ class ScriptedCRESTConfiguration extends ScriptedConfiguration {

customizerClass.metaClass.customize << { Closure cl ->
init = null
release = null
setReleaseClosure(null)
beforeRequest = null
onComplete = null
onFail = null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,12 @@ public void release() {
clone.call();
releaseClosure = null;
}
if (null != publishedCustomizerClass) {
// Drops the metaclass createCustomizerScript() may have put on the class,
// which would otherwise keep this engine's loader alive.
InvokerHelper.removeClass(publishedCustomizerClass);
publishedCustomizerClass = null;
}
groovyScriptEngine = null;
propertyBag.clear();
loggerCache.clear();
Expand Down Expand Up @@ -782,8 +788,18 @@ protected Log getLogger(final Class<?> clazz) {
return logger;
}

/** The engine, published only once its customizer has run. Guarded by {@code this}. */
private GroovyScriptEngine groovyScriptEngine = null;

/**
* The engine whose customizer is running, for the calls the customizer makes back into
* {@link #getGroovyScriptEngine()} on the same thread. Guarded by {@code this}.
*/
private GroovyScriptEngine customizingGroovyScriptEngine = null;

/** The customizer class of the published engine; {@link #release()} unregisters it. Guarded by {@code this}. */
private Class publishedCustomizerClass = null;

/**
* Synchronised for the whole initialisation, not double-checked: the
* customizer script runs against the half-initialised engine (it may call
Expand All @@ -792,35 +808,58 @@ protected Log getLogger(final Class<?> clazz) {
*/
protected synchronized GroovyScriptEngine getGroovyScriptEngine() {
if (null == groovyScriptEngine) {
if (null != customizingGroovyScriptEngine) {
return customizingGroovyScriptEngine;
}

final CompilerConfiguration compilerConfiguration =
new CompilerConfiguration(config);
compilerConfiguration.addCompilationCustomizers(getImportCustomizer(null));

final GroovyClassLoader loader =
new GroovyClassLoader(getParentLoader(), compilerConfiguration, true);

groovyScriptEngine =
final GroovyScriptEngine engine =
new GroovyScriptEngine(getRoots(compilerConfiguration, loader), loader);

initializeCustomizer();
// If the customizer fails, the engine is dropped and the next call retries.
final Class customizerClass;
customizingGroovyScriptEngine = engine;
try {
customizerClass = initializeCustomizer();
} finally {
customizingGroovyScriptEngine = null;
}
publishedCustomizerClass = customizerClass;
groovyScriptEngine = engine;
}
return groovyScriptEngine;
}

/*
* This must be called once from thread-safe location and inside the
* synchronized to avoid deadlock.
* synchronized to avoid deadlock. The customizer runs while this
* configuration's monitor is held: it must not wait for another thread
* that calls getGroovyScriptEngine(), evaluate() or loadScript().
* Returns the customizer class, or null if there is none.
*/
private void initializeCustomizer() {
private Class initializeCustomizer() {
Class customizerClass = null;
try {
Class customizerClass = getCustomizerClass();
customizerClass = getCustomizerClass();

if (null != customizerClass) {
Binding binding = new Binding();
binding.setVariable(LOGGER, getLogger(customizerClass));
createCustomizerScript(customizerClass, binding).run();
}
return customizerClass;
} catch (Throwable t) {
if (null != customizerClass) {
// The retry compiles a new class; unregister this one, together with any
// metaclass createCustomizerScript() put on it, so its loader can be collected.
InvokerHelper.removeClass(customizerClass);
}
logger.error(t, "Failed to customize the connector");
throw ConnectorException.wrap(t);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
/*
* The contents of this file are subject to the terms of the Common Development and
* Distribution License (the License). You may not use this file except in compliance with the
* License.
*
* You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
* specific language governing permission and limitations under the License.
*
* When distributing Covered Software, include this CDDL Header Notice in each file and include
* the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
* Header, with the fields enclosed by brackets [] replaced by your own identifying
* information: "Portions copyright [year] [name of copyright owner]".
*
* Copyright 2026 3A Systems, LLC.
*/

package org.forgerock.openicf.misc.scriptedcommon;

import static org.fest.assertions.api.Assertions.assertThat;
import static org.fest.assertions.api.Assertions.fail;

import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;

import org.codehaus.groovy.reflection.ClassInfo;
import org.forgerock.openicf.connectors.scriptedcrest.ScriptedCRESTConfiguration;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;

import groovy.lang.Binding;
import groovy.lang.ExpandoMetaClass;
import groovy.lang.GroovySystem;
import groovy.lang.Script;
import groovy.util.GroovyScriptEngine;

/**
* Tests that {@link ScriptedConfiguration#getGroovyScriptEngine()} only hands out an engine whose
* customizer script has run.
*/
public class ScriptedConfigurationTest {

/** What the customizer script does; set by each test. */
private static volatile Runnable customizer;

/** Called from the customizer script written by {@link #setUp()}. */
public static void customize() {
customizer.run();
}

/** How often {@link #isFirstAttempt()} was called; reset by {@link #setUp()}. */
private static final AtomicInteger attempts = new AtomicInteger();

/** Whether the release closure of a customizer script ran; reset by {@link #setUp()}. */
private static volatile boolean releaseClosureRan;

/** Called from a customizer script to behave differently on its first attempt. */
public static boolean isFirstAttempt() {
return attempts.incrementAndGet() == 1;
}

/** Called from the release closure of a customizer script. */
public static void releaseClosureRan() {
releaseClosureRan = true;
}

private File scriptRoot;

private ScriptedConfiguration configuration;

private ExecutorService executor;

@BeforeMethod
public void setUp() throws Exception {
attempts.set(0);
releaseClosureRan = false;
scriptRoot = Files.createTempDirectory("scripted-configuration").toFile();
Files.write(new File(scriptRoot, "Customizer.groovy").toPath(),
(ScriptedConfigurationTest.class.getName() + ".customize()\n")
.getBytes(StandardCharsets.UTF_8));
configuration = new ScriptedConfiguration();
configuration.setScriptRoots(new String[] { scriptRoot.getAbsolutePath() });
configuration.setCustomizerScriptFileName("Customizer.groovy");
executor = Executors.newCachedThreadPool();
}

@AfterMethod
public void tearDown() throws Exception {
customizer = null;
executor.shutdownNow();
new File(scriptRoot, "Customizer.groovy").delete();
scriptRoot.delete();
}

@Test
public void testFailedCustomizerIsRetriedOnTheNextCall() {
final AtomicInteger calls = new AtomicInteger();
customizer = () -> {
if (calls.incrementAndGet() == 1) {
throw new IllegalStateException("customizer failed");
}
};

try {
configuration.getGroovyScriptEngine();
fail("The customizer failure must reach the caller");
} catch (IllegalStateException e) {
assertThat(e).hasMessage("customizer failed");
}

assertThat(configuration.getGroovyScriptEngine()).isNotNull();
assertThat(calls.get()).isEqualTo(2);
}

@Test
public void testFailedCustomizerClassIsUnregistered() {
customizer = () -> {
throw new IllegalStateException("customizer failed");
};
final AtomicReference<Class> customizerClass = new AtomicReference<>();
configuration = registeringMetaClassOnCustomizer(customizerClass);

try {
configuration.getGroovyScriptEngine();
fail("The customizer failure must reach the caller");
} catch (IllegalStateException e) {
assertThat(e).hasMessage("customizer failed");
}

assertThat(customizerClass.get()).isNotNull();
assertThat(ClassInfo.getClassInfo(customizerClass.get()).getStrongMetaClass()).isNull();
}

@Test
public void testReleaseUnregistersThePublishedCustomizerClass() {
customizer = () -> {
};
final AtomicReference<Class> customizerClass = new AtomicReference<>();
configuration = registeringMetaClassOnCustomizer(customizerClass);

assertThat(configuration.getGroovyScriptEngine()).isNotNull();
assertThat(customizerClass.get()).isNotNull();
assertThat(ClassInfo.getClassInfo(customizerClass.get()).getStrongMetaClass())
.as("metaclass of the published customizer before release()").isNotNull();

configuration.release();
assertThat(ClassInfo.getClassInfo(customizerClass.get()).getStrongMetaClass()).isNull();
}

/**
* A configuration that registers a metaclass on its customizer class, as the REST, CREST and
* SSH configurations do; a registered metaclass keeps the class's loader alive.
*/
private ScriptedConfiguration registeringMetaClassOnCustomizer(
final AtomicReference<Class> customizerClass) {
ScriptedConfiguration result = new ScriptedConfiguration() {
@Override
protected Script createCustomizerScript(Class clazz, Binding binding) {
customizerClass.set(clazz);
ExpandoMetaClass metaClass = new ExpandoMetaClass(clazz, false, true);
metaClass.initialize();
GroovySystem.getMetaClassRegistry().setMetaClass(clazz, metaClass);
return super.createCustomizerScript(clazz, binding);
}
};
result.setScriptRoots(new String[] { scriptRoot.getAbsolutePath() });
result.setCustomizerScriptFileName("Customizer.groovy");
return result;
}

@Test
public void testRetriedCRESTCustomizerDropsTheReleaseClosureOfTheFailedAttempt() throws Exception {
final String test = ScriptedConfigurationTest.class.getName();
Files.write(new File(scriptRoot, "Customizer.groovy").toPath(),
("if (" + test + ".isFirstAttempt()) {\n"
+ " customize {\n"
+ " release { " + test + ".releaseClosureRan() }\n"
+ " }\n"
+ " throw new IllegalStateException('customizer failed')\n"
+ "}\n"
+ "customize {\n"
+ "}\n").getBytes(StandardCharsets.UTF_8));
configuration = new ScriptedCRESTConfiguration();
configuration.setScriptRoots(new String[] { scriptRoot.getAbsolutePath() });
configuration.setCustomizerScriptFileName("Customizer.groovy");

try {
configuration.getGroovyScriptEngine();
fail("The customizer failure must reach the caller");
} catch (IllegalStateException e) {
assertThat(e).hasMessage("customizer failed");
}
assertThat(configuration.getGroovyScriptEngine()).isNotNull();

configuration.release();
assertThat(releaseClosureRan).as("release closure of the failed attempt ran").isFalse();
}

@Test(timeOut = 30000)
public void testEngineIsHiddenFromOtherThreadsUntilCustomized() throws Exception {
final CountDownLatch customizing = new CountDownLatch(1);
final CountDownLatch finishCustomizing = new CountDownLatch(1);
customizer = () -> {
customizing.countDown();
try {
finishCustomizing.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
};

Future<GroovyScriptEngine> first = executor.submit(configuration::getGroovyScriptEngine);
customizing.await();
final AtomicReference<Thread> secondThread = new AtomicReference<>();
Future<GroovyScriptEngine> second = executor.submit(() -> {
secondThread.set(Thread.currentThread());
return configuration.getGroovyScriptEngine();
});
// The second caller either returns early or blocks on the configuration's monitor.
while (!second.isDone() && (secondThread.get() == null
|| secondThread.get().getState() != Thread.State.BLOCKED)) {
Thread.sleep(10);
}
assertThat(second.isDone()).as("second caller returned while the customizer ran").isFalse();

finishCustomizing.countDown();
assertThat(first.get()).isNotNull();
assertThat(second.get()).isSameAs(first.get());
}
}
Loading
Loading