[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 <florianh_dev@icloud.com>
pull/13659/head
Florian Hotze 2022-11-05 15:26:46 +01:00 committed by GitHub
parent bbc744e3ff
commit 51d3fc211a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 254 additions and 136 deletions

View File

@ -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<String, Object> 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<String, Object> 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();
}
}

View File

@ -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<GraalJSScriptEngine> {
@ -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<Object[], Object>) 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...
*

View File

@ -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 (<code>setTimeout()</code>, <code>setInterval()</code> 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<Long, ScheduledCompletableFuture<Object>> 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<Object> 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);
/**
* <a href="https://developer.mozilla.org/en-US/docs/Web/API/setTimeout"><code>setTimeout()</code></a> 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
* <code>clearTimeout()</code> to cancel the timeout.
*/
public long setTimeout(Runnable callback, Long delay) {
return setTimeout(callback, delay, new Object());
}
/**
* <a href="https://developer.mozilla.org/en-US/docs/Web/API/setTimeout"><code>setTimeout()</code></a> 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
* <code>clearTimeout()</code> to cancel the timeout.
*/
public long setTimeout(Runnable callback, Long delay, Object... args) {
long id = lastId.incrementAndGet();
ScheduledCompletableFuture<Object> future = createFuture(id, ZonedDateTime.now().plusNanos(delay * 1000000),
callback);
idSchedulerMapping.put(id, future);
return id;
}
/**
* <a href="https://developer.mozilla.org/en-US/docs/Web/API/clearTimeout"><code>clearTimeout()</code></a> polyfill.
* Cancels a timeout previously created by <code>setTimeout()</code>.
*
* @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<Object> 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<Object> 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());
/**
* <a href="https://developer.mozilla.org/en-US/docs/Web/API/setInterval"><code>setInterval()</code></a> 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
* <code>clearInterval()</code> to cancel the interval.
*/
public long setInterval(Runnable callback, Long delay) {
return setInterval(callback, delay, new Object());
}
/**
* <a href="https://developer.mozilla.org/en-US/docs/Web/API/setInterval"><code>setInterval()</code></a> 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
* <code>clearInterval()</code> to cancel the interval.
*/
public long setInterval(Runnable callback, Long delay, Object... args) {
long id = lastId.incrementAndGet();
createLoopingFuture(id, delay, callback);
return id;
}
/**
* <a href="https://developer.mozilla.org/en-US/docs/Web/API/clearInterval"><code>clearInterval()</code></a>
* polyfill.
* Cancels a timed, repeating action which was previously established by a call to <code>setInterval()</code>.
*
* @param intervalID The identifier of the repeated action you want to cancel. This ID was returned by the
* corresponding call to <code>setInterval()</code>.
*/
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;
}
}
}

View File

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