1#!/usr/bin/env bash 2# See ./README.md for what this does and why. 3set -euo pipefail 4 5REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" 6cd "$REPO_ROOT" 7 8# research/subsecond-patch-build.md §5: re-entering `nix develop` on every 9# buck2/curl call this script makes is most of the cycle time when it's not 10# already inside one. Skip it when it is (nix sets IN_NIX_SHELL) and say so 11# once; run this script from a `nix develop` shell to get the fast path. 12if [[ -n "${IN_NIX_SHELL:-}" ]]; then 13 echo "patch.sh: already inside the devshell, running buck2 directly" >&2 14 run() { "$@"; } 15else 16 run() { nix develop -c "$@"; } 17fi 18 19TARGET="${1:?usage: patch.sh <buck2 target, e.g. //apps/hotdemo:hotdemo or //apps/native:native>}" 20CRATE="${TARGET##*:}" 21 22PATCH_DIR="run/patches" 23BUILD_LOG="run/patch-build.log" 24mkdir -p "$PATCH_DIR" 25: >"$BUILD_LOG" 26 27# --- buck2 helpers ----------------------------------------------------- 28# 29# Where a target's `-Csave-temps=true` objects land is read from buck2's OWN 30# rustc-invocation record (`<crate>-link-diag.args`'s `--out-dir=`), not 31# guessed from buck2's internal per-action directory codes ("XIPL" for a 32# rust_binary, "LPPL" for a rust_library, seen 2026-09-20) - those are not 33# documented API and differ by target kind. See tools/hotpatch/CLAUDE.md. 34 35build_output() { # $1 = buck2 label -> the main output path 36 run buck2 build "$1" --show-full-output 2>>"$BUILD_LOG" | awk '{print $2}' 37} 38 39extras_objects() { # $1 = main output path -> one .rcgu.o path per line, or fails 40 local main_out="$1" link_args extras 41 link_args="$(find "$(dirname "$main_out")" -iname '*-link-diag.args' -print -quit)" 42 [[ -n "$link_args" ]] || return 1 43 extras="$(grep -o -- '--out-dir=[^ ]*' "$link_args" | head -1 | cut -d= -f2-)" 44 shopt -s nullglob 45 local found=("$extras"/*.rcgu.o) 46 shopt -u nullglob 47 ((${#found[@]} > 0)) || return 1 48 printf '%s\n' "${found[@]}" 49} 50 51buck_rule() { # $1 = buck2 label -> that target's own BUCK stanza, for grepping 52 local dir="${1#//}" 53 dir="${dir%%:*}" 54 local name="${1##*:}" 55 sed -n "/name = \"${name}\"/,/^)/p" "$dir/BUCK" 56} 57 58# A target's own first-party deps under //apps/ or //packages/, PLUS any 59# same-package `:name` dep - broadened 2026-09-20 from //packages/-only, so 60# a rust_binary that splits its patchable logic into a sibling rust_library 61# in its OWN apps/<name>/ directory (apps/native:app, next to 62# apps/native:native, referred to from BUCK as plain ":app") is discovered 63# the same way a packages/ dependency already was. 64first_party_deps_of() { # $1 = buck2 label -> its own //apps/..., //packages/... or :name deps 65 local label="$1" dir rule 66 dir="${label#//}" 67 dir="${dir%%:*}" 68 rule="$(buck_rule "$label")" 69 grep -o '"//\(apps\|packages\)/[a-zA-Z0-9_/-]*:[a-zA-Z0-9_-]*"' <<<"$rule" | tr -d '"' 70 grep -o '":[a-zA-Z0-9_-]*"' <<<"$rule" | tr -d '":' | sed "s|^|//$dir:|" 71} 72 73has_save_temps() { # $1 = buck2 label -> true if its OWN rustc_flags carry -Csave-temps=true 74 buck_rule "$1" | grep -q -- '-Csave-temps=true' 75} 76 77# Every first-party dep of $1, TRANSITIVELY, each printed once - needed since 78# apps/native:native -> apps/native:app -> packages/panels is now two hops, 79# not one: a one-level walk found `app` but never recursed into panels, so a 80# panels edit built fresh objects that were never fed to hotpatch at all 81# (the patch silently fell back to the base binary's OLD panels code via the 82# undefined-symbol stub - wrong, and no error anywhere; found 2026-09-20 83# proving this end to end, exactly the kind of silent failure 84# research/subsecond-patch-build.md §8 warns about). 85all_first_party_deps_of() { # $1 = buck2 label -> every first-party dep, transitively, deduped 86 local seen=" $1 " queue=("$1") i=0 current dep 87 while ((i < ${#queue[@]})); do 88 current="${queue[$i]}" 89 i=$((i + 1)) 90 while IFS= read -r dep; do 91 [[ -n "$dep" ]] || continue 92 [[ "$seen" == *" $dep "* ]] && continue 93 seen+="$dep " 94 queue+=("$dep") 95 echo "$dep" 96 done < <(first_party_deps_of "$current") 97 done 98} 99 100stale_extras_of() { # $1 = buck2 label -> its own extras, found ON DISK, with NO buck2 build 101 local label="$1" dir name cfg main_dir 102 dir="${label#//}" 103 dir="${dir%%:*}" 104 name="${label##*:}" 105 for cfg in buck-out/v2/art/root/*/; do 106 main_dir="${cfg}${dir}/__${name}__" 107 [[ -d "$main_dir" ]] || continue 108 extras_objects "$main_dir/." && return 0 109 done 110 return 1 111} 112 113delegates_to_hot_patch_dep() { # $1 = buck2 label -> true if any first-party dep of it (transitively) has_save_temps 114 local dep 115 while IFS= read -r dep; do 116 [[ -n "$dep" ]] || continue 117 has_save_temps "$dep" && return 0 118 done < <(all_first_party_deps_of "$1") 119 return 1 120} 121 122# --- where pid/aslr_reference/the base image come from ----------------- 123# 124# apps/native has an MCP server inside the window (packages/mcp); everything 125# else (apps/hotdemo) only has a log line to scrape and a file to drop, per 126# research/subsecond-patch-build.md §4/§6.3. Resolved FIRST and from the 127# running process alone, with no buck2 build: --base is /proc/$PID/exe, the 128# exact bytes the process is executing, never a freshly-linked copy from 129# disk. See CLAUDE.md - this is both cheaper (no relink of the executable, 130# ever, in this script) and more correct (a fresh relink's symbol addresses 131# are not guaranteed to still match what the OS actually has mapped for a 132# process that has not been restarted). 133 134MCP_URL="http://127.0.0.1:7637/mcp" 135MCP_HEADERS="$PATCH_DIR/mcp-headers.txt" 136MCP_SESSION="" 137MCP_ID=0 138 139mcp_raw() { # $1 = JSON-RPC request body -> the raw (SSE) HTTP response body 140 local extra=() 141 [[ -n "$MCP_SESSION" ]] && extra+=(-H "Mcp-Session-Id: $MCP_SESSION") 142 curl -sS -D "$MCP_HEADERS" -X POST "$MCP_URL" \ 143 -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \ 144 "${extra[@]}" -d "$1" 145} 146 147mcp_connect() { 148 mcp_raw '{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"patch.sh","version":"0"}}}' >/dev/null 149 MCP_SESSION="$(grep -i '^mcp-session-id:' "$MCP_HEADERS" | tr -d '\r' | awk '{print $2}')" 150 [[ -n "$MCP_SESSION" ]] || { echo "patch.sh: no Mcp-Session-Id back from $MCP_URL - is the window running?" >&2; exit 1; } 151 mcp_raw '{"jsonrpc":"2.0","method":"notifications/initialized"}' >/dev/null 152} 153 154mcp_tool() { # $1 = tool name, $2 = arguments JSON -> the tool's text content, or exits loudly 155 MCP_ID=$((MCP_ID + 1)) 156 local body result 157 body="$(mcp_raw "$(jq -nc --arg name "$1" --argjson args "$2" --argjson id "$MCP_ID" \ 158 '{jsonrpc:"2.0",id:$id,method:"tools/call",params:{name:$name,arguments:$args}}')")" 159 result="$(grep -m1 '^data: {' <<<"$body" | sed 's/^data: //' | jq '.result')" 160 if [[ "$(jq -r '.isError // false' <<<"$result")" == "true" ]]; then 161 echo "patch.sh: $1 failed: $(jq -r '.content[0].text' <<<"$result")" >&2 162 exit 1 163 fi 164 jq -r '.content[0].text' <<<"$result" 165} 166 167mcp_close() { 168 curl -sS -o /dev/null -X DELETE "$MCP_URL" -H "Mcp-Session-Id: $MCP_SESSION" || true 169} 170 171if [[ "$TARGET" == "//apps/native:native" ]]; then 172 echo "patch.sh: asking the window for pid/aslr_reference over MCP" >&2 173 mcp_connect 174 info="$(mcp_tool patch_info '{}')" 175 PID="$(jq -r '.pid' <<<"$info")" 176 ASLR="$(jq -r '.aslr_reference' <<<"$info")" 177else 178 LOG="run/${CRATE}.log" 179 [[ -f "$LOG" ]] || { echo "patch.sh: $LOG does not exist - is $CRATE running and logging there?" >&2; exit 1; } 180 PID="$(grep -o 'pid=[0-9]*' "$LOG" | tail -1 | cut -d= -f2)" 181 ASLR="$(grep -o 'aslr_reference=0x[0-9a-f]*' "$LOG" | tail -1 | cut -d= -f2)" 182fi 183if [[ -z "${PID:-}" || -z "${ASLR:-}" ]]; then 184 echo "patch.sh: could not get $CRATE's pid/aslr_reference" >&2 185 exit 1 186fi 187BASE_OUT="/proc/$PID/exe" 188[[ -r "$BASE_OUT" ]] || { echo "patch.sh: cannot read $BASE_OUT - is pid $PID still running as this user?" >&2; exit 1; } 189 190# --- the tip's own objects, and folding in any hot-patch-flagged first- 191# party dependency ------------------------------------------------------- 192# 193# Three shapes, told apart mechanically rather than by target name: 194# 195# 1. A tip with save-temps and no first-party dependency that ALSO has 196# save-temps (apps/hotdemo) IS the whole patchable crate: rebuild it and 197# take its own fresh objects. 198# 2. A tip with save-temps that DELEGATES to such a dependency 199# (apps/native:native -> apps/native:app, since 2026-09-20) still needs 200# an object of its OWN, because subsecond's `apply_patch` looks up the 201# "main" sentinel symbol by name inside whatever gets linked into the 202# patch, and `main` only exists in the executable's own crate - but 203# REBUILDING it would force exactly the relink this split exists to 204# avoid, whenever the dependency it delegates to has changed. So its 205# object is read from disk, from whatever buck2 last actually built it 206# (initial launch, or a `restart`), with no buck2 build here at all. 207# 3. A tip with no save-temps of its own contributes nothing directly; 208# every object comes from its dependencies. 209 210OBJECTS=() 211if has_save_temps "$TARGET" && ! delegates_to_hot_patch_dep "$TARGET"; then 212 echo "patch.sh: building $TARGET" >&2 213 TIP_OUT="$(build_output "$TARGET")" 214 [[ -n "$TIP_OUT" ]] || { echo "patch.sh: rebuild of $TARGET produced no output path - see $BUILD_LOG" >&2; exit 1; } 215 mapfile -t OBJECTS < <(extras_objects "$TIP_OUT") \ 216 || { echo "patch.sh: $TARGET claims -Csave-temps=true but produced no .rcgu.o files - see $BUILD_LOG" >&2; exit 1; } 217elif has_save_temps "$TARGET"; then 218 echo "patch.sh: $TARGET delegates its patch points to a dependency - reusing its own last-built object as the 'main' sentinel, without rebuilding it" >&2 219 mapfile -t OBJECTS < <(stale_extras_of "$TARGET") \ 220 || { echo "patch.sh: no on-disk objects for $TARGET yet - build or run it at least once before patching" >&2; exit 1; } 221else 222 echo "patch.sh: $TARGET has no patch points of its own - patching its first-party dependencies only, without rebuilding it" >&2 223fi 224 225while IFS= read -r dep; do 226 [[ -n "$dep" ]] || continue 227 # Checked BEFORE building, not after: a dep that isn't hot-patch-flagged 228 # (packages/mcp) is skipped without ever invoking buck2 on it. Building 229 # it anyway to find that out costs real time - mcp depends on panels, so 230 # a panels edit invalidates mcp's own (proc-macro-heavy) compile too, 231 # for a dependency whose result was always going to be thrown away 232 # (measured 2026-09-20: ~10s wasted on packages/mcp per panels edit, 233 # before this check moved earlier). 234 if ! has_save_temps "$dep"; then 235 echo "patch.sh: $dep is not hot-patch-flagged - skipping it, unbuilt" >&2 236 continue 237 fi 238 dep_out="$(build_output "${dep}[static_pic]")" 239 if [[ -z "$dep_out" ]]; then 240 echo "patch.sh: $dep did not build - skipping it (see $BUILD_LOG)" >&2 241 continue 242 fi 243 if dep_objects="$(extras_objects "$dep_out")"; then 244 n="$(wc -l <<<"$dep_objects")" 245 echo "patch.sh: folding in $n object(s) from $dep" >&2 246 while IFS= read -r o; do OBJECTS+=("$o"); done <<<"$dep_objects" 247 else 248 echo "patch.sh: $dep claims -Csave-temps=true but produced no .rcgu.o - see $BUILD_LOG" >&2 249 fi 250done < <(all_first_party_deps_of "$TARGET") 251 252((${#OBJECTS[@]} > 0)) || { echo "patch.sh: no hot-patch-flagged objects found for $TARGET or its dependencies" >&2; exit 1; } 253 254echo "patch.sh: building tools/hotpatch" >&2 255HOTPATCH_OUT="$(build_output //tools/hotpatch:hotpatch)" 256 257echo "patch.sh: patching pid=$PID aslr_reference=$ASLR with ${#OBJECTS[@]} object(s)" >&2 258 259run "$HOTPATCH_OUT" \ 260 --base "$BASE_OUT" \ 261 --objects "${OBJECTS[@]}" \ 262 --aslr-reference "$ASLR" \ 263 --out-dir "$PATCH_DIR" 264 265LATEST_JSON="$(ls -t "$PATCH_DIR"/patch-*.json | head -1)" 266 267if [[ "$TARGET" == "//apps/native:native" ]]; then 268 JSON_PATH="$(realpath "$LATEST_JSON")" 269 mcp_tool dev_mode '{"on":true}' >/dev/null 270 echo "patch.sh: $(mcp_tool hot_patch "$(jq -nc --arg p "$JSON_PATH" '{jump_table:$p}')")" >&2 271 mcp_tool dev_mode '{"on":false}' >/dev/null 272 mcp_close 273else 274 cp "$LATEST_JSON" "run/${CRATE}.patch.json" 275 echo "patch.sh: dropped $(basename "$LATEST_JSON") at run/${CRATE}.patch.json" >&2 276fi