[fx][bazel] Ensure proper Ninja build plan regeneration.

`fx bazel` first check whether the Ninja build plan needs
to be regenerated before rebuilding the workspace.

The regeneration itself was performed directly in update_workspace.py
by invoking Ninja, but didn't define environment variables in the
same way as `fx build`, resulting problems, such as broken RBE-enabled
builds when invoking `fx bazel` just after modifying a `BUILD.gn`
file.

This CL fixes the issue by doing the following:

- Moving the fast 'build plan freshness' check to its own
  Python script, invoked from `fx bazel`.

- Ensure that `fx build` is called to regenerate the build plan
  if needed, to ensure consistent environment variables in
  all cases.

+ Ensure `fx-rbe-enabled` is not called twice by `fx build`
  when `--fint-params` is not used (the function is already
  called by `fx-run-ninja` defined lib `lib/vars.sh`.

Manual testing:

  1) Ensure your have RBE enabled in your args.gn

  2) `touch BUILD.gn`, to force a Ninja regen on the next invocation.

  3) `fx bazel`, which will launch the regen operation before
     invoking Ninja.

Before this CL, step 3) would print many Ninja errors such as:
```
../../build/rbe/reclient_cxx.sh: line 112: RBE_server_adress: unbound variable
```

This does not happen after the CL, because the environment variable
is correctly defined when invoking Ninja.

Bug: b/315393497
Change-Id: I584eb734c4fde4f92a97ece1924e8fb3dfafb39a
Reviewed-on: https://fuchsia-review.googlesource.com/c/fuchsia/+/957556
Commit-Queue: David Turner <digit@google.com>
Reviewed-by: Oliver Newman <olivernewman@google.com>
Reviewed-by: David Fang <fangism@google.com>
diff --git a/build/bazel/BUILD.gn b/build/bazel/BUILD.gn
index 37cb11e..40cf8d7 100644
--- a/build/bazel/BUILD.gn
+++ b/build/bazel/BUILD.gn
@@ -87,6 +87,9 @@
     # The script reads this configuration file
     "//build/bazel/config/main_workspace_top_dir",
 
+    # The script imports this python file
+    "//build/bazel/scripts/check_ninja_build_plan.py",
+
     # The script reads RBE configs
     rewrapper_config_file,
     reproxy_config_file,
diff --git a/build/bazel/scripts/BUILD.gn b/build/bazel/scripts/BUILD.gn
index 442fc05..2cf2088 100644
--- a/build/bazel/scripts/BUILD.gn
+++ b/build/bazel/scripts/BUILD.gn
@@ -7,7 +7,10 @@
 if (is_host) {
   python_host_test("update_workspace_test") {
     main_source = "update_workspace_test.py"
-    sources = [ "update_workspace.py" ]
+    sources = [
+      "check_ninja_build_plan.py",
+      "update_workspace.py",
+    ]
     libraries = [ "//third_party/parameterized" ]
   }
 }
diff --git a/build/bazel/scripts/check_ninja_build_plan.py b/build/bazel/scripts/check_ninja_build_plan.py
new file mode 100755
index 0000000..b379fd4
--- /dev/null
+++ b/build/bazel/scripts/check_ninja_build_plan.py
@@ -0,0 +1,76 @@
+#!/usr/bin/env python3
+# Copyright 2023 The Fuchsia Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""Check very quickly whether the Ninja build plan needs to be rebuilt."""
+
+import argparse
+import os
+import sys
+
+# NOTE: Do not use pathlib.Path here since benchmarking shows that this makes
+# this script _significantly_ slower (e.g. 42ms vs 16ms !) compared to os.path
+# operations.
+
+
+def ninja_plan_is_up_to_date(ninja_build_dir: str) -> bool:
+    """Return True if the Ninja build plan is up-to-date.
+
+    This expects the Ninja build plan to be generated by GN, which
+    produces a `build_ninja.d` file with the right set of dependencies.
+
+    Args:
+      ninja_build_dir: Ninja build output directory.
+
+    Returns:
+      True if the build plan is up-to-date, False if the next Ninja call
+      would invoke a regen step.
+    """
+    # This reads the build.ninja.d directly and tries to stat() all
+    # dependencies in it directly (around 7000+), which is much
+    # faster than Ninja trying to stat all build graph paths!
+    build_ninja_d = os.path.join(ninja_build_dir, "build.ninja.d")
+    if not os.path.exists(build_ninja_d):
+        return False
+
+    with open(build_ninja_d) as f:
+        build_ninja_deps = f.read().split(" ")
+
+    assert len(build_ninja_deps) > 1
+    # The first item is the top-level manifest file followed by a colon,
+    # e.g. 'build.ninja:'
+    ninja_stamp = os.path.join(ninja_build_dir, build_ninja_deps[0][:-1])
+    ninja_stamp_timestamp = os.stat(ninja_stamp).st_mtime
+
+    try:
+        for dep in build_ninja_deps[1:]:
+            dep_path = os.path.join(ninja_build_dir, dep)
+            dep_timestamp = os.stat(dep_path).st_mtime
+            if dep_timestamp > ninja_stamp_timestamp:
+                return False
+    except FileNotFoundError:
+        return False
+
+    return True
+
+
+def main():
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument("ninja_build_dir", help="Ninja build directory.")
+    parser.add_argument(
+        "--quiet", action="store_true", help="Do not print anything."
+    )
+    args = parser.parse_args()
+    if ninja_plan_is_up_to_date(args.ninja_build_dir):
+        if not args.quiet:
+            print("up-to-date!")
+        return 0
+
+    if not args.quiet:
+        print("regen required!")
+    return 1
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/build/bazel/scripts/update_workspace.py b/build/bazel/scripts/update_workspace.py
index 6f4c440..9ed474b 100755
--- a/build/bazel/scripts/update_workspace.py
+++ b/build/bazel/scripts/update_workspace.py
@@ -43,6 +43,8 @@
 import sys
 from typing import Sequence
 
