PSTimer.java
/*
** Module : PSTimer.java
** Abstract : An implementation of the Timer FWD extension to 4GL.
**
** Copyright (c) 2017-2023, Golden Code Development Corporation.
**
** -#- -I- --Date-- --------------------------------Description-----------------------------------
** 001 OM 20170530 First commit.
** 002 OM 20180425 Keep a reference to procedure that created the PSTimer to locate the callback.
** 003 CA 20180517 Fixed comhandle FWD-specific resource management.
** 004 OM 20190225 Pushed creator field up to super class.
** CA 20190315 Fixed resource cleanup on session end.
** Resources are added to the unnamed pools, if exist (TODO: add named pool
** support).
** CA 20190322 Wait for the fired async event to finish before continuing.
** 005 HC 20190810 Fixed initial timer values.
** 006 CA 20200930 Avoid string calculation for log messages, if logging is disabled.
** 007 VVT 20220329 Typos fixed in Javadoc.
** 008 GBB 20230512 Logging methods replaced by CentralLogger/ConversionStatus.
*/
/*
** This program is free software: you can redistribute it and/or modify
** it under the terms of the GNU Affero General Public License as
** published by the Free Software Foundation, either version 3 of the
** License, or (at your option) any later version.
**
** This program is distributed in the hope that it will be useful,
** but WITHOUT ANY WARRANTY; without even the implied warranty of
** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
** GNU Affero General Public License for more details.
**
** You may find a copy of the GNU Affero GPL version 3 at the following
** location: https://www.gnu.org/licenses/agpl-3.0.en.html
**
** Additional terms under GNU Affero GPL version 3 section 7:
**
** Under Section 7 of the GNU Affero GPL version 3, the following additional
** terms apply to the works covered under the License. These additional terms
** are non-permissive additional terms allowed under Section 7 of the GNU
** Affero GPL version 3 and may not be removed by you.
**
** 0. Attribution Requirement.
**
** You must preserve all legal notices or author attributions in the covered
** work or Appropriate Legal Notices displayed by works containing the covered
** work. You may not remove from the covered work any author or developer
** credit already included within the covered work.
**
** 1. No License To Use Trademarks.
**
** This license does not grant any license or rights to use the trademarks
** Golden Code, FWD, any Golden Code or FWD logo, or any other trademarks
** of Golden Code Development Corporation. You are not authorized to use the
** name Golden Code, FWD, or the names of any author or contributor, for
** publicity purposes without written authorization.
**
** 2. No Misrepresentation of Affiliation.
**
** You may not represent yourself as Golden Code Development Corporation or FWD.
**
** You may not represent yourself for publicity purposes as associated with
** Golden Code Development Corporation, FWD, or any author or contributor to
** the covered work, without written authorization.
**
** 3. No Misrepresentation of Source or Origin.
**
** You may not represent the covered work as solely your work. All modified
** versions of the covered work must be marked in a reasonable way to make it
** clear that the modified work is not originating from Golden Code Development
** Corporation or FWD. All modified versions must contain the notices of
** attribution required in this license.
*/
package com.goldencode.p2j.extension;
import com.goldencode.p2j.comauto.*;
import com.goldencode.p2j.net.*;
import com.goldencode.p2j.security.*;
import com.goldencode.p2j.ui.*;
import com.goldencode.p2j.ui.client.event.*;
import com.goldencode.p2j.util.*;
import com.goldencode.p2j.util.logging.*;
import java.util.concurrent.*;
import java.util.function.*;
import java.util.logging.*;
/**
* Implementation of the FWD TIMER Extension. An {@code PSTimer} object can be scheduled to run
* repetitive tasks at fixed intervals. This is not fully compatible with ABL standard language,
* it is an extension to 4GL specific to FWD.
*
* <p>
* To create a timer use the following syntax:
* <pre>
* CREATE TIMER handle [ASSIGN
* [INTERVAL = n-millis]
* [CALLBACK = procedure]
* [ENABLED = auto-start]].
* </pre>
* Where:
* <ul>
* <li><strong>handle</strong> is the handle that will hold the new timer object;</li>
* <li><strong>n-millis</strong> is the interval between two consecutive ticks, in
* milliseconds. Note that the actual time where the callback procedure is executed may
* be delayed, depending on how the event messages get processed by FWD. It is illegal to
* set a negative value. Default is 0, not active.</li>
* <li><strong>procedure</strong> is the callback procedure legacy name that will be called
* when the timer elapses. The procedure must not have any parameters;</li>
* <li><strong>auto-start</strong>. If set to {@code yes} the timer is started automatically
* at the moment it is created, otherwise a call to {@link #start()} or setting the
* {@code enabled} property to {@code true} is needed to arm it.</li>
* </ul>
* Setting any of the properties to same value has no effect. Setting a new value for
* {@code interval} when the timer is armed will force the timer to restart, he next event will
* be fired after the timeout of the newly set interval. There are a few ways to stop the timer,
* with small differences among them:
* <ul>
* <li>set the {@code interval} to 0. It remain enabled, setting the {@code interval} to a new
* value will automatically arm it;</li>
* <li>set the {@code enabled} to {@code false}. The timer will stop until it is restarted by
* setting the value back to {@code yes} or calling {@code start};</li>
* <li>call {@code cease}, has the same effect like above;</li>
* <li>setting the {@code callback} to unknown will make the timer unable to call an event
* procedure, but it will continue to tick.</li>
* </ul>
* At the end of lifetime of the object, it must be deleted, as usual with
* <pre>DELETE OBJECT th.</pre>
*
* <p>
* The {@code PSTimer} object extends the {@code ComObject} class. This means that the programmer
* can use a {@link comhandle} interface to access any properties and/or methods annotated as such
* of the object, even they are known at compile time. The COM mode of operation is somewhat
* compatible with legacy ActiveX objects from ABL.
*/
public class PSTimer
extends ComObject
implements Runnable,
FWDTimer,
SessionListener
{
/**
* The logger used for tracing internal events.
*/
private static final CentralLogger LOG = CentralLogger.get(PSTimer.class.getName());
/** Flag indicating that the logging is enabled or not. */
private static final boolean LOG_ENABLED = LOG.isLoggable(Level.FINE);
/**
* The background thread that does the power-horse work. It is {@code null} if this timer has
* not been started yet, or if the object was released/deleted.
*/
private AssociatedThread timer = null;
/**
* The ticking interval, in milliseconds. Maps holds {@code INTERVAL} property/attribute.
* If it is zero, the timer is not enabled (does not fire). It is illegal to set it to a
* negative value.
*/
private volatile int interval = 1000;
/**
* Flags if this timer is enabled. If not enabled, the timer will not fire the {@code tick}
* event.
*/
private volatile boolean enabled = false;
/**
* Flags indicating whether this timer was reset while armed ({@code interval} changed to
* another valid value while timer was sleeping). In this case it must be re-initiated with
* the new interval.
*/
private volatile boolean timerReset = false;
/**
* The callback procedure in non-COM mode (legacy name). If not set timer is useless. One may
* set it to {@code unknown} / {@code null} if you want some ticks to be skipped. The
* difference from setting the enabled to {@code NO} is that in this case the timer is not
* restarted.
*/
private String callback = null;
/**
* Flags the operation mode. In the COM-mode the object is compatible with ABL {@code PSTimer}
* ActiveX object. There are two differences:
* <ul>
* <li><strong>enabled</strong> attribute/property is by default {@code yes} in COM mode
* and {@code false} in extension mode. This means that in COM mode, setting the
* {@code INTERVAL} will automatically arm the timer, while in extension mode, one
* need to explicitly set {@code enabled} to {@code yes} or invoke the {@code start}
* method;</li>
* <li>the <strong>callback</strong> is only used in extension mode, if set. In COM/ActiveX
* mode the {@code callback} property is ignored and ABL compatible algorithm is used
* to identify the proper event procedure to call. The legacy name of the procedure is
* computed using {@code <ControlFrame-name>.<ActiveX-name>.<Event-name>}.</li>
* </ul>
*/
private boolean isCom = true;
/**
* Flags if this object has been yet destroyed and is no longer valid. A {@code PSTimer} object
* is valid throughout it lifetime until it is disposed using {@link #delete()}.
*/
private boolean invalid = false;
/**
* A t<sub>0</sub> timestamp used to relate all events in LOG.
*/
private long t0 = System.currentTimeMillis();
/**
* The constructor. It makes sure that the object is fully and safely initialized.
* The super c'tor {@code ComObject} will be calling {@link comhandle#resourceId} and
* configure the {@code parentProcedure}.
*/
public PSTimer()
{
comhandle.resourceId(this);
}
/**
* Static factory method for initialization of a fresh new TIMER as an extension object.
*
* @param h
* The {@link handle} to store the newly created object.
*/
public static void create(handle h)
{
PSTimer newInstance = new PSTimer();
newInstance.isCom = false;
newInstance.enabled = false;
h.assign(newInstance);
// TODO: add named widget pool support
WidgetPool.addResource(newInstance, null);
}
/**
* Obtain the name of this ActiveX object.
*
* @return the name of this ActiveX object.
*/
public String getActivexName()
{
return "PSTimer";
}
/**
* The emulation of the ActiveX {@code AboutBox} legacy method. In ABL it will open a modal
* dialog with the name of the ActiveX and its version. The emulated method is a NO-OP, except
* for the optional log of the call.
*/
@ComMethod(name = "AboutBox")
public void AboutBox()
{
log(() -> "Executing PSTimer:AboutBox() COM method...");
}
/**
* Check whether this timer is armed. However, it will not fire unless the {@code interval} is
* also set to a strictly positive value.
*
* @return The value of the {@code Enabled} property/attribute.
*/
@ComProperty(name = "Enabled")
public logical isEnabled()
{
return new logical(enabled);
}
/**
* Sets the value of the {@code Enabled} property/attribute. If {@code interval} property is
* already set, the timer gets armed.
*
* @param setOn
* new value of the {@code Enabled} property/attribute.
*/
// @ComProperty(name = "Enabled", setter = true)
public void setEnabled(boolean setOn)
{
if (setOn == enabled)
{
return;
}
log(() -> "Enabled = " + setOn);
enabled = setOn;
if (setOn)
{
startWorker();
}
else
{
if (timer != null)
{
timer.interrupt();
}
}
// otherwise this is a NO-OP
}
/**
* Sets the value of the {@code Enabled} property/attribute. If {@code interval} property is
* already set, the timer gets armed.
*
* @param setOn
* new value of the {@code Enabled} property/attribute.
*/
@ComProperty(name = "Enabled", setter = true)
public void setEnabled(logical setOn)
{
if (setOn != null && !setOn.isUnknown())
{
setEnabled(setOn.booleanValue());
}
}
/**
* Obtain the current {@code interval} between two consecutive events.
*
* @return the current value of {@code interval} property.
*/
@ComProperty(name = "Interval")
public integer getInterval()
{
return new integer(interval);
}
/**
* Sets the current {@code interval} between two consecutive events. If the timer is already
* {@code enabled}, it will be automatically armed and will fire after {@code interval} millis
* elapses. Setting it to 0, will stop the timer. Setting to a negative value will lead to
* an error message to be displayed.
*
* @param interval
* The new interval between two timer events, in milliseconds.
*/
// @ComProperty(name = "Interval", setter = true)
public void setInterval(long interval)
throws PropertyAccessException
{
if (interval < 0)
{
String msg = "The Interval property must not be a negative number";
throw new PropertyAccessException(msg, "0x80020009");
}
if (interval == this.interval)
{
return; // no-op ?
}
log(() -> "Interval = " + interval);
this.interval = (int) interval;
startWorker();
}
/**
* Sets the current {@code interval} between two consecutive events. If the timer is already
* {@code enabled}, it will be automatically armed and will fire after {@code interval} millis
* elapses. Setting it to 0, will stop the timer. Setting to a negative value will lead to
* an error message to be displayed.
*
* @param interval
* The new interval between two timer events, in milliseconds.
*/
@ComProperty(name = "Interval", setter = true)
public void setInterval(NumberType interval)
throws PropertyAccessException
{
if (interval == null || interval.isUnknown())
{
throw new PropertyAccessException("Type mismatch", "0x80020005");
}
setInterval(interval.longValue());
}
/**
* Gets the legacy name of the callback procedure to be called at fixed interval when running
* in FWD extension mode (not as a COM).
*
* @return the legacy name of the callback procedure.
*/
@Override
public character getCallback()
{
return new character(callback);
}
/**
* Sets the legacy name of the callback procedure to be called at fixed interval when running
* in FWD extension mode (ignored when in COM mode). If set to {@code unknown} the timer will
* continue 'ticking' but since there is no known peer to call, nothing will happen. This
* property can be set while the timer is active, without resetting it.
*
* @param callback
* the new legacy name of the callback procedure.
*/
@Override
public void setCallback(character callback)
{
if (callback != null && !callback.isUnknown())
{
setCallback(callback.toStringMessage());
}
}
/**
* Sets the legacy name of the callback procedure to be called at fixed interval when running
* in FWD extension mode (ignored when in COM mode). If set to {@code unknown} the timer will
* continue 'ticking' but since there is no known peer to call, nothing will happen. This
* property can be set while the timer is active, without resetting it.
*
* @param callback
* the new legacy name of the callback procedure.
*/
@Override
public void setCallback(String callback)
{
this.callback = callback;
}
/**
* Force the timer to cease firing events. It has the exact effect like setting the
* {@code enabled} property to {@code false}.
* <p>
* The name of the method was selected intentionally for the FWD extension since {@code STOP}
* would create some syntactic incompatibility with standard ABL attributes.
*/
@ComMethod(name = "cease")
@Override
public void cease()
{
setEnabled(false);
}
/**
* Starts the timer. It has the exact effect like setting the {@code enabled} property to
* {@code true}. Note that timer will not be activated unless the {@code interval} property
* was set to a strictly positive value.
*/
@ComMethod(name = "start")
@Override
public void start()
{
setEnabled(true);
}
/**
* Perform actual delete of all resources. At the time of this call, it is assumed the resource
* is valid for deletion (the handle and the resource are both valid).
* <p>
* This is called from a handle when working as FWD extension object.
*/
@Override
public void delete()
{
if (invalid)
{
return; // already deleted
}
destroy();
log(() -> "Releasing");
// end of lifetime: mark the object are invalid
invalid = true;
AssociatedThread save = this.timer;
if (save == null)
{
return;
}
this.timer = null;
enabled = false;
interval = 0;
save.interrupt();
SessionManager.get().deregisterAsyncThread(save);
}
/**
* Checks whether this object is valid. The object is valid from the moment it was created
* (even if not active) and until expressly deleted.
*
* @return {@code true} if this object is valid.
*/
@Override
public boolean valid()
{
return !invalid;
}
/**
* This callback will be called after the security context has been set up and is valid for
* the life of this thread and before any of the client code it will be executed.
* <p>
* Implement this method if you some work must be done from the thread that has the proper
* security context set up.
* <p>
* No-op for the {@link PSTimer} case.
*
* @param session
* The session that is starting.
*/
@Override
public void initialize(Session session)
{
// no-op
}
/**
* This method is called when the session is ending. This is useful for
* cleaning up resources or state.
* <p>
* It ensures the runner thread gets terminated.
*
* @param session
* The session that is ending.
*/
@Override
public void terminate(Session session)
{
delete();
}
/**
* Worker method. Checks the internal state and start the timer. On the first call, it makes
* sure the {@link AssociatedThread} is created and started. If already armed, calling this
* will cause the current interval to be dropped, and a new schedule is performed for the
* current {@code interval}.
*/
private void startWorker()
{
if (invalid)
{
return; // object already deleted/released cannot be brought back to life
}
if (enabled && interval > 0)
{
if (timer == null)
{
SessionManager sm = SessionManager.get();
log(() -> "Creating timing thread.");
if (!sm.getSession().addSessionListener(this, true))
{
// the session doesn't exist...
invalid = true;
return;
}
timer = new AssociatedThread(this);
sm.registerAsyncThread(timer);
timer.setDaemon(true);
timer.setName("PSTimer");
timer.start();
}
else
{
// restart: abort current sleep and restart with new
log(() -> "Notify / restart (" + timer + ")");
timerReset = true; // let it know the reason for interruption
timer.interrupt();
}
}
}
/**
* The background worker thread. It get repeatably suspended for {@code interval} milliseconds
* and, if not interrupted, will trigger the event. In case of interruption, it will analyze
* the internal state and rearm or wait for the timer to be re-enabled.
*/
@Override
public void run()
{
timerLoops:
while (timer != null)
{
if (interval > 0 && enabled)
{
long sleepTo = System.currentTimeMillis() + interval;
log(() -> "Arming timer (" + timer + "). See you in " + interval / 1000f + " secs.");
do
{
try
{
synchronized (timer)
{
long timeout = sleepTo - System.currentTimeMillis();
if (timeout > 0)
{
timer.wait(timeout);
}
}
}
catch (InterruptedException e)
{
log(() -> "Timer interrupted.");
if (this.timer == null)
{
log(() -> "Timer released (1). Quitting thread.");
break timerLoops;
}
}
if (!enabled || interval <= 0)
{
// something changed, firing is not allowed in these conditions
// let the big loop decide what to do
continue timerLoops;
}
else if (timerReset) // && enabled && interval > 0
{
log(() -> "Timer reset detected.");
timerReset = false;
// loop with the new interval
continue timerLoops;
}
}
while (System.currentTimeMillis() <= sleepTo);
log(() -> "Timer completed. Firing event.");
if (getParentProcedure()._isValid())
{
fireEvent();
}
else
{
// if the external procedure of callback was deleted we cannot invoke the callback
// anymore, end of object and thread lifetime
delete();
return;
}
}
else
{
log(() -> "Waiting for restart...");
try
{
synchronized (timer)
{
timer.wait(0);
}
}
catch (InterruptedException e)
{
if (this.timer == null)
{
log(() -> "Timer released (2). Quitting thread.");
break;
}
}
}
}
}
/**
* Calls the callback or the event procedure asynchronously. In both cases a
* {@link ServerEvent} will be posted with {@link LogicalTerminal#postServerEvent}. When
* running as FWD extension, the {@code callback} procedure is invoked, if set. When running as
* a COM/ActiveX, a new {@code ComEvent} event is dispatched through {@code ComServer}, which
* will use the {@code ControlFrame} container, the name of this {@code ComObject} and the
* declared event name to compute the legacy name of teh procedure to call.
*/
private void fireEvent()
{
if (isCom)
{
// if the object ws created as a COM object, fire using COM protocol
ComServer.emit(new ComEvent(PSTimer.this, "Tick"), false);
return;
}
// this is a TIMER FWD extension. Fire event only if callback attribute was set.
if (callback != null)
{
try
{
CountDownLatch latch = new CountDownLatch(1);
LogicalTerminal.postServerEvent(
new ServerEvent(id(),
"TIMER-EVENT",
() ->
{
try
{
if (ComServer.checkValid(this))
{
ControlFlowOps.invokeIn(callback, getParentProcedure());
}
}
finally
{
latch.countDown();
}
}));
try
{
latch.await();
}
catch (InterruptedException ie)
{
// ignore
}
}
catch (Exception e)
{
if (LOG.isLoggable(Level.WARNING))
{
LOG.log(Level.WARNING, "Fail to asynchronously call '" + callback + ".", e);
}
}
}
}
/**
* Logs information mostly for debugging purposes. The method will automatically insert a
* timestamp relative to creation of this object (for easy tracking) and the thread id this
* call is performed.
*
* @param s
* The message to be logged.
*/
private void log(Supplier<String> s)
{
if (LOG_ENABLED)
{
float dt = (System.currentTimeMillis() - t0) / 1000f;
LOG.log(Level.FINE, dt + "\tT" + Thread.currentThread().getId() + "\t" + s.get());
}
}
}