jevsnes.git / tools / hotpatch / patch.sh
patch.shannotatedpatch.shsource276 lines · 12.2 KB · raw
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