#!/usr/bin/env bash
# Cap gate CPU + IO + memory so the host stays responsive while the turbo
# pipeline (typecheck / test / build across the workspace) runs.
#
# Wrap any turbo invocation: `./scripts/cpu-limit.sh turbo run test`.
# taskset pins turbo AND every child it spawns (tsc, vitest fork workers,
# esbuild) to the same cpuset, so the cap is a HARD kernel guarantee — a
# runaway package cannot escape the budget.
#
# The systemd scope joins build.slice. CPUQuota here is PER INVOCATION; the
# cross-invocation aggregate cap only binds where the machine defines a quota
# on build.slice itself (~/.config/systemd/user/build.slice). Without one,
# concurrent gates are bounded only by taskset pinning them to the same cpuset.
#
# Tunables:
#   BUILD_CPU_RESERVE=N      cores to reserve for the rest of the system (default 8)
#   BUILD_MEM_HIGH=12G       soft memory ceiling (systemd-run only; default 12G)
#   BUILD_MEM_MAX=16G        hard memory kill ceiling (systemd-run only; default 16G)
#   BUILD_NODE_HEAP=4096     Node --max-old-space-size MiB (default 4096)
#   BUILD_NO_CAP=1           bypass all caps (debug / idle-machine fast run)
#   BUILD_NO_LOCK=1          skip the machine-wide build-serialization lock
#   BUILD_SCHED_IDLE=0       keep normal scheduling (escape hatch if an idle-
#                            scheduled gate starves under sustained host load)
set -e

# One turbo cache machine-wide. Preference order: explicit TURBO_CACHE_DIR >
# /var/cache/platform-turbo (shared across ALL checkouts and users — dev
# worktrees AND the self-hosted CI runner, which is a different user with a
# separate checkout; ACL-provisioned, absent on other machines) > the linked
# worktrees' shared .git anchor > turbo's default.
if [ -z "${TURBO_CACHE_DIR:-}" ]; then
  if [ -d /var/cache/platform-turbo ] && [ -w /var/cache/platform-turbo ]; then
    export TURBO_CACHE_DIR=/var/cache/platform-turbo
  elif command -v git >/dev/null 2>&1; then
    common_git_dir=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)
    if [ -n "$common_git_dir" ]; then
      export TURBO_CACHE_DIR="$(dirname "$common_git_dir")/.turbo/cache"
    fi
  fi
fi

if [ -n "$BUILD_NO_CAP" ]; then
  exec "$@"
fi

# Serialize heavy runs machine-wide: concurrent gates (parallel worktrees, CI
# runner, agent sessions) compute identical tasks before either writes the
# cache; queued, the second run is mostly a cache replay. The lock fd is held
# for the process lifetime; BUILD_LOCK_HELD stops a nested invocation from
# deadlocking on its own lock. BUILD_NO_LOCK=1 opts out.
if [ -z "${BUILD_NO_LOCK:-}" ] && [ -z "${BUILD_LOCK_HELD:-}" ] && command -v flock >/dev/null 2>&1; then
  lock_file="${TURBO_CACHE_DIR:-/tmp}/.platform-build.lock"
  lock_parent=$(dirname "$lock_file")
  if { [ -e "$lock_file" ] && [ -w "$lock_file" ]; } || { [ ! -e "$lock_file" ] && [ -d "$lock_parent" ] && [ -w "$lock_parent" ]; }; then
    exec 9>>"$lock_file"
    if ! flock -n 9; then
      echo "[cpu-limit] waiting for machine-wide build lock ($lock_file)" >&2
      flock 9
    fi
    export BUILD_LOCK_HELD=1
  else
    echo "[cpu-limit] build lock unavailable ($lock_file not writable) — running unserialized" >&2
  fi
fi

# nproc is affinity-aware; nproc --all is the physical count. When they differ,
# an outer guard (agent sandbox, container, taskset) already restricted this
# shell — that restriction IS the budget, and subtracting the reserve again
# would double-count (16-core box, 2-CPU affinity, reserve 8 → 1 core).
total=$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)
all=$(nproc --all 2>/dev/null || echo "$total")
reserve=${BUILD_CPU_RESERVE:-8}
if [ "$total" -lt "$all" ]; then
  limit=$total
