test(daemon): add fake_dragonxd, a slow dragonxd stand-in for stop/kill testing

A scriptable fake dragonxd that reproduces, in minutes, the incident where
the GUI hard-killed a node busy with an hour-long witness rebuild:

- dragonxd's HTTP-RPC queue (8 workers + 16 queued, -rpcthreads /
  -rpcworkqueue honoured): a full queue answers an immediate plain-text
  500 "Work queue depth exceeded", and a request whose client gave up
  keeps its slot until a worker reaches it
- a global cs_main held by a "witness rebuild" that logs the real
  markers; every method except stop/help takes it
- warmup -28 with the daemon's allow-list, an optional startup replay
- dragonxd's shutdown order: stop (or SIGTERM on POSIX) unbinds RPC,
  503s open connections, waits for the rebuild, then logs
  "Shutdown: In progress..." / "Shutdown: done" and exits 0

Everything is appended to <datadir>/fake_dragonxd.events; a missing
"clean-exit" line means the process was killed. One portable C++17 file
(POSIX + Winsock), OFF by default (-DDRAGONX_BUILD_FAKE_DAEMON=ON), built
into <build>/fake_dragonxd/ so it can't shadow the real bin/dragonxd.
run-gui-with-fake.sh runs the GUI against it in a throwaway HOME.

