Add support for running ART tests on a Linux virtual machine.

Test: setup a local VM and run ART tests on it:
  lunch aosp_arm64-userdebug

  export ART_TEST_SSH_USER=ubuntu
  export ART_TEST_SSH_HOST=localhost
  export ART_TEST_SSH_PORT=10001
  export ART_TEST_ON_VM=true

  . art/tools/buildbot-utils.sh
  art/tools/buildbot-build.sh --target

  # Create, boot and configure the VM.
  art/tools/buildbot-vm.sh create
  art/tools/buildbot-vm.sh boot
  art/tools/buildbot-vm.sh setup-ssh  # password: 'ubuntu'

  art/tools/buildbot-cleanup-device.sh
  art/tools/buildbot-setup-device.sh
  art/tools/buildbot-sync.sh

  art/test.py --target -r 001-HelloWorld
  art/tools/run-gtests.sh

Test: check that non-VM testing still works:
  lunch aosp_arm32-userdebug

  . art/tools/buildbot-utils.sh
  art/tools/buildbot-build.sh --target

  export ART_TEST_CHROOT=/data/local/art-test-chroot

  art/tools/buildbot-cleanup-device.sh
  art/tools/buildbot-setup-device.sh
  art/tools/buildbot-sync.sh

  art/test.py --target -r --32 001-HelloWorld

