jevsnes.git / tools / hotpatch / patch.sh
patch.shannotatedpatch.shsource276 lines · 12.2 KB · raw
1#!/usr/bin/env bash

See ./README.md for what this does and why.

3set -euo pipefail
5REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
6cd "$REPO_ROOT"

research/subsecond-patch-build.md §5: re-entering nix develop on every buck2/curl call this script makes is most of the cycle time when it's not already inside one. Skip it when it is (nix sets IN_NIX_SHELL) and say so 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
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"

--- buck2 helpers -----------------------------------------------------

Where a target's -Csave-temps=true objects land is read from buck2's OWN rustc-invocation record (<crate>-link-diag.args's --out-dir=), not guessed from buck2's internal per-action directory codes ("XIPL" for a rust_binary, "LPPL" for a rust_library, seen 2026-09-20) - those are not documented API and differ by target kind. See tools/hotpatch/CLAUDE.md.

35build_output() {  # $1 = buck2 label -> the main output path
36    run buck2 build "$1" --show-full-output 2>>"$BUILD_LOG" | awk '{print $2}'
37}
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}

A target's own first-party deps under //apps/ or //packages/, PLUS any same-package :name dep - broadened 2026-09-20 from //packages/-only, so a rust_binary that splits its patchable logic into a sibling rust_library in its OWN apps/<name>/ directory (apps/native:app, next to apps/native:native, referred to from BUCK as plain ":app") is discovered 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}
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}

Every first-party dep of $1, TRANSITIVELY, each printed once - needed since apps/native:native -> apps/native:app -> packages/panels is now two hops, not one: a one-level walk found app but never recursed into panels, so a panels edit built fresh objects that were never fed to hotpatch at all (the patch silently fell back to the base binary's OLD panels code via the undefined-symbol stub - wrong, and no error anywhere; found 2026-09-20 proving this end to end, exactly the kind of silent failure 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}
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}

--- where pid/aslr_reference/the base image come from -----------------

apps/native has an MCP server inside the window (packages/mcp); everything else (apps/hotdemo) only has a log line to scrape and a file to drop, per research/subsecond-patch-build.md §4/§6.3. Resolved FIRST and from the running process alone, with no buck2 build: --base is /proc/$PID/exe, the exact bytes the process is executing, never a freshly-linked copy from disk. See CLAUDE.md - this is both cheaper (no relink of the executable, ever, in this script) and more correct (a fresh relink's symbol addresses are not guaranteed to still match what the OS actually has mapped for a process that has not been restarted).

134MCP_URL="http://127.0.0.1:7637/mcp"
135MCP_HEADERS="$PATCH_DIR/mcp-headers.txt"
136MCP_SESSION=""
137MCP_ID=0
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; }

--- the tip's own objects, and folding in any hot-patch-flagged first- party dependency -------------------------------------------------------

Three shapes, told apart mechanically rather than by target name:

  1. A tip with save-temps and no first-party dependency that ALSO has save-temps (apps/hotdemo) IS the whole patchable crate: rebuild it and take its own fresh objects.
  2. A tip with save-temps that DELEGATES to such a dependency (apps/native:native -> apps/native:app, since 2026-09-20) still needs an object of its OWN, because subsecond's apply_patch looks up the "main" sentinel symbol by name inside whatever gets linked into the patch, and main only exists in the executable's own crate - but REBUILDING it would force exactly the relink this split exists to avoid, whenever the dependency it delegates to has changed. So its object is read from disk, from whatever buck2 last actually built it (initial launch, or a restart), with no buck2 build here at all.
  3. A tip with no save-temps of its own contributes nothing directly; every object comes from its dependencies.
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
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