Add SIGSEGV crash reporter and automated stress test infrastructure
ober
67ea670f69547d4ce74f585f89e75eac4d5702d4
--- a/Makefile +++ b/Makefile @@ -15,7 +15,8 @@ export CHEZ_QT_SHIM_DIR := . test-org-lint test-org-num test-org-property test-org-src test-org-tempo \ test-vtscreen test-debug-repl test-qt test-qt-e2e build-qt binary binary-qt \ test-pty test-emacs test-functional test-term-hang \ - docker-deps static-qt clean-docker check-root build-jemacs-qt-static + docker-deps static-qt clean-docker check-root build-jemacs-qt-static \ + stress-run stress-run-static stress-test stress-burn stress-burn-static all: build test @@ -412,6 +413,88 @@ linux-static-qt-docker: exec su-exec $(UID):$(GID) env HOME=/tmp/jemacs-build sh -c '\ cd /src && make build-jemacs-qt-static'" +# ============================================================================= +# Stress testing targets +# ============================================================================= + +STRESS_PORT ?= 9999 + +# Launch jemacs-qt (interpreted) headless with REPL for manual stress testing +stress-run: build repl_shim.so libqt_shim.so vterm_shim.so qt_chez_shim.so + xvfb-run -a env LD_PRELOAD=./qt_chez_shim.so \ + $(SCHEME) $(LIBDIRS) --script qt-main.ss --repl $(STRESS_PORT) + +# Launch jemacs-qt (static binary) under gdb with REPL for crash diagnosis +stress-run-static: + xvfb-run -a gdb -batch \ + -ex 'handle SIGALRM nostop noprint' \ + -ex 'handle SIG34 nostop noprint' \ + -ex run \ + -ex 'bt full' \ + -ex 'thread apply all bt full' \ + -ex 'info registers' \ + --args ./jemacs-qt --repl $(STRESS_PORT) + +# Run the stress test driver against an already-running jemacs-qt REPL +stress-test: + $(SCHEME) $(LIBDIRS) --script tests/stress-test.ss --port $(STRESS_PORT) + +# All-in-one: launch interpreted jemacs-qt + run stress test driver +stress-burn: build repl_shim.so libqt_shim.so vterm_shim.so qt_chez_shim.so + @echo "=== Starting jemacs-qt stress burn-in ===" + @rm -f $(HOME)/.jerboa-repl-port stress-test.log + @xvfb-run -a env LD_PRELOAD=./qt_chez_shim.so \ + $(SCHEME) $(LIBDIRS) --script qt-main.ss --repl 0 & + @for i in $$(seq 1 30); do \ + [ -f $(HOME)/.jerboa-repl-port ] && break; \ + sleep 0.5; \ + done + @if [ ! -f $(HOME)/.jerboa-repl-port ]; then \ + echo "ERROR: jemacs-qt failed to start (no REPL port file after 15s)"; exit 1; \ + fi + @PORT=$$(grep -oP '\d+' $(HOME)/.jerboa-repl-port); \ + echo "jemacs-qt running on REPL port $$PORT"; \ + $(SCHEME) $(LIBDIRS) --script tests/stress-test.ss --port $$PORT; \ + echo ""; \ + echo "=== Stress test ended ==="; \ + if [ -f $(HOME)/.jemacs-crash.log ]; then \ + echo "=== CRASH LOG ==="; \ + cat $(HOME)/.jemacs-crash.log; \ + fi; \ + echo "=== STRESS LOG (last 50 lines) ==="; \ + tail -50 stress-test.log 2>/dev/null + +# All-in-one: launch static jemacs-qt under gdb + run stress test driver +stress-burn-static: + @echo "=== Starting jemacs-qt (static) stress burn-in under gdb ===" + @rm -f $(HOME)/.jerboa-repl-port stress-test.log + @xvfb-run -a gdb -batch \ + -ex 'handle SIGALRM nostop noprint' \ + -ex 'handle SIG34 nostop noprint' \ + -ex run \ + -ex 'bt full' \ + -ex 'thread apply all bt full' \ + -ex 'info registers' \ + --args ./jemacs-qt --repl 0 & + @for i in $$(seq 1 30); do \ + [ -f $(HOME)/.jerboa-repl-port ] && break; \ + sleep 0.5; \ + done + @if [ ! -f $(HOME)/.jerboa-repl-port ]; then \ + echo "ERROR: jemacs-qt failed to start (no REPL port file after 15s)"; exit 1; \ + fi + @PORT=$$(grep -oP '\d+' $(HOME)/.jerboa-repl-port); \ + echo "jemacs-qt (static) running under gdb on REPL port $$PORT"; \ + $(SCHEME) $(LIBDIRS) --script tests/stress-test.ss --port $$PORT; \ + echo ""; \ + echo "=== Stress test ended ==="; \ + if [ -f $(HOME)/.jemacs-crash.log ]; then \ + echo "=== CRASH LOG ==="; \ + cat $(HOME)/.jemacs-crash.log; \ + fi; \ + echo "=== STRESS LOG (last 50 lines) ==="; \ + tail -50 stress-test.log 2>/dev/null + clean: find lib -name '*.so' -delete 2>/dev/null; true new file mode 100644 --- /dev/null +++ b/docs/stress-test.md @@ -0,0 +1,281 @@ +# Stress Test & Crash Reporting Infrastructure + +## Problem + +jemacs-qt segfaults in production use. The static binary provides no diagnostic +information — it just dies. We need two things: + +1. **Crash reporting** — when a segfault happens, capture useful diagnostics + before dying (which FFI call, backtrace, editor state). +2. **Automated stress testing** — a burn-in harness that exercises the editor + heavily and continuously until it crashes, without human interaction. + +## Architecture Overview + +``` +┌─────────────────────┐ TCP (REPL) ┌──────────────────────────┐ +│ stress-test.ss │ ────────────────── │ jemacs-qt --repl 9999 │ +│ (controller) │ eval commands │ under gdb │ +│ │ ◄──────────────── │ │ +│ Logs every command │ results/errors │ SIGSEGV handler: │ +│ to stress.log │ │ - backtrace │ +└─────────────────────┘ │ - FFI call ring buffer │ + │ - crash log file │ + └──────────────────────────┘ +``` + +## Part 1: SIGSEGV Crash Reporter (C++ shim) + +### Design + +Added to `vendor/qt_shim.cpp`, the crash reporter has three components: + +#### 1. FFI Call Ring Buffer + +A lock-free ring buffer (64 entries) records the name of every FFI function as it +enters and exits. When a segfault occurs, the last ~64 FFI calls are available +for inspection. + +```c +#define CRASH_RING_SIZE 64 +static struct { + const char* func_name; + int entering; // 1 = enter, 0 = exit + uint64_t timestamp; // CLOCK_MONOTONIC nanoseconds +} s_crash_ring[CRASH_RING_SIZE]; +static atomic_int s_crash_ring_idx = 0; +``` + +Every FFI function entry/exit goes through: +```c +static inline void crash_ring_push(const char* fn, int entering) { + int idx = atomic_fetch_add(&s_crash_ring_idx, 1) % CRASH_RING_SIZE; + s_crash_ring[idx] = { fn, entering, now_ns() }; +} +``` + +#### 2. SIGSEGV Handler + +Installed at startup via `sigaction(SIGSEGV, ...)`. On crash: + +1. Writes crash report to `~/.jemacs-crash.log` (using only async-signal-safe + functions: `write()`, `open()`, no `fprintf`/`malloc`) +2. Captures native backtrace via `backtrace()` (glibc) — not strictly + async-signal-safe but works in practice and we're dying anyway +3. Dumps the FFI call ring buffer showing what was happening +4. Re-raises SIGSEGV with default handler so gdb/core dump still works + +#### 3. Integration with QT_VOID / QT_RETURN macros + +The existing `QT_VOID` / `QT_RETURN` macros that marshal calls to the Qt thread +are augmented with `crash_ring_push()` calls, so every cross-thread FFI dispatch +is logged. Zero overhead when no crash occurs (just an atomic increment per call). + +### What the crash log looks like + +``` +=== JEMACS CRASH REPORT === +Signal: SIGSEGV (addr: 0x0000000000000018) +Time: 2026-03-31T15:42:07 + +FFI Call Ring (most recent last): + [ 0] ENTER qt_scintilla_set_text +0.000ms + [ 1] EXIT qt_scintilla_set_text +0.152ms + [ 2] ENTER qt_scintilla_get_length +0.003ms + [ 3] EXIT qt_scintilla_get_length +0.089ms + [ 4] ENTER qt_splitter_add_widget +0.001ms ← LAST ENTRY (still inside) + +Native backtrace: + #0 0x7f3a2b1c4e00 in QSplitter::insertWidget() + #1 0x5555556789ab in qt_splitter_add_widget() + #2 0x555555612345 in ... + ... + +Written to: /home/user/.jemacs-crash.log +``` + +### Why not full recovery? + +We considered using `siglongjmp` to recover from segfaults and convert them to +Scheme exceptions. This is **unsafe** for mutating Qt calls because: + +- C++ destructors won't run (RAII violations) +- Qt's internal widget tree may be in an inconsistent state +- Subsequent Qt calls would cascade into more crashes + +For **read-only** FFI calls (getters), recovery would be safe but the +complexity isn't worth it yet. The crash log gives us what we need to fix the +root cause. + +## Part 2: Stress Test Driver + +### Design + +`tests/stress-test.ss` is a Scheme script that connects to jemacs-qt's debug +REPL over TCP and drives random editor operations in a continuous loop. + +#### Phases (run in rotation) + +**Phase 1: Window Chaos** +- `split-window` / `split-window-right` / `split-window-below` +- `delete-window` / `delete-other-windows` +- `other-window` (cycle through splits) +- `balance-windows` +- `toggle-fullscreen` +- Random sequences of 5-15 operations + +**Phase 2: Vterm Storm** +- Open 3-5 vterm buffers +- Send shell commands: `find /usr -ls`, `top -b -n 5`, `ls -laR /tmp`, + `yes | head -10000` +- Let them run concurrently while doing other operations +- Kill vterm buffers randomly + +**Phase 3: File Churn** +- Create temp files with random content +- Open them with `find-file` (via eval, bypassing echo-area prompts) +- Insert large text blocks, navigate around +- Copy/paste between buffers (kill-region + yank in another) +- Save, revert, kill buffers + +**Phase 4: Navigation Stress** +- Rapid cursor movement: forward-char, next-line, scroll-up/down +- goto-line to random positions +- beginning-of-buffer / end-of-buffer +- mark-whole-buffer, kill-region, undo + +**Phase 5: EWW (Web Browser)** +- Open `eww` with simple URLs (localhost, file:// paths) +- Navigate, switch between eww and editor buffers + +**Phase 6: Combined Chaos** +- All phases interleaved randomly +- Random delays between 0ms and 500ms +- Window splits + vterms + file edits simultaneously + +#### Controller Architecture + +```scheme +;; Connect to REPL +(def conn (tcp-connect "127.0.0.1" repl-port)) + +;; Send command and log it +(def (stress-cmd! cmd) + (log! cmd) + (send-eval conn (format "(execute-command! *app* '~a)" cmd)) + (read-response conn)) + +;; Send raw eval (for commands that need arguments) +(def (stress-eval! expr) + (log! expr) + (send-eval conn expr) + (read-response conn)) + +;; Main loop +(def (stress-loop!) + (let loop ((cycle 0)) + (displayln "=== Cycle " cycle " ===") + (phase-window-chaos!) + (phase-vterm-storm!) + (phase-file-churn!) + (phase-navigation-stress!) + (phase-eww!) + (phase-combined-chaos!) + (loop (+ cycle 1)))) +``` + +#### Command Execution Strategy + +Commands that normally prompt for user input (like `find-file`) can't be used +directly through `execute-command!` since there's no one to answer the +minibuffer prompt. Instead, we eval the underlying operations directly: + +```scheme +;; Instead of (execute-command! *app* 'find-file) which prompts: +(stress-eval! "(let ((path \"/tmp/stress-test-1.txt\")) + (write-file-string path \"test content\") + (qt-open-file! *app* path))") + +;; Instead of vterm which might prompt: +(stress-eval! "(cmd-vterm *app*)") +``` + +#### Logging + +Every command sent is logged with timestamp to `stress-test.log`: + +``` +[2026-03-31T15:42:07.123] SEND: (execute-command! *app* 'split-window-right) +[2026-03-31T15:42:07.125] RECV: ok +[2026-03-31T15:42:07.200] SEND: (execute-command! *app* 'vterm) +[2026-03-31T15:42:07.450] RECV: ok +[2026-03-31T15:42:07.451] SEND: (execute-command! *app* 'other-window) +... +[2026-03-31T15:42:12.891] SEND: (execute-command! *app* 'delete-window) +[2026-03-31T15:42:12.892] CONNECTION LOST — jemacs-qt likely crashed +``` + +When the connection drops, the last N commands in the log show what triggered +the crash. Combined with the crash report in `~/.jemacs-crash.log`, this gives +a complete picture. + +## Part 3: Running the Stress Test + +### Quick start + +```bash +# Terminal 1: Run jemacs-qt under gdb +make stress-run + +# Terminal 2: Run the stress test driver +make stress-test +``` + +### Makefile targets + +```makefile +# Launch jemacs-qt under gdb with REPL enabled, virtual display +stress-run: + xvfb-run -a gdb -ex run \ + -ex 'handle SIGALRM nostop noprint' \ + -ex 'bt' -ex 'thread apply all bt' \ + --args ./jemacs-qt --repl 9999 + +# Run the stress test driver (connects to running jemacs-qt) +stress-test: + scheme --libdirs lib:... --script tests/stress-test.ss --port 9999 + +# All-in-one: launch jemacs-qt in background, run stress test +stress-burn: + # Launches both, runs until crash, prints diagnostics +``` + +### Under gdb + +When the segfault hits, gdb catches it and you get: +1. **gdb backtrace** — full native stack trace with debug symbols +2. **~/.jemacs-crash.log** — FFI call ring buffer showing which Scheme→C + boundary was active +3. **stress-test.log** — the exact sequence of editor commands that triggered it + +### Iterative debugging workflow + +1. Run `make stress-burn` +2. Wait for crash (minutes to hours) +3. Examine crash log + gdb backtrace + stress log +4. Fix the bug +5. `make static-qt` to rebuild +6. Repeat from step 1 + +Over time, the stress test becomes a regression suite — crashes that took +minutes to find initially should never reappear. + +## Part 4: Future Enhancements + +- **Crash log viewer** — `M-x jemacs-crash-report` to view last crash in-editor +- **Minimizer** — binary-search the stress log to find minimal repro sequence +- **Coverage tracking** — track which commands have been exercised, bias toward + under-tested ones +- **Deterministic replay** — seed the PRNG so crash sequences are reproducible +- **Memory sanitizer** — run under ASan/UBSan for the dynamic build to catch + issues before they become segfaults new file mode 100644 --- /dev/null +++ b/tests/stress-test.ss @@ -0,0 +1,589 @@ +;;; -*- Gerbil -*- +;;; Automated stress test driver for jemacs-qt. +;;; +;;; Connects to the jemacs-qt debug REPL (TCP text mode via nc subprocess) +;;; and drives random editor commands continuously until the editor crashes +;;; (connection drops) or is manually stopped with Ctrl-C. +;;; +;;; Usage: +;;; scheme --libdirs lib:$JERBOA_PATH/lib --script tests/stress-test.ss +;;; scheme --libdirs lib:$JERBOA_PATH/lib --script tests/stress-test.ss --port 9999 +;;; scheme --libdirs lib:$JERBOA_PATH/lib --script tests/stress-test.ss --cycles 100 +;;; +;;; The REPL port is auto-detected from ~/.jerboa-repl-port if --port is not given. + +(import (jerboa prelude)) + +;;;============================================================================ +;;; Configuration +;;;============================================================================ + +(def *repl-port* #f) ;; auto-detect from port file if not given +(def *log-file* "stress-test.log") +(def *max-cycles* #f) ;; #f = infinite +(def *commands-sent* 0) +(def *errors-caught* 0) + +;;;============================================================================ +;;; TCP connection via nc subprocess +;;;============================================================================ + +(def *nc-stdin* #f) +(def *nc-stdout* #f) +(def *nc-stderr* #f) +(def *nc-pid* #f) +(def *log-port* #f) + +(def (read-repl-port-file) + "Read the REPL port from ~/.jerboa-repl-port. Returns port number or #f." + (let ((path (str (getenv "HOME") "/.jerboa-repl-port"))) + (if (file-exists? path) + (with-catch + (lambda (e) #f) + (lambda () + (let ((content (read-file-string path))) + ;; Format: PORT=<number> + (let ((idx (string-contains content "="))) + (and idx + (string->number + (string-trim + (substring content (+ idx 1) + (string-length content))))))))) + #f))) + +(def (connect! port) + "Connect to the debug REPL via nc subprocess." + (let-values (((stdin stdout stderr pid) + (open-process-ports + (str "nc 127.0.0.1 " port) + 'block (native-transcoder)))) + (set! *nc-stdin* stdin) + (set! *nc-stdout* stdout) + (set! *nc-stderr* stderr) + (set! *nc-pid* pid) + ;; Read the initial prompt "jemacs> " — consume it + (consume-prompt!))) + +(def (disconnect!) + "Close the nc subprocess." + (with-catch (lambda (e) #f) + (lambda () + (when *nc-stdin* (close-port *nc-stdin*)) + (when *nc-stdout* (close-port *nc-stdout*)) + (when *nc-stderr* (close-port *nc-stderr*)))) + (set! *nc-stdin* #f) + (set! *nc-stdout* #f) + (set! *nc-stderr* #f) + (set! *nc-pid* #f)) + +(def (consume-prompt!) + "Read and discard bytes until we see the prompt or a newline. + The text-mode REPL sends 'jemacs> ' as a prompt." + ;; Read character by character until we get '> ' or timeout + (with-catch + (lambda (e) #f) + (lambda () + (let loop ((count 0)) + (when (and (< count 1000) (char-ready? *nc-stdout*)) + (read-char *nc-stdout*) + (loop (+ count 1))))))) + +;;;============================================================================ +;;; Logging +;;;============================================================================ + +(def (format-time) + "Return a simple HH:MM:SS timestamp." + (let ((t (current-time))) + (let* ((secs (time-second t)) + (h (modulo (quotient secs 3600) 24)) + (m (modulo (quotient secs 60) 60)) + (s (modulo secs 60))) + (format "~2,'0d:~2,'0d:~2,'0d" h m s)))) + +(def (log! msg) + (let ((line (str "[" (format-time) "] " msg))) + (displayln line) + (when *log-port* + (display line *log-port*) + (newline *log-port*) + (flush-output-port *log-port*)))) + +;;;============================================================================ +;;; REPL communication +;;;============================================================================ + +(def (send-eval! expr-str) + "Send an expression string to the text-mode REPL. + Returns the response string, or triggers exit on connection loss." + (set! *commands-sent* (+ *commands-sent* 1)) + (log! (str "SEND [" *commands-sent* "]: " expr-str)) + ;; Send the expression + (display expr-str *nc-stdin*) + (newline *nc-stdin*) + (flush-output-port *nc-stdin*) + ;; Read response lines until we see the next prompt or eof. + ;; The text REPL prints the result, then "jemacs> ". + ;; We read until we find char '>' followed by ' ' at start of a line, + ;; or we get eof. + (let ((resp (read-response!))) + (log! (str "RECV: " resp)) + resp)) + +(def (read-response!) + "Read lines from the REPL until we get a prompt or eof. + Returns the concatenated response text." + (let loop ((acc '()) + (timeout-chars 0)) + ;; Give the REPL up to 10 seconds to respond + (let wait-loop ((waited 0)) + (cond + ((char-ready? *nc-stdout*) + ;; Read a full line if available + (let ((line (get-line *nc-stdout*))) + (cond + ((eof-object? line) + (log! "CONNECTION LOST — jemacs-qt likely crashed!") + (log! (str "Stats: " *commands-sent* " commands sent, " + *errors-caught* " errors caught")) + (disconnect!) + (exit 1)) + ;; Check if this line is the prompt "jemacs> " + ((and (>= (string-length line) 7) + (string-prefix? "jemacs>" line)) + ;; This is the prompt — we're done + (string-join (reverse acc) "\n")) + (else + (loop (cons line acc) 0))))) + ((> waited 100) + ;; 10 seconds with no data — assume response is complete + ;; Try to consume any partial prompt + (consume-prompt!) + (string-join (reverse acc) "\n")) + (else + ;; Wait 100ms and try again + (thread-sleep! 0.1) + (wait-loop (+ waited 1))))))) + +(def (stress-cmd! cmd-name) + "Execute a named command via the dispatch chain." + (send-eval! (str "(execute-command! *app* '" cmd-name ")"))) + +(def (stress-eval! expr-str) + "Evaluate an arbitrary expression in the REPL." + (send-eval! expr-str)) + +;;;============================================================================ +;;; Random utilities +;;;============================================================================ + +(def (random-choice lst) + (list-ref lst (random (length lst)))) + +(def (maybe-delay!) + "50% chance of a short delay (50-200ms)." + (when (zero? (random 2)) + (thread-sleep! (/ (+ 50 (random 150)) 1000.0)))) + +(def (short-delay!) + "Fixed short delay (100-300ms)." + (thread-sleep! (/ (+ 100 (random 200)) 1000.0))) + +(def (with-phase-error-handler phase-name thunk) + "Run thunk, catching any Scheme-level exceptions and logging them." + (with-catch + (lambda (e) + (set! *errors-caught* (+ *errors-caught* 1)) + (log! (str "ERROR in " phase-name ": " + (with-output-to-string (lambda () (display-condition e)))))) + thunk)) + +;;;============================================================================ +;;; Phase 1: Window Chaos +;;;============================================================================ + +(def (phase-window-chaos!) + (log! "=== PHASE: Window Chaos ===") + (let ((ops '(split-window-right split-window-below + delete-window delete-other-windows other-window + balance-windows))) + (dotimes (_ (+ 5 (random 15))) + (with-phase-error-handler "window-chaos" + (lambda () + (stress-cmd! (random-choice ops)) + (maybe-delay!)))))) + +;;;============================================================================ +;;; Phase 2: Vterm Storm +;;;============================================================================ + +(def (phase-vterm-storm!) + (log! "=== PHASE: Vterm Storm ===") + ;; Open several vterms + (dotimes (_ (+ 1 (random 3))) + (with-phase-error-handler "vterm-open" + (lambda () + (stress-cmd! 'vterm) + (thread-sleep! 0.5)))) + ;; Switch between windows + (dotimes (_ (+ 2 (random 5))) + (with-phase-error-handler "vterm-switch" + (lambda () + (stress-cmd! 'other-window) + (maybe-delay!)))) + ;; Let things settle, then maybe close some + (thread-sleep! 1.0) + (dotimes (_ (random 3)) + (with-phase-error-handler "vterm-close" + (lambda () + (stress-cmd! 'kill-buffer-cmd) + (maybe-delay!))))) + +;;;============================================================================ +;;; Phase 3: File Churn +;;;============================================================================ + +(def (phase-file-churn!) + (log! "=== PHASE: File Churn ===") + (dotimes (i (+ 3 (random 8))) + (with-phase-error-handler "file-churn" + (lambda () + (let ((path (str "/tmp/jemacs-stress-" i ".txt")) + ;; Generate content of varying sizes + (content (make-string (+ 100 (random 5000)) #\x))) + ;; Create file on disk and open it in the editor + (stress-eval! + (str "(begin" + " (write-file-string \"" path "\" " + "\"" (make-string (min 200 (string-length content)) #\A) "\")" + " (qt-open-file! *app* \"" path "\"))")) + (short-delay!) + ;; Do some editing operations + (stress-cmd! 'beginning-of-buffer) + (stress-cmd! 'open-line) + (stress-cmd! 'end-of-buffer) + ;; Random navigation + (dotimes (_ (+ 5 (random 20))) + (stress-cmd! (random-choice + '(forward-char backward-char next-line previous-line)))) + ;; Mark and kill region + (stress-cmd! 'set-mark) + (dotimes (_ (+ 1 (random 30))) + (stress-cmd! 'forward-char)) + (stress-cmd! 'kill-region) + ;; Switch and yank + (stress-cmd! 'other-window) + (stress-cmd! 'yank) + (maybe-delay!) + ;; Maybe kill the buffer + (when (zero? (random 3)) + (stress-cmd! 'kill-buffer-cmd))))))) + +;;;============================================================================ +;;; Phase 4: Navigation Stress +;;;============================================================================ + +(def (phase-navigation-stress!) + (log! "=== PHASE: Navigation Stress ===") + (dotimes (_ (+ 20 (random 50))) + (with-phase-error-handler "navigation" + (lambda () + (stress-cmd! (random-choice + '(forward-char backward-char forward-word backward-word + next-line previous-line + beginning-of-line end-of-line + beginning-of-buffer end-of-buffer + scroll-up scroll-down + recenter))))))) + +;;;============================================================================ +;;; Phase 5: EWW (web browser) +;;;============================================================================ + +(def (phase-eww!) + (log! "=== PHASE: EWW ===") + (with-phase-error-handler "eww" + (lambda () + ;; Open eww + (stress-cmd! 'eww) + (thread-sleep! 1.0) + ;; Navigate around + (dotimes (_ (+ 3 (random 10))) + (stress-cmd! (random-choice + '(scroll-up scroll-down forward-char next-line + previous-line backward-char beginning-of-buffer))) + (maybe-delay!)) + ;; Switch away + (stress-cmd! 'other-window)))) + +;;;============================================================================ +;;; Phase 6: Edit Storm (insert/delete heavy) +;;;============================================================================ + +(def (phase-edit-storm!) + (log! "=== PHASE: Edit Storm ===") + ;; Open a scratch buffer with content + (with-phase-error-handler "edit-storm-setup" + (lambda () + (let ((path "/tmp/jemacs-stress-edit.txt")) + (stress-eval! + (str "(begin" + " (write-file-string \"" path + "\" (make-string 2000 #\\newline))" + " (qt-open-file! *app* \"" path "\"))")) + (short-delay!)))) + ;; Rapid editing operations + (dotimes (_ (+ 30 (random 50))) + (with-phase-error-handler "edit-storm" + (lambda () + (stress-cmd! (random-choice + '(kill-line yank undo redo open-line delete-char + backward-delete-char duplicate-line + forward-char backward-char next-line previous-line + beginning-of-line end-of-line + kill-word backward-kill-word + transpose-chars))))))) + +;;;============================================================================ +;;; Phase 7: Buffer Management +;;;============================================================================ + +(def (phase-buffer-management!) + (log! "=== PHASE: Buffer Management ===") + ;; Open many buffers + (dotimes (i (+ 3 (random 6))) + (with-phase-error-handler "buffer-open" + (lambda () + (let ((path (str "/tmp/jemacs-stress-buf-" i ".txt"))) + (stress-eval! + (str "(begin" + " (write-file-string \"" path "\" \"buffer " i "\")" + " (qt-open-file! *app* \"" path "\"))")) + (maybe-delay!))))) + ;; Switch between them rapidly + (dotimes (_ (+ 10 (random 20))) + (with-phase-error-handler "buffer-switch" + (lambda () + (stress-cmd! (random-choice + '(switch-buffer other-window list-buffers))) + (maybe-delay!)))) + ;; Kill some + (dotimes (_ (+ 1 (random 4))) + (with-phase-error-handler "buffer-kill" + (lambda () + (stress-cmd! 'kill-buffer-cmd) + (maybe-delay!))))) + +;;;============================================================================ +;;; Phase 8: Combined Chaos +;;;============================================================================ + +(def (phase-combined-chaos!) + (log! "=== PHASE: Combined Chaos ===") + (dotimes (_ (+ 20 (random 40))) + (with-phase-error-handler "combined-chaos" + (lambda () + (let ((action (random 10))) + (cond + ((< action 3) + ;; Window operations + (stress-cmd! (random-choice + '(split-window-right split-window-below delete-window + other-window delete-other-windows balance-windows)))) + ((< action 5) + ;; Navigation + (stress-cmd! (random-choice + '(forward-char backward-char next-line previous-line + scroll-up scroll-down beginning-of-buffer end-of-buffer + forward-word backward-word)))) + ((< action 7) + ;; Editing + (stress-cmd! (random-choice + '(kill-line yank undo redo open-line delete-char + backward-delete-char duplicate-line transpose-chars)))) + ((< action 9) + ;; Buffer operations + (stress-cmd! (random-choice + '(other-window list-buffers kill-buffer-cmd)))) + (else + ;; Meta operations + (stress-cmd! (random-choice + '(mark-whole-buffer recenter)))))) + (maybe-delay!))))) + +;;;============================================================================ +;;; Phase 9: Rapid Window Splits and Deletes +;;;============================================================================ + +(def (phase-window-thrash!) + (log! "=== PHASE: Window Thrash ===") + ;; Split many times + (dotimes (_ (+ 5 (random 10))) + (with-phase-error-handler "window-thrash-split" + (lambda () + (stress-cmd! (random-choice '(split-window-right split-window-below))) + (maybe-delay!)))) + ;; Switch around + (dotimes (_ (+ 5 (random 10))) + (with-phase-error-handler "window-thrash-nav" + (lambda () + (stress-cmd! 'other-window)))) + ;; Delete all but one + (with-phase-error-handler "window-thrash-cleanup" + (lambda () + (stress-cmd! 'delete-other-windows)))) + +;;;============================================================================ +;;; Phase 10: Search Operations +;;;============================================================================ + +(def (phase-search-ops!) + (log! "=== PHASE: Search Operations ===") + ;; Make sure we have a buffer with content + (with-phase-error-handler "search-setup" + (lambda () + (let ((path "/tmp/jemacs-stress-search.txt")) + (stress-eval! + (str "(begin" + " (write-file-string \"" path + "\" \"hello world\\nfoo bar baz\\nline three\\nfour five six\\n\")" + " (qt-open-file! *app* \"" path "\"))")) + (short-delay!)))) + ;; Navigate with word motions (search-like behavior) + (dotimes (_ (+ 10 (random 20))) + (with-phase-error-handler "search-nav" + (lambda () + (stress-cmd! (random-choice + '(forward-word backward-word + beginning-of-line end-of-line + next-line previous-line + beginning-of-buffer end-of-buffer))))))) + +;;;============================================================================ +;;; Cleanup +;;;============================================================================ + +(def (cleanup-temp-files!) + (log! "Cleaning up temp files...") + (with-catch (lambda (e) #f) + (lambda () + (for-each + (lambda (prefix) + (dotimes (i 20) + (let ((f (str "/tmp/jemacs-stress-" prefix i ".txt"))) + (when (file-exists? f) + (delete-file f))))) + '("" "buf-")) + (for-each + (lambda (f) + (when (file-exists? f) + (delete-file f))) + '("/tmp/jemacs-stress-edit.txt" + "/tmp/jemacs-stress-search.txt"))))) + +;;;============================================================================ +;;; Main entry point +;;;============================================================================ + +(def (parse-args args) + "Parse command-line arguments. Returns (port . max-cycles) pair." + (let ((port #f) + (cycles #f)) + (let loop ((rest args)) + (cond + ((null? rest) (values port cycles)) + ((and (string=? (car rest) "--port") + (pair? (cdr rest))) + (set! port (string->number (cadr rest))) + (loop (cddr rest))) + ((and (string=? (car rest) "--cycles") + (pair? (cdr rest))) + (set! cycles (string->number (cadr rest))) + (loop (cddr rest))) + ((string=? (car rest) "--help") + (displayln "Usage: stress-test.ss [--port PORT] [--cycles N]") + (displayln " --port PORT REPL port (auto-detected from ~/.jerboa-repl-port)") + (displayln " --cycles N Max cycles to run (default: infinite)") + (exit 0)) + (else (loop (cdr rest))))))) + +(def (main . args) + (let-values (((port cycles) (parse-args args))) + ;; Determine port + (let ((port (or port (read-repl-port-file)))) + (unless port + (displayln "ERROR: Could not determine REPL port.") + (displayln "Either pass --port or ensure jemacs-qt is running with --repl.") + (exit 1)) + + (set! *repl-port* port) + (set! *max-cycles* cycles) + + (displayln "=== JEMACS-QT STRESS TEST ===") + (displayln "REPL port: " *repl-port*) + (when *max-cycles* + (displayln "Max cycles: " *max-cycles*)) + (displayln "Log file: " *log-file*) + (displayln "") + + ;; Open log file + (set! *log-port* (open-output-file *log-file* 'replace)) + + ;; Connect + (log! (str "Connecting to REPL on port " *repl-port* "...")) + (connect! *repl-port*) + (log! "Connected!") + + ;; Verify connection + (let ((resp (send-eval! "(+ 1 1)"))) + (log! (str "Connection verified: (+ 1 1) => " resp))) + + ;; Run stress loop + (let loop ((cycle 0)) + (when (or (not *max-cycles*) (< cycle *max-cycles*)) + (log! (str "========== CYCLE " cycle + " (cmds=" *commands-sent* + " errs=" *errors-caught* ") ==========")) + + ;; Randomize phase order each cycle for variety + (let ((phases (list + (cons "window-chaos" phase-window-chaos!) + (cons "vterm-storm" phase-vterm-storm!) + (cons "file-churn" phase-file-churn!) + (cons "navigation" phase-navigation-stress!) + (cons "eww" phase-eww!) + (cons "edit-storm" phase-edit-storm!) + (cons "buffer-mgmt" phase-buffer-management!) + (cons "combined-chaos" phase-combined-chaos!) + (cons "window-thrash" phase-window-thrash!) + (cons "search-ops" phase-search-ops!)))) + ;; Run a random subset of phases each cycle (5-10 phases) + (let ((count (+ 5 (random 6)))) + (dotimes (_ count) + (let ((phase (random-choice phases))) + (with-phase-error-handler (car phase) + (cdr phase)))))) + + ;; Cleanup periodically + (when (zero? (modulo (+ cycle 1) 5)) + (cleanup-temp-files!)) + + (loop (+ cycle 1)))) + + ;; Final stats + (log! "=== STRESS TEST COMPLETE ===") + (log! (str "Total commands: " *commands-sent*)) + (log! (str "Total errors caught: " *errors-caught*)) + (displayln "") + (displayln "Stress test finished.") + (displayln "Total commands: " *commands-sent*) + (displayln "Total errors caught: " *errors-caught*) + + ;; Cleanup + (cleanup-temp-files!) + (disconnect!) + (when *log-port* + (close-port *log-port*))))) + +;; Run +(apply main (cdr (command-line))) --- a/vendor/qt_shim.cpp +++ b/vendor/qt_shim.cpp @@ -188,6 +188,230 @@ static void vlog_bqc_exit(const char* fn) { vlog_write("BQC-EXIT ", fn); } +// ============================================================ +// Crash reporter — FFI call ring buffer + SIGSEGV handler +// ============================================================ + +#include <fcntl.h> +#include <unistd.h> + +// execinfo.h (backtrace) is not available on musl (Alpine static builds). +// Guard with __GLIBC__ so the crash reporter still works without backtraces. +#ifdef __GLIBC__ +#include <execinfo.h> +#define HAVE_BACKTRACE 1 +#else +#define HAVE_BACKTRACE 0 +#endif + +#define CRASH_RING_SIZE 64 + +static struct crash_ring_entry { + const char* func_name;