]> git.basschouten.com Git - openhab-addons.git/commitdiff
[jsscripting] Reimplement timer polyfills to conform standard JS (#13623)
authorFlorian Hotze <florianh_dev@icloud.com>
Sat, 5 Nov 2022 14:26:46 +0000 (15:26 +0100)
committerGitHub <noreply@github.com>
Sat, 5 Nov 2022 14:26:46 +0000 (15:26 +0100)
* [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>
bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/JSRuntimeFeatures.java [new file with mode: 0644]
bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/OpenhabGraalJSScriptEngine.java
bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/threading/ThreadsafeTimers.java
bundles/org.openhab.automation.jsscripting/src/main/resources/node_modules/@jsscripting-globals.js

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 (file)
index 0000000..790b464
--- /dev/null
@@ -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();
+    }
+}
index 8a1282c8fd1c594d9d60763efd2e77072bfe2016..f810e2375e7b2b28a92132214bd41a062a56fe73 100644 (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...
      *
index 9583ca57c12b01c357bdb607bbe30b9cf2c4b79c..8b88ac3aad4d9db0eff034ed2123dd9cbf1fe193 100644 (file)
  */
 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;
+        this.scheduler = ScriptServiceUtil.getScheduler();
     }
 
-    public Timer createTimer(ZonedDateTime instant, Runnable callable) {
-        return createTimer(null, instant, callable);
+    /**
+     * Set the identifier base string used for naming scheduled jobs.
+     *
+     * @param identifier identifier to use
+     */
+    public void setIdentifier(String identifier) {
+        this.identifier = identifier;
     }
 
-    public Timer createTimer(@Nullable String identifier, ZonedDateTime instant, Runnable callable) {
-        Scheduler scheduler = ScriptServiceUtil.getScheduler();
-
-        return new TimerImpl(scheduler, instant, () -> {
+    /**
+     * 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) {
-                callable.run();
+                callback.run();
             }
+        }, identifier + ".timeout." + id, zdt.toInstant());
+    }
 
-        }, identifier);
+    /**
+     * <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());
     }
 
-    public Timer createTimerWithArgument(ZonedDateTime instant, Object arg1, Runnable callable) {
-        return createTimerWithArgument(null, instant, arg1, callable);
+    /**
+     * <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;
     }
 
-    public Timer createTimerWithArgument(@Nullable String identifier, ZonedDateTime instant, Object arg1,
-            Runnable callable) {
-        Scheduler scheduler = ScriptServiceUtil.getScheduler();
-        return new TimerImpl(scheduler, instant, () -> {
+    /**
+     * <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);
+        }
+    }
+
+    /**
+     * 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) {
-                callable.run();
+                callback.run();
             }
-
-        }, identifier);
+        }, identifier + ".interval." + id, new LoopingAdjuster(Duration.ofMillis(delay)));
+        idSchedulerMapping.put(id, future);
     }
 
     /**
-     * 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.
+     * <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.
      *
-     * @author Kai Kreuzer - Initial contribution
+     * @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.
      */
-    @NonNullByDefault
-    public static class TimerImpl implements Timer {
-
-        private final Scheduler scheduler;
-        private final ZonedDateTime startTime;
-        private final SchedulerRunnable runnable;
-        private final @Nullable String identifier;
-        private ScheduledCompletableFuture<?> future;
-
-        public TimerImpl(Scheduler scheduler, ZonedDateTime startTime, SchedulerRunnable runnable) {
-            this(scheduler, startTime, runnable, null);
-        }
-
-        public TimerImpl(Scheduler scheduler, ZonedDateTime startTime, SchedulerRunnable runnable,
-                @Nullable String identifier) {
-            this.scheduler = scheduler;
-            this.startTime = startTime;
-            this.runnable = runnable;
-            this.identifier = identifier;
+    public long setInterval(Runnable callback, Long delay) {
+        return setInterval(callback, delay, new Object());
+    }
 
-            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
+     * @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;
+    }
 
-        @Override
-        public boolean cancel() {
-            return future.cancel(true);
-        }
+    /**
+     * <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);
+    }
 
-        @Override
-        public synchronized boolean reschedule(ZonedDateTime newTime) {
-            future.cancel(false);
-            future = scheduler.schedule(runnable, identifier, newTime.toInstant());
-            return true;
-        }
+    /**
+     * 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();
+    }
 
-        @Override
-        public @Nullable ZonedDateTime getExecutionTime() {
-            return future.isCancelled() ? null : ZonedDateTime.now().plusNanos(future.getDelay(TimeUnit.NANOSECONDS));
-        }
+    /**
+     * 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 {
 
-        @Override
-        public boolean isActive() {
-            return !future.isDone();
-        }
+        private Duration delay;
+        private @Nullable Temporal timeDone;
 
-        @Override
-        public boolean isCancelled() {
-            return future.isCancelled();
+        LoopingAdjuster(Duration delay) {
+            this.delay = delay;
         }
 
         @Override
-        public boolean isRunning() {
-            return isActive() && ZonedDateTime.now().isAfter(startTime);
+        public boolean isDone(Temporal temporal) {
+            // Always return false so that a new job will be scheduled
+            return false;
         }
 
         @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;
         }
     }
 }
index e3b3399147560570c4a5a95db43d2aada2963974..88d31c686412b6d2c5758af89bd8870c51f9bf94 100644 (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);
         },
 
         // 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;