/*
* The MIT License
*
* Copyright 2012 Jesse Glick.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package jenkins.util;
import edu.umd.cs.findbugs.annotations.SuppressWarnings;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.annotation.Nonnull;
import javax.servlet.http.HttpServletRequest;
import net.sf.json.JSON;
import net.sf.json.JSONObject;
import org.acegisecurity.context.SecurityContext;
import org.acegisecurity.context.SecurityContextHolder;
import org.kohsuke.stapler.Ancestor;
import org.kohsuke.stapler.RequestImpl;
import org.kohsuke.stapler.Stapler;
import org.kohsuke.stapler.TokenList;
import org.kohsuke.stapler.bind.Bound;
import org.kohsuke.stapler.bind.BoundObjectTable;
import org.kohsuke.stapler.bind.JavaScriptMethod;
import org.kohsuke.stapler.jelly.BindTag;
/**
* A helper thread which does some computation in the background and displays incremental results using JavaScript.
* This is appropriate when the computation may be slow—too slow to do synchronously within the initial HTTP request—and has no side effects
* (since it may be canceled if the user simply browses to another page while it is running).
* <ol>
* <li>Write a {@code <script>} section defining {@code function display(data)}.
* (Call {@code ts_refresh($('someid'))} if using a {@code sortable} table.)
* <li>Use {@code <l:progressiveRendering handler="${it.something()}" callback="display"/>} from your
* Jelly page to display a progress bar and initialize JavaScript infrastructure.
* (The callback attribute can take arbitrary JavaScript expression to be evaluated in the browser
* so long as it produces a function object.)
* <li>Implement {@code something()} to create an instance of your subclass of {@code ProgressiveRendering}.
* <li>Perform your work in {@link #compute}.
* <li>Periodically check {@link #canceled}.
* <li>As results become available, call {@link #progress}.
* <li>Make {@link #data} produce whatever JSON you want to send to the page to be displayed.
* </ol>
* {@code ui-samples-plugin} demonstrates all this.
* @since 1.484
*/
public abstract class ProgressiveRendering {
private static final Logger LOG = Logger.getLogger(ProgressiveRendering.class.getName());
/** May be set to a number of milliseconds to sleep in {@link #canceled}, useful for watching what are normally fast computations. */
private static final Long DEBUG_SLEEP = SystemProperties.getLong("jenkins.util.ProgressiveRendering.DEBUG_SLEEP");
private static final int CANCELED = -1;
private static final int ERROR = -2;
private double status = 0;
private long lastNewsTime;
private final SecurityContext securityContext;
private final RequestImpl request;
/** just for logging */
private final String uri;
private long start;
private BoundObjectTable.Table boundObjectTable;
/** Unfortunately we cannot get the {@link Bound} that was created for us; it is thrown out by {@link BindTag}. */
private String boundId;
/** Constructor for subclasses. */
protected ProgressiveRendering() {
securityContext = SecurityContextHolder.getContext();
request = createMockRequest();
uri = request.getRequestURI();
}
/**
* For internal use.
*/
@SuppressWarnings("RV_RETURN_VALUE_IGNORED_BAD_PRACTICE")
@JavaScriptMethod public final void start() {
Ancestor ancestor = Stapler.getCurrentRequest().findAncestor(BoundObjectTable.class);
if (ancestor == null) {
throw new IllegalStateException("no BoundObjectTable");
}
boundObjectTable = ((BoundObjectTable) ancestor.getObject()).getTable();
boundId = ancestor.getNextToken(0);
LOG.log(Level.FINE, "starting rendering {0} at {1}", new Object[] {uri, boundId});
final ExecutorService executorService = executorService();
executorService.submit(new Runnable() {
@Override public void run() {
lastNewsTime = start = System.currentTimeMillis();
setCurrentRequest(request);
SecurityContext orig = SecurityContextHolder.getContext();
try {
SecurityContextHolder.setContext(securityContext);
compute();
if (status != CANCELED && status != ERROR) {
status = 1;
}
} catch (Exception x) {
LOG.log(Level.WARNING, "failed to compute " + uri, x);
status = ERROR;
} finally {
SecurityContextHolder.setContext(orig);
setCurrentRequest(null);
LOG.log(Level.FINE, "{0} finished in {1}msec with status {2}", new Object[] {uri, System.currentTimeMillis() - start, status});
}
if (executorService instanceof ScheduledExecutorService) {
((ScheduledExecutorService) executorService).schedule(new Runnable() {
@Override public void run() {
LOG.log(Level.FINE, "some time has elapsed since {0} finished, so releasing", boundId);
release();
}
}, timeout() /* add some grace period for browser/network overhead */ * 2, TimeUnit.MILLISECONDS);
}
}
});
}
/** {@link BoundObjectTable#releaseMe} just cannot work the way we need it to. */
private void release() {
try {
Method release = BoundObjectTable.Table.class.getDeclaredMethod("release", String.class);
release.setAccessible(true);
release.invoke(boundObjectTable, boundId);
} catch (Exception x) {
LOG.log(Level.WARNING, "failed to unbind " + boundId, x);
}
}
/**
* Copies important fields from the current HTTP request and makes them available during {@link #compute}.
* This is necessary because some model methods such as {@link AbstractItem#getUrl} behave differently when called from a request.
*/
@java.lang.SuppressWarnings({"rawtypes", "unchecked"}) // public RequestImpl ctor requires List<AncestorImpl> yet AncestorImpl is not public! API design flaw
private static RequestImpl createMockRequest() {
RequestImpl currentRequest = (RequestImpl) Stapler.getCurrentRequest();
HttpServletRequest original = (HttpServletRequest) currentRequest.getRequest();
final Map<String,Object> getters = new HashMap<String,Object>();
for (Method method : HttpServletRequest.class.getMethods()) {
String m = method.getName();
if ((m.startsWith("get") || m.startsWith("is")) && method.getParameterTypes().length == 0) {
Class<?> type = method.getReturnType();
// TODO could add other types which are known to be safe to copy: Cookie[], Principal, HttpSession, etc.
if (type.isPrimitive() || type == String.class || type == Locale.class) {
try {
getters.put(m, method.invoke(original));
} catch (Exception x) {
LOG.log(Level.WARNING, "cannot mock Stapler request " + method, x);
}
}
}
}
List/*<AncestorImpl>*/ ancestors = currentRequest.ancestors;
LOG.log(Level.FINER, "mocking ancestors {0} using {1}", new Object[] {ancestors, getters});
TokenList tokens = currentRequest.tokens;
return new RequestImpl(Stapler.getCurrent(), (HttpServletRequest) Proxy.newProxyInstance(ProgressiveRendering.class.getClassLoader(), new Class<?>[] {HttpServletRequest.class}, new InvocationHandler() {
@Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
String m = method.getName();
if (getters.containsKey(m)) {
return getters.get(m);
} else { // TODO implement other methods as needed
throw new UnsupportedOperationException(m);
}
}
}), ancestors, tokens);
}
@java.lang.SuppressWarnings("unchecked")
private static void setCurrentRequest(RequestImpl request) {
try {
Field field = Stapler.class.getDeclaredField("CURRENT_REQUEST");
field.setAccessible(true);
((ThreadLocal<RequestImpl>) field.get(null)).set(request);
} catch (Exception x) {
LOG.log(Level.WARNING, "cannot mock Stapler request", x);
}
}
/**
* Actually do the work.
* <p>The security context will be that in effect when the web request was made.
* {@link Stapler#getCurrentRequest} will also be similar to that in effect when the web request was made;
* at least, {@link Ancestor}s and basic request properties (URI, locale, and so on) will be available.
* @throws Exception whenever you like; the progress bar will indicate that an error occurred but details go to the log only
*/
protected abstract void compute() throws Exception;
/**
* Provide current data to the web page for display.
* <p>While this could be an aggregate of everything that has been computed so far,
* more likely you want to supply only that data that is new since the last call
* (maybe just {@code {}} or {@code []}),
* so that the page can incrementally update bits of HTML rather than refreshing everything.
* <p>You may want to make your implementation {@code synchronized}, so that it
* can track what was sent on a previous call, in which case any code running in
* {@link #compute} which modifies these fields should also <em>temporarily</em> be synchronized
* on the same monitor such as {@code this}.
* @return any JSON data you like
*/
protected abstract @Nonnull JSON data();
/**
* Indicate what portion of the work has been done.
* (Once {@link #compute} returns, the work is assumed to be complete regardless of this method.)
* @param completedFraction estimated portion of work now done, from 0 (~ 0%) to 1 (~ 100%)
*/
protected final void progress(double completedFraction) {
if (completedFraction < 0 || completedFraction > 1) {
throw new IllegalArgumentException(completedFraction + " should be in [0,1]");
}
status = completedFraction;
}
/**
* Checks whether the task has been canceled.
* If the rendering page fails to send a heartbeat within a certain amount of time,
* the user is assumed to have moved on.
* Therefore {@link #compute} should periodically say:
* {@code if (canceled()) return;}
* @return true if user seems to have abandoned us, false if we should still run
*/
protected final boolean canceled() {
if (DEBUG_SLEEP != null) {
try {
Thread.sleep(DEBUG_SLEEP);
} catch (InterruptedException x) {}
}
if (status == ERROR) {
return true; // recent call to data() failed
}
long now = System.currentTimeMillis();
long elapsed = now - lastNewsTime;
if (elapsed > timeout()) {
status = CANCELED;
LOG.log(Level.FINE, "{0} canceled due to {1}msec inactivity after {2}msec", new Object[] {uri, elapsed, now - start});
return true;
} else {
return false;
}
}
/**
* For internal use.
*/
@JavaScriptMethod public final JSONObject news() {
lastNewsTime = System.currentTimeMillis();
JSONObject r = new JSONObject();
try {
r.put("data", data());
} catch (RuntimeException x) {
LOG.log(Level.WARNING, "failed to update " + uri, x);
status = ERROR;
}
Object statusJSON = status == 1 ? "done" : status == CANCELED ? "canceled" : status == ERROR ? "error" : status;
r.put("status", statusJSON);
if (statusJSON instanceof String) { // somehow completed
LOG.log(Level.FINE, "finished in news so releasing {0}", boundId);
release();
}
lastNewsTime = System.currentTimeMillis();
LOG.log(Level.FINER, "news from {0}", uri);
return r;
}
/**
* May be overridden to provide an alternate executor service.
* @return by default, {@link Timer#get}
*/
protected ExecutorService executorService() {
return Timer.get();
}
/**
* May be overridden to control the inactivity timeout.
* If no request from the browser is received within this time,
* the next call to {@link #canceled} will be true.
* @return timeout in milliseconds; by default, 15000 (~ 15 seconds)
*/
protected long timeout() {
return 15000;
}
}