Against master's GUI on Linux it reproduces the bug: closing during a
rebuild queues stop behind cs_main-blocked workers, then the GUI sends
SIGTERM and SIGKILL mid-rebuild, with no clean exit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 00:24:42 -05:00
co-authored by Claude Opus 5.5
parent 46189da0da
commit 7b6e8a8a8e
4 changed files with 1549 additions and 0 deletions
+22
View File
@@ -45,6 +45,7 @@ option(DRAGONX_ENABLE_EMBEDDED_DAEMON "Enable embedded dragonxd support" ON)
option(DRAGONX_BUILD_LITE "Build ObsidianDragonLite variant without full-node features" OFF)
option(DRAGONX_ENABLE_LITE_BACKEND "Enable real lite wallet backend integration" OFF)
option(DRAGONX_ENABLE_CHAT "Enable the HushChat protocol/UI integration" ON)
option(DRAGONX_BUILD_FAKE_DAEMON "Build the fake slow dragonxd test stub (tools/fake_dragonxd); never shipped" OFF)
set(DRAGONX_LITE_BACKEND_LIBRARY "" CACHE FILEPATH "Path to a prebuilt SDXL-compatible lite backend library")
set(DRAGONX_LITE_BACKEND_INCLUDE_DIR "" CACHE PATH "Optional include directory for SDXL-compatible lite backend headers")
set(DRAGONX_LITE_BACKEND_EXTRA_LIBS "" CACHE STRING "Additional libraries needed by the SDXL-compatible lite backend")
@@ -1105,6 +1106,27 @@ else()
message(STATUS "wallet-rebuild helper: OFF (no Berkeley DB found; set DRAGONX_BDB_ROOT for release builds)")
endif()
# -----------------------------------------------------------------------------
# fake_dragonxd — a deliberately slow, scriptable dragonxd stand-in that reproduces the daemon-stop
# incident (usage and the incident it models: tools/fake_dragonxd/README.md).
# Test tooling only: OFF by default, never packaged, and written to <build>/fake_dragonxd/ so its
# "dragonxd" name can't shadow the real bin/dragonxd.
# -----------------------------------------------------------------------------
if(DRAGONX_BUILD_FAKE_DAEMON)
add_executable(fake_dragonxd tools/fake_dragonxd/fake_dragonxd.cpp)
target_link_libraries(fake_dragonxd PRIVATE nlohmann_json::nlohmann_json)
if(WIN32)
target_link_libraries(fake_dragonxd PRIVATE ws2_32)
else()
find_package(Threads REQUIRED)
target_link_libraries(fake_dragonxd PRIVATE Threads::Threads)
endif()
set_target_properties(fake_dragonxd PROPERTIES
OUTPUT_NAME dragonxd
RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/fake_dragonxd)
message(STATUS "fake_dragonxd test stub: ON (${CMAKE_BINARY_DIR}/fake_dragonxd/)")
endif()
# -----------------------------------------------------------------------------
# Tests
# -----------------------------------------------------------------------------
+135
View File
@@ -0,0 +1,135 @@
# fake_dragonxd
A deliberately slow, scriptable stand-in for `dragonxd`, used to reproduce and regression-test how
ObsidianDragon stops (or kills) a busy node. It is test tooling: off by default, never packaged.
The incident it models (2026-09):
1. A 13.6k-note wallet spent ~1 h in a witness rebuild that held `cs_main`.
2. The GUI's own abandoned polls filled the node's 24 RPC slots, so `stop` was never delivered.
3. On close, the GUI hard-killed the node mid-rebuild, so every restart replayed ~62k blocks and redid the rebuild.
What it copies from the real daemon (DragonX v1.3.0):
- **HTTP-RPC queue.** 8 workers plus a 16-deep queue (or `-rpcthreads` / `-rpcworkqueue` from the
command line or `DRAGONX.conf`). When the queue is full the request gets an immediate plain-text
`HTTP 500 "Work queue depth exceeded"`. A request whose client timed out **keeps its slot** until
a worker reaches it.
- **cs_main.** One global lock. Every method except `stop` and `help` takes it. A "witness rebuild"
holds it for `FAKE_REBUILD_SECS` and logs the real markers:
- `Reading blocks for witness rebuild: <off> / <total>` (Phase 1, abortable);
- nothing during Phase 2, which ignores shutdown;
- `BuildWitnessCache: rebuilt N note witness cache(s) to height H in Tms using 8 thread(s)` at the end.
- **Warmup.** `-28` errors carrying the current init message, using the daemon's warmup allow-list.
Optionally an `Activating best chain...` replay, which logs `UpdateTip` lines and stops between steps on shutdown.
- **Shutdown.** Triggered by `stop`, or by SIGTERM/SIGINT on POSIX (Windows has no signal channel,
as in v1.3.0). In order, the stub:
1. unbinds the listener at once;
2. answers new requests on open connections with `503`;
3. waits for the rebuild to finish;
4. logs `Shutdown: In progress...` and renames its main thread to `hush-shutoff` (Linux `comm`);
5. answers never-run queued requests with `500 Unhandled request`;
6. flushes for `FAKE_FLUSH_SECS`, logs `Shutdown: done`, and exits 0.
- **Logging.** With `-printtoconsole`, untimestamped lines go to stdout (this is what the GUI uses on
Linux and macOS). Otherwise, timestamped UTC lines go to `<datadir>/debug.log` (the Windows path the GUI tails).
- **Datadir.** It takes `.lock`, writes `hushd.pid` (POSIX) and `.cookie` (when there is no
`rpcpassword`), and uses the same default datadir as dragonxd (`~/.hush/DRAGONX`, `%APPDATA%\Hush\DRAGONX`).
Its replies are canned: `getinfo` reports version `1030050`, the balance is fixed, and there are two
peers on TEST-NET addresses. Unknown methods get the real `-32601 Method not found` (HTTP 404).
## Events file
Everything that matters for a test is appended to `<datadir>/fake_dragonxd.events`, one
`<name> t=<unix ms> [k=v ...]` per line:
| Event | Meaning |
|---|---|
| `start`, `listening`, `warmup-end` | startup; `start` lists every knob |
| `rebuild-start`, `rebuild-end`, `rebuild-aborted` | the cs_main hold |
| `queue-full` | the first `Work queue depth exceeded` |
| `max-abandoned n=` | a new high for client-abandoned requests still holding a slot |
| `stop-received n=` | a `stop` RPC ran (every one is logged, so resends are visible) |
| `stop-rejected status=` | a `stop` never reached a worker (500 queue-full, or 503 after interrupt) |
| `sigterm` | SIGTERM/SIGINT received (POSIX) |
| `http-interrupted`, `shutdown-begin` | shutdown progress |
| `stats ...` | counters, every 30 s when they change |
| `clean-exit ...` | **graceful exit**, with final counters. If this is missing, the process was killed. |
## Build
```bash
cmake -S . -B build/linux -DDRAGONX_BUILD_FAKE_DAEMON=ON
cmake --build build/linux --target fake_dragonxd # -> build/linux/fake_dragonxd/dragonxd
# Windows (mingw cross, from Linux/WSL), with the toolchain ./build.sh --win-release writes; own tree
cmake -S . -B build/windows-tests -DCMAKE_TOOLCHAIN_FILE=build/windows/mingw-toolchain.cmake \
-DDRAGONX_USE_SYSTEM_SDL3=OFF -DDRAGONX_BUILD_FAKE_DAEMON=ON
cmake --build build/windows-tests --target fake_dragonxd # -> build/windows-tests/fake_dragonxd/dragonxd.exe
```
The output goes to its own `fake_dragonxd/` directory so it never replaces the real `bin/dragonxd`.
## Run the GUI against it (Linux)
```bash
FAKE_REBUILD_SECS=300 tools/fake_dragonxd/run-gui-with-fake.sh [--scratch DIR]
```
The script builds a throwaway `HOME` with:
- copies of the GUI and the stub (as `dragonxd`, next to the GUI, where `findDaemonBinary()` looks first);
- a `DRAGONX.conf` and an empty `blocks/`, so the first-run wizard is skipped;
- `settings.json` with `verbose_logging`.
It then starts the GUI and prints the events file when the GUI exits. It refuses to run if
something already listens on 21769, because the GUI would adopt that process as an external node.
To "close the window" from a script, send SIGTERM to the GUI (`kill -TERM <gui pid>`); SDL turns it
into a normal quit.
To run the stub alone: `FAKE_... build/linux/fake_dragonxd/dragonxd -datadir=/some/dir [-printtoconsole]`
with a `DRAGONX.conf` (`rpcuser`/`rpcpassword`/`rpcport`) in that dir.
## Windows
Use a **separate Windows user or a VM**:
- The datadir is `%APPDATA%\Hush\DRAGONX` and can't be redirected for the GUI.
- Until S3 lands, the GUI `TerminateProcess`es **any** `dragonxd.exe` of the same user, including a real node.
Put `dragonxd.exe` (the stub) next to `ObsidianDragon.exe`. Set the `FAKE_*` variables in that
user's environment, or start the stub by hand from a console for case E. Each new GUI-launched stub
inherits the GUI's environment.
## Knobs (environment)
| Variable | Default | Meaning |
|---|---|---|
| `FAKE_WARMUP_SECS` | 3 | `-28 Loading block index...` phase |
| `FAKE_REPLAY_SECS` / `FAKE_REPLAY_BPS` | 0 / 12 | startup replay (`Activating best chain...`, UpdateTip lines) |
| `FAKE_REBUILD_DELAY_SECS` | 20 | wait after `Done Loading` before the rebuild, so the GUI connects first |
| `FAKE_REBUILD_SECS` | 300 | how long the rebuild holds cs_main; `0` = healthy node, no rebuild |
| `FAKE_PHASE1_SECS` | 2 | the logged, abortable part of the rebuild |
| `FAKE_POST_REBUILD_LOCK_SECS` | 0 | cs_main held after the end line (models the 18–36 s flush + wallet `SetBestChain`) |
| `FAKE_FLUSH_SECS` | 3 | `Shutdown: In progress...` → `Shutdown: done` |
| `FAKE_UPDATETIP_SECS` | 36 | block interval once loaded |
| `FAKE_NOISE_SECS` | 20 | an unrelated log line this often (defeats "newest line" heuristics) |
| `FAKE_SERVER_TIMEOUT_SECS` | 30 | idle keep-alive connections are closed (`-rpcservertimeout`) |
| `FAKE_PREFILL` | 0 | inject N abandoned requests when the rebuild starts (`24` = saturated) |
| `FAKE_MINING` | 0 | internal miner on: `stop` waits for cs_main first (`GenerateBitcoins` join) |
| `FAKE_STUCK_SHUTDOWN` | 0 | the Sep 29 orphan: hold the datadir lock, no listener, ignore SIGTERM, never exit |
| `FAKE_HEIGHT`, `FAKE_REBUILD_TOTAL`, `FAKE_NOTES`, `FAKE_VERSION` | 3327145, 62583, 13632, 1030050 | cosmetic numbers |
## Test cases
"Today" is the behaviour of v2.0.1 / `master` @ `46189da`. "Fixed" is the target behaviour of the
daemon-stop hardening work.
| Case | Setup | Today (bug) | Fixed |
|---|---|---|---|
| A | defaults; leave the GUI open ~5 min into the rebuild | ~24 abandoned, then queue-full 500s; overlay "Launching dragonxd…" | busy overlay; `max-abandoned` ≤ 7 |
| B | close the GUI during the rebuild | Linux: `sigterm` at +20 s, killed at +30 s, no `clean-exit`. Windows: killed at +20 s, no `stop-received` | modal; `stop-received`/`sigterm` within 2 s; waits; `clean-exit` |
| C | close so the rebuild ends ~8 s later | killed | `stop` retried, `clean-exit` |
| D | `FAKE_PREFILL=24`, close | `stop` never delivered | POSIX via SIGTERM; Windows `stop` retried until it lands |
| E | Windows: start the stub by hand with `FAKE_STUCK_SHUTDOWN=1`, launch the GUI, close it | "Found orphaned dragonxd.exe … forcing termination" | no kill |
| F | Detach on the shutdown screen | n/a | GUI exits at once; stub runs on to `clean-exit` |
| G | Overview page during the 500 phase; count `[connect #N]` per minute in the GUI log | ~1,800–3,200/min | ≤ ~2–4/min |
File diff suppressed because it is too large Load Diff
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# Run ObsidianDragon against tools/fake_dragonxd in a throwaway, fully isolated HOME (Linux).
#
# tools/fake_dragonxd/run-gui-with-fake.sh [--build-dir DIR] [--scratch DIR] [-- GUI args...]
#
# FAKE_* variables in the environment pass through to the stub (see README.md). It never touches
# the real ~/.hush or ~/.config: HOME, XDG_CONFIG_HOME and TMPDIR all point into the scratch dir.
# Re-using a --scratch dir keeps its settings, conf and GUI log; the stub's events file is appended.
set -euo pipefail
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
build_dir="$repo/build/linux"
scratch=""
port=21769 # the GUI's external-node / port checks are hard-wired to the default RPC port
usage() { sed -n '2,8p' "$0" | sed 's/^# \{0,1\}//'; }
while [[ $# -gt 0 ]]; do
case "$1" in
--build-dir) build_dir="$(cd "$2" && pwd)"; shift 2 ;;
--scratch) scratch="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
--) shift; break ;;
*) echo "unknown option: $1" >&2; usage >&2; exit 2 ;;
esac
done
gui="$build_dir/bin/ObsidianDragon"
stub="$build_dir/fake_dragonxd/dragonxd"
if [[ ! -x "$gui" ]]; then
echo "missing $gui; build the app first (./build.sh)" >&2
exit 1
fi
if [[ ! -x "$stub" ]]; then
echo "missing $stub; build it with:" >&2
echo " cmake -S . -B ${build_dir#"$repo"/} -DDRAGONX_BUILD_FAKE_DAEMON=ON && cmake --build ${build_dir#"$repo"/} --target fake_dragonxd" >&2
exit 1
fi
if ss -ltn 2>/dev/null | grep -qE "[:.]$port( |$)"; then
echo "port $port is already in use (a real dragonxd?). The GUI would adopt it as an external node; stop it first." >&2
exit 1
fi
scratch="${scratch:-$(mktemp -d "${TMPDIR:-/tmp}/fake-dragonxd.XXXXXX")}"
mkdir -p "$scratch"
scratch="$(cd "$scratch" && pwd)"
app="$scratch/app"
home="$scratch/home"
datadir="$home/.hush/DRAGONX"
config_dir="$home/.config/ObsidianDragon"
mkdir -p "$app" "$datadir/blocks" "$config_dir" "$scratch/tmp"
# Copies, not symlinks: the GUI looks for dragonxd next to /proc/self/exe, which resolves links.
cp -f "$gui" "$app/ObsidianDragon"
cp -f "$stub" "$app/dragonxd"
ln -sfn "$build_dir/bin/res" "$app/res"
for f in sapling-spend.params sapling-output.params asmap.dat; do
if [[ -e "$build_dir/bin/$f" ]]; then ln -sf "$build_dir/bin/$f" "$app/$f"; fi
done
# The GUI also keeps a dragonxd copy in its daemon dir; keep that one the stub too.
if [[ -e "$config_dir/dragonx/dragonxd" ]]; then cp -f "$stub" "$config_dir/dragonx/dragonxd"; fi
# A pre-made conf + blocks/ dir skips the first-run wizard (App::isFirstRun).
if [[ ! -e "$datadir/DRAGONX.conf" ]]; then
printf 'rpcuser=fake\nrpcpassword=fake-%s\nrpcport=%s\n' "$RANDOM$RANDOM" "$port" > "$datadir/DRAGONX.conf"
fi
if [[ ! -e "$config_dir/settings.json" ]]; then
printf '{\n "wizard_completed": true,\n "verbose_logging": true\n}\n' > "$config_dir/settings.json"
fi
cat <<EOF
scratch : $scratch
stub events : $datadir/fake_dragonxd.events
GUI log : $config_dir/dragonx-debug.log
FAKE_* : $(env | grep -E '^FAKE_' | tr '\n' ' ')
EOF
rc=0
HOME="$home" XDG_CONFIG_HOME="$home/.config" TMPDIR="$scratch/tmp" "$app/ObsidianDragon" "$@" || rc=$?
echo "--- GUI exited ($rc). Stub events:"
cat "$datadir/fake_dragonxd.events" 2>/dev/null || echo "(none)"
if pgrep -f "^$app/dragonxd" >/dev/null; then
echo "--- the stub is still running (pid $(pgrep -f "^$app/dragonxd" | tr '\n' ' ')); it exits after its rebuild + flush."
fi
exit "$rc"