Super Dev Mode: introduce compile job tracking

Externally visible changes:

- The /progress RPC call now returns jobId instead of compileId.
(But this field isn't used in dev_mode_on.js or stub.nocache.js.)

- The server-side output looks different. We create a separate
TreeLogger branch for each HTTP request and each Job.

Introduces the Job, JobRunner, and ProgressTable classes. Each Job
tracks the state of one compile request submitted to Super Dev Mode.
The ProgressTable instance keeps track of all submitted jobs.

This will allow us to implement better progress tracking, since we
can find out about jobs that are submitted but haven't started
running yet. (Before, this queuing happened invisibly by blocking
on a synchronized method.)

Additional refactoring:

- Fixed logging so that each logger has the right parent. This
means we need to pass the logger as a parameter instead of storing
it as a field most of the time.

- Renamed and documented the various methods that invoke the
compiler, so it's easier to keep them straight.

Future patches will change the /progress and /recompile API's
to allow multiple modules to be submitted at once and their
progress tracked.

Change-Id: I76be48ed98f0fa2ca8af728dbc68e078d4665492
Review-Link: https://gwt-review.googlesource.com/#/c/8921/
diff --git a/dev/codeserver/java/com/google/gwt/dev/codeserver/CodeServer.java b/dev/codeserver/java/com/google/gwt/dev/codeserver/CodeServer.java
index a3ac485..c490ee4 100644
--- a/dev/codeserver/java/com/google/gwt/dev/codeserver/CodeServer.java
+++ b/dev/codeserver/java/com/google/gwt/dev/codeserver/CodeServer.java
@@ -16,6 +16,8 @@
 
 package com.google.gwt.dev.codeserver;
 
+import com.google.gwt.core.ext.TreeLogger;
+import com.google.gwt.core.ext.TreeLogger.Type;
 import com.google.gwt.core.ext.UnableToCompleteException;
 import com.google.gwt.dev.util.log.PrintWriterTreeLogger;
 import com.google.gwt.util.tools.Utility;
@@ -71,7 +73,9 @@
       for (int i = 0; i < retries; i++) {
         System.out.println("\n### Recompile " + (i + 1) + "\n");
         try {
-          modules.defaultCompileAll(options.getNoPrecompile());
+          // TODO: actually test recompiling here.
+          // (This is just running precompiles repeatedly.)
+          modules.defaultCompileAll(options.getNoPrecompile(), logger);
         } catch (Throwable t) {
           t.printStackTrace();
           System.out.println("FAIL");
@@ -108,16 +112,19 @@
    * a lot of static variables.</p>
    */
   public static WebServer start(Options options) throws IOException, UnableToCompleteException {
-    PrintWriterTreeLogger logger = new PrintWriterTreeLogger();
-    logger.setMaxDetail(options.getLogLevel());
+    PrintWriterTreeLogger topLogger = new PrintWriterTreeLogger();
+    topLogger.setMaxDetail(options.getLogLevel());
 
-    Modules modules = makeModules(options, logger);
+    TreeLogger startupLogger = topLogger.branch(Type.INFO, "Super Dev Mode starting up");
+    Modules modules = makeModules(options, startupLogger);
 
-    SourceHandler sourceHandler = new SourceHandler(modules, logger);
+    SourceHandler sourceHandler = new SourceHandler(modules);
+    ProgressTable progressTable = new ProgressTable();
+    JobRunner runner = new JobRunner(progressTable, modules);
 
     WebServer webServer = new WebServer(sourceHandler, modules,
-        options.getBindAddress(), options.getPort(), logger);
-    webServer.start();
+        runner, progressTable, options.getBindAddress(), options.getPort());
+    webServer.start(topLogger);
 
     return webServer;
   }
@@ -125,18 +132,18 @@
   /**
    * Configures and compiles all the modules (unless {@link Options#getNoPrecompile} is false).
    */
-  private static Modules makeModules(Options options, PrintWriterTreeLogger logger)
+  private static Modules makeModules(Options options, TreeLogger logger)
       throws IOException, UnableToCompleteException {
 
     File workDir = ensureWorkDir(options);
-    System.out.println("workDir: " + workDir);
+    logger.log(Type.INFO, "workDir: " + workDir);
 
     Modules modules = new Modules(options);
     for (String moduleName : options.getModuleNames()) {
       AppSpace appSpace = AppSpace.create(new File(workDir, moduleName));
 
-      Recompiler recompiler = new Recompiler(appSpace, moduleName, options, logger);
-      modules.addModuleState(new ModuleState(recompiler, logger, options.getNoPrecompile()));
+      Recompiler recompiler = new Recompiler(appSpace, moduleName, options);
+      modules.addModuleState(new ModuleState(recompiler, options.getNoPrecompile(), logger));
     }
     return modules;
   }
diff --git a/dev/codeserver/java/com/google/gwt/dev/codeserver/Job.java b/dev/codeserver/java/com/google/gwt/dev/codeserver/Job.java
new file mode 100644
index 0000000..482d10d
--- /dev/null
+++ b/dev/codeserver/java/com/google/gwt/dev/codeserver/Job.java
@@ -0,0 +1,240 @@
+/*
+ * Copyright 2014 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.google.gwt.dev.codeserver;
+
+import com.google.gwt.core.ext.TreeLogger;
+import com.google.gwt.core.ext.TreeLogger.Type;
+import com.google.gwt.dev.codeserver.Progress.Status;
+import com.google.gwt.thirdparty.guava.common.collect.ImmutableMap;
+import com.google.gwt.thirdparty.guava.common.util.concurrent.Futures;
+import com.google.gwt.thirdparty.guava.common.util.concurrent.ListenableFuture;
+import com.google.gwt.thirdparty.guava.common.util.concurrent.SettableFuture;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * A request for Super Dev Mode to compile something.
+ *
+ * <p>Each job has a lifecycle where it goes through up to four states. See
+ * {@link Progress.Status}.
+ *
+ * <p>Jobs are thread-safe.
+ */
+class Job {
+  private static final ConcurrentMap<String, AtomicInteger> moduleToNextId =
+      new ConcurrentHashMap<String, AtomicInteger>();
+
+  private final String id;
+
+  // Input
+
+  private final String moduleName;
+
+  private final ImmutableMap<String, String> bindingProperties;
+
+  // Output
+
+  private final SettableFuture<Result> result = SettableFuture.create();
+
+  // Listeners
+
+  private final LogSupplier logSupplier;
+
+  private ProgressTable table; // non-null when submitted
+
+  /**
+   * Creates a job to recompile a module.
+   * @param moduleName The name of the module to recompile, suitable for
+   *     passing to {@link Modules#get}.
+   * @param bindingProperties  Properties that uniquely identify a permutation.
+   *     (Otherwise, more than one permutation will be compiled.)
+   * @param parentLogger  The parent of the logger that will be used for this job.
+   */
+  Job(String moduleName, Map<String, String> bindingProperties, TreeLogger parentLogger) {
+    this.id = chooseNextId(moduleName);
+    this.moduleName = moduleName;
+    this.bindingProperties = ImmutableMap.copyOf(bindingProperties);
+    this.logSupplier = new LogSupplier(parentLogger, id);
+  }
+
+  private static String chooseNextId(String moduleName) {
+    moduleToNextId.putIfAbsent(moduleName, new AtomicInteger(0));
+    return moduleName + "-" + moduleToNextId.get(moduleName).getAndIncrement();
+  }
+
+  /**
+   * A string uniquely identifying this job (within this process).
+   *
+   * <p>Note that the number doesn't have any particular relationship
+   * with the output directory's name since jobs can be submitted out of order.
+   */
+  String getId() {
+    return id;
+  }
+
+  /**
+   * The module to compile.
+   */
+  String getModuleName() {
+    return moduleName;
+  }
+
+  /**
+   * The binding properties to use for this recompile.
+   */
+  ImmutableMap<String, String> getBindingProperties() {
+    return bindingProperties;
+  }
+
+  /**
+   * Returns the logger for this job. (Creates it on first use.)
+   */
+  TreeLogger getLogger() {
+    return logSupplier.get();
+  }
+
+  /**
+   * Blocks until we have the result of this recompile.
+   */
+  Result waitForResult() {
+    return Futures.getUnchecked(getFutureResult());
+  }
+
+  /**
+   * Returns a Future that will contain the result of this recompile.
+   */
+  ListenableFuture<Result> getFutureResult() {
+    return result;
+  }
+
+  // === state transitions ===
+
+  /**
+   * Returns true if this job has been submitted to the JobRunner.
+   * (That is, if {@link #onSubmitted} has ever been called.)
+   */
+  synchronized boolean wasSubmitted() {
+    return table != null;
+  }
+
+  boolean isDone() {
+    return result.isDone();
+  }
+
+  /**
+   * Reports that this job has been submitted to the JobRunner.
+   * Starts sending updates to the JobTable.
+   * @throws IllegalStateException if the job was already started.
+   */
+  synchronized void onSubmitted(ProgressTable table) {
+    if (wasSubmitted()) {
+      throw new IllegalStateException("compile job has already started: " + id);
+    }
+    this.table = table;
+    table.publish(new Progress(this, Status.WAITING), getLogger());
+  }
+
+  /**
+   * Reports that this job has made progress.
+   * @throws IllegalStateException if the job is not running.
+   */
+  synchronized void onCompilerProgress(Progress.Compiling newProgress) {
+    if (table == null || !table.isActive(this)) {
+      throw new IllegalStateException("compile job is not active: " + id);
+    }
+    table.publish(newProgress, getLogger());
+  }
+
+  /**
+   * Reports that this job has finished.
+   * @throws IllegalStateException if the job is not running.
+   */
+  synchronized void onFinished(Result newResult) {
+    if (table == null || !table.isActive(this)) {
+      throw new IllegalStateException("compile job is not active: " + id);
+    }
+    result.set(newResult);
+    if (newResult.isOk()) {
+      table.publish(new Progress(this, Status.SERVING), getLogger());
+    } else {
+      table.publish(new Progress(this, Status.GONE), getLogger());
+    }
+  }
+
+  /**
+   * Reports that this job's output is no longer available.
+   */
+  synchronized void onGone() {
+    if (table == null || !table.isActive(this)) {
+      throw new IllegalStateException("compile job is not active: " + id);
+    }
+    table.publish(new Progress(this, Status.GONE), getLogger());
+  }
+
+  /**
+   * Creates a child logger on first use.
+   */
+  static class LogSupplier {
+    private final TreeLogger parent;
+    private final String jobId;
+    private TreeLogger child;
+
+    LogSupplier(TreeLogger parent, String jobId) {
+      this.parent = parent;
+      this.jobId = jobId;
+    }
+
+    synchronized TreeLogger get() {
+      if (child == null) {
+        child = parent.branch(Type.INFO, "Job " + jobId);
+      }
+      return child;
+    }
+  }
+
+  /**
+   * The result of a recompile.
+   */
+  static class Result {
+
+    final Job job;
+
+    /**
+     * non-null if successful
+     */
+    final CompileDir outputDir;
+
+    /**
+     * non-null for an error
+     */
+    final Throwable error;
+
+    Result(Job job, CompileDir outputDir, Throwable error) {
+      assert job != null;
+      assert (outputDir == null) != (error == null);
+      this.job = job;
+      this.outputDir = outputDir;
+      this.error = error;
+    }
+
+    boolean isOk() {
+      return error == null;
+    }
+  }
+}
diff --git a/dev/codeserver/java/com/google/gwt/dev/codeserver/JobRunner.java b/dev/codeserver/java/com/google/gwt/dev/codeserver/JobRunner.java
new file mode 100644
index 0000000..f0507a3
--- /dev/null
+++ b/dev/codeserver/java/com/google/gwt/dev/codeserver/JobRunner.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2014 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.google.gwt.dev.codeserver;
+
+import com.google.gwt.core.ext.TreeLogger.Type;
+import com.google.gwt.dev.codeserver.Job.Result;
+
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/**
+ * Executes requests to compile modules using Super Dev Mode.
+ *
+ * <p>Guarantees that only one thread invokes the GWT compiler at a time and reports
+ * progress on waiting jobs.
+ *
+ * <p>JobRunners are thread-safe.
+ */
+public class JobRunner {
+  private final ProgressTable table;
+  private final Modules modules;
+  private final ExecutorService executor = Executors.newSingleThreadExecutor();
+
+  JobRunner(ProgressTable table, Modules modules) {
+    this.table = table;
+    this.modules = modules;
+  }
+
+  /**
+   * Submits a job to be executed. (Returns immediately.)
+   */
+  synchronized void submit(final Job job) {
+    if (table.wasSubmitted(job)) {
+      throw new IllegalStateException("job already submitted: " + job.getId());
+    }
+    job.onSubmitted(table);
+    executor.submit(new Runnable() {
+      @Override
+      public void run() {
+        recompile(job, modules);
+      }
+    });
+    job.getLogger().log(Type.TRACE, "added job to queue");
+  }
+
+  private static void recompile(Job job, Modules modules) {
+    job.getLogger().log(Type.INFO, "starting job: " + job.getId());
+    ModuleState m = modules.get(job.getModuleName());
+    if (m == null) {
+      String msg = "skipped a compile job with an unknown module: " + job.getModuleName();
+      job.getLogger().log(Type.WARN, msg);
+      job.onFinished(new Result(job, null,  new RuntimeException(msg)));
+      return;
+    }
+
+    m.recompile(job);
+  }
+}
diff --git a/dev/codeserver/java/com/google/gwt/dev/codeserver/ModuleState.java b/dev/codeserver/java/com/google/gwt/dev/codeserver/ModuleState.java
index e107df4..c52098a 100644
--- a/dev/codeserver/java/com/google/gwt/dev/codeserver/ModuleState.java
+++ b/dev/codeserver/java/com/google/gwt/dev/codeserver/ModuleState.java
@@ -18,6 +18,7 @@
 
 import com.google.gwt.core.ext.TreeLogger;
 import com.google.gwt.core.ext.UnableToCompleteException;
+import com.google.gwt.dev.codeserver.Job.Result;
 import com.google.gwt.dev.json.JsonArray;
 import com.google.gwt.dev.json.JsonObject;
 
@@ -29,8 +30,6 @@
 import java.io.InputStream;
 import java.net.URL;
 import java.util.Arrays;
-import java.util.HashMap;
-import java.util.Map;
 import java.util.concurrent.atomic.AtomicReference;
 
 /**
@@ -44,51 +43,59 @@
    */
   private static final String SOURCEMAP_FILE_SUFFIX = "_sourceMap0.json";
 
-  private final AtomicReference<CompileDir> current = new AtomicReference<CompileDir>();
+  private final AtomicReference<Job.Result> published = new AtomicReference<Job.Result>();
   private final Recompiler recompiler;
-  private final TreeLogger logger;
 
-  ModuleState(Recompiler recompiler, TreeLogger logger, boolean noPrecompile)
+  ModuleState(Recompiler recompiler, boolean noPrecompile,
+      TreeLogger logger)
       throws UnableToCompleteException {
     this.recompiler = recompiler;
-    this.logger = logger;
-    defaultCompile(noPrecompile);
+    maybePrecompile(noPrecompile, logger);
   }
 
   /**
-   * Compiles the module with the default set of properties.
+   * Loads the module and maybe compiles it. Sets up the output directory.
+   * Throws an exception if unable. (In this case, Super Dev Mode fails to start.)
    */
-  void defaultCompile(boolean noPrecompile) throws UnableToCompleteException {
-    CompileDir compileDir;
+  void maybePrecompile(boolean noPrecompile, TreeLogger logger) throws UnableToCompleteException {
     if (noPrecompile) {
-      compileDir = recompiler.noCompile();
+      publish(recompiler.initWithoutPrecompile(logger));
     } else {
-      Map<String, String> defaultProps = new HashMap<String, String>();
-      defaultProps.put("user.agent", "safari");
-      defaultProps.put("locale", "en");
-      compileDir = recompiler.compile(defaultProps, new AtomicReference<Progress>());
+      publish(recompiler.precompile(logger));
     }
-    current.set(compileDir);
   }
 
   /**
-   * Recompiles the module with the given binding properties. If successful, this changes the
-   * location of the output directory. (The log file changes both on success and on failure.
-   *
-   * @param bindingProperties The properties used to compile. (Chooses the permutation.)
-   * @param progress a variable to update with current progress while the compile is running.
-   * @return true if the compile finished successfully.
-   *
-   * @see Modules#recompile for a thread-safe version.
+   * Compiles the module again, possibly changing the output directory.
+   * After returning, the result of the compile can be found via {@link Job#waitForResult}
    */
-  boolean recompile(Map<String, String> bindingProperties, AtomicReference<Progress> progress) {
-    try {
-      current.set(recompiler.recompile(bindingProperties, progress));
-      return true;
-    } catch (UnableToCompleteException e) {
-      logger.log(TreeLogger.Type.WARN, "continuing to serve previous version");
-      return false;
+  void recompile(Job job) {
+    if (!job.wasSubmitted() || job.isDone()) {
+      throw new IllegalStateException(
+          "tried to recompile using a job in the wrong state:"  + job.getId());
     }
+
+    Result result = recompiler.recompile(job);
+
+    if (result.isOk()) {
+      publish(result);
+    } else {
+      job.getLogger().log(TreeLogger.Type.WARN, "continuing to serve previous version");
+    }
+  }
+
+  /**
+   * Makes the result of a compile downloadable via HTTP.
+   */
+  private void publish(Result result) {
+    Result previous = published.getAndSet(result);
+    if (previous != null) {
+      previous.job.onGone();
+    }
+  }
+
+  private CompileDir getOutputDir() {
+    return published.get().outputDir;
   }
 
   /**
@@ -149,7 +156,7 @@
    */
   private File findSymbolMapDir() {
     String moduleName = recompiler.getModuleName();
-    File symbolMapsDir = current.get().findSymbolMapDir(moduleName);
+    File symbolMapsDir = getOutputDir().findSymbolMapDir(moduleName);
     if (symbolMapsDir == null) {
       throw new RuntimeException("Can't find symbol map directory for " + moduleName);
     }
@@ -192,23 +199,23 @@
    * @return The location of the file, which might not actually exist.
    */
   File getOutputFile(String urlPath) {
-    return new File(current.get().getWarDir(), urlPath);
+    return new File(getOutputDir().getWarDir(), urlPath);
   }
 
   /**
    * Returns the log file from the last time this module was recompiled. This changes
-   * after every call to {@link #recompile}.
+   * after each compile.
    */
   File getCompileLog() {
     return recompiler.getLastLog();
   }
 
   File getGenDir() {
-    return current.get().getGenDir();
+    return getOutputDir().getGenDir();
   }
 
   File getWarDir() {
-    return current.get().getWarDir();
+    return getOutputDir().getWarDir();
   }
 
   /**
@@ -217,7 +224,7 @@
    * @return The location of the file, which might not actually exist.
    */
   File getExtraFile(String path) {
-    File prefix = new File(current.get().getExtraDir(), getModuleName());
+    File prefix = new File(getOutputDir().getExtraDir(), getModuleName());
     return new File(prefix, path);
   }
 
diff --git a/dev/codeserver/java/com/google/gwt/dev/codeserver/Modules.java b/dev/codeserver/java/com/google/gwt/dev/codeserver/Modules.java
index 5e8ab8c..48cfcbf 100644
--- a/dev/codeserver/java/com/google/gwt/dev/codeserver/Modules.java
+++ b/dev/codeserver/java/com/google/gwt/dev/codeserver/Modules.java
@@ -16,6 +16,7 @@
 
 package com.google.gwt.dev.codeserver;
 
+import com.google.gwt.core.ext.TreeLogger;
 import com.google.gwt.core.ext.UnableToCompleteException;
 import com.google.gwt.dev.json.JsonArray;
 import com.google.gwt.dev.json.JsonObject;
@@ -23,7 +24,6 @@
 
 import java.util.Iterator;
 import java.util.Map;
-import java.util.concurrent.atomic.AtomicReference;
 
 /**
  * An in-memory directory of all the modules available on this code server. The {@link WebServer}
@@ -34,8 +34,6 @@
   private final Options options;
   private final Map<String, ModuleState> moduleStateMap = Maps.newHashMap();
 
-  private AtomicReference<Progress> progress = new AtomicReference<Progress>(Progress.IDLE);
-
   public Modules(Options options) {
     this.options = options;
   }
@@ -50,9 +48,12 @@
 
   /**
    * Retrieves a {@link ModuleState} corresponding to a given module name.
-   * @param moduleName the module name to look up
+   * This should be the module name after renaming.
    */
   public ModuleState get(String moduleName) {
+    // TODO: maybe this lookup should also succeed if passed the module name before renaming?
+    // (I believe currently everything breaks if you change how a module is renamed, requiring
+    // a restart.)
     return moduleStateMap.get(moduleName);
   }
 
@@ -64,9 +65,9 @@
     return moduleStateMap.keySet().iterator();
   }
 
-  void defaultCompileAll(boolean noPrecompile) throws UnableToCompleteException {
+  void defaultCompileAll(boolean noPrecompile, TreeLogger logger) throws UnableToCompleteException {
     for (ModuleState m: moduleStateMap.values()) {
-      m.defaultCompile(noPrecompile);
+      m.maybePrecompile(noPrecompile, logger);
     }
   }
 
@@ -84,24 +85,4 @@
     config.put("warnings", options.getWarningsAsJson());
     return config;
   }
-
-  /**
-   * Returns the recompiler's current state.
-   */
-  JsonObject getProgress() {
-    return progress.get().toJsonObject();
-  }
-
-  /**
-   * Compiles a module.
-   *
-   * <p>Updates progress and ensures that only one compile happens at a time.
-   */
-  synchronized boolean recompile(ModuleState module, Map<String, String> bindingProperties) {
-    try {
-      return module.recompile(bindingProperties, progress);
-    } finally {
-      progress.set(Progress.IDLE);
-    }
-  }
 }
diff --git a/dev/codeserver/java/com/google/gwt/dev/codeserver/Progress.java b/dev/codeserver/java/com/google/gwt/dev/codeserver/Progress.java
index 7d49713..59eb4a9 100644
--- a/dev/codeserver/java/com/google/gwt/dev/codeserver/Progress.java
+++ b/dev/codeserver/java/com/google/gwt/dev/codeserver/Progress.java
@@ -18,27 +18,55 @@
 import com.google.gwt.dev.json.JsonObject;
 
 /**
- * A snapshot of the compiler's current state, for progress dialogs.
+ * A snapshot of a {@link Job}'s current state, for progress dialogs.
  */
-abstract class Progress {
+class Progress {
 
   /**
-   * Returns the json representation of this progress snapshot.
+   * The id of the job being compiled. (Unique within the same CodeServer process.)
    */
-  abstract JsonObject toJsonObject();
+  final String jobId;
+
+  final String module;
+  final Status status;
+
+  Progress(Job job, Status status) {
+    this.jobId = job.getId();
+    this.module = job.getModuleName();
+    this.status = status;
+  }
 
   /**
-   * Returned when no compile is running.
+   * Returns true if the job's progress should be shown in the progress view.
+   * (For jobs that are GONE, their status is only available by request.)
    */
-  static final Progress IDLE = new Progress() {
+  public boolean isActive() {
+    return status == Status.WAITING || status == Status.COMPILING || status == Status.SERVING;
+  }
 
-    @Override
-    JsonObject toJsonObject() {
-      JsonObject out = new JsonObject();
-      out.put("status", "idle");
-      return out;
+  JsonObject toJsonObject() {
+    JsonObject out = new JsonObject();
+    out.put("jobId", jobId);
+    out.put("module", module);
+    out.put("status", status.jsonName);
+    return out;
+  }
+
+  /**
+   * Defines the lifecycle of a job.
+   */
+  static enum Status {
+    WAITING("waiting"),
+    COMPILING("compiling"),
+    SERVING("serving"), // Output is available to HTTP requests
+    GONE("gone"); // Output directory is no longer being served
+
+    final String jsonName;
+
+    Status(String jsonName) {
+      this.jsonName = jsonName;
     }
-  };
+  }
 
   /**
    * Returned when a compile is in progress.
@@ -46,17 +74,6 @@
   static class Compiling extends Progress {
 
     /**
-     * The module being compiled.
-     */
-    final String module;
-
-    /**
-     * Identifies the currently running compile.
-     * (It's unique within the same CodeServer process and module.)
-     */
-    final int compileId;
-
-    /**
      * The number of steps finished, for showing progress.
      */
     final int finishedSteps;
@@ -71,9 +88,8 @@
      */
     final String stepMessage;
 
-    Compiling(String module, int compileId, int finishedSteps, int totalSteps, String stepMessage) {
-      this.module = module;
-      this.compileId = compileId;
+    Compiling(Job job, int finishedSteps, int totalSteps, String stepMessage) {
+      super(job, Status.COMPILING);
       this.finishedSteps = finishedSteps;
       this.totalSteps = totalSteps;
       this.stepMessage = stepMessage;
@@ -82,9 +98,9 @@
     @Override
     JsonObject toJsonObject() {
       JsonObject out = new JsonObject();
-      out.put("status", "compiling");
+      out.put("jobId", jobId);
       out.put("module", module);
-      out.put("compileId", compileId);
+      out.put("status", "compiling");
       out.put("finishedSteps", finishedSteps);
       out.put("totalSteps", totalSteps);
       out.put("stepMessage", stepMessage);
diff --git a/dev/codeserver/java/com/google/gwt/dev/codeserver/ProgressTable.java b/dev/codeserver/java/com/google/gwt/dev/codeserver/ProgressTable.java
new file mode 100644
index 0000000..4711908
--- /dev/null
+++ b/dev/codeserver/java/com/google/gwt/dev/codeserver/ProgressTable.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright 2014 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.google.gwt.dev.codeserver;
+
+import com.google.gwt.core.ext.TreeLogger;
+import com.google.gwt.core.ext.TreeLogger.Type;
+import com.google.gwt.dev.codeserver.Progress.Status;
+import com.google.gwt.thirdparty.guava.common.collect.ImmutableList;
+import com.google.gwt.thirdparty.guava.common.collect.Maps;
+
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Contains the progress of all the jobs that have been submitted to the JobRunner.
+ */
+class ProgressTable {
+
+  /**
+   * The progress of each known job, by job id.
+   */
+  private final Map<String, Progress> progressById = Maps.newHashMap();
+
+  /**
+   * A set of submitted job ids that are still active, in the order they were submitted.
+   * Jobs that are waiting, compiling, or serving are considered active.
+   */
+  private final Set<String> activeJobIds = new LinkedHashSet<String>();
+
+  /**
+   * The set of compiling job ids. Since compiling is single-threaded, this set should
+   * contain zero or one entries.
+   */
+  private final Set<String> compilingJobIds = new LinkedHashSet<String>();
+
+  /**
+   * Publishes the progress of a job after it changed. (This replaces any previous progress.)
+   */
+  synchronized void publish(Progress progress, TreeLogger logger) {
+    String id = progress.jobId;
+
+    progressById.put(id, progress);
+
+    // Update indexes
+
+    if (progress.isActive()) {
+      activeJobIds.add(id);
+    } else {
+      activeJobIds.remove(id);
+    }
+
+    if (progress.status == Status.COMPILING) {
+      compilingJobIds.add(id);
+      assert compilingJobIds.size() <= 1;
+    } else {
+      compilingJobIds.remove(id);
+    }
+
+    logger.log(Type.TRACE, "job's progress set to " + progress.status + ": " + id);
+  }
+
+  /**
+   * Returns true if the job's status was ever published.
+   */
+  synchronized boolean wasSubmitted(Job job) {
+    return progressById.containsKey(job.getId());
+  }
+
+  synchronized boolean isActive(Job job) {
+    return activeJobIds.contains(job.getId());
+  }
+
+  /**
+   * Returns the progress of the job that's currently being compiled, or null if idle.
+   */
+  synchronized Progress getProgressForCompilingJob() {
+    if (compilingJobIds.isEmpty()) {
+      return null;
+    }
+
+    String id = compilingJobIds.iterator().next();
+    Progress progress = progressById.get(id);
+    assert progress != null;
+    return progress;
+  }
+
+  /**
+   * Returns the progress of all active jobs, in the order submitted.
+   * TODO: hook this up.
+   */
+  synchronized ImmutableList<Progress> getProgressForActiveJobs() {
+    ImmutableList.Builder<Progress> builder = ImmutableList.builder();
+    for (String id : activeJobIds) {
+      Progress p = progressById.get(id);
+      assert p != null;
+      builder.add(p);
+    }
+    return builder.build();
+  }
+}
diff --git a/dev/codeserver/java/com/google/gwt/dev/codeserver/Recompiler.java b/dev/codeserver/java/com/google/gwt/dev/codeserver/Recompiler.java
index 1c42f2c..93c91d4 100644
--- a/dev/codeserver/java/com/google/gwt/dev/codeserver/Recompiler.java
+++ b/dev/codeserver/java/com/google/gwt/dev/codeserver/Recompiler.java
@@ -34,6 +34,7 @@
 import com.google.gwt.dev.cfg.ModuleDefLoader;
 import com.google.gwt.dev.cfg.ResourceLoader;
 import com.google.gwt.dev.cfg.ResourceLoaders;
+import com.google.gwt.dev.codeserver.Job.Result;
 import com.google.gwt.dev.javac.UnitCacheSingleton;
 import com.google.gwt.dev.resource.impl.ResourceOracleImpl;
 import com.google.gwt.dev.resource.impl.ZipFileClassPathEntry;
@@ -45,6 +46,7 @@
 
 import java.io.File;
 import java.io.IOException;
+import java.util.HashMap;
 import java.util.Map;
 import java.util.Objects;
 import java.util.concurrent.atomic.AtomicReference;
@@ -57,7 +59,6 @@
   private final AppSpace appSpace;
   private final String originalModuleName;
   private IncrementalBuilder incrementalBuilder;
-  private final TreeLogger logger;
   private String serverPrefix;
   private int compilesDone = 0;
   private MinimalRebuildCache minimalRebuildCache = new MinimalRebuildCache();
@@ -76,31 +77,84 @@
   private CompilerContext compilerContext;
   private Options options;
 
-  Recompiler(AppSpace appSpace, String moduleName, Options options, TreeLogger logger) {
+  Recompiler(AppSpace appSpace, String moduleName, Options options) {
     this.appSpace = appSpace;
     this.originalModuleName = moduleName;
     this.options = options;
-    this.logger = logger;
     this.serverPrefix = options.getPreferredHost() + ":" + options.getPort();
     compilerContext = compilerContextBuilder.build();
   }
 
-  CompileDir recompile(Map<String, String> bindingProperties, AtomicReference<Progress> progress)
-      throws UnableToCompleteException {
-    if (options.shouldCompilePerFile()) {
-      return compile(bindingProperties, progress, minimalRebuildCache);
+  /**
+   * Compiles the first time, while Super Dev Mode is starting up.
+   * Either this method or {@link #initWithoutPrecompile} should be called first.
+   */
+  synchronized Job.Result precompile(TreeLogger logger) throws UnableToCompleteException {
+    Map<String, String> defaultProps = new HashMap<String, String>();
+    defaultProps.put("user.agent", "safari");
+    defaultProps.put("locale", "en");
+
+    // Create a dummy job for the first compile.
+    // Its progress is not visible externally but will still be logged.
+    //
+    // If we ever start reporting progress on this job, we should make the module name consistent.
+    // (We don't know what the module name will change to before loading the module, so we use
+    // the original name.)
+    ProgressTable dummy = new ProgressTable();
+    Job job = new Job(originalModuleName, defaultProps, logger);
+    job.onSubmitted(dummy);
+    Result result = compile(job, new MinimalRebuildCache());
+    job.onFinished(result);
+
+    assert result.isOk();
+    return result;
+  }
+
+  /**
+   * Recompiles the module.
+   *
+   * <p>Prerequisite: either {@link #precompile} or {@link #initWithoutPrecompile} should have been
+   * called first.
+   *
+   * <p>Sets the job's result and returns normally whether the compile succeeds or not.
+   *
+   * @param job should already be in the "in progress" state.
+   */
+  synchronized Job.Result recompile(Job job) {
+
+    Job.Result result;
+    try {
+      if (options.shouldCompilePerFile()) {
+        result = compile(job, minimalRebuildCache);
+      } else {
+        result = compile(job, new MinimalRebuildCache());
+      }
+    } catch (UnableToCompleteException e) {
+      // No point in logging a stack trace for this exception
+      job.getLogger().log(TreeLogger.Type.WARN, "recompile failed");
+      result = new Result(job, null, e);
+    } catch (Throwable error) {
+      job.getLogger().log(TreeLogger.Type.WARN, "recompile failed", error);
+      result = new Result(job, null, error);
     }
-    return compile(bindingProperties, progress, new MinimalRebuildCache());
+
+    job.onFinished(result);
+    return result;
   }
 
-  synchronized CompileDir compile(Map<String, String> bindingProperties,
-      AtomicReference<Progress> progress) throws UnableToCompleteException {
-    return compile(bindingProperties, progress, new MinimalRebuildCache());
-  }
-
-  private synchronized CompileDir compile(Map<String, String> bindingProperties,
-      AtomicReference<Progress> progress, MinimalRebuildCache minimalRebuildCache)
+  /**
+   * Calls the GWT compiler with the appropriate settings.
+   * Side-effect: the given cache will be used and saved in {@link #minimalRebuildCache}.
+   *
+   * @param job used for reporting progress. (Its result will not be set.)
+   * @return a non-error Job.Result if successful.
+   * @throws UnableToCompleteException for compile failures.
+   */
+  private Job.Result compile(Job job, MinimalRebuildCache minimalRebuildCache)
       throws UnableToCompleteException {
+
+    assert job.wasSubmitted();
+
     if (compilesDone == 0) {
       System.setProperty("java.awt.headless", "true");
       if (System.getProperty("gwt.speedtracerlog") == null) {
@@ -108,13 +162,13 @@
             appSpace.getSpeedTracerLogFile().getAbsolutePath());
       }
       compilerContext = compilerContextBuilder.unitCache(
-          UnitCacheSingleton.get(logger, appSpace.getUnitCacheDir())).build();
+          UnitCacheSingleton.get(job.getLogger(), appSpace.getUnitCacheDir())).build();
     }
 
     long startTime = System.currentTimeMillis();
     int compileId = ++compilesDone;
-    CompileDir compileDir = makeCompileDir(compileId);
-    TreeLogger compileLogger = makeCompileLogger(compileDir);
+    CompileDir compileDir = makeCompileDir(compileId, job.getLogger());
+    TreeLogger compileLogger = makeCompileLogger(compileDir, job.getLogger());
 
     boolean listenerFailed = false;
     try {
@@ -128,12 +182,11 @@
     try {
       if (options.shouldCompileIncremental()) {
         // Just have one message for now.
-        progress.set(new Progress.Compiling(moduleName.get(), compilesDone, 0, 1, "Compiling"));
+        job.onCompilerProgress(new Progress.Compiling(job, 0, 1, "Compiling"));
 
         success = compileIncremental(compileLogger, compileDir);
       } else {
-        success = compileMonolithic(compileLogger, bindingProperties, compileDir, progress,
-            minimalRebuildCache);
+        success = compileMonolithic(compileLogger, compileDir, job, minimalRebuildCache);
       }
     } finally {
       try {
@@ -160,13 +213,23 @@
       throw new UnableToCompleteException();
     }
 
-    return publishedCompileDir;
+    return new Result(job, publishedCompileDir, null);
   }
 
-  synchronized CompileDir noCompile() throws UnableToCompleteException {
+  /**
+   * Creates a dummy output directory without compiling the module.
+   * Either this method or {@link #precompile} should be called first.
+   */
+  synchronized Job.Result initWithoutPrecompile(TreeLogger parentLogger)
+      throws UnableToCompleteException {
+    ProgressTable dummy = new ProgressTable();
+    Map<String, String> bindingProps = ImmutableMap.of();
+    Job job = new Job(originalModuleName, bindingProps, parentLogger);
+    job.onSubmitted(dummy);
+
     long startTime = System.currentTimeMillis();
-    CompileDir compileDir = makeCompileDir(++compilesDone);
-    TreeLogger compileLogger = makeCompileLogger(compileDir);
+    CompileDir compileDir = makeCompileDir(++compilesDone, job.getLogger());
+    TreeLogger compileLogger = makeCompileLogger(compileDir, job.getLogger());
 
     ModuleDef module = loadModule(compileLogger);
     String newModuleName = module.getName();  // includes any rename.
@@ -195,7 +258,10 @@
     }
     long elapsedTime = System.currentTimeMillis() - startTime;
     compileLogger.log(TreeLogger.Type.INFO, "Module setup completed in " + elapsedTime + " ms");
-    return compileDir;
+
+    Result result = new Result(job, compileDir, null);
+    job.onFinished(result);
+    return result;
   }
 
   private boolean compileIncremental(TreeLogger compileLogger, CompileDir compileDir) {
@@ -231,17 +297,19 @@
     return buildResultStatus.isSuccess();
   }
 
-  private boolean compileMonolithic(TreeLogger compileLogger, Map<String, String> bindingProperties,
-      CompileDir compileDir, AtomicReference<Progress> progress, MinimalRebuildCache rebuildCache)
+  private boolean compileMonolithic(TreeLogger compileLogger,
+      CompileDir compileDir, Job job, MinimalRebuildCache rebuildCache)
       throws UnableToCompleteException {
 
-    progress.set(new Progress.Compiling(moduleName.get(), compilesDone, 0, 2, "Loading modules"));
+    job.onCompilerProgress(
+        new Progress.Compiling(job, 0, 2, "Loading modules"));
 
     CompilerOptions loadOptions = new CompilerOptionsImpl(compileDir, originalModuleName, options);
     compilerContext = compilerContextBuilder.options(loadOptions).build();
 
     ModuleDef module = loadModule(compileLogger);
-    bindingProperties = restrictPermutations(logger, module, bindingProperties);
+    Map<String, String> bindingProperties = restrictPermutations(compileLogger, module,
+        job.getBindingProperties());
 
     // Propagates module rename.
     String newModuleName = module.getName();
@@ -254,7 +322,7 @@
       return true;
     }
 
-    progress.set(new Progress.Compiling(newModuleName, compilesDone, 1, 2, "Compiling"));
+    job.onCompilerProgress(new Progress.Compiling(job, 1, 2, "Compiling"));
     // TODO: use speed tracer to get more compiler events?
 
     CompilerOptions runOptions = new CompilerOptionsImpl(compileDir, newModuleName, options);
@@ -288,15 +356,15 @@
     return resourceLoader.get();
   }
 
-  private TreeLogger makeCompileLogger(CompileDir compileDir)
+  private TreeLogger makeCompileLogger(CompileDir compileDir, TreeLogger parent)
       throws UnableToCompleteException {
     try {
       PrintWriterTreeLogger fileLogger =
           new PrintWriterTreeLogger(compileDir.getLogFile());
       fileLogger.setMaxDetail(options.getLogLevel());
-      return new CompositeTreeLogger(logger, fileLogger);
+      return new CompositeTreeLogger(parent, fileLogger);
     } catch (IOException e) {
-      logger.log(TreeLogger.ERROR, "unable to open log file: " + compileDir.getLogFile(), e);
+      parent.log(TreeLogger.ERROR, "unable to open log file: " + compileDir.getLogFile(), e);
       throw new UnableToCompleteException();
     }
   }
@@ -482,7 +550,7 @@
     }
   }
 
-  private CompileDir makeCompileDir(int compileId)
+  private CompileDir makeCompileDir(int compileId, TreeLogger logger)
       throws UnableToCompleteException {
     return CompileDir.create(appSpace.getCompileDir(compileId), logger);
   }
diff --git a/dev/codeserver/java/com/google/gwt/dev/codeserver/SourceHandler.java b/dev/codeserver/java/com/google/gwt/dev/codeserver/SourceHandler.java
index 2982d5e..bc57320 100644
--- a/dev/codeserver/java/com/google/gwt/dev/codeserver/SourceHandler.java
+++ b/dev/codeserver/java/com/google/gwt/dev/codeserver/SourceHandler.java
@@ -76,11 +76,8 @@
 
   private Modules modules;
 
-  private final TreeLogger logger;
-
-  SourceHandler(Modules modules, TreeLogger logger) {
+  SourceHandler(Modules modules) {
     this.modules = modules;
-    this.logger = logger;
   }
 
   static boolean isSourceMapRequest(String target) {
@@ -95,7 +92,8 @@
     return SOURCEMAP_PATH + moduleName + "/__HASH__" + SOURCEMAP_URL_SUFFIX;
   }
 
-  void handle(String target, HttpServletRequest request, HttpServletResponse response)
+  void handle(String target, HttpServletRequest request, HttpServletResponse response,
+      TreeLogger logger)
       throws IOException {
     String moduleName = getModuleNameFromRequest(target);
     if (moduleName == null) {
@@ -106,23 +104,24 @@
     String rest = target.substring(rootDir.length());
 
     if (rest.isEmpty()) {
-      sendDirectoryListPage(moduleName, response);
+      sendDirectoryListPage(moduleName, response, logger);
     } else if (rest.equals("gwtSourceMap.json")) {
       // This URL is no longer used by debuggers (we use the strong name) but is used for testing.
       // It's useful not to need the strong name to download the sourcemap.
       // (But this only works when there is one permutation.)
       ModuleState moduleState = modules.get(moduleName);
-      sendSourceMap(moduleName, moduleState.findSourceMapForOnePermutation(), request, response);
+      sendSourceMap(moduleName, moduleState.findSourceMapForOnePermutation(), request, response,
+          logger);
     } else if (rest.endsWith("/")) {
-      sendFileListPage(moduleName, rest, response);
+      sendFileListPage(moduleName, rest, response, logger);
     } else if (rest.endsWith(".java")) {
-      sendSourceFile(moduleName, rest, request.getQueryString(), response);
+      sendSourceFile(moduleName, rest, request.getQueryString(), response, logger);
     } else {
       String strongName = getStrongNameFromSourcemapFilename(rest);
       if (strongName != null) {
         ModuleState moduleState = modules.get(moduleName);
         File sourceMap = moduleState.findSourceMap(strongName).getAbsoluteFile();
-        sendSourceMap(moduleName, sourceMap, request, response);
+        sendSourceMap(moduleName, sourceMap, request, response, logger);
       } else {
         response.sendError(HttpServletResponse.SC_NOT_FOUND);
         logger.log(TreeLogger.WARN, "returned not found for request: " + target);
@@ -141,7 +140,7 @@
   }
 
   private void sendSourceMap(String moduleName, File sourceMap, HttpServletRequest request,
-      HttpServletResponse response) throws IOException {
+      HttpServletResponse response, TreeLogger logger) throws IOException {
 
     long startTime = System.currentTimeMillis();
 
@@ -161,12 +160,12 @@
 
     long elapsedTime = System.currentTimeMillis() - startTime;
 
-    logger.log(TreeLogger.WARN, "sent source map for module '" + moduleName +
+    logger.log(TreeLogger.INFO, "sent source map for module '" + moduleName +
         "' in " + elapsedTime + " ms");
   }
 
-  private void sendDirectoryListPage(String moduleName, HttpServletResponse response)
-      throws IOException {
+  private void sendDirectoryListPage(String moduleName, HttpServletResponse response,
+      TreeLogger logger) throws IOException {
 
     SourceMap map = loadSourceMap(moduleName);
 
@@ -183,8 +182,8 @@
     PageUtil.sendJsonAndHtml("config", config, "directorylist.html", response, logger);
   }
 
-  private void sendFileListPage(String moduleName, String rest, HttpServletResponse response)
-      throws IOException {
+  private void sendFileListPage(String moduleName, String rest, HttpServletResponse response,
+      TreeLogger logger) throws IOException {
 
     SourceMap map = loadSourceMap(moduleName);
 
@@ -207,8 +206,7 @@
    * or as HTML if the query string is equal to "html".
    */
   private void sendSourceFile(String moduleName, String sourcePath, String query,
-      HttpServletResponse response)
-      throws IOException {
+      HttpServletResponse response, TreeLogger logger) throws IOException {
     ModuleState moduleState = modules.get(moduleName);
     InputStream pageBytes = moduleState.openSourceFile(sourcePath);
 
@@ -220,7 +218,7 @@
 
     if (query != null && query.equals("html")) {
       BufferedReader reader = new BufferedReader(new InputStreamReader(pageBytes));
-      sendSourceFileAsHtml(moduleName, sourcePath, reader, response);
+      sendSourceFileAsHtml(moduleName, sourcePath, reader, response, logger);
     } else {
       PageUtil.sendStream("text/plain", pageBytes, response);
     }
@@ -232,7 +230,7 @@
    * source map).
    */
   private void sendSourceFileAsHtml(String moduleName, String sourcePath, BufferedReader lines,
-      HttpServletResponse response) throws IOException {
+      HttpServletResponse response, TreeLogger logger) throws IOException {
 
     ReverseSourceMap sourceMap = ReverseSourceMap.load(logger, modules.get(moduleName));
 
diff --git a/dev/codeserver/java/com/google/gwt/dev/codeserver/WebServer.java b/dev/codeserver/java/com/google/gwt/dev/codeserver/WebServer.java
index 7fcc85c..29d8b6a 100644
--- a/dev/codeserver/java/com/google/gwt/dev/codeserver/WebServer.java
+++ b/dev/codeserver/java/com/google/gwt/dev/codeserver/WebServer.java
@@ -17,6 +17,7 @@
 package com.google.gwt.dev.codeserver;
 
 import com.google.gwt.core.ext.TreeLogger;
+import com.google.gwt.core.ext.TreeLogger.Type;
 import com.google.gwt.core.ext.UnableToCompleteException;
 import com.google.gwt.dev.json.JsonObject;
 
@@ -89,24 +90,26 @@
   private static final MimeTypes MIME_TYPES = new MimeTypes();
 
   private final SourceHandler handler;
-
   private final Modules modules;
+  private final JobRunner runner;
+  private final ProgressTable progressTable;
 
   private final String bindAddress;
   private final int port;
-  private final TreeLogger logger;
+
   private Server server;
 
-  WebServer(SourceHandler handler, Modules modules, String bindAddress, int port,
-      TreeLogger logger) {
+  WebServer(SourceHandler handler, Modules modules, JobRunner runner,
+      ProgressTable progressTable, String bindAddress, int port) {
     this.handler = handler;
     this.modules = modules;
+    this.runner = runner;
+    this.progressTable = progressTable;
     this.bindAddress = bindAddress;
     this.port = port;
-    this.logger = logger;
   }
 
-  public void start() throws UnableToCompleteException {
+  void start(final TreeLogger logger) throws UnableToCompleteException {
 
     SelectChannelConnector connector = new SelectChannelConnector();
     connector.setHost(bindAddress);
@@ -123,7 +126,7 @@
       @Override
       protected void doGet(HttpServletRequest request, HttpServletResponse response)
           throws ServletException, IOException {
-        handleRequest(request.getPathInfo(), request, response);
+        handleRequest(request.getPathInfo(), request, response, logger);
       }
     }), "/*");
     newHandler.addFilter(GzipFilter.class, "/*", EnumSet.allOf(DispatcherType.class));
@@ -154,17 +157,20 @@
   }
 
   private void handleRequest(String target, HttpServletRequest request,
-      HttpServletResponse response)
+      HttpServletResponse response, TreeLogger logger)
       throws IOException {
 
     if (request.getMethod().equalsIgnoreCase("get")) {
-      doGet(target, request, response);
+      doGet(target, request, response, logger);
     }
   }
 
-  private void doGet(String target, HttpServletRequest request, HttpServletResponse response)
+  private void doGet(String target, HttpServletRequest request, HttpServletResponse response,
+      TreeLogger parentLogger)
       throws IOException {
 
+    TreeLogger logger = parentLogger.branch(Type.TRACE, "GET " + target);
+
     if (!target.endsWith(".cache.js")) {
       // Make sure IE9 doesn't cache any pages.
       // (Nearly all pages may change on server restart.)
@@ -205,11 +211,13 @@
       // cause a spurious recompile, resulting in an unexpected permutation being loaded later.
       //
       // It would be unsafe to allow a configuration property to be changed.
-      boolean ok = modules.recompile(moduleState, getBindingProperties(request));
+      Job job = new Job(moduleState.getModuleName(), getBindingProperties(request), logger);
+      runner.submit(job);
+      boolean ok = job.waitForResult().isOk();
 
       JsonObject config = modules.getConfig();
       config.put("status", ok ? "ok" : "failed");
-      sendJsonResult(config, request, response);
+      sendJsonResult(config, request, response, logger);
       return;
     }
 
@@ -240,21 +248,31 @@
 
     if (target.equals("/progress")) {
       setHandled(request);
-      sendJsonResult(modules.getProgress(), request, response);
+      // TODO: return a list of progress objects here, one for each job.
+      Progress progress = progressTable.getProgressForCompilingJob();
+
+      JsonObject json;
+      if (progress == null) {
+        json = new JsonObject();
+        json.put("status", "idle");
+      } else {
+        json = progress.toJsonObject();
+      }
+      sendJsonResult(json, request, response, logger);
       return;
     }
 
     Matcher matcher = SAFE_MODULE_PATH.matcher(target);
     if (matcher.matches()) {
       setHandled(request);
-      sendModulePage(matcher.group(1), response);
+      sendModulePage(matcher.group(1), response, logger);
       return;
     }
 
     matcher = SAFE_DIRECTORY_PATH.matcher(target);
     if (matcher.matches() && handler.isSourceMapRequest(target)) {
       setHandled(request);
-      handler.handle(target, request, response);
+      handler.handle(target, request, response, logger);
       return;
     }
 
@@ -262,14 +280,14 @@
     if (matcher.matches()) {
       setHandled(request);
       if (handler.isSourceMapRequest(target)) {
-        handler.handle(target, request, response);
+        handler.handle(target, request, response, logger);
         return;
       }
       if (target.startsWith("/policies/")) {
-        sendPolicyFile(target, response);
+        sendPolicyFile(target, response, logger);
         return;
       }
-      sendOutputFile(target, request, response);
+      sendOutputFile(target, request, response, logger);
       return;
     }
 
@@ -277,7 +295,7 @@
   }
 
   private void sendOutputFile(String target, HttpServletRequest request,
-      HttpServletResponse response) throws IOException {
+      HttpServletResponse response, TreeLogger logger) throws IOException {
 
     int secondSlash = target.indexOf('/', 1);
     String moduleName = target.substring(1, secondSlash);
@@ -312,7 +330,8 @@
     PageUtil.sendFile(mimeType, file, response);
   }
 
-  private void sendModulePage(String moduleName, HttpServletResponse response) throws IOException {
+  private void sendModulePage(String moduleName, HttpServletResponse response, TreeLogger logger)
+      throws IOException {
     ModuleState module = modules.get(moduleName);
     if (module == null) {
       response.sendError(HttpServletResponse.SC_NOT_FOUND);
@@ -383,7 +402,8 @@
     out.endTag("html").nl();
   }
 
-  private void sendPolicyFile(String target, HttpServletResponse response) throws IOException {
+  private void sendPolicyFile(String target, HttpServletResponse response, TreeLogger logger)
+      throws IOException {
     int secondSlash = target.indexOf('/', 1);
     if (secondSlash < 1) {
       response.sendError(HttpServletResponse.SC_NOT_FOUND);
@@ -409,7 +429,7 @@
   }
 
   private void sendJsonResult(JsonObject json, HttpServletRequest request,
-      HttpServletResponse response) throws IOException {
+      HttpServletResponse response, TreeLogger logger) throws IOException {
 
     response.setStatus(HttpServletResponse.SC_OK);
     response.setHeader("Cache-control", "no-cache");