View Javadoc
1   /*
2    * Copyright (c) 2002-2026 Gargoyle Software Inc.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    * https://www.apache.org/licenses/LICENSE-2.0
8    *
9    * Unless required by applicable law or agreed to in writing, software
10   * distributed under the License is distributed on an "AS IS" BASIS,
11   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12   * See the License for the specific language governing permissions and
13   * limitations under the License.
14   */
15  package org.htmlunit.javascript.background;
16  
17  import java.io.IOException;
18  import java.io.ObjectInputStream;
19  import java.lang.ref.WeakReference;
20  import java.util.PriorityQueue;
21  import java.util.concurrent.atomic.AtomicInteger;
22  
23  import org.apache.commons.logging.Log;
24  import org.apache.commons.logging.LogFactory;
25  import org.htmlunit.Page;
26  import org.htmlunit.WebWindow;
27  
28  /**
29   * <p>Default implementation of {@link JavaScriptJobManager}.</p>
30   *
31   * <p>This job manager class is guaranteed not to keep old windows in memory (no window memory leaks).</p>
32   *
33   * <p>This job manager is serializable, but any running jobs are transient and are not serialized.</p>
34   *
35   * @author Daniel Gredler
36   * @author Katharina Probst
37   * @author Amit Manjhi
38   * @author Ronald Brill
39   * @author Carsten Steul
40   */
41  class JavaScriptJobManagerImpl implements JavaScriptJobManager {
42  
43      /**
44       * The window to which this job manager belongs (weakly referenced, so as not
45       * to leak memory).
46       */
47      private final transient WeakReference<WebWindow> window_;
48  
49      /**
50       * Queue of jobs that are scheduled to run. This is a priority queue, sorted
51       * by closest target execution time.
52       */
53      private transient PriorityQueue<JavaScriptJob> scheduledJobsQ_ = new PriorityQueue<>();
54  
55      private transient JavaScriptJob currentlyRunningJob_;
56  
57      /** A counter used to generate the IDs assigned to {@link JavaScriptJob}s. */
58      private static final AtomicInteger NEXT_JOB_ID_ = new AtomicInteger(1);
59  
60      /** Logging support. */
61      private static final Log LOG = LogFactory.getLog(JavaScriptJobManagerImpl.class);
62  
63      /**
64       * Creates a new instance.
65       *
66       * @param window the window associated with the new job manager
67       */
68      JavaScriptJobManagerImpl(final WebWindow window) {
69          window_ = new WeakReference<>(window);
70      }
71  
72      /** {@inheritDoc} */
73      @Override
74      public synchronized int getJobCount() {
75          return scheduledJobsQ_.size() + (currentlyRunningJob_ != null ? 1 : 0);
76      }
77  
78      /** {@inheritDoc} */
79      @Override
80      public synchronized int getJobCount(final JavaScriptJobFilter filter) {
81          if (filter == null) {
82              return scheduledJobsQ_.size() + (currentlyRunningJob_ != null ? 1 : 0);
83          }
84  
85          int count = 0;
86          if (currentlyRunningJob_ != null && filter.passes(currentlyRunningJob_)) {
87              count++;
88          }
89          for (final JavaScriptJob job : scheduledJobsQ_) {
90              if (filter.passes(job)) {
91                  count++;
92              }
93          }
94          return count;
95      }
96  
97      /** {@inheritDoc} */
98      @Override
99      public int addJob(final JavaScriptJob job, final Page page) {
100         final WebWindow w = getWindow();
101         if (w == null) {
102             /*
103              * The window to which this job manager belongs has been garbage
104              * collected. Don't spawn any more jobs for it.
105              */
106             return 0;
107         }
108         if (w.getEnclosedPage() != page) {
109             /*
110              * The page requesting the addition of the job is no longer contained by
111              * our owner window. Don't let it spawn any more jobs.
112              */
113             return 0;
114         }
115         final int id = NEXT_JOB_ID_.getAndIncrement();
116         job.setId(Integer.valueOf(id));
117 
118         synchronized (this) {
119             scheduledJobsQ_.add(job);
120 
121             if (LOG.isDebugEnabled()) {
122                 LOG.debug("job added to queue");
123                 LOG.debug("    window is: " + w);
124                 LOG.debug("    added job: " + job);
125                 LOG.debug("after adding job to the queue, the queue is: ");
126                 printQueue();
127             }
128 
129             notify();
130         }
131 
132         return id;
133     }
134 
135     /** {@inheritDoc} */
136     @Override
137     public synchronized void removeJob(final int id) {
138         for (final JavaScriptJob job : scheduledJobsQ_) {
139             final int jobId = job.getId().intValue();
140             if (jobId == id) {
141                 scheduledJobsQ_.remove(job);
142                 notify();
143                 break;
144             }
145         }
146     }
147 
148     /** {@inheritDoc} */
149     @Override
150     public synchronized void stopJob(final int id) {
151         // at the moment the same as removeJob(int) because we
152         // do not touch the current job
153         removeJob(id);
154     }
155 
156     /** {@inheritDoc} */
157     @Override
158     public synchronized void removeAllJobs() {
159         scheduledJobsQ_.clear();
160         notify();
161     }
162 
163     /** {@inheritDoc} */
164     @Override
165     @SuppressWarnings("PMD.GuardLogStatement")
166     public int waitForJobs(final long timeoutMillis) {
167         final boolean debug = LOG.isDebugEnabled();
168         if (debug) {
169             LOG.debug("Waiting for all jobs to finish (will wait max " + timeoutMillis + " millis).");
170         }
171         if (timeoutMillis > 0) {
172             long now = System.currentTimeMillis();
173             final long end = now + timeoutMillis;
174 
175             synchronized (this) {
176                 while (getJobCount() > 0 && now < end) {
177                     try {
178                         wait(end - now);
179                     }
180                     catch (final InterruptedException e) {
181                         LOG.error("InterruptedException while in waitForJobs", e);
182 
183                         // restore interrupted status
184                         Thread.currentThread().interrupt();
185                     }
186                     // maybe a change triggers the wakeup; we have to recalculate the
187                     // wait time
188                     now = System.currentTimeMillis();
189                 }
190             }
191         }
192         final int jobs = getJobCount();
193         if (debug) {
194             LOG.debug("Finished waiting for all jobs to finish (final job count is " + jobs + ").");
195         }
196         return jobs;
197     }
198 
199     /** {@inheritDoc} */
200     @Override
201     public int waitForJobsStartingBefore(final long delayMillis) {
202         return waitForJobsStartingBefore(delayMillis, -1, null);
203     }
204 
205     /** {@inheritDoc} */
206     @Override
207     public int waitForJobsStartingBefore(final long delayMillis, final long timeoutMillis) {
208         return waitForJobsStartingBefore(delayMillis, timeoutMillis, null);
209     }
210 
211     /** {@inheritDoc} */
212     @Override
213     @SuppressWarnings("PMD.GuardLogStatement")
214     public int waitForJobsStartingBefore(final long delayMillis, final JavaScriptJobFilter filter) {
215         return waitForJobsStartingBefore(delayMillis, -1, filter);
216     }
217 
218     /** {@inheritDoc} */
219     @Override
220     @SuppressWarnings("PMD.GuardLogStatement")
221     public int waitForJobsStartingBefore(final long delayMillis, final long timeoutMillis,
222             final JavaScriptJobFilter filter) {
223         final boolean debug = LOG.isDebugEnabled();
224 
225         long now = System.currentTimeMillis();
226         long end = now + timeoutMillis;
227         if (timeoutMillis < 0 || timeoutMillis < delayMillis) {
228             end = -1;
229         }
230 
231         final long latestExecutionTime = System.currentTimeMillis() + delayMillis;
232         if (debug) {
233             LOG.debug("Waiting for all jobs that have execution time before "
234                   + delayMillis + " (" + latestExecutionTime + ") to finish");
235         }
236 
237         final long interval = Math.max(40, delayMillis);
238         synchronized (this) {
239             JavaScriptJob earliestJob = getEarliestJob(filter);
240             boolean pending = earliestJob != null && earliestJob.getTargetExecutionTime() < latestExecutionTime;
241             pending = pending
242                     || (
243                             currentlyRunningJob_ != null
244                             && (filter == null || filter.passes(currentlyRunningJob_))
245                             && currentlyRunningJob_.getTargetExecutionTime() < latestExecutionTime
246                        );
247 
248             while (pending && (end == -1 || now < end)) {
249                 try {
250                     final long waitTime = (end == -1)
251                                 ? Math.max(40, interval)
252                                 : Math.max(40, Math.min(interval, end - now));
253                     wait(waitTime);
254                 }
255                 catch (final InterruptedException e) {
256                     LOG.error("InterruptedException while in waitForJobsStartingBefore", e);
257 
258                     // restore interrupted status
259                     Thread.currentThread().interrupt();
260                 }
261 
262                 earliestJob = getEarliestJob(filter);
263                 pending = earliestJob != null && earliestJob.getTargetExecutionTime() < latestExecutionTime;
264                 pending = pending
265                         || (
266                                 currentlyRunningJob_ != null
267                                 && (filter == null || filter.passes(currentlyRunningJob_))
268                                 && currentlyRunningJob_.getTargetExecutionTime() < latestExecutionTime
269                            );
270                 if (pending) {
271                     now = System.currentTimeMillis();
272                 }
273             }
274         }
275 
276         final int jobs = getJobCount(filter);
277         if (debug) {
278             LOG.debug("Finished waiting for all jobs that have target execution time earlier than "
279                 + latestExecutionTime + ", final job count is " + jobs);
280         }
281         return jobs;
282     }
283 
284     /** {@inheritDoc} */
285     @Override
286     public synchronized void shutdown() {
287         scheduledJobsQ_.clear();
288         notify();
289     }
290 
291     /**
292      * Returns the window to which this job manager belongs, or {@code null} if
293      * it has been garbage collected.
294      *
295      * @return the window to which this job manager belongs, or {@code null} if
296      *         it has been garbage collected
297      */
298     private WebWindow getWindow() {
299         return window_.get();
300     }
301 
302     /**
303      * Utility method to print current queue.
304      */
305     private void printQueue() {
306         if (LOG.isDebugEnabled()) {
307             LOG.debug("------ printing JavaScript job queue -----");
308             LOG.debug("  number of jobs on the queue: " + scheduledJobsQ_.size());
309             int count = 1;
310             for (final JavaScriptJob job : scheduledJobsQ_) {
311                 LOG.debug("  " + count + ")  Job target execution time: " + job.getTargetExecutionTime());
312                 LOG.debug("      job to string: " + job);
313                 LOG.debug("      job id: " + job.getId());
314                 if (job.isPeriodic()) {
315                     LOG.debug("      period: " + job.getPeriod().intValue());
316                 }
317                 count++;
318             }
319             LOG.debug("------------------------------------------");
320         }
321     }
322 
323     /**
324      * <span style="color:red">INTERNAL API - SUBJECT TO CHANGE AT ANY TIME - USE AT YOUR OWN RISK.</span><br>
325      */
326     @Override
327     public synchronized String jobStatusDump(final JavaScriptJobFilter filter) {
328         final String lineSeparator = System.lineSeparator();
329 
330         final StringBuilder status = new StringBuilder(110)
331                 .append("------ JavaScript job status -----")
332                 .append(lineSeparator);
333 
334         if (null != currentlyRunningJob_ && (filter == null || filter.passes(currentlyRunningJob_))) {
335             status.append("  current running job: ").append(currentlyRunningJob_.toString())
336                 .append("      job id: ").append(currentlyRunningJob_.getId())
337                 .append(lineSeparator)
338                 .append(lineSeparator)
339                 .append(lineSeparator);
340         }
341         status.append("  number of jobs on the queue: ")
342             .append(scheduledJobsQ_.size())
343             .append(lineSeparator);
344 
345         int count = 1;
346         for (final JavaScriptJob job : scheduledJobsQ_) {
347             if (filter == null || filter.passes(job)) {
348                 final long now = System.currentTimeMillis();
349                 final long execTime = job.getTargetExecutionTime();
350                 status.append("  ").append(count).append(")  Job target execution time: ")
351                         .append(execTime).append(" (should start in ")
352                         .append((execTime - now) / 1000d).append("s)")
353                         .append(lineSeparator)
354                         .append("      job to string: ").append(job)
355                         .append(lineSeparator).append("      job id: ").append(job.getId())
356                     .append(lineSeparator);
357                 if (job.isPeriodic()) {
358                     status.append("      period: ")
359                         .append(job.getPeriod().toString())
360                         .append(lineSeparator);
361                 }
362                 count++;
363             }
364         }
365         status.append("------------------------------------------")
366             .append(lineSeparator);
367 
368         return status.toString();
369     }
370 
371     /**
372      * {@inheritDoc}
373      */
374     @Override
375     public JavaScriptJob getEarliestJob() {
376         return scheduledJobsQ_.peek();
377     }
378 
379     /**
380      * {@inheritDoc}
381      */
382     @Override
383     public synchronized JavaScriptJob getEarliestJob(final JavaScriptJobFilter filter) {
384         if (filter == null) {
385             return scheduledJobsQ_.peek();
386         }
387 
388         for (final JavaScriptJob job : scheduledJobsQ_) {
389             if (filter.passes(job)) {
390                 return job;
391             }
392         }
393         return null;
394     }
395 
396     /**
397      * {@inheritDoc}
398      */
399     @Override
400     @SuppressWarnings("PMD.GuardLogStatement")
401     public boolean runSingleJob(final JavaScriptJob givenJob) {
402         assert givenJob != null;
403         final JavaScriptJob job = getEarliestJob();
404         if (job != givenJob) {
405             return false;
406         }
407 
408         final long currentTime = System.currentTimeMillis();
409         if (job.getTargetExecutionTime() > currentTime) {
410             return false;
411         }
412 
413         final boolean debug = LOG.isDebugEnabled();
414 
415         synchronized (this) {
416             if (scheduledJobsQ_.remove(job)) {
417                 currentlyRunningJob_ = job;
418                 // no need to notify if processing is started
419             }
420 
421             // we have to do this inside the sync block because the removeJob() methods
422             // only looks at the scheduledJobsQ_
423             if (job.isPeriodic()) {
424                 final long jobPeriod = job.getPeriod().longValue();
425 
426                 // reference: http://ejohn.org/blog/how-javascript-timers-work/
427                 final long missedPeriods = (currentTime - job.getTargetExecutionTime()) / jobPeriod + 1;
428                 job.setTargetExecutionTime(job.getTargetExecutionTime() + missedPeriods * jobPeriod);
429 
430                 // queue to run again after the next period
431                 if (debug) {
432                     LOG.debug("Rescheduling periodic job " + job);
433                 }
434                 scheduledJobsQ_.add(job);
435                 notify();
436             }
437         }
438 
439         if (debug) {
440             final String periodicJob = job.isPeriodic() ? "interval " : "";
441             LOG.debug("Starting " + periodicJob + "job " + job);
442         }
443         try {
444             job.run();
445         }
446         catch (final RuntimeException e) {
447             LOG.error("Job run failed with unexpected RuntimeException: " + e.getMessage(), e);
448         }
449         finally {
450             synchronized (this) {
451                 if (job == currentlyRunningJob_) {
452                     currentlyRunningJob_ = null;
453                 }
454                 notify();
455             }
456         }
457         if (debug) {
458             final String periodicJob = job.isPeriodic() ? "interval " : "";
459             LOG.debug("Finished " + periodicJob + "job " + job);
460         }
461         return true;
462     }
463 
464     /**
465      * Our own serialization (to handle the weak reference).
466      *
467      * @param in the stream to read from
468      * @throws IOException in case of error
469      * @throws ClassNotFoundException in case of error
470      */
471     private void readObject(final ObjectInputStream in) throws IOException, ClassNotFoundException {
472         in.defaultReadObject();
473 
474         // we do not store the jobs (at the moment)
475         scheduledJobsQ_ = new PriorityQueue<>();
476         currentlyRunningJob_ = null;
477     }
478 }