else
  limit=$((total - reserve))
fi
if [ "$limit" -lt 1 ]; then limit=1; fi
last=$((limit - 1))

# Node heap cap — prevents one rollup/tsc worker from ballooning into swap.
heap=${BUILD_NODE_HEAP:-4096}
export NODE_OPTIONS="${NODE_OPTIONS:-} --max-old-space-size=${heap}"
# libuv worker pool — keeps incidental thread fan-out sane.
export UV_THREADPOOL_SIZE="${UV_THREADPOOL_SIZE:-4}"
# esbuild is Go: taskset pins it to N cores but it still spawns a goroutine per
# *logical* CPU (it reads the host count, not the cpuset). GOMAXPROCS is the only
# knob that actually bounds its parallelism — without it esbuild floods all cores.
export GOMAXPROCS="${BUILD_GOMAXPROCS:-$limit}"
# Vitest pool size — keep test workers in lockstep with the core budget so the
# runner doesn't spawn one worker per host CPU inside the taskset window. Set
# BOTH pools: vitest's default pool is 'forks', not 'threads', so capping only
# threads is inert for the default runner.
export VITEST_MAX_FORKS="${BUILD_VITEST_MAX_FORKS:-$limit}"
export VITEST_MAX_THREADS="${BUILD_VITEST_MAX_THREADS:-$limit}"

argv=("$@")
if [ "${argv[0]##*/}" = "turbo" ] && [ "${argv[1]:-}" = "run" ]; then
  has_concurrency=0
  insert_at=${#argv[@]}
  for i in "${!argv[@]}"; do
    arg=${argv[$i]}
    if [ "$arg" = "--" ]; then
      insert_at=$i
      break
    fi
    case "$arg" in
      --concurrency|--concurrency=*) has_concurrency=1 ;;
    esac
  done
  if [ "$has_concurrency" -eq 0 ]; then
    argv=("${argv[@]:0:$insert_at}" "--concurrency=$limit" "${argv[@]:$insert_at}")
  fi
fi

# Build the wrapper chain: systemd-run (mem cgroup) > nice > ionice > taskset.
cmd=()

if command -v systemd-run >/dev/null 2>&1 && systemctl --user show-environment >/dev/null 2>&1; then
  mem_high=${BUILD_MEM_HIGH:-12G}
  mem_max=${BUILD_MEM_MAX:-16G}
  cmd+=(systemd-run --user --scope --quiet
        --slice=build.slice
        -p CPUQuota="${limit}00%"
        -p MemoryHigh="$mem_high"
        -p MemoryMax="$mem_max"
        -p CPUWeight=50
        -p IOWeight=50)
fi

if command -v nice >/dev/null 2>&1; then
  cmd+=(nice -n 19)
fi
if command -v ionice >/dev/null 2>&1; then
  cmd+=(ionice -c 3)
fi
# SCHED_IDLE: the kernel gives these processes CPU only when nothing else wants
# it — interactive work always preempts, regardless of how many cores the gate
# is allowed. Stronger than nice 19 (which still competes for a share).
if [ "${BUILD_SCHED_IDLE:-1}" != "0" ] && command -v chrt >/dev/null 2>&1 && chrt --idle 0 true 2>/dev/null; then
  cmd+=(chrt --idle 0)
fi
if [ "$limit" -lt "$total" ] && command -v taskset >/dev/null 2>&1; then
  cmd+=(taskset -c 0-"$last")
fi

# turbo (and other build tools) live in pnpm's local .bin, not in the system PATH.
# systemd-run + taskset drop the shell's PATH, so we must pre-resolve or inject it.
PNPM_BIN="$(pnpm bin 2>/dev/null || true)"
if [[ -n "$PNPM_BIN" && -d "$PNPM_BIN" ]]; then
  export PATH="$PNPM_BIN:$PATH"
fi

echo "[cpu-limit] total=$total reserve=$reserve limit=$limit concurrency=$limit heap=${heap}MiB wrappers='${cmd[*]}'" >&2
exec "${cmd[@]}" "${argv[@]}"
