Allow specifying environment variables.

Refactor the code a little to make this easier to munge around.
diff --git a/tools/run_tests/run_tests.py b/tools/run_tests/run_tests.py
index a4cb08f..8cc029e 100755
--- a/tools/run_tests/run_tests.py
+++ b/tools/run_tests/run_tests.py
@@ -17,13 +17,17 @@
 # SimpleConfig: just compile with CONFIG=config, and run the binary to test
 class SimpleConfig(object):
 
-  def __init__(self, config):
+  def __init__(self, config, environ={}):
     self.build_config = config
     self.maxjobs = 2 * multiprocessing.cpu_count()
     self.allow_hashing = (config != 'gcov')
+    self.environ = environ
 
-  def run_command(self, binary):
-    return [binary]
+  def job_spec(self, binary, hash_targets):
+    return jobset.JobSpec(cmdline=[binary],
+                          environ=self.environ,
+                          hash_targets=hash_targets
+                              if self.allow_hashing else None)
 
 
 # ValgrindConfig: compile with some CONFIG=config, but use valgrind to run
@@ -35,14 +39,14 @@
     self.maxjobs = 2 * multiprocessing.cpu_count()
     self.allow_hashing = False
 
-  def run_command(self, binary):
-    return ['valgrind', '--tool=%s' % self.tool, binary]
+  def job_spec(self, binary, hash_targets):
+    return JobSpec(cmdline=['valgrind', '--tool=%s' % self.tool, binary],
+                   hash_targets=None)
 
 
 class CLanguage(object):
 
   def __init__(self, make_target, test_lang):
-    self.allow_hashing = True
     self.make_target = make_target
     with open('tools/run_tests/tests.json') as f:
       js = json.load(f)
@@ -50,8 +54,12 @@
                        for tgt in js
                        if tgt['language'] == test_lang]
 
-  def test_binaries(self, config):
-    return ['bins/%s/%s' % (config, binary) for binary in self.binaries]
+  def test_specs(self, config):
+    out = []
+    for name in self.binaries:
+      binary = 'bins/%s/%s' % (config.build_config, name)
+      out.append(config.job_spec(binary, [binary]))
+    return out
 
   def make_targets(self):
     return ['buildtests_%s' % self.make_target]
@@ -62,11 +70,8 @@
 
 class NodeLanguage(object):
 
-  def __init__(self):
-    self.allow_hashing = False
-
-  def test_binaries(self, config):
-    return ['tools/run_tests/run_node.sh']
+  def test_specs(self, config):
+    return [config.job_spec('tools/run_tests/run_node.sh', None)]
 
   def make_targets(self):
     return ['static_c']
@@ -77,11 +82,8 @@
 
 class PhpLanguage(object):
 
-  def __init__(self):
-    self.allow_hashing = False
-
-  def test_binaries(self, config):
-    return ['src/php/bin/run_tests.sh']
+  def test_specs(self, config):
+    return [config.job_spec('src/php/bin/run_tests.sh', None)]
 
   def make_targets(self):
     return ['static_c']
@@ -92,11 +94,8 @@
 
 class PythonLanguage(object):
 
-  def __init__(self):
-    self.allow_hashing = False
-
-  def test_binaries(self, config):
-    return ['tools/run_tests/run_python.sh']
+  def test_specs(self, config):
+    return [config.job_spec('tools/run_tests/run_python.sh', None)]
 
   def make_targets(self):
     return[]
@@ -111,7 +110,8 @@
     'opt': SimpleConfig('opt'),
     'tsan': SimpleConfig('tsan'),
     'msan': SimpleConfig('msan'),
-    'asan': SimpleConfig('asan'),
+    'asan': SimpleConfig('asan', environ={
+        'ASAN_OPTIONS': 'detect_leaks=1:color=always'}),
     'gcov': SimpleConfig('gcov'),
     'memcheck': ValgrindConfig('valgrind', 'memcheck'),
     'helgrind': ValgrindConfig('dbg', 'helgrind')
@@ -157,14 +157,20 @@
 
 make_targets = []
 languages = set(_LANGUAGES[l] for l in args.language)
-build_steps = [['make',
-                '-j', '%d' % (multiprocessing.cpu_count() + 1),
-                'CONFIG=%s' % cfg] + list(set(
-                    itertools.chain.from_iterable(l.make_targets()
-                                                  for l in languages)))
-               for cfg in build_configs] + list(
-                   itertools.chain.from_iterable(l.build_steps()
-                                                 for l in languages))
+build_steps = [jobset.JobSpec(['make',
+                               '-j', '%d' % (multiprocessing.cpu_count() + 1),
+                               'CONFIG=%s' % cfg] + list(set(
+                                   itertools.chain.from_iterable(
+                                       l.make_targets() for l in languages))))
+               for cfg in build_configs] + list(set(
+                   jobset.JobSpec(cmdline)
+                   for l in languages
+                   for cmdline in l.build_steps()))
+one_run = set(
+    spec
+    for config in run_configs
+    for language in args.language
+    for spec in _LANGUAGES[language].test_specs(config))
 
 runs_per_test = args.runs_per_test
 forever = args.forever
@@ -177,7 +183,6 @@
     self._last_successful_run = {}
 
   def should_run(self, cmdline, bin_hash):
-    cmdline = ' '.join(cmdline)
     if cmdline not in self._last_successful_run:
       return True
     if self._last_successful_run[cmdline] != bin_hash:
@@ -185,7 +190,7 @@
     return False
 
   def finished(self, cmdline, bin_hash):
-    self._last_successful_run[' '.join(cmdline)] = bin_hash
+    self._last_successful_run[cmdline] = bin_hash
 
   def dump(self):
     return [{'cmdline': k, 'hash': v}
@@ -211,12 +216,6 @@
     return 1
 
   # run all the tests
-  one_run = dict(
-      (' '.join(config.run_command(x)), config.run_command(x))
-      for config in run_configs
-      for language in args.language
-      for x in _LANGUAGES[language].test_binaries(config.build_config)
-      ).values()
   all_runs = itertools.chain.from_iterable(
       itertools.repeat(one_run, runs_per_test))
   if not jobset.run(all_runs, check_cancelled,
@@ -228,12 +227,8 @@
   return 0
 
 
-test_cache = (None
-              if not all(x.allow_hashing
-                         for x in itertools.chain(languages, run_configs))
-              else TestCache())
-if test_cache:
-  test_cache.maybe_load()
+test_cache = TestCache()
+test_cache.maybe_load()
 
 if forever:
   success = True
@@ -250,7 +245,7 @@
                      'All tests are now passing properly',
                      do_newline=True)
     jobset.message('IDLE', 'No change detected')
-    if test_cache: test_cache.save()
+    test_cache.save()
     while not have_files_changed():
       time.sleep(1)
 else:
@@ -261,5 +256,5 @@
     jobset.message('SUCCESS', 'All tests passed', do_newline=True)
   else:
     jobset.message('FAILED', 'Some tests failed', do_newline=True)
-  if test_cache: test_cache.save()
+  test_cache.save()
   sys.exit(result)