Change-Id: I1393f8132b0b5d6ad10e291504550a5e7751f8e2
diff --git a/test/README.chroot_vm.md b/test/README.chroot_vm.md
new file mode 100644
index 0000000..2f163de
--- /dev/null
+++ b/test/README.chroot_vm.md
@@ -0,0 +1,74 @@
+# ART chroot-based testing on a Linux VM
+
+This doc describes how to set up a Linux VM and how to run ART tests on it.
+
+## Set up the VM
+
+Use script art/build/buildbot-vm.sh. It has various commands (actions) described
+below. First, set up some environment variables used by the script (change as
+you see fit):
+```
+export ART_TEST_SSH_USER=ubuntu
+export ART_TEST_SSH_HOST=localhost
+export ART_TEST_SSH_PORT=10001
+```
+Create the VM (download it and do some initial setup):
+```
+art/tools/buildbot-vm.sh create
+```
+Boot the VM (login is `$ART_TEST_SSH_USER`, password is `ubuntu`):
+```
+art/tools/buildbot-vm.sh boot
+```
+Configure SSH (enter `yes` to add VM to `known_hosts` and then the password):
+```
+art/tools/buildbot-vm.sh setup-ssh
+```
+Now you have the shell (no need to enter password every time):
+```
+art/tools/buildbot-vm.sh connect
+```
+To power off the VM, do:
+```
+art/tools/buildbot-vm.sh quit
+```
+To speed up SSH access, set `UseDNS no` in /etc/ssh/sshd_config on the VM (and
+apply other tweaks described in https://jrs-s.net/2017/07/01/slow-ssh-logins).
+
+# Run ART tests
+```
+This is done in the same way as you would run tests in chroot on device (except
+for a few extra environment variables):
+
+export ANDROID_SERIAL=nonexistent
+export ART_TEST_SSH_USER=ubuntu
+export ART_TEST_SSH_HOST=localhost
+export ART_TEST_SSH_PORT=10001
+export ART_TEST_ON_VM=true
+
+. ./build/envsetup.sh
+lunch armv8-eng  # or aosp_riscv64-userdebug, etc.
+art/tools/buildbot-build.sh --target # --installclean
+
+art/tools/buildbot-cleanup-device.sh
+
+# The following two steps can be skipped for faster iteration, but it doesn't
+# always track and update dependencies correctly (e.g. if only an assembly file
+# has been modified).
+art/tools/buildbot-setup-device.sh
+art/tools/buildbot-sync.sh
+
+art/test/run-test --chroot $ART_TEST_CHROOT --64 --interpreter -O 001-HelloWorld
+art/test.py --target -r --ndebug --no-image --64 --interpreter  # specify tests
+art/tools/run-gtests.sh
+
+art/tools/buildbot-cleanup-device.sh
+```
+Both test.py and run-test scripts can be used. Tweak options as necessary.
+
+# Limitations
+
+Limitations are mostly related to the absence of system properties on the Linux.
+They are not really needed for ART tests, but they are used for test-related
+things, e.g. to find out if the tests should run in debug configuration (option
+`ro.debuggable`). Therefore debug configuration is currently broken.
diff --git a/test/default_run.py b/test/default_run.py
index f457956..e102311 100755
--- a/test/default_run.py
+++ b/test/default_run.py
@@ -213,6 +213,8 @@
     else:
       setattr(args, name, new_value)
 
+  ON_VM = os.environ.get("ART_TEST_ON_VM")
+
   # Store copy of stdout&stderr of command in files so that we can diff them later.
   # This may run under 'adb shell' so we are limited only to 'sh' shell feature set.
   def tee(cmd: str):
@@ -244,7 +246,7 @@
   ANDROID_I18N_ROOT = args.android_i18n_root
   ANDROID_TZDATA_ROOT = args.android_tzdata_root
   ARCHITECTURES_32 = "(arm|x86|none)"
-  ARCHITECTURES_64 = "(arm64|x86_64|none)"
+  ARCHITECTURES_64 = "(arm64|x86_64|riscv64|none)"
   ARCHITECTURES_PATTERN = ARCHITECTURES_32
   GET_DEVICE_ISA_BITNESS_FLAG = "--32"
   BOOT_IMAGE = args.boot
@@ -455,7 +457,7 @@
     VDEX_ARGS += f" {arg}"
 
 # HACK: Force the use of `signal_dumper` on host.
-  if HOST:
+  if HOST or ON_VM:
     TIME_OUT = "timeout"
 
 # If you change this, update the timeout in testrunner.py as well.
@@ -683,7 +685,7 @@
   else:
     FLAGS += " -Xnorelocate"
 
-  if BIONIC:
+  if BIONIC and not ON_VM:
     # This is the location that soong drops linux_bionic builds. Despite being
     # called linux_bionic-x86 the build is actually amd64 (x86_64) only.
     assert path.exists(f"{OUT_DIR}/soong/host/linux_bionic-x86"), (
@@ -961,6 +963,10 @@
   # b/27185632
   # b/24664297
 
+  dalvikvm_logger = ""
+  if ON_VM:
+    dalvikvm_logger = "-Xuse-stderr-logger"
+
   dalvikvm_cmdline = f"{INVOKE_WITH} {GDB} {ANDROID_ART_BIN_DIR}/{DALVIKVM} \
                        {GDB_ARGS} \
                        {FLAGS} \
@@ -974,6 +980,7 @@
                        {DEBUGGER_OPTS} \
                        {QUOTED_DALVIKVM_BOOT_OPT} \
                        {TMP_DIR_OPTION} \
+                       {dalvikvm_logger} \
                        -XX:DumpNativeStackOnSigQuit:false \
                        -cp {DALVIKVM_CLASSPATH} {MAIN} {ARGS}"
 
@@ -1008,6 +1015,26 @@
 
   ANDROID_LOG_TAGS = args.android_log_tags
 
+  def filter_output():
+    # Remove unwanted log messages from stderr before diffing with the expected output.
+    # NB: The unwanted log line can be interleaved in the middle of wanted stderr printf.
+    #     In particular, unhandled exception is printed using several unterminated printfs.
+    ALL_LOG_TAGS = ["V", "D", "I", "W", "E", "F", "S"]
+    skip_tag_set = "|".join(ALL_LOG_TAGS[:ALL_LOG_TAGS.index(args.diff_min_log_tag.upper())])
+    skip_reg_exp = fr'[[:alnum:]]+ ({skip_tag_set}) #-# #:#:# [^\n]*\n'.replace('#', '[0-9]+')
+    ctx.run(fr"sed -i -z -E 's/{skip_reg_exp}//g' '{args.stderr_file}'")
+    if not HAVE_IMAGE:
+      message = "(Unable to open file|Could not create image space)"
+      ctx.run(fr"sed -i -E '/^dalvikvm(|32|64) E .* {message}/d' '{args.stderr_file}'")
+    if ANDROID_LOG_TAGS != "*:i" and "D" in skip_tag_set:
+      ctx.run(fr"sed -i -E '/^(Time zone|I18n) APEX ICU file found/d' '{args.stderr_file}'")
+    if ON_VM:
+      messages = "|".join([
+        "failed to connect to tombstoned",
+        "Failed to write stack traces to tombstoned",
+        "Failed to setpriority to :0"])
+      ctx.run(fr"sed -i -E '/({messages})/d' '{args.stderr_file}'")
+
   if not HOST:
     # Populate LD_LIBRARY_PATH.
     LD_LIBRARY_PATH = ""
@@ -1033,8 +1060,9 @@
     dlib = ""
     art_test_internal_libraries = []
 
-    # Needed to access the test's Odex files.
-    LD_LIBRARY_PATH = f"{DEX_LOCATION}/oat/{ISA}:{LD_LIBRARY_PATH}"
+    if not ON_VM:
+      # Needed to access the test's Odex files.
+      LD_LIBRARY_PATH = f"{DEX_LOCATION}/oat/{ISA}:{LD_LIBRARY_PATH}"
     # Needed to access the test's native libraries (see e.g. 674-hiddenapi,
     # which generates `libhiddenapitest_*.so` libraries in `{DEX_LOCATION}`).
     LD_LIBRARY_PATH = f"{DEX_LOCATION}:{LD_LIBRARY_PATH}"
@@ -1060,7 +1088,10 @@
       #       dumping do not lead to a deadlock, we also use the "-k" option to definitely kill the
       #       child.
       # Note: Using "--foreground" to not propagate the signal to children, i.e., the runtime.
-      timeout_prefix = f"timeout --foreground -k 120s {TIME_OUT_VALUE}s {timeout_dumper_cmd}"
+      if ON_VM:
+        timeout_prefix = f"timeout -k 120s {TIME_OUT_VALUE}s"
+      else:
+        timeout_prefix = f"timeout --foreground -k 120s {TIME_OUT_VALUE}s {timeout_dumper_cmd}"
 
     ctx.export(
       ASAN_OPTIONS = RUN_TEST_ASAN_OPTIONS,
@@ -1088,6 +1119,10 @@
     ctx.run(f"{sync_cmdline}")
     ctx.run(tee(f"{timeout_prefix} {dalvikvm_cmdline}"),
             expected_exit_code=args.expected_exit_code, desc="DalvikVM")
+
+    if ON_VM:
+      filter_output()
+
   else:
     # Host run.
     if USE_ZIPAPEX or USE_EXRACTED_ZIPAPEX:
@@ -1168,16 +1203,4 @@
       ctx.run(cmdline)
     else:
       ctx.run(tee(cmdline), expected_exit_code=args.expected_exit_code, desc="DalvikVM")
-
-      # Remove unwanted log messages from stderr before diffing with the expected output.
-      # NB: The unwanted log line can be interleaved in the middle of wanted stderr printf.
-      #     In particular, unhandled exception is printed using several unterminated printfs.
-      ALL_LOG_TAGS = ["V", "D", "I", "W", "E", "F", "S"]
-      skip_tag_set = "|".join(ALL_LOG_TAGS[:ALL_LOG_TAGS.index(args.diff_min_log_tag.upper())])
-      skip_reg_exp = fr'[[:alnum:]]+ ({skip_tag_set}) #-# #:#:# [^\n]*\n'.replace('#', '[0-9]+')
-      ctx.run(fr"sed -i -z -E 's/{skip_reg_exp}//g' '{args.stderr_file}'")
-      if not HAVE_IMAGE:
-        message = "(Unable to open file|Could not create image space)"
-        ctx.run(fr"sed -i -E '/^dalvikvm(|32|64) E .* {message}/d' '{args.stderr_file}'")
-      if ANDROID_LOG_TAGS != "*:i" and "D" in skip_tag_set:
-        ctx.run(fr"sed -i -E '/^(Time zone|I18n) APEX ICU file found/d' '{args.stderr_file}'")
+      filter_output()
diff --git a/test/run-test b/test/run-test
index e283079..f2be1d8 100755
--- a/test/run-test
+++ b/test/run-test
@@ -113,6 +113,15 @@
     tmp_dir = f"{TMPDIR}/{test_dir}"
   checker = f"{progdir}/../tools/checker/checker.py"
 
+  ON_VM = os.environ.get("ART_TEST_ON_VM")
+  SSH_USER = os.environ.get("ART_TEST_SSH_USER")
+  SSH_HOST = os.environ.get("ART_TEST_SSH_HOST")
+  SSH_PORT = os.environ.get("ART_TEST_SSH_PORT")
+  SSH_CMD = os.environ.get("ART_SSH_CMD")
+  SCP_CMD = os.environ.get("ART_SCP_CMD")
+  CHROOT = os.environ.get("ART_TEST_CHROOT")
+  CHROOT_CMD = os.environ.get("ART_CHROOT_CMD")
+
   def fail(message: str, caller:Optional[FrameInfo]=None):
     caller = caller or getframeinfo(currentframe().f_back)  # type: ignore
     assert caller
@@ -939,7 +948,10 @@
       os.chdir(oldwd)
       shutil.rmtree(tmp_dir)
       if target_mode == "yes":
-        run(f"adb shell rm -rf {chroot_dex_location}")
+        if ON_VM:
+          run(f"{SSH_CMD} \"rm -rf {chroot_dex_location}\"")
+        else:
+          run(f"adb shell rm -rf {chroot_dex_location}")
       print(f"{TEST_NAME} files deleted from host" +
             (" and from target" if target_mode == "yes" else ""))
     else:
@@ -994,22 +1006,37 @@
   print(f"{test_dir}: Run...")
   if target_mode == "yes":
     # Prepare the on-device test directory
-    run("adb root")
-    run("adb wait-for-device")
-    run(f"adb shell 'rm -rf {chroot_dex_location} && mkdir -p {chroot_dex_location}'")
+    if ON_VM:
+      run(f"{SSH_CMD} 'rm -rf {chroot_dex_location} && mkdir -p {chroot_dex_location}'")
+    else:
+      run("adb root")
+      run("adb wait-for-device")
+      run(f"adb shell 'rm -rf {chroot_dex_location} && mkdir -p {chroot_dex_location}'")
     push_files = [Path(runner.name)]
     push_files += list(Path(".").glob(f"{TEST_NAME}*.jar"))
     push_files += list(Path(".").glob(f"expected-*.txt"))
     push_files += [p for p in [Path("profile"), Path("res")] if p.exists()]
-    run("adb push {} {}".format(" ".join(map(str, push_files)), chroot_dex_location))
+    push_files = " ".join(map(str, push_files))
+    if ON_VM:
+      run(f"{SCP_CMD} {push_files} {SSH_USER}@{SSH_HOST}:{chroot_dex_location}")
+    else:
+      run("adb push {} {}".format(push_files, chroot_dex_location))
 
-    chroot_prefix = f"chroot {chroot}" if chroot else ""
-    run(f"adb shell {chroot_prefix} sh {DEX_LOCATION}/run.sh",
-        fail_message=f"Runner {chroot_dex_location}/run.sh failed")
+    if ON_VM:
+      run(f"{SSH_CMD} {CHROOT_CMD} bash {DEX_LOCATION}/run.sh",
+          fail_message=f"Runner {chroot_dex_location}/run.sh failed")
+    else:
+      chroot_prefix = f"chroot {chroot}" if chroot else ""
+      run(f"adb shell {chroot_prefix} sh {DEX_LOCATION}/run.sh",
+          fail_message=f"Runner {chroot_dex_location}/run.sh failed")
 
     # Copy the on-device stdout/stderr to host.
     pull_files = [test_stdout, test_stderr, "expected-stdout.txt", "expected-stderr.txt"]
-    run("adb pull {} .".format(" ".join(f"{chroot_dex_location}/{f}" for f in pull_files)))
+    if ON_VM:
+      srcs = " ".join(f"{SSH_USER}@{SSH_HOST}:{chroot_dex_location}/{f}" for f in pull_files)
+      run(f"{SCP_CMD} {srcs} .")
+    else:
+      run("adb pull {} .".format(" ".join(f"{chroot_dex_location}/{f}" for f in pull_files)))
   else:
     run(str(runner), fail_message=f"Runner {str(runner)} failed")
 
@@ -1049,7 +1076,10 @@
 
   if run_checker == "yes":
     if target_mode == "yes":
-      run(f'adb pull "{chroot}/{cfg_output_dir}/{cfg_output}"')
+      if ON_VM:
+        run(f'{SCP_CMD} "{SSH_USER}@${SSH_HOST}:{CHROOT}/{cfg_output_dir}/{cfg_output}"')
+      else:
+        run(f'adb pull "{chroot}/{cfg_output_dir}/{cfg_output}"')
     run(f'"{checker}" -q {checker_args} "{cfg_output}" "{tmp_dir}"',
         fail_message="CFG checker failed")
 
@@ -1057,7 +1087,10 @@
   if dump_cfg == "true":
     assert run_optimizing == "true", "The CFG can be dumped only in optimizing mode"
     if target_mode == "yes":
-      run(f"adb pull {chroot}/{cfg_output_dir}/{cfg_output} {dump_cfg_path}")
+      if ON_VM:
+        run(f'{SCP_CMD} "{SSH_USER}@${SSH_HOST}:{CHROOT}/{cfg_output_dir}/{cfg_output} {dump_cfg_output}"')
+      else:
+        run(f"adb pull {chroot}/{cfg_output_dir}/{cfg_output} {dump_cfg_path}")
     else:
       run(f"cp {cfg_output_dir}/{cfg_output} {dump_cfg_path}")
 
diff --git a/test/testrunner/env.py b/test/testrunner/env.py
index d5e0543..44d0db6 100644
--- a/test/testrunner/env.py
+++ b/test/testrunner/env.py
@@ -146,3 +146,6 @@
 SOONG_OUT_DIR = _get_build_var('SOONG_OUT_DIR')
 
 ART_TEST_RUN_ON_ARM_FVP = _getEnvBoolean('ART_TEST_RUN_ON_ARM_FVP', False)
+
+ART_TEST_ON_VM = _env.get('ART_TEST_ON_VM')
+ART_SSH_CMD = _env.get('ART_SSH_CMD')
diff --git a/test/testrunner/testrunner.py b/test/testrunner/testrunner.py
index 80b98fe..4499ffd 100755
--- a/test/testrunner/testrunner.py
+++ b/test/testrunner/testrunner.py
@@ -354,8 +354,13 @@
 
 def get_device_name():
   """
-  Gets the value of ro.product.name from remote device.
+  Gets the value of ro.product.name from remote device (unless running on a VM).
   """
+  if env.ART_TEST_ON_VM:
+    return subprocess.Popen(f"{env.ART_SSH_CMD} uname -a".split(),
+                            stdout = subprocess.PIPE,
+                            universal_newlines=True).stdout.read().strip()
+
   proc = subprocess.Popen(['adb', 'shell', 'getprop', 'ro.product.name'],
                           stderr=subprocess.STDOUT,
                           stdout = subprocess.PIPE,
@@ -710,7 +715,7 @@
     failed_tests.append((test_name, 'Timed out in %d seconds' % timeout))
 
     # HACK(b/142039427): Print extra backtraces on timeout.
-    if "-target-" in test_name:
+    if "-target-" in test_name and not env.ART_TEST_ON_VM:
       for i in range(8):
         proc_name = "dalvikvm" + test_name[-2:]
         pidof = subprocess.run(["adb", "shell", "pidof", proc_name], stdout=subprocess.PIPE)
@@ -1070,8 +1075,11 @@
 
 
 def get_target_cpu_count():
-  adb_command = 'adb shell cat /sys/devices/system/cpu/present'
-  cpu_info_proc = subprocess.Popen(adb_command.split(), stdout=subprocess.PIPE)
+  if env.ART_TEST_ON_VM:
+    command = f"{env.ART_SSH_CMD} cat /sys/devices/system/cpu/present"
+  else:
+    command = 'adb shell cat /sys/devices/system/cpu/present'
+  cpu_info_proc = subprocess.Popen(command.split(), stdout=subprocess.PIPE)
   cpu_info = cpu_info_proc.stdout.read()
   if type(cpu_info) is bytes:
     cpu_info = cpu_info.decode('utf-8')
diff --git a/tools/buildbot-build.sh b/tools/buildbot-build.sh
index 449ea37..e55eb35 100755
--- a/tools/buildbot-build.sh
+++ b/tools/buildbot-build.sh
@@ -25,8 +25,6 @@
   exit 1
 fi
 
-TARGET_ARCH=$(build/soong/soong_ui.bash --dumpvar-mode TARGET_ARCH)
-
 # Logic for setting out_dir from build/make/core/envsetup.mk:
 if [[ -z $OUT_DIR ]]; then
   if [[ -z $OUT_DIR_COMMON_BASE ]]; then
@@ -210,18 +208,22 @@
       arch32=x86
       arch64=x86_64
     fi
-    for so in ${implementation_libs[@]}; do
-      if [ -d "$ANDROID_PRODUCT_OUT/system/lib" ]; then
-        cmd="cp -p prebuilts/runtime/mainline/platform/impl/$arch32/$so $ANDROID_PRODUCT_OUT/system/lib/$so"
-        msginfo "Executing" "$cmd"
-        eval "$cmd"
-      fi
-      if [ -d "$ANDROID_PRODUCT_OUT/system/lib64" ]; then
-        cmd="cp -p prebuilts/runtime/mainline/platform/impl/$arch64/$so $ANDROID_PRODUCT_OUT/system/lib64/$so"
-        msginfo "Executing" "$cmd"
-        eval "$cmd"
-      fi
-   done
+    if [ "$TARGET_ARCH" = riscv64 ]; then
+      true # no 32-bit arch for RISC-V
+    else
+      for so in ${implementation_libs[@]}; do
+        if [ -d "$ANDROID_PRODUCT_OUT/system/lib" ]; then
+          cmd="cp -p prebuilts/runtime/mainline/platform/impl/$arch32/$so $ANDROID_PRODUCT_OUT/system/lib/$so"
+          msginfo "Executing" "$cmd"
+          eval "$cmd"
+        fi
+        if [ -d "$ANDROID_PRODUCT_OUT/system/lib64" ]; then
+          cmd="cp -p prebuilts/runtime/mainline/platform/impl/$arch64/$so $ANDROID_PRODUCT_OUT/system/lib64/$so"
+          msginfo "Executing" "$cmd"
+          eval "$cmd"
+        fi
+      done
+    fi
   fi
 
   # Create canonical name -> file name symlink in the symbol directory for the
diff --git a/tools/buildbot-cleanup-device.sh b/tools/buildbot-cleanup-device.sh
index 7dee149..7fd57b4 100755
--- a/tools/buildbot-cleanup-device.sh
+++ b/tools/buildbot-cleanup-device.sh
@@ -16,7 +16,22 @@
 
 . "$(dirname $0)/buildbot-utils.sh"
 
-# Setup as root, as device cleanup requires it.
+# Testing on a Linux VM requires special cleanup.
+if [[ -n "$ART_TEST_ON_VM" ]]; then
+  [[ -d "$ART_TEST_VM_DIR" ]] || { msgfatal "no VM found in $ART_TEST_VM_DIR"; }
+  $ART_SSH_CMD "true" || { msgfatal "VM not responding (tried \"$ART_SSH_CMD true\""; }
+  $ART_SSH_CMD "
+    sudo umount $ART_TEST_CHROOT/proc
+    sudo umount $ART_TEST_CHROOT/sys
+    sudo umount $ART_TEST_CHROOT/dev
+    sudo umount $ART_TEST_CHROOT/bin
+    sudo umount $ART_TEST_CHROOT/lib
+    rm -rf $ART_TEST_CHROOT
+  "
+  exit 0
+fi
+
+# Regular Android device. Setup as root, as device cleanup requires it.
 adb root
 adb wait-for-device
 
diff --git a/tools/buildbot-setup-device.sh b/tools/buildbot-setup-device.sh
index 811ba80..90d680b 100755
--- a/tools/buildbot-setup-device.sh
+++ b/tools/buildbot-setup-device.sh
@@ -25,7 +25,38 @@
   verbose=false
 fi
 
-# Setup as root, as some actions performed here require it.
+# Testing on a Linux VM requires special setup.
+if [[ -n "$ART_TEST_ON_VM" ]]; then
+  [[ -d "$ART_TEST_VM_DIR" ]] || { msgfatal "no VM found in $ART_TEST_VM_DIR"; }
+  $ART_SSH_CMD "true" || { msgerror "no VM (tried \"$ART_SSH_CMD true\""; }
+  $ART_SSH_CMD "
+    mkdir $ART_TEST_CHROOT
+
+    mkdir $ART_TEST_CHROOT/apex
+    mkdir $ART_TEST_CHROOT/bin
+    mkdir $ART_TEST_CHROOT/data
+    mkdir $ART_TEST_CHROOT/data/local
+    mkdir $ART_TEST_CHROOT/data/local/tmp
+    mkdir $ART_TEST_CHROOT/dev
+    mkdir $ART_TEST_CHROOT/etc
+    mkdir $ART_TEST_CHROOT/lib
+    mkdir $ART_TEST_CHROOT/linkerconfig
+    mkdir $ART_TEST_CHROOT/proc
+    mkdir $ART_TEST_CHROOT/sys
+    mkdir $ART_TEST_CHROOT/system
+    mkdir $ART_TEST_CHROOT/tmp
+
+    sudo mount -t proc /proc art-test-chroot/proc
+    sudo mount -t sysfs /sys art-test-chroot/sys
+    sudo mount --bind /dev art-test-chroot/dev
+    sudo mount --bind /bin art-test-chroot/bin
+    sudo mount --bind /lib art-test-chroot/lib
+    $ART_CHROOT_CMD echo \"Hello from chroot! I am \$(uname -a).\"
+  "
+  exit 0
+fi
+
+# Regular Android device. Setup as root, as some actions performed here require it.
 adb version
 adb root
 adb wait-for-device
diff --git a/tools/buildbot-sync.sh b/tools/buildbot-sync.sh
index ba49c61..ef9ec8b 100755
--- a/tools/buildbot-sync.sh
+++ b/tools/buildbot-sync.sh
@@ -20,27 +20,21 @@
 
 . "$(dirname $0)/buildbot-utils.sh"
 
-# Setup as root, as some actions performed here require it.
-adb root
-adb wait-for-device
+if [[ -z "$ART_TEST_ON_VM" ]]; then
+  # Setup as root, as some actions performed here require it.
+  adb root
+  adb wait-for-device
+fi
 
 if [[ -z "$ANDROID_BUILD_TOP" ]]; then
-  msgerror 'ANDROID_BUILD_TOP environment variable is empty; did you forget to run `lunch`?'
-  exit 1
-fi
-
-if [[ -z "$ANDROID_PRODUCT_OUT" ]]; then
-  msgerror 'ANDROID_PRODUCT_OUT environment variable is empty; did you forget to run `lunch`?'
-  exit 1
-fi
-
-if [[ -z "$ART_TEST_CHROOT" ]]; then
-  msgerror 'ART_TEST_CHROOT environment variable is empty; ' \
+  msgfatal 'ANDROID_BUILD_TOP environment variable is empty; did you forget to run `lunch`?'
+elif [[ -z "$ANDROID_PRODUCT_OUT" ]]; then
+  msgfatal 'ANDROID_PRODUCT_OUT environment variable is empty; did you forget to run `lunch`?'
+elif [[ -z "$ART_TEST_CHROOT" ]]; then
+  msgfatal 'ART_TEST_CHROOT environment variable is empty; ' \
       'please set it before running this script.'
-  exit 1
 fi
 
-
 # Sync relevant product directories
 # ---------------------------------
 
@@ -53,23 +47,40 @@
       continue
     fi
     msginfo "Syncing $dir directory..."
-    adb shell mkdir -p "$ART_TEST_CHROOT/$dir"
-    adb push $dir "$ART_TEST_CHROOT/$(dirname $dir)"
+    if [[ -n "$ART_TEST_ON_VM" ]]; then
+      $ART_RSYNC_CMD -R $dir "$ART_TEST_SSH_USER@$ART_TEST_SSH_HOST:$ART_TEST_CHROOT"
+    else
+      adb shell mkdir -p "$ART_TEST_CHROOT/$dir"
+      adb push $dir "$ART_TEST_CHROOT/$(dirname $dir)"
+    fi
   done
 )
 
 # Overwrite the default public.libraries.txt file with a smaller one that
 # contains only the public libraries pushed to the chroot directory.
-adb push "$ANDROID_BUILD_TOP/art/tools/public.libraries.buildbot.txt" \
-  "$ART_TEST_CHROOT/system/etc/public.libraries.txt"
+if [[ -n "$ART_TEST_ON_VM" ]]; then
+  $ART_RSYNC_CMD "$ANDROID_BUILD_TOP/art/tools/public.libraries.buildbot.txt" \
+    "$ART_TEST_SSH_USER@$ART_TEST_SSH_HOST:$ART_TEST_CHROOT/system/etc/public.libraries.txt"
+else
+  adb push "$ANDROID_BUILD_TOP/art/tools/public.libraries.buildbot.txt" \
+    "$ART_TEST_CHROOT/system/etc/public.libraries.txt"
+fi
 
 # Create the framework directory if it doesn't exist. Some gtests need it.
-adb shell mkdir -p "$ART_TEST_CHROOT/system/framework"
+if [[ -n "$ART_TEST_ON_VM" ]]; then
+  $ART_SSH_CMD "$ART_CHROOT_CMD mkdir -p $ART_TEST_CHROOT/system/framework"
+else
+  adb shell mkdir -p "$ART_TEST_CHROOT/system/framework"
+fi
 
 # APEX packages activation.
 # -------------------------
 
-adb shell mkdir -p "$ART_TEST_CHROOT/apex"
+if [[ -n "$ART_TEST_ON_VM" ]]; then
+  $ART_SSH_CMD "$ART_CHROOT_CMD mkdir -p $ART_TEST_CHROOT/apex"
+else
+  adb shell mkdir -p "$ART_TEST_CHROOT/apex"
+fi
 
 # Manually "activate" the flattened APEX $1 by syncing it to /apex/$2 in the
 # chroot. $2 defaults to $1.
@@ -101,8 +112,13 @@
   fi
 
   msginfo "Activating APEX ${src_apex} as ${dst_apex}..."
-  adb shell rm -rf "$ART_TEST_CHROOT/apex/${dst_apex}"
-  adb push $src_apex_path "$ART_TEST_CHROOT/apex/${dst_apex}"
+  if [[ -n "$ART_TEST_ON_VM" ]]; then
+    $ART_RSYNC_CMD $src_apex_path/* \
+      "$ART_TEST_SSH_USER@$ART_TEST_SSH_HOST:$ART_TEST_CHROOT/apex/${dst_apex}"
+  else
+    adb shell rm -rf "$ART_TEST_CHROOT/apex/${dst_apex}"
+    adb push $src_apex_path "$ART_TEST_CHROOT/apex/${dst_apex}"
+  fi
 }
 
 # "Activate" the required APEX modules.
@@ -113,19 +129,34 @@
 activate_apex com.android.conscrypt
 activate_apex com.android.os.statsd
 
-# Generate primary boot images on device for testing.
-for b in {32,64}; do
-  basename="generate-boot-image$b"
-  bin_on_host="$ANDROID_PRODUCT_OUT/system/bin/$basename"
-  bin_on_device="/data/local/tmp/$basename"
-  output_dir="/system/framework/art_boot_images"
-  if [ -f $bin_on_host ]; then
-    msginfo "Generating the primary boot image ($b-bit)..."
-    adb push "$bin_on_host" "$ART_TEST_CHROOT$bin_on_device"
-    adb shell mkdir -p "$ART_TEST_CHROOT$output_dir"
-    # `compiler-filter=speed-profile` is required because OatDumpTest checks the compiled code in
-    # the boot image.
-    adb shell chroot "$ART_TEST_CHROOT" \
-      "$bin_on_device" --output-dir=$output_dir --compiler-filter=speed-profile
-  fi
-done
+if [[ "$TARGET_ARCH" = "riscv64" ]]; then
+  true # Skip boot image generation for RISC-V; it's not supported.
+else
+  # Generate primary boot images on device for testing.
+  for b in {32,64}; do
+    basename="generate-boot-image$b"
+    bin_on_host="$ANDROID_PRODUCT_OUT/system/bin/$basename"
+    bin_on_device="/data/local/tmp/$basename"
+    output_dir="/system/framework/art_boot_images"
+    if [ -f $bin_on_host ]; then
+      msginfo "Generating the primary boot image ($b-bit)..."
+      if [[ -n "$ART_TEST_ON_VM" ]]; then
+        $ART_RSYNC_CMD "$bin_on_host" \
+          "$ART_TEST_SSH_USER@$ART_TEST_SSH_HOST:$ART_TEST_CHROOT$bin_on_device"
+        $ART_SSH_CMD "mkdir -p $ART_TEST_CHROOT$output_dir"
+      else
+        adb push "$bin_on_host" "$ART_TEST_CHROOT$bin_on_device"
+        adb shell mkdir -p "$ART_TEST_CHROOT$output_dir"
+      fi
+      # `compiler-filter=speed-profile` is required because OatDumpTest checks the compiled code in
+      # the boot image.
+      if [[ -n "$ART_TEST_ON_VM" ]]; then
+        $ART_SSH_CMD \
+          "$ART_CHROOT_CMD $bin_on_device --output-dir=$output_dir --compiler-filter=speed-profile"
+      else
+        adb shell chroot "$ART_TEST_CHROOT" \
+          "$bin_on_device" --output-dir=$output_dir --compiler-filter=speed-profile
+      fi
+    fi
+  done
+fi
diff --git a/tools/buildbot-teardown-device.sh b/tools/buildbot-teardown-device.sh
index 156b4f1..e71dcbe 100755
--- a/tools/buildbot-teardown-device.sh
+++ b/tools/buildbot-teardown-device.sh
@@ -19,6 +19,8 @@
 
 . "$(dirname $0)/buildbot-utils.sh"
 
+[[ -n "$ART_TEST_ON_VM" ]] && exit 0
+
 # Setup as root, as some actions performed here require it.
 adb root
 adb wait-for-device
diff --git a/tools/buildbot-utils.sh b/tools/buildbot-utils.sh
index 32ed234..5bc58af 100755
--- a/tools/buildbot-utils.sh
+++ b/tools/buildbot-utils.sh
@@ -53,7 +53,43 @@
   echo -e "${boldred}Error: ${nc}${message}"
 }
 
+function msgfatal() {
+  local message="$*"
+  echo -e "${boldred}Fatal: ${nc}${message}"
+  exit 1
+}
+
 function msgnote() {
   local message="$*"
   echo -e "${boldcyan}Note: ${nc}${message}"
 }
+
+export TARGET_ARCH=$(build/soong/soong_ui.bash --dumpvar-mode TARGET_ARCH)
+
+# Do some checks and prepare environment for tests that run on Linux (not on Android).
+if [[ -n "$ART_TEST_ON_VM" ]]; then
+  if [[ -z $ANDROID_BUILD_TOP ]]; then
+    msgfatal "ANDROID_BUILD_TOP is not set"
+  elif [[ -z "$ART_TEST_SSH_USER" ]]; then
+    msgfatal "ART_TEST_SSH_USER not set"
+  elif [[ -z "$ART_TEST_SSH_HOST" ]]; then
+    msgfatal "ART_TEST_SSH_HOST not set"
+  elif [[ -z "$ART_TEST_SSH_PORT" ]]; then
+    msgfatal "ART_TEST_SSH_PORT not set"
+  fi
+
+  export ART_TEST_CHROOT="/home/$ART_TEST_SSH_USER/art-test-chroot"
+  export ART_CHROOT_CMD="unshare --user --map-root-user chroot art-test-chroot"
+  export ART_SSH_CMD="ssh -q -p $ART_TEST_SSH_PORT $ART_TEST_SSH_USER@$ART_TEST_SSH_HOST"
+  export ART_SCP_CMD="scp -P $ART_TEST_SSH_PORT -p -r"
+  export ART_RSYNC_CMD="rsync -az"
+  export RSYNC_RSH="ssh -p $ART_TEST_SSH_PORT" # don't prefix with "ART_", rsync expects this name
+
+  if [[ "$TARGET_ARCH" =~ ^(arm64|riscv64)$ ]]; then
+    export ART_TEST_VM_IMG="ubuntu-22.04-server-cloudimg-$TARGET_ARCH.img"
+    export ART_TEST_VM_DIR="$ANDROID_BUILD_TOP/vm/$TARGET_ARCH"
+    export ART_TEST_VM="$ART_TEST_VM_DIR/$ART_TEST_VM_IMG"
+  else
+    msgfatal "unexpected TARGET_ARCH=$TARGET_ARCH; expected one of {arm64,riscv64}"
+  fi
+fi
diff --git a/tools/buildbot-vm.sh b/tools/buildbot-vm.sh
new file mode 100755
index 0000000..524a57b
--- /dev/null
+++ b/tools/buildbot-vm.sh
@@ -0,0 +1,128 @@
+#! /bin/bash
+#
+# Copyright (C) 2023 The Android Open Source Project
+#
+# 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.
+
+set -e
+
+ART_TEST_ON_VM=true . "$(dirname $0)/buildbot-utils.sh"
+
+known_actions="create|boot|setup-ssh|connect|quit"
+
+if [[ -z $ANDROID_BUILD_TOP ]]; then
+    msgfatal "ANDROID_BUILD_TOP is not set"
+elif [[ ( $# -ne 1 ) || ! ( "$1" =~ ^($known_actions)$ ) ]]; then
+    msgfatal "usage: $0 <$known_actions>"
+fi
+
+action="$1"
+
+get_stable_binary() {
+    mkdir tmp && cd tmp
+    wget "http://security.ubuntu.com/ubuntu/pool/main/$1"
+    7z x "$(basename $1)" && zstd -d data.tar.zst && tar -xf data.tar
+    mv "$2" ..
+    cd .. && rm -rf tmp
+}
+
+if [[ $action = create ]]; then
+(
+    rm -rf "$ART_TEST_VM_DIR"
+    mkdir -p "$ART_TEST_VM_DIR"
+    cd "$ART_TEST_VM_DIR"
+
+    # sudo apt install qemu-system-<arch> qemu-efi cloud-image-utils
+
+    # Get the cloud image for Ubunty 22.04 (Jammy)
+    wget "http://cloud-images.ubuntu.com/releases/22.04/release/$ART_TEST_VM_IMG"
+
+    if [[ "$TARGET_ARCH" = "riscv64" ]]; then
+        # Get U-Boot for Ubuntu 22.04 (Jammy)
+        get_stable_binary \
+            u/u-boot/u-boot-qemu_2022.01+dfsg-2ubuntu2.3_all.deb \
+            usr/lib/u-boot/qemu-riscv64_smode/uboot.elf
+
+        # Get OpenSBI for Ubuntu 22.04 (Jammy)
+        get_stable_binary \
+            o/opensbi/opensbi_1.1-0ubuntu0.22.04.1_all.deb \
+            usr/lib/riscv64-linux-gnu/opensbi/generic/fw_jump.elf
+
+    elif [[ "$TARGET_ARCH" = "arm64" ]]; then
+        # Get EFI (ARM64) for Ubuntu 22.04 (Jammy)
+        get_stable_binary \
+            e/edk2/qemu-efi-aarch64_2022.02-3ubuntu0.22.04.1_all.deb \
+            usr/share/qemu-efi-aarch64/QEMU_EFI.fd
+
+        dd if=/dev/zero of=flash0.img bs=1M count=64
+        dd if=QEMU_EFI.fd of=flash0.img conv=notrunc
+        dd if=/dev/zero of=flash1.img bs=1M count=64
+    fi
+
+    qemu-img resize "$ART_TEST_VM_IMG" +128G
+
+    # https://help.ubuntu.com/community/CloudInit
+    cat >user-data <<EOF
+#cloud-config
+ssh_pwauth: true
+chpasswd:
+  expire: false
+  list:
+    - $ART_TEST_SSH_USER:ubuntu
+EOF
+    cloud-localds user-data.img user-data
+)
+elif [[ $action = boot ]]; then
+(
+    cd "$ART_TEST_VM_DIR"
+    if [[ "$TARGET_ARCH" = "riscv64" ]]; then
+        qemu-system-riscv64 \
+            -m 16G \
+            -smp 8 \
+            -M virt \
+            -nographic \
+            -bios fw_jump.elf \
+            -kernel uboot.elf \
+            -drive file="$ART_TEST_VM_IMG",if=virtio \
+            -drive file=user-data.img,format=raw,if=virtio \
+            -device virtio-net-device,netdev=usernet \
+            -netdev user,id=usernet,hostfwd=tcp::$ART_TEST_SSH_PORT-:22
+    elif [[ "$TARGET_ARCH" = "arm64" ]]; then
+        qemu-system-aarch64 \
+            -m 16G \
+            -smp 8 \
+            -cpu cortex-a57 \
+            -M virt \
+            -nographic \
+            -drive if=none,file="$ART_TEST_VM_IMG",id=hd0 \
+            -pflash flash0.img \
+            -pflash flash1.img \
+            -drive file=user-data.img,format=raw,id=cloud \
+            -device virtio-blk-device,drive=hd0 \
+            -device virtio-net-device,netdev=usernet \
+            -netdev user,id=usernet,hostfwd=tcp::$ART_TEST_SSH_PORT-:22
+    fi
+
+)
+elif [[ $action = setup-ssh ]]; then
+    # Clean up mentions of this VM from known_hosts
+    sed -i -E "/\[$ART_TEST_SSH_HOST.*\]:$ART_TEST_SSH_PORT .*/d" $HOME/.ssh/known_hosts
+    ssh-copy-id -p "$ART_TEST_SSH_PORT" "$ART_TEST_SSH_USER@$ART_TEST_SSH_HOST"
+
+elif [[ $action = connect ]]; then
+    ssh -p "$ART_TEST_SSH_PORT" "$ART_TEST_SSH_USER@$ART_TEST_SSH_HOST"
+
+elif [[ $action = quit ]]; then
+    ssh -p "$ART_TEST_SSH_PORT" "$ART_TEST_SSH_USER@$ART_TEST_SSH_HOST" "sudo poweroff"
+
+fi
diff --git a/tools/run-gtests.sh b/tools/run-gtests.sh
index 21064c1..0f333f6 100755
--- a/tools/run-gtests.sh
+++ b/tools/run-gtests.sh
@@ -59,9 +59,17 @@
 
 options="$@"
 
+run_in_chroot() {
+  if [ -n $ART_TEST_ON_VM ]; then
+    $ART_SSH_CMD $ART_CHROOT_CMD $@
+  else
+    "$adb" shell chroot "$ART_TEST_CHROOT" $@
+  fi
+}
+
 if [[ ${#tests[@]} -eq 0 ]]; then
   # Search for executables under the `bin/art` directory of the ART APEX.
-  readarray -t tests <<<$("$adb" shell chroot "$ART_TEST_CHROOT" \
+  readarray -t tests <<<$(run_in_chroot \
     find "$android_art_root/bin/art" -type f -perm /ugo+x | sort)
 fi
 
@@ -69,7 +77,7 @@
 
 for t in ${tests[@]}; do
   echo "$t"
-  "$adb" shell chroot "$ART_TEST_CHROOT" \
+  run_in_chroot \
     env ANDROID_ART_ROOT="$android_art_root" \
         ANDROID_I18N_ROOT="$android_i18n_root" \
         ANDROID_TZDATA_ROOT="$android_tzdata_root" \