Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[jsscripting] Reimplement timer polyfills to conform standard JS #13623

Merged
merged 16 commits into from
Nov 5, 2022
Merged
Show file tree
Hide file tree
Changes from 15 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
@@ -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.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
*/
public class JSRuntimeFeatures {
florian-h05 marked this conversation as resolved.
Show resolved Hide resolved
/**
* 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<>();
private final Object lock;
florian-h05 marked this conversation as resolved.
Show resolved Hide resolved
public final ThreadsafeTimers threadsafeTimers;

JSRuntimeFeatures(Object lock) {
this.lock = 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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@
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;
Expand All @@ -59,6 +58,7 @@
* @author Jonathan Gilbert - Initial contribution
* @author Dan Cunningham - Script injections
* @author Florian Hotze - Create lock object for multi-thread synchronization
* @author Florian Hotze - Inject the {@link JSRuntimeFeatures} into the JS context
florian-h05 marked this conversation as resolved.
Show resolved Hide resolved
*/
public class OpenhabGraalJSScriptEngine
extends InvocationInterceptingScriptEngineWithInvocableAndAutoCloseable<GraalJSScriptEngine> {
Expand All @@ -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;
Expand Down Expand Up @@ -209,7 +210,7 @@ protected void beforeInvocation() {
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;

Expand All @@ -220,6 +221,19 @@ protected void beforeInvocation() {
}
}

@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...
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,126 +12,206 @@
*/
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(@Nullable Temporal temporal) {
if (timeDone == null) {
timeDone = temporal;
}
Temporal nextTime = timeDone.plus(delay);
florian-h05 marked this conversation as resolved.
Show resolved Hide resolved
timeDone = nextTime;
return nextTime;
}
}
}
Loading