updates
ober
ff13076965c26db3a8d43e67be986236c542020a
--- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ signal-cli listAccounts make install # installs ~/.local/bin/jerboa-signal jerboa-signal send +15551234567 "hello world" jerboa-signal listen | jq . # pretty-print incoming envelopes +jerboa-signal --trace /tmp/jerboa-signal.trace tui ``` Without `make install`, run via the Makefile: @@ -93,6 +94,38 @@ phone's contacts. If a removed person messages you again the thread reappears; to remove someone from your Signal account everywhere, delete them on your phone too. Use Ctrl-Q or Ctrl-C to quit. +## Debug tracing + +Runtime tracing is opt-in and writes a chmod 600 file: + +```sh +jerboa-signal --trace /tmp/jerboa-signal.trace tui +``` + +The default trace records safe internal telemetry such as send phases, timings, +byte counts, log backend selection, history replay counts, and encrypted log +persistence stages. It does not include message bodies or raw Signal RPC frames. + +For maximum debugging detail, including raw RPC frames and signal-cli verbose +stderr, use: + +```sh +jerboa-signal --trace-all /tmp/jerboa-signal.raw.trace tui +``` + +That file is sensitive: it can include message content and Signal identifiers. +When debugging send hangs caused by rewriting a large encrypted log, this mode +is also useful: + +```sh +jerboa-signal --trace-all /tmp/jerboa-signal.raw.trace --log-persist close tui +``` + +`--log-persist close` defers jsqlite encrypted-container writes until clean +exit. The default is `immediate`, which persists after every logged message. If +the process crashes in close-only mode, new log rows from that session may be +lost. + ## Encrypted message log The TUI can log every message to an encrypted SQLite database so you keep a copy --- a/signal/capture.ss +++ b/signal/capture.ss @@ -19,7 +19,8 @@ make-date make-time) (except (jerboa prelude) meta atom?) (std text json) - (signal logdb)) + (signal logdb) + (signal trace)) ;; Log one notification. Returns a short summary string (for daemon output) ;; or #f when nothing is written. Never raises -- logging must not break @@ -27,41 +28,77 @@ (def (capture-notification! logdb account notif) (and logdb (hashtable? notif) - (guard (e [#t #f]) - (let ([raw (event->json notif)] - [env (notif-envelope notif)]) - (if (hashtable? env) - (let ([row (envelope->row env)]) - (logdb-put logdb (safe account) - (list-ref row 0) ;; direction - (safe (list-ref row 1)) ;; conversation - (safe (list-ref row 2)) ;; sender - (as-int (list-ref row 3)) ;; timestamp - (list-ref row 4) ;; kind - (safe (list-ref row 5)) ;; body - raw) - (string-append (list-ref row 0) " " - (safe (list-ref row 1)) " " - (list-ref row 4))) - (begin - (logdb-put logdb (safe account) "event" "" "" 0 - (or (notif-method notif) "event") "" raw) - "event")))))) + (let ([started (real-time)]) + (trace-public-event! + "capture-notification-start" + (list (cons 'method (or (notif-method notif) "event")))) + (guard (e [#t + (trace-event! "capture-notification-failed-detail" + (safe-display e)) + (trace-public-event! + "capture-notification-failed" + (list (cons 'ms (- (real-time) started)) + (cons 'error-chars + (string-length (safe-display e))))) + #f]) + (let* ([raw (event->json notif)] + [env (notif-envelope notif)] + [summary + (if (hashtable? env) + (let ([row (envelope->row env)]) + (logdb-put logdb (safe account) + (list-ref row 0) ;; direction + (safe (list-ref row 1)) ;; conversation + (safe (list-ref row 2)) ;; sender + (as-int (list-ref row 3)) ;; timestamp + (list-ref row 4) ;; kind + (safe (list-ref row 5)) ;; body + raw) + (string-append (list-ref row 0) " " + (safe (list-ref row 1)) " " + (list-ref row 4))) + (begin + (logdb-put logdb (safe account) "event" "" "" 0 + (or (notif-method notif) "event") "" raw) + "event"))]) + (trace-public-event! + "capture-notification-done" + (list (cons 'ms (- (real-time) started)) + (cons 'raw-chars (string-length raw)))) + summary))))) ;; Log a locally-sent message (TUI composer). Returns a summary or #f. (def (capture-outbound! logdb account conversation text timestamp) (and logdb - (guard (e [#t #f]) - (let ([raw (make-hashtable equal-hash equal?)]) - (hashtable-set! raw "type" "sent") - (hashtable-set! raw "conversation" (safe conversation)) - (hashtable-set! raw "message" (safe text)) - (when (number? timestamp) - (hashtable-set! raw "timestamp" timestamp)) - (logdb-put logdb (safe account) "out" (safe conversation) "You" - (as-int timestamp) "data" (safe text) - (event->json raw)) - "out sent")))) + (let ([started (real-time)]) + (trace-public-event! + "capture-outbound-start" + (list (cons 'text-chars (string-length (safe text))) + (cons 'has-timestamp (and (number? timestamp) #t)))) + (guard (e [#t + (trace-event! "capture-outbound-failed-detail" + (safe-display e)) + (trace-public-event! + "capture-outbound-failed" + (list (cons 'ms (- (real-time) started)) + (cons 'error-chars + (string-length (safe-display e))))) + #f]) + (let ([raw (make-hashtable equal-hash equal?)]) + (hashtable-set! raw "type" "sent") + (hashtable-set! raw "conversation" (safe conversation)) + (hashtable-set! raw "message" (safe text)) + (when (number? timestamp) + (hashtable-set! raw "timestamp" timestamp)) + (let ([raw-json (event->json raw)]) + (logdb-put logdb (safe account) "out" (safe conversation) "You" + (as-int timestamp) "data" (safe text) + raw-json) + (trace-public-event! + "capture-outbound-done" + (list (cons 'ms (- (real-time) started)) + (cons 'raw-chars (string-length raw-json)))) + "out sent")))))) ;; --- extraction: envelope -> (direction conversation sender ts kind body) --- --- a/signal/logdb.ss +++ b/signal/logdb.ss @@ -26,10 +26,11 @@ (std os path) (jsqlite api) (jsqlite value) - (signal log_crypto)) + (signal log_crypto) + (signal trace)) (defstruct logdb-handle (backend inner)) - (defstruct jlog (path key salt db)) + (defstruct jlog (path key salt db dirty?)) ;; -------------------------------------------------------------------------- ;; Legacy SQLCipher shim. This stays for old messages-*.db files that were @@ -263,7 +264,7 @@ (let* ([salt (log-random-bytes *jlog-salt-len*)] [key (derive-key passphrase salt)] [db (sqlite-open-bytevector (make-bytevector 0 0))] - [log (make-jlog path key salt db)]) + [log (make-jlog path key salt db #f)]) (ensure-schema! db) (persist-jlog! log) (make-logdb-handle 'jsqlite log))) @@ -272,7 +273,7 @@ (let-values ([(key salt plain) (decrypt-container bytes passphrase)]) (let ([db (sqlite-open-bytevector plain)]) (ensure-schema! db) - (make-logdb-handle 'jsqlite (make-jlog path key salt db))))) + (make-logdb-handle 'jsqlite (make-jlog path key salt db #f))))) (def (open-jlog path passphrase) (and (log-crypto-available?) @@ -287,19 +288,118 @@ [else #f])) (new-jlog path passphrase))))) + (def (safe-display x) + (cond + [(string? x) x] + [else + (let ([p (open-output-string)]) + (if (condition? x) + (display-condition x p) + (display x p)) + (get-output-string p))])) + + (def (elapsed-ms start) + (- (real-time) start)) + + (def (allocated-bytes*) + (guard (_ [#t -1]) + (bytes-allocated))) + + (def (alloc-delta before after) + (if (and (integer? before) (integer? after) + (>= before 0) (>= after 0)) + (- after before) + -1)) + + (def (maybe-string-length v) + (if (string? v) (string-length v) 0)) + + (def (log-persist-policy) + (let ([v (getenv "JERBOA_SIGNAL_LOG_PERSIST")]) + (cond + [(not (and (string? v) (not (string=? v "")))) 'immediate] + [(or (string-ci=? v "close") + (string-ci=? v "defer") + (string-ci=? v "deferred") + (string-ci=? v "exit")) 'close] + [else 'immediate]))) + + (def (persist-on-write?) + (eq? (log-persist-policy) 'immediate)) + (def (persist-jlog! log) - (let* ([plain (sqlite-db->bytevector (jlog-db log))] - [container (encrypt-container (jlog-key log) (jlog-salt log) plain)]) - (write-file-atomic! (jlog-path log) container))) + (let ([total-start (real-time)] + [path (jlog-path log)]) + (trace-public-event! + "logdb-persist-start" + (list (cons 'path path) + (cons 'dirty (jlog-dirty? log)))) + (guard (e [#t + (trace-event! "logdb-persist-failed-detail" + (safe-display e)) + (trace-public-event! + "logdb-persist-failed" + (list (cons 'path path) + (cons 'ms (elapsed-ms total-start)) + (cons 'error-chars + (string-length (safe-display e))))) + (raise e)]) + (let* ([plain-start (real-time)] + [plain-alloc-start (allocated-bytes*)] + [plain (sqlite-db->bytevector (jlog-db log))] + [plain-alloc-end (allocated-bytes*)] + [plain-ms (elapsed-ms plain-start)] + [enc-start (real-time)] + [enc-alloc-start (allocated-bytes*)] + [container (encrypt-container (jlog-key log) (jlog-salt log) plain)] + [enc-alloc-end (allocated-bytes*)] + [enc-ms (elapsed-ms enc-start)] + [write-start (real-time)]) + (trace-public-event! + "logdb-persist-serialize" + (list (cons 'path path) + (cons 'bytes (bytevector-length plain)) + (cons 'alloc-delta + (alloc-delta plain-alloc-start plain-alloc-end)) + (cons 'ms plain-ms))) + (trace-public-event! + "logdb-persist-encrypt" + (list (cons 'path path) + (cons 'bytes (bytevector-length container)) + (cons 'alloc-delta + (alloc-delta enc-alloc-start enc-alloc-end)) + (cons 'ms enc-ms))) + (write-file-atomic! path container) + (jlog-dirty?-set! log #f) + (trace-public-event! + "logdb-persist-write" + (list (cons 'path path) + (cons 'bytes (bytevector-length container)) + (cons 'ms (elapsed-ms write-start)))) + (trace-public-event! + "logdb-persist-done" + (list (cons 'path path) + (cons 'ms (elapsed-ms total-start)))))))) (def (empty->null s) (if (and (string? s) (string=? s "")) sql-null s)) (def (jlog-put log account direction conversation sender timestamp kind body raw) + (trace-public-event! + "logdb-jsqlite-put-start" + (list (cons 'direction direction) + (cons 'kind kind) + (cons 'body-chars (maybe-string-length body)) + (cons 'raw-chars (maybe-string-length raw)) + (cons 'persist (log-persist-policy)))) (jlog-put-row log (now-seconds) account direction conversation sender timestamp kind body raw) - (persist-jlog! log) + (if (persist-on-write?) + (persist-jlog! log) + (trace-public-event! + "logdb-jsqlite-put-deferred" + (list (cons 'path (jlog-path log))))) #t) (def (jlog-put-row log logged-at account direction conversation sender @@ -314,7 +414,8 @@ timestamp (empty->null kind) (empty->null body) - (or raw "")))) + (or raw "")) + (jlog-dirty?-set! log #t))) (def (jlog-count log) (let ([rows (sqlite-query (jlog-db log) "SELECT count(*) FROM messages")]) @@ -445,41 +546,90 @@ (rename-file tmp path))) (def (logdb-open path key) - (cond - [(or (backend-env-is? "sqlcipher") (backend-env-is? "legacy")) - (wrap-legacy (legacy-open path key))] - [(backend-env-is? "jsqlite") - (open-jlog path key)] - [else - (or (open-jlog path key) - (wrap-legacy (legacy-open path key)))])) + (let ([started (real-time)]) + (trace-public-event! + "logdb-open-start" + (list (cons 'path path) + (cons 'backend-env (or (getenv "JERBOA_SIGNAL_LOG_BACKEND") "auto")))) + (let ([handle + (cond + [(or (backend-env-is? "sqlcipher") (backend-env-is? "legacy")) + (wrap-legacy (legacy-open path key))] + [(backend-env-is? "jsqlite") + (open-jlog path key)] + [else + (or (open-jlog path key) + (wrap-legacy (legacy-open path key)))])]) + (trace-public-event! + "logdb-open-done" + (list (cons 'path path) + (cons 'backend (if (logdb-handle? handle) + (logdb-handle-backend handle) + 'none)) + (cons 'ms (elapsed-ms started)))) + handle))) (def (logdb-close handle) (when (logdb-handle? handle) + (trace-public-event! + "logdb-close-start" + (list (cons 'backend (logdb-handle-backend handle)))) (case (logdb-handle-backend handle) [(jsqlite) (guard (e [#t (void)]) (let ([log (logdb-handle-inner handle)]) - (persist-jlog! log) + (when (jlog-dirty? log) + (trace-public-event! + "logdb-close-persist" + (list (cons 'path (jlog-path log)))) + (persist-jlog! log)) (sqlite-close (jlog-db log))))] [(legacy) (legacy-close (logdb-handle-inner handle))] - [else (void)]))) + [else (void)]) + (trace-public-event! + "logdb-close-done" + (list (cons 'backend (logdb-handle-backend handle)))))) (def (logdb-put handle account direction conversation sender timestamp kind body raw) (and (logdb-handle? handle) - (guard (e [#t #f]) - (case (logdb-handle-backend handle) - [(jsqlite) - (jlog-put (logdb-handle-inner handle) - account direction conversation sender - timestamp kind body raw)] - [(legacy) - (legacy-put (logdb-handle-inner handle) - account direction conversation sender - timestamp kind body raw)] - [else #f])))) + (let ([started (real-time)] + [backend (logdb-handle-backend handle)]) + (trace-public-event! + "logdb-put-start" + (list (cons 'backend backend) + (cons 'direction direction) + (cons 'kind kind) + (cons 'body-chars (maybe-string-length body)) + (cons 'raw-chars (maybe-string-length raw)))) + (guard (e [#t + (trace-event! "logdb-put-failed-detail" + (safe-display e)) + (trace-public-event! + "logdb-put-failed" + (list (cons 'backend backend) + (cons 'ms (elapsed-ms started)) + (cons 'error-chars + (string-length (safe-display e))))) + #f]) + (let ([ok + (case backend + [(jsqlite) + (jlog-put (logdb-handle-inner handle) + account direction conversation sender + timestamp kind body raw)] + [(legacy) + (legacy-put (logdb-handle-inner handle) + account direction conversation sender + timestamp kind body raw)] + [else #f])]) + (trace-public-event! + "logdb-put-done" + (list (cons 'backend backend) + (cons 'ok ok) + (cons 'ms (elapsed-ms started)))) + ok))))) (def (logdb-count handle) (if (logdb-handle? handle) @@ -492,11 +642,33 @@ (def (logdb-recent handle limit) (if (logdb-handle? handle) - (guard (e [#t '()]) - (case (logdb-handle-backend handle) - [(jsqlite) (jlog-recent (logdb-handle-inner handle) limit)] - [(legacy) (legacy-recent (logdb-handle-inner handle) limit)] - [else '()])) + (let ([started (real-time)] + [backend (logdb-handle-backend handle)]) + (trace-public-event! + "logdb-recent-start" + (list (cons 'backend backend) + (cons 'limit limit))) + (guard (e [#t + (trace-event! "logdb-recent-failed-detail" + (safe-display e)) + (trace-public-event! + "logdb-recent-failed" + (list (cons 'backend backend) + (cons 'ms (elapsed-ms started)) + (cons 'error-chars + (string-length (safe-display e))))) + '()]) + (let ([rows + (case backend + [(jsqlite) (jlog-recent (logdb-handle-inner handle) limit)] + [(legacy) (legacy-recent (logdb-handle-inner handle) limit)] + [else '()])]) + (trace-public-event! + "logdb-recent-done" + (list (cons 'backend backend) + (cons 'rows (length rows)) + (cons 'ms (elapsed-ms started)))) + rows))) '())) ;; Read a passphrase before termbox takes over the terminal. This avoids --- a/signal/main.ss +++ b/signal/main.ss @@ -42,7 +42,13 @@ (def (usage) (display -"Usage: jerboa-signal SUBCOMMAND [OPTIONS] +"Usage: jerboa-signal [GLOBAL OPTIONS] SUBCOMMAND [OPTIONS] + +Global options: + --trace FILE Write safe internal trace to FILE + --trace-all FILE Trace raw RPC/details too; sensitive + --trace-sensitive Include raw RPC/details in trace + --log-persist immediate|close Persist encrypted log per write or exit Subcommands: send [-a +PHONE] RECIPIENT MESSAGE Send a Signal message @@ -61,6 +67,57 @@ linking once before using this tool: # then scan/paste the printed sgnl://linkdevice URI on your phone. ")) +(def (usage-error msg) + (display "jerboa-signal: " (current-error-port)) + (display msg (current-error-port)) + (newline (current-error-port)) + (usage) + (exit 2)) + +(def (need-option-arg opt rest) + (if (pair? rest) + (car rest) + (usage-error (string-append opt " requires a value")))) + +(def (normalize-log-persist value) + (cond + [(or (string-ci=? value "immediate") + (string-ci=? value "sync") + (string-ci=? value "write")) "immediate"] + [(or (string-ci=? value "close") + (string-ci=? value "defer") + (string-ci=? value "deferred") + (string-ci=? value "exit")) "close"] + [else #f])) + +(def (parse-global-flags args) + (cond + [(null? args) args] + [(string=? (car args) "--") (cdr args)] + [(string=? (car args) "--trace") + (let ([path (need-option-arg "--trace" (cdr args))]) + (putenv "JERBOA_SIGNAL_TRACE" path) + (parse-global-flags (cddr args)))] + [(string=? (car args) "--trace-all") + (let ([path (need-option-arg "--trace-all" (cdr args))]) + (putenv "JERBOA_SIGNAL_TRACE" path) + (putenv "JERBOA_SIGNAL_TRACE_SENSITIVE" "1") + (putenv "JERBOA_SIGNAL_CLI_VERBOSE" "1") + (parse-global-flags (cddr args)))] + [(string=? (car args) "--trace-sensitive") + (putenv "JERBOA_SIGNAL_TRACE_SENSITIVE" "1") + (parse-global-flags (cdr args))] + [(string=? (car args) "--log-persist") + (let* ([value (need-option-arg "--log-persist" (cdr args))] + [normalized (normalize-log-persist value)]) + (if normalized + (begin + (putenv "JERBOA_SIGNAL_LOG_PERSIST" normalized) + (parse-global-flags (cddr args))) + (usage-error + "--log-persist must be immediate or close")))] + [else args])) + (def (parse-account-flag args) ;; Returns (values account remaining-args). ;; Recognizes `-a VALUE` or `--account VALUE` anywhere in the first two slots. @@ -119,7 +176,4 @@ linking once before using this tool: (usage) (exit 2)]))])) -(main (let ([args (cdr (command-line))]) - (if (and (pair? args) (string=? (car args) "--")) - (cdr args) - args))) +(main (parse-global-flags (cdr (command-line)))) --- a/signal/trace.ss +++ b/signal/trace.ss @@ -5,6 +5,7 @@ (export trace-enabled? trace-event! + trace-public-event! signal-cli-command) (import (except (chezscheme) @@ -33,21 +34,33 @@ (def (trace-enabled?) (and (trace-port) #t)) - (def (trace-event! tag detail) + (def (write-trace-line! tag detail) (let ([port (trace-port)]) (when port (with-trace-lock (lambda () - (guard (_ [#t (void)]) - (display (number->string (real-time)) port) - (display " " port) + (guard (_ [#t (void)]) + (display (number->string (real-time)) port) + (display " " port) (display tag port) (display " " port) - (display (one-line (trace-detail tag detail)) port) + (display (runtime-stats-detail) port) + (display " " port) + (display (one-line detail) port) (newline port) (flush-output-port port)))))) (void)) + ;; Use this for potentially sensitive details, such as raw Signal RPC frames. + ;; The payload is redacted unless JERBOA_SIGNAL_TRACE_SENSITIVE=1 is set. + (def (trace-event! tag detail) + (write-trace-line! tag (trace-detail tag detail))) + + ;; Use this for safe internal telemetry: timings, byte counts, backend names, + ;; and state transitions that do not include message contents or identities. + (def (trace-public-event! tag detail) + (write-trace-line! tag (safe-display detail))) + (def (signal-cli-command args) (append (list (signal-cli-program)) (verbose-args) @@ -140,6 +153,16 @@ s (format "[redacted tag=~a chars=~a]" tag (string-length s))))) + (def (runtime-stats-detail) + (guard (_ [#t "runtime=(unavailable)"]) + (let ([p (open-output-string)]) + (display "runtime=" p) + (display + (list (cons 'alloc-bytes (bytes-allocated)) + (cons 'gc-count (collections))) + p) + (get-output-string p)))) + (def (one-line s) (let ([out (open-output-string)] [n (string-length s)]) --- a/signal/tui/main.ss +++ b/signal/tui/main.ss @@ -25,6 +25,7 @@ (signal store) (signal logdb) (signal capture) + (signal trace) (signal attach-save) (signal tui ffi) (signal tui theme)) @@ -142,20 +143,28 @@ (def (preload-history! state) (let ([logdb (tui-state-logdb state)]) (if logdb - (let loop ([rows (logdb-recent logdb *history-preload-limit*)] - [count 0]) - (cond - [(null? rows) - (when (> count 0) - (tui-state-status-set! - state - (string-append "Restored " - (number->string count) - " messages from the encrypted log."))) - count] - [else - (loop (cdr rows) - (+ count (if (apply-history-row! state (car rows)) 1 0)))])) + (let ([started (real-time)]) + (trace-public-event! + "tui-history-preload-start" + (list (cons 'limit *history-preload-limit*))) + (let loop ([rows (logdb-recent logdb *history-preload-limit*)] + [count 0]) + (cond + [(null? rows) + (trace-public-event! + "tui-history-preload-done" + (list (cons 'restored count) + (cons 'ms (- (real-time) started)))) + (when (> count 0) + (tui-state-status-set! + state + (string-append "Restored " + (number->string count) + " messages from the encrypted log."))) + count] + [else + (loop (cdr rows) + (+ count (if (apply-history-row! state (car rows)) 1 0)))]))) 0))) ;; Non-interactive diagnostic/test helper: rebuild the same initial state used @@ -1165,34 +1174,65 @@ [(conversation-rate-limit-active? state conv) (tui-state-status-set! state (conversation-rate-limit-status state conv))] [else - ;; Clear the composer BEFORE the call: once the request is written to - ;; signal-cli the message may be delivered even if the RPC reply is an - ;; error (partial/multi-device failures), so Enter must never re-fire - ;; the same text. On failure the text is stashed for Ctrl-R. - (tui-state-input-set! state "") - (tui-state-status-set! state "Sending...") - (let ([outcome - (guard (e [#t (cons 'failed e)]) - (let ([result (actor-call actor "send" - (make-send-params-for-conversation conv text))]) - (ensure-send-result-success! result) - (cons 'sent result)))]) - (if (eq? (car outcome) 'sent) - (let ([ts (send-result-timestamp (cdr outcome))]) - (append-message-to-conversation! - conv - (make-chat-message 'out "You" text ts 'data 'sent)) - (capture-outbound! (tui-state-logdb state) - (tui-state-account state) - (conversation-id conv) text ts) - (tui-state-resend-set! state #f) - (clear-conversation-rate-limit! state conv) - (conversation-unread-set! conv 0) - (tui-state-status-set! state "Sent.")) - (begin - (tui-state-resend-set! state text) - (report-send-failure! state conv (cdr outcome) - "Ctrl-R restores the message."))))]))) + (let ([started (real-time)]) + (trace-public-event! + "tui-send-start" + (list (cons 'text-chars (string-length text)) + (cons 'has-logdb (and (tui-state-logdb state) #t)))) + ;; Clear the composer BEFORE the call: once the request is written to + ;; signal-cli the message may be delivered even if the RPC reply is an + ;; error (partial/multi-device failures), so Enter must never re-fire + ;; the same text. On failure the text is stashed for Ctrl-R. + (tui-state-input-set! state "") + (tui-state-status-set! state "Sending...") + (let* ([rpc-start (real-time)] + [outcome + (guard (e [#t + (trace-event! "tui-send-rpc-failed-detail" + (safe-display e)) + (trace-public-event! + "tui-send-rpc-failed" + (list (cons 'ms (- (real-time) rpc-start)) + (cons 'error-chars + (string-length (safe-display e))))) + (cons 'failed e)]) + (let ([result (actor-call actor "send" + (make-send-params-for-conversation conv text))]) + (trace-public-event! + "tui-send-rpc-done" + (list (cons 'ms (- (real-time) rpc-start)))) + (ensure-send-result-success! result) + (cons 'sent result)))]) + (if (eq? (car outcome) 'sent) + (let ([ts (send-result-timestamp (cdr outcome))]) + (append-message-to-conversation! + conv + (make-chat-message 'out "You" text ts 'data 'sent)) + (let ([capture-start (real-time)]) + (capture-outbound! (tui-state-logdb state) + (tui-state-account state) + (conversation-id conv) text ts) + (trace-public-event! + "tui-send-capture-done" + (list (cons 'ms (- (real-time) capture-start))))) + (tui-state-resend-set! state #f) + (clear-conversation-rate-limit! state conv) + (conversation-unread-set! conv 0) + (trace-public-event! + "tui-send-done" + (list (cons 'ms (- (real-time) started)))) + (tui-state-status-set! state "Sent.")) + (begin + (trace-event! "tui-send-failed-detail" + (safe-display (cdr outcome))) + (trace-public-event! + "tui-send-failed" + (list (cons 'ms (- (real-time) started)) + (cons 'error-chars + (string-length (safe-display (cdr outcome)))))) + (tui-state-resend-set! state text) + (report-send-failure! state conv (cdr outcome) + "Ctrl-R restores the message.")))))]))) ;; Ctrl-R: put the last failed message back in the composer. Resending is a ;; deliberate two-step (restore, then Enter), never an accident.