From 51d3fc211a5577feb6f384e2e839d5f2fe926a1a Mon Sep 17 00:00:00 2001 From: Florian Hotze Date: Sat, 5 Nov 2022 15:26:46 +0100 Subject: [PATCH] [jsscripting] Reimplement timer polyfills to conform standard JS (#13623) * [jsscripting] Reimplement timers to conform standard JS * [jsscripting] Name scheduled jobs by loggerName + id * [jsscripting] Update timer identifiers * [jsscripting] Update identifiers for scheduled jobs * [jsscripting] Synchronize method that is called when the script is reloaded * [jsscripting] Cancel all scheduled jobs when the engine is closed * [jsscripting] Ensure that a timerId is never reused by a subsequent call & Use long primitive type instead of Integer * [jsscripting] Use an abstraction class to inject features into the JS runtime * [jsscripting] Make ThreadsafeTimers threadsafe for concurrent access to the class itself * [jsscripting] Move the locking for `invokeFunction` to `OpenhabGraalJSScriptEngine` Signed-off-by: Florian Hotze --- .../internal/JSRuntimeFeatures.java | 56 ++++ .../internal/OpenhabGraalJSScriptEngine.java | 20 +- .../internal/threading/ThreadsafeTimers.java | 259 ++++++++++++------ .../node_modules/@jsscripting-globals.js | 55 +--- 4 files changed, 254 insertions(+), 136 deletions(-) create mode 100644 bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/JSRuntimeFeatures.java diff --git a/bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/JSRuntimeFeatures.java b/bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/JSRuntimeFeatures.java new file mode 100644 index 000000000..790b46416 --- /dev/null +++ b/bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/JSRuntimeFeatures.java @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2010-2022 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.automation.jsscripting.internal; + +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.automation.jsscripting.internal.threading.ThreadsafeTimers; + +/** + * Abstraction layer to collect all features injected into the JS runtime during the context creation. + * + * @author Florian Hotze - Initial contribution + */ +@NonNullByDefault +public class JSRuntimeFeatures { + /** + * All elements of this Map are injected into the JS runtime using their key as the name. + */ + private final Map features = new HashMap<>(); + public final ThreadsafeTimers threadsafeTimers; + + JSRuntimeFeatures(Object lock) { + this.threadsafeTimers = new ThreadsafeTimers(lock); + + features.put("ThreadsafeTimers", threadsafeTimers); + } + + /** + * Get the features that are to be injected into the JS runtime during context creation. + * + * @return the runtime features + */ + public Map getFeatures() { + return features; + } + + /** + * Un-initialization hook, called when the engine is closed. + * Use this method to clean up resources or cancel operations that were created by the JS runtime. + */ + public void close() { + threadsafeTimers.clearAll(); + } +} diff --git a/bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/OpenhabGraalJSScriptEngine.java b/bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/OpenhabGraalJSScriptEngine.java index 8a1282c8f..f810e2375 100644 --- a/bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/OpenhabGraalJSScriptEngine.java +++ b/bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/OpenhabGraalJSScriptEngine.java @@ -46,7 +46,6 @@ import org.openhab.automation.jsscripting.internal.fs.PrefixedSeekableByteChanne import org.openhab.automation.jsscripting.internal.fs.ReadOnlySeekableByteArrayChannel; import org.openhab.automation.jsscripting.internal.fs.watch.JSDependencyTracker; import org.openhab.automation.jsscripting.internal.scriptengine.InvocationInterceptingScriptEngineWithInvocableAndAutoCloseable; -import org.openhab.automation.jsscripting.internal.threading.ThreadsafeTimers; import org.openhab.core.automation.module.script.ScriptExtensionAccessor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -58,7 +57,8 @@ import com.oracle.truffle.js.scriptengine.GraalJSScriptEngine; * * @author Jonathan Gilbert - Initial contribution * @author Dan Cunningham - Script injections - * @author Florian Hotze - Create lock object for multi-thread synchronization + * @author Florian Hotze - Create lock object for multi-thread synchronization; Inject the {@link JSRuntimeFeatures} + * into the JS context */ public class OpenhabGraalJSScriptEngine extends InvocationInterceptingScriptEngineWithInvocableAndAutoCloseable { @@ -71,6 +71,7 @@ public class OpenhabGraalJSScriptEngine // shared lock object for synchronization of multi-thread access private final Object lock = new Object(); + private final JSRuntimeFeatures jsRuntimeFeatures = new JSRuntimeFeatures(lock); // these fields start as null because they are populated on first use private String engineIdentifier; @@ -209,7 +210,7 @@ public class OpenhabGraalJSScriptEngine delegate.getBindings(ScriptContext.ENGINE_SCOPE).put(REQUIRE_WRAPPER_NAME, wrapRequireFn); // Injections into the JS runtime delegate.put("require", wrapRequireFn.apply((Function) delegate.get("require"))); - delegate.put("ThreadsafeTimers", new ThreadsafeTimers(lock)); + jsRuntimeFeatures.getFeatures().forEach((key, obj) -> delegate.put(key, obj)); initialized = true; @@ -220,6 +221,19 @@ public class OpenhabGraalJSScriptEngine } } + @Override + public Object invokeFunction(String s, Object... objects) throws ScriptException, NoSuchMethodException { + // Synchronize multi-thread access to avoid exceptions when reloading a script file while the script is running + synchronized (lock) { + return super.invokeFunction(s, objects); + } + } + + @Override + public void close() { + jsRuntimeFeatures.close(); + } + /** * Tests if this is a root node directory, `/node_modules`, `C:\node_modules`, etc... * diff --git a/bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/threading/ThreadsafeTimers.java b/bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/threading/ThreadsafeTimers.java index 9583ca57c..8b88ac3aa 100644 --- a/bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/threading/ThreadsafeTimers.java +++ b/bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/threading/ThreadsafeTimers.java @@ -12,126 +12,209 @@ */ package org.openhab.automation.jsscripting.internal.threading; +import java.time.Duration; import java.time.ZonedDateTime; -import java.util.concurrent.TimeUnit; +import java.time.temporal.Temporal; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; -import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.core.model.script.ScriptServiceUtil; -import org.openhab.core.model.script.actions.Timer; import org.openhab.core.scheduler.ScheduledCompletableFuture; import org.openhab.core.scheduler.Scheduler; -import org.openhab.core.scheduler.SchedulerRunnable; +import org.openhab.core.scheduler.SchedulerTemporalAdjuster; /** - * A replacement for the timer functionality of {@link org.openhab.core.model.script.actions.ScriptExecution - * ScriptExecution} which controls multithreaded execution access to the single-threaded GraalJS contexts. + * A polyfill implementation of NodeJS timer functionality (setTimeout(), setInterval() and + * the cancel methods) which controls multithreaded execution access to the single-threaded GraalJS contexts. * * @author Florian Hotze - Initial contribution + * @author Florian Hotze - Reimplementation to conform standard JS setTimeout and setInterval */ public class ThreadsafeTimers { private final Object lock; + private final Scheduler scheduler; + // Mapping of positive, non-zero integer values (used as timeoutID or intervalID) and the Scheduler + private final Map> idSchedulerMapping = new ConcurrentHashMap<>(); + private AtomicLong lastId = new AtomicLong(); + private String identifier = "noIdentifier"; public ThreadsafeTimers(Object lock) { this.lock = lock; - } - - public Timer createTimer(ZonedDateTime instant, Runnable callable) { - return createTimer(null, instant, callable); - } - - public Timer createTimer(@Nullable String identifier, ZonedDateTime instant, Runnable callable) { - Scheduler scheduler = ScriptServiceUtil.getScheduler(); - - return new TimerImpl(scheduler, instant, () -> { - synchronized (lock) { - callable.run(); - } - - }, identifier); - } - - public Timer createTimerWithArgument(ZonedDateTime instant, Object arg1, Runnable callable) { - return createTimerWithArgument(null, instant, arg1, callable); - } - - public Timer createTimerWithArgument(@Nullable String identifier, ZonedDateTime instant, Object arg1, - Runnable callable) { - Scheduler scheduler = ScriptServiceUtil.getScheduler(); - return new TimerImpl(scheduler, instant, () -> { - synchronized (lock) { - callable.run(); - } - - }, identifier); + this.scheduler = ScriptServiceUtil.getScheduler(); } /** - * This is an implementation of the {@link Timer} interface. - * Copy of {@link org.openhab.core.model.script.internal.actions.TimerImpl} as this is not accessible from outside - * the - * package. + * Set the identifier base string used for naming scheduled jobs. * - * @author Kai Kreuzer - Initial contribution + * @param identifier identifier to use */ - @NonNullByDefault - public static class TimerImpl implements Timer { + public void setIdentifier(String identifier) { + this.identifier = identifier; + } - private final Scheduler scheduler; - private final ZonedDateTime startTime; - private final SchedulerRunnable runnable; - private final @Nullable String identifier; - private ScheduledCompletableFuture future; + /** + * Schedules a callback to run at a given time. + * + * @param id timerId to append to the identifier base for naming the scheduled job + * @param zdt time to schedule the job + * @param callback function to run at the given time + * @return a {@link ScheduledCompletableFuture} + */ + private ScheduledCompletableFuture createFuture(long id, ZonedDateTime zdt, Runnable callback) { + return scheduler.schedule(() -> { + synchronized (lock) { + callback.run(); + } + }, identifier + ".timeout." + id, zdt.toInstant()); + } - public TimerImpl(Scheduler scheduler, ZonedDateTime startTime, SchedulerRunnable runnable) { - this(scheduler, startTime, runnable, null); + /** + * setTimeout() polyfill. + * Sets a timer which executes a given function once the timer expires. + * + * @param callback function to run after the given delay + * @param delay time in milliseconds that the timer should wait before the callback is executed + * @return Positive integer value which identifies the timer created; this value can be passed to + * clearTimeout() to cancel the timeout. + */ + public long setTimeout(Runnable callback, Long delay) { + return setTimeout(callback, delay, new Object()); + } + + /** + * setTimeout() polyfill. + * Sets a timer which executes a given function once the timer expires. + * + * @param callback function to run after the given delay + * @param delay time in milliseconds that the timer should wait before the callback is executed + * @param args + * @return Positive integer value which identifies the timer created; this value can be passed to + * clearTimeout() to cancel the timeout. + */ + public long setTimeout(Runnable callback, Long delay, Object... args) { + long id = lastId.incrementAndGet(); + ScheduledCompletableFuture future = createFuture(id, ZonedDateTime.now().plusNanos(delay * 1000000), + callback); + idSchedulerMapping.put(id, future); + return id; + } + + /** + * clearTimeout() polyfill. + * Cancels a timeout previously created by setTimeout(). + * + * @param timeoutId The identifier of the timeout you want to cancel. This ID was returned by the corresponding call + * to setTimeout(). + */ + public void clearTimeout(long timeoutId) { + ScheduledCompletableFuture scheduled = idSchedulerMapping.remove(timeoutId); + if (scheduled != null) { + scheduled.cancel(true); } + } - public TimerImpl(Scheduler scheduler, ZonedDateTime startTime, SchedulerRunnable runnable, - @Nullable String identifier) { - this.scheduler = scheduler; - this.startTime = startTime; - this.runnable = runnable; - this.identifier = identifier; + /** + * Schedules a callback to run in a loop with a given delay between the executions. + * + * @param id timerId to append to the identifier base for naming the scheduled job + * @param delay time in milliseconds that the timer should delay in between executions of the callback + * @param callback function to run + */ + private void createLoopingFuture(long id, Long delay, Runnable callback) { + ScheduledCompletableFuture future = scheduler.schedule(() -> { + synchronized (lock) { + callback.run(); + } + }, identifier + ".interval." + id, new LoopingAdjuster(Duration.ofMillis(delay))); + idSchedulerMapping.put(id, future); + } - future = scheduler.schedule(runnable, identifier, startTime.toInstant()); + /** + * setInterval() polyfill. + * Repeatedly calls a function with a fixed time delay between each call. + * + * @param callback function to run + * @param delay time in milliseconds that the timer should delay in between executions of the callback + * @return Numeric, non-zero value which identifies the timer created; this value can be passed to + * clearInterval() to cancel the interval. + */ + public long setInterval(Runnable callback, Long delay) { + return setInterval(callback, delay, new Object()); + } + + /** + * setInterval() polyfill. + * Repeatedly calls a function with a fixed time delay between each call. + * + * @param callback function to run + * @param delay time in milliseconds that the timer should delay in between executions of the callback + * @param args + * @return Numeric, non-zero value which identifies the timer created; this value can be passed to + * clearInterval() to cancel the interval. + */ + public long setInterval(Runnable callback, Long delay, Object... args) { + long id = lastId.incrementAndGet(); + createLoopingFuture(id, delay, callback); + return id; + } + + /** + * clearInterval() + * polyfill. + * Cancels a timed, repeating action which was previously established by a call to setInterval(). + * + * @param intervalID The identifier of the repeated action you want to cancel. This ID was returned by the + * corresponding call to setInterval(). + */ + public void clearInterval(long intervalID) { + clearTimeout(intervalID); + } + + /** + * Cancels all timed actions (i.e. timeouts and intervals) that were created with this instance of + * {@link ThreadsafeTimers}. + * Should be called in a de-initialization/unload hook of the script engine to avoid having scheduled jobs that are + * running endless. + */ + public void clearAll() { + idSchedulerMapping.forEach((id, future) -> future.cancel(true)); + idSchedulerMapping.clear(); + } + + /** + * This is a temporal adjuster that takes a single delay. + * This adjuster makes the scheduler run as a fixed rate scheduler from the first time adjustInto was called. + * + * @author Florian Hotze - Initial contribution + */ + private static class LoopingAdjuster implements SchedulerTemporalAdjuster { + + private Duration delay; + private @Nullable Temporal timeDone; + + LoopingAdjuster(Duration delay) { + this.delay = delay; } @Override - public boolean cancel() { - return future.cancel(true); + public boolean isDone(Temporal temporal) { + // Always return false so that a new job will be scheduled + return false; } @Override - public synchronized boolean reschedule(ZonedDateTime newTime) { - future.cancel(false); - future = scheduler.schedule(runnable, identifier, newTime.toInstant()); - return true; - } - - @Override - public @Nullable ZonedDateTime getExecutionTime() { - return future.isCancelled() ? null : ZonedDateTime.now().plusNanos(future.getDelay(TimeUnit.NANOSECONDS)); - } - - @Override - public boolean isActive() { - return !future.isDone(); - } - - @Override - public boolean isCancelled() { - return future.isCancelled(); - } - - @Override - public boolean isRunning() { - return isActive() && ZonedDateTime.now().isAfter(startTime); - } - - @Override - public boolean hasTerminated() { - return future.isDone(); + public Temporal adjustInto(Temporal temporal) { + Temporal localTimeDone = timeDone; + Temporal nextTime; + if (localTimeDone != null) { + nextTime = localTimeDone.plus(delay); + } else { + nextTime = temporal.plus(delay); + } + timeDone = nextTime; + return nextTime; } } } diff --git a/bundles/org.openhab.automation.jsscripting/src/main/resources/node_modules/@jsscripting-globals.js b/bundles/org.openhab.automation.jsscripting/src/main/resources/node_modules/@jsscripting-globals.js index e3b339914..88d31c686 100644 --- a/bundles/org.openhab.automation.jsscripting/src/main/resources/node_modules/@jsscripting-globals.js +++ b/bundles/org.openhab.automation.jsscripting/src/main/resources/node_modules/@jsscripting-globals.js @@ -1,3 +1,4 @@ +// ThreadsafeTimers is injected into the JS runtime (function (global) { 'use strict'; @@ -5,8 +6,9 @@ // Append the script file name OR rule UID depending on which is available const defaultIdentifier = "org.openhab.automation.script" + (globalThis["javax.script.filename"] ? ".file." + globalThis["javax.script.filename"].replace(/^.*[\\\/]/, '') : globalThis["ruleUID"] ? ".ui." + globalThis["ruleUID"] : ""); const System = Java.type('java.lang.System'); - const ZonedDateTime = Java.type('java.time.ZonedDateTime'); const formatRegExp = /%[sdj%]/g; + // Pass the defaultIdentifier to ThreadsafeTimers to enable naming of scheduled jobs + ThreadsafeTimers.setIdentifier(defaultIdentifier); function createLogger(name = defaultIdentifier) { return Java.type("org.slf4j.LoggerFactory").getLogger(name); @@ -162,61 +164,24 @@ }, // Allow user customizable logging names + // Be aware that a log4j2 required a logger defined for the logger name, otherwise messages won't be logged! set loggerName(name) { log = createLogger(name); this._loggerName = name; + ThreadsafeTimers.setIdentifier(name); }, get loggerName() { - return this._loggerName || defaultLoggerName; + return this._loggerName || defaultIdentifier; } }; - function setTimeout(cb, delay) { - const args = Array.prototype.slice.call(arguments, 2); - return ThreadsafeTimers.createTimerWithArgument( - defaultIdentifier + '.setTimeout', - ZonedDateTime.now().plusNanos(delay * 1000000), - args, - function (args) { - cb.apply(global, args); - } - ); - } - - function clearTimeout(timer) { - if (timer !== undefined && timer.isActive()) { - timer.cancel(); - } - } - - function setInterval(cb, delay) { - const args = Array.prototype.slice.call(arguments, 2); - const delayNanos = delay * 1000000 - let timer = ThreadsafeTimers.createTimerWithArgument( - defaultIdentifier + '.setInterval', - ZonedDateTime.now().plusNanos(delayNanos), - args, - function (args) { - cb.apply(global, args); - if (!timer.isCancelled()) { - timer.reschedule(ZonedDateTime.now().plusNanos(delayNanos)); - } - } - ); - return timer; - } - - function clearInterval(timer) { - clearTimeout(timer); - } - // Polyfill common NodeJS functions onto the global object globalThis.console = console; - globalThis.setTimeout = setTimeout; - globalThis.clearTimeout = clearTimeout; - globalThis.setInterval = setInterval; - globalThis.clearInterval = clearInterval; + globalThis.setTimeout = ThreadsafeTimers.setTimeout; + globalThis.clearTimeout = ThreadsafeTimers.clearTimeout; + globalThis.setInterval = ThreadsafeTimers.setInterval; + globalThis.clearInterval = ThreadsafeTimers.clearInterval; // Support legacy NodeJS libraries globalThis.global = globalThis;