+import check_ninja_build_plan
+
 
 def get_host_platform() -> str:
     """Return host platform name, following Fuchsia conventions."""
@@ -624,21 +626,14 @@
         )
     )
 
-    if maybe_regenerate_ninja(gn_output_dir, ninja_binary):
-        log(
-            "Re-generating Ninja build plan and incrementally rebuilding Bazel main workspace (to make sure all dependencies are up-to-date)!"
+    if not check_ninja_build_plan.ninja_plan_is_up_to_date(gn_output_dir):
+        print(
+            "Ninja build plan is not up-to-date, please run `fx build` before this script!",
+            file=sys.stderr,
         )
-        subprocess.run(
-            [
-                ninja_binary,
-                "-C",
-                gn_output_dir,
-                "build.ninja",
-                "bazel_workspace",
-            ]
-        )
-    else:
-        log2("Ninja build plan up to date.")
+        return 1
+
+    log2("Ninja build plan up to date.")
 
     generated = GeneratedFiles()
 
diff --git a/tools/devshell/bazel b/tools/devshell/bazel
index c867379..bb80571 100755
--- a/tools/devshell/bazel
+++ b/tools/devshell/bazel
@@ -20,13 +20,17 @@
 ##
 ## All arguments are passed to the Bazel binary.
 
-source "$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"/lib/vars.sh || exit $?
+_script_dir="${BASH_SOURCE[0]%/*}";
+if [[ "${_script_dir}" == "${BASH_SOURCE[0]}" ]] then _script_dir="."; fi
+readonly _script_dir
+
+source "${_script_dir}/lib/vars.sh" || exit $?
 fx-config-read
 
 if [[ ! -d "${FUCHSIA_BUILD_DIR}" ]]; then
   fx-error "No Fuchsia build directory, please run `fx set` or `fx gen`."
 fi
 
-source "$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"/lib/bazel_utils.sh || exit $?
+source "${_script_dir}/lib/bazel_utils.sh" || exit $?
 
 fx-bazel "$@"
diff --git a/tools/devshell/build b/tools/devshell/build
index 24a3061..5650b3d 100755
--- a/tools/devshell/build
+++ b/tools/devshell/build
@@ -153,15 +153,16 @@
   # to kick off the build and wait for the failure.
   goma-check
 
-  local rbe_wrapper=()
-  if fx-rbe-enabled
-  then
-    rbe_wrapper=("${RBE_WRAPPER[@]}")
-  fi
-
   if [ -n "${fint_params_path}" ]; then
     readonly fint="${FX_CACHE_DIR}/fint"
     "$FUCHSIA_DIR/tools/integration/bootstrap.sh" -o "$fint" || exit $?
+
+    local rbe_wrapper=()
+    if fx-rbe-enabled
+    then
+      rbe_wrapper=("${RBE_WRAPPER[@]}")
+    fi
+
     # It's not ideal that we resort to constructing the textproto file as a
     # string, but it's easier than writing a Go tool solely for the purpose of
     # constructing a protobuf with a couple top-level string fields set.
diff --git a/tools/devshell/lib/bazel_utils.sh b/tools/devshell/lib/bazel_utils.sh
index 86f64f0..75713a4 100644
--- a/tools/devshell/lib/bazel_utils.sh
+++ b/tools/devshell/lib/bazel_utils.sh
@@ -11,7 +11,7 @@
   # $1: Optional workspace name, defaults to "main".
   #
   local INPUT_FILE="${FUCHSIA_DIR}/build/bazel/config/${1:-main}_workspace_top_dir"
-  local TOPDIR=$(cat "${INPUT_FILE}")
+  local TOPDIR=$(<"${INPUT_FILE}")
   echo "${FUCHSIA_BUILD_DIR}/${TOPDIR}"
 }
 
@@ -28,6 +28,15 @@
 # Regenerate Bazel workspace and launcher script if needed.
 # Note that this also regenerates the Ninja build plan if necessary.
 fx-update-bazel-workspace () {
+  # First, refresh Ninja build plan if needed.
+  local check_script="${FUCHSIA_DIR}/build/bazel/scripts/check_ninja_build_plan.py"
+  if ! "${PREBUILT_PYTHON3}" -S "${check_script}" --quiet "${FUCHSIA_BUILD_DIR}"; then
+    # Calling fx build is needed to ensure RBE environment variables are
+    # properly set when generating the Ninja build plan. See b/315393497
+    echo "fx-bazel: Regenerating Ninja build plan to ensure all workspace dependencies are correct!"
+    fx-command-run build build.ninja
+  fi
+  # Second, refresh Bazel workspace files if needed.
   "${FUCHSIA_DIR}"/build/bazel/scripts/update_workspace.py
 }