Print an error if a host tool is running on copy

If a host tool is running when the pw_host_tool tries to copy a newer
version into the host_tools/ directory, the copy fails. This change
prints out a user-friendly error indicating the problem.

Change-Id: I7b25f709150f40e1ad9c8c7e63cbfa9f9f9a53e5
diff --git a/pw_build/host_tool.gni b/pw_build/host_tool.gni
index 3011b6e..cc0fde9 100644
--- a/pw_build/host_tool.gni
+++ b/pw_build/host_tool.gni
@@ -43,6 +43,8 @@
       _out_target,
       "--dst",
       _host_tools_dir,
+      "--out-root",
+      root_out_dir,
     ]
 
     if (defined(invoker.name) && invoker.name != "") {
diff --git a/pw_build/py/host_tool.py b/pw_build/py/host_tool.py
index ca0fbc8..00ba3b0 100644
--- a/pw_build/py/host_tool.py
+++ b/pw_build/py/host_tool.py
@@ -15,7 +15,7 @@
 
 import argparse
 import logging
-import os
+from pathlib import Path
 import shutil
 import sys
 from typing import Optional
@@ -34,10 +34,16 @@
         parser = argparse.ArgumentParser(description=__doc__)
 
     parser.add_argument('--dst',
+                        type=Path,
                         required=True,
                         help='Path to host tools directory')
     parser.add_argument('--name', help='Name for the installed tool')
+    parser.add_argument('--out-root',
+                        type=Path,
+                        required=True,
+                        help='Root of Ninja out directory')
     parser.add_argument('--src',
+                        type=Path,
                         required=True,
                         help='Path to host tool executable')
 
@@ -45,21 +51,46 @@
 
 
 def main() -> int:
+    """Copies a host tool into a destination directory."""
     args = argument_parser().parse_args()
 
-    if not os.path.isfile(args.src):
+    if not args.src.is_file():
         _LOG.error('%s is not a file', args.src)
         return 1
 
-    os.makedirs(args.dst, exist_ok=True)
+    args.dst.mkdir(parents=True, exist_ok=True)
 
     if args.name is not None:
         if '/' in args.name:
             _LOG.error('Host tool name cannot contain "/"')
             return 1
-        args.dst = os.path.join(args.dst, args.name)
+        name = args.name
+    else:
+        name = args.src.name
 
-    shutil.copy2(args.src, args.dst)
+    try:
+        shutil.copy2(args.src, args.dst.joinpath(name))
+    except OSError as err:
+        _LOG.error('%s', err)
+
+        # Errno 26 (text file busy) indicates that a host tool binary is
+        # currently running.
+        # TODO(frolv): Check if this works on Windows.
+        if err.errno == 26:
+            _LOG.error('')
+            _LOG.error('  %s has been rebuilt but cannot be', name)
+            _LOG.error('  copied into the host tools directory:')
+            _LOG.error('')
+            _LOG.error('    %s',
+                       args.dst.relative_to(args.out_root).joinpath(name))
+            _LOG.error('')
+            _LOG.error('  This can occur if the program is already running.')
+            _LOG.error(
+                '  If it is running, exit it and try re-running the build.')
+            _LOG.error('')
+
+        return 1
+
     return 0
 
 
diff --git a/targets/stm32f429i-disc1/py/stm32f429i_disc1_utils/unit_test_server.py b/targets/stm32f429i-disc1/py/stm32f429i_disc1_utils/unit_test_server.py
index 696539d..564928f 100644
--- a/targets/stm32f429i-disc1/py/stm32f429i_disc1_utils/unit_test_server.py
+++ b/targets/stm32f429i-disc1/py/stm32f429i_disc1_utils/unit_test_server.py
@@ -63,7 +63,7 @@
     return '\n'.join(runner)
 
 
-def generate_server_config() -> str:
+def generate_server_config() -> TextIO:
     """Returns a temporary generated file for use as the server config."""
     boards = stm32f429i_detector.detect_boards()
     if not boards: