Run debug REPL in dedicated thread instead of master timer polling

ober

ba64e5ed4fe3c0c6b3cb2b4d18b72d92dabf8187

diff --git a/lib/jerboa-emacs/debug-repl.sls b/lib/jerboa-emacs/debug-repl.sls
index 5bf55c5..d9b507b 100644
--- a/lib/jerboa-emacs/debug-repl.sls
+++ b/lib/jerboa-emacs/debug-repl.sls
@@ -9,14 +9,11 @@
       getenv path-extension path-absolute? thread? make-mutex
       mutex? mutex-name)
     (std sugar) (std srfi srfi-13) (jerboa repl-socket)
-    (jerboa-emacs async) (jerboa core) (jerboa runtime))
+    (jerboa core) (jerboa runtime))
   (def *repl-listen-fd* #f)
-  (def *repl-client-fd* #f)
-  (def *repl-line-buf* "")
   (def *repl-actual-port* #f)
-  (def *repl-token* #f)
-  (def *repl-authed* #f)
-  (def *repl-prompted* #f)
+  (def *repl-running* #f)
+  (def *repl-thread* #f)
   (def *repl-port-file*
        (string-append (getenv "HOME") "/.jerboa-repl-port"))
   (def (write-repl-port-file! port-num)
@@ -32,57 +29,45 @@
          (with-catch
            (lambda _ (void))
            (lambda () (delete-file *repl-port-file*)))))
-  (def (repl-send! str)
-       "Send a string to the connected client.  No-op if no client."
-       (when *repl-client-fd*
-         (unless (repl-socket-write *repl-client-fd* str)
-           (repl-disconnect!))))
-  (def (repl-disconnect!)
-       "Close the client connection and reset state for next accept."
-       (when *repl-client-fd*
-         (with-catch
-           (lambda _ (void))
-           (lambda () (repl-socket-close *repl-client-fd*))))
-       (set! *repl-client-fd* #f) (set! *repl-line-buf* "")
-       (set! *repl-authed* #f) (set! *repl-prompted* #f))
   (def help-text
        "  ,help           This help message\n  ,threads        List active threads\n  ,state          Show state summary\n  ,gc             Force GC and show heap info\n  ,quit           Close this REPL connection\n  <expr>          Evaluate arbitrary Chez Scheme expression\n")
-  (def (process-repl-line! line)
-       "Process one REPL command line.  Returns #t to continue, #f to disconnect."
+  (def (process-repl-line line client-fd)
+       "Process one REPL command line. Returns #t to continue, #f to disconnect."
        (let ([cmd (string-trim-both line)])
          (cond
            [(string=? cmd "") #t]
            [(string=? cmd ",quit")
-            (repl-send! "Connection closed.\n")
+            (repl-fd-send! client-fd "Connection closed.\n")
             #f]
-           [(string=? cmd ",help") (repl-send! help-text) #t]
+           [(string=? cmd ",help")
+            (repl-fd-send! client-fd help-text)
+            #t]
            [(string=? cmd ",threads")
             (with-catch
-              (lambda (e) (repl-send! "  (error listing threads)\n"))
-              (lambda () (repl-send! "  master-timer (active)\n")))
+              (lambda (e)
+                (repl-fd-send! client-fd "  (error listing threads)\n"))
+              (lambda ()
+                (repl-fd-send! client-fd "  REPL thread (active)\n")))
             #t]
            [(string=? cmd ",state")
-            (repl-send!
+            (repl-fd-send!
+              client-fd
               (string-append "  listen-fd: " (number->string (or *repl-listen-fd* -1))
-                "\n  client-fd: " (number->string (or *repl-client-fd* -1))
+                "\n  client-fd: " (number->string client-fd)
                 "\n  bytes-allocated: " (number->string (bytes-allocated))
                 "\n"))
             #t]
            [(string=? cmd ",gc")
             (with-catch
               (lambda (e)
-                (repl-send!
-                  (string-append
-                    "  GC skipped (would deadlock). bytes-allocated: "
-                    (number->string (bytes-allocated))
-                    "\n")))
+                (repl-fd-send! client-fd "  (error reading GC stats)\n"))
               (lambda ()
-                (collect 0)
-                (repl-send!
-                  (string-append
-                    "  GC done (gen 0). bytes-allocated: "
-                    (number->string (bytes-allocated))
-                    "\n"))))
+                (repl-fd-send!
+                  client-fd
+                  (string-append "  bytes-allocated: "
+                    (number->string (bytes-allocated)) "\n  collections: "
+                    (number->string (collections))
+                    "\n  Note: (collect) not safe from REPL thread; GC runs automatically.\n"))))
             #t]
            [else
             (with-catch
@@ -95,86 +80,22 @@
                                    (display-condition
                                      e
                                      (current-output-port))))))])
-                  (repl-send! (string-append "ERROR: " msg "\n"))))
+                  (repl-fd-send!
+                    client-fd
+                    (string-append "ERROR: " msg "\n"))))
               (lambda ()
                 (let* ([result (eval
                                  (read (open-input-string cmd))
                                  (interaction-environment))]
                        [out (open-output-string)])
                   (write result out)
-                  (repl-send!
+                  (repl-fd-send!
+                    client-fd
                     (string-append (get-output-string out) "\n")))))
             #t])))
-  (def (debug-repl-tick!)
-       "Non-blocking REPL poll.  Called from the master timer.\n   Tries to accept a connection or read data from an existing client."
-       (when *repl-listen-fd*
-         (with-catch
-           (lambda (e) (void))
-           (lambda ()
-             (cond
-               [(not *repl-client-fd*)
-                (let ([cfd (repl-socket-accept *repl-listen-fd*)])
-                  (when cfd
-                    (set! *repl-client-fd* cfd)
-                    (set! *repl-line-buf* "")
-                    (set! *repl-prompted* #f)
-                    (if *repl-token*
-                        (begin
-                          (set! *repl-authed* #f)
-                          (repl-send! "token: "))
-                        (begin
-                          (set! *repl-authed* #t)
-                          (repl-send!
-                            "jerboa debug REPL — type ,help for commands\n")))))]
-               [*repl-client-fd*
-                (when (and *repl-authed* (not *repl-prompted*))
-                  (repl-send! "jerboa-dbg> \n")
-                  (set! *repl-prompted* #t))
-                (let ([data (repl-socket-read *repl-client-fd*)])
-                  (cond
-                    [(string? data)
-                     (set! *repl-line-buf*
-                       (string-append *repl-line-buf* data))
-                     (repl-process-lines!)]
-                    [(eq? data 'eof) (repl-disconnect!)]))])))))
-  (def (repl-process-lines!)
-       "Extract and process complete lines from *repl-line-buf*."
-       (let loop ()
-         (let ([nl (repl-string-index *repl-line-buf* #\newline)])
-           (when nl
-             (let ([line (substring *repl-line-buf* 0 nl)]
-                   [rest (substring
-                           *repl-line-buf*
-                           (+ nl 1)
-                           (string-length *repl-line-buf*))])
-               (set! *repl-line-buf* rest)
-               (let ([line (if (and (> (string-length line) 0)
-                                    (char=?
-                                      (string-ref
-                                        line
-                                        (- (string-length line) 1))
-                                      #\return))
-                               (substring
-                                 line
-                                 0
-                                 (- (string-length line) 1))
-                               line)])
-                 (if *repl-authed*
-                     (let ([continue? (process-repl-line! line)])
-                       (if continue?
-                           (begin (set! *repl-prompted* #f) (loop))
-                           (repl-disconnect!)))
-                     (let ([tok (string-trim-both line)])
-                       (if (string=? tok *repl-token*)
-                           (begin
-                             (set! *repl-authed* #t)
-                             (repl-send!
-                               "jerboa debug REPL — type ,help for commands\n")
-                             (set! *repl-prompted* #f)
-                             (loop))
-                           (begin
-                             (repl-send! "Access denied.\n")
-                             (repl-disconnect!)))))))))))
+  (def (repl-fd-send! fd str)
+       "Send string to fd. Returns #t on success, #f on error."
+       (repl-socket-write fd str))
   (def (repl-string-index str ch)
        "Return the index of the first occurrence of ch in str, or #f."
        (let ([len (string-length str)])
@@ -183,31 +104,110 @@
              [(>= i len) #f]
              [(char=? (string-ref str i) ch) i]
              [else (loop (+ i 1))]))))
+  (def (handle-client! client-fd token)
+       "Handle one client connection. Blocks until client disconnects or ,quit.\n   Uses non-blocking reads + thread-sleep! for GC safety."
+       (with-catch
+         (lambda (e) (void))
+         (lambda ()
+           (let ([authed (if token
+                             (begin
+                               (repl-fd-send! client-fd "token: ")
+                               (let-values ([(tok-line rest)
+                                             (repl-read-line
+                                               client-fd
+                                               "")])
+                                 (and tok-line
+                                      (string=?
+                                        (string-trim-both tok-line)
+                                        token))))
+                             #t)])
+             (when authed
+               (repl-fd-send!
+                 client-fd
+                 "jerboa debug REPL — type ,help for commands\n")
+               (let loop ([buf ""])
+                 (when *repl-running*
+                   (repl-fd-send! client-fd "jerboa-dbg> ")
+                   (let-values ([(line rest)
+                                 (repl-read-line client-fd buf)])
+                     (when (and line *repl-running*)
+                       (when (process-repl-line line client-fd)
+                         (loop rest))))))))))
+       (with-catch
+         (lambda _ (void))
+         (lambda () (repl-socket-close client-fd))))
+  (def (repl-read-line client-fd buf)
+       "Read one line from client-fd using non-blocking reads + thread-sleep!.\n   Returns (values line remaining-buffer) or (values #f \"\") on EOF/disconnect.\n   Carries over leftover data in buf from previous reads."
+       (let loop ([buf buf])
+         (if (not *repl-running*)
+             (values #f "")
+             (let ([nl (repl-string-index buf #\newline)])
+               (if nl
+                   (let ([line (substring buf 0 nl)]
+                         [rest (substring
+                                 buf
+                                 (+ nl 1)
+                                 (string-length buf))])
+                     (values
+                       (if (and (> (string-length line) 0)
+                                (char=?
+                                  (string-ref
+                                    line
+                                    (- (string-length line) 1))
+                                  #\return))
+                           (substring line 0 (- (string-length line) 1))
+                           line)
+                       rest))
+                   (let ([data (repl-socket-read client-fd)])
+                     (cond
+                       [(string? data) (loop (string-append buf data))]
+                       [(eq? data 'eof) (values #f "")]
+                       [else (thread-sleep! 0.05) (loop buf)])))))))
+  (def (repl-accept-loop! token)
+       "Main accept loop. Runs in the dedicated REPL thread.\n   Accepts one client at a time (single-connection REPL)."
+       (let loop ()
+         (when *repl-running*
+           (let ([cfd (with-catch
+                        (lambda _ #f)
+                        (lambda ()
+                          (repl-socket-accept *repl-listen-fd*)))])
+             (if cfd
+                 (begin (handle-client! cfd token) (loop))
+                 (begin (thread-sleep! 0.1) (loop)))))))
   (def (start-debug-repl! port-num . args)
-       "Start the TCP debug REPL on 127.0.0.1:port-num.\n   Optional second argument: token string for authentication.\n   Use port 0 for OS-assigned ephemeral port.\n   Returns the actual port number and writes ~/.jerboa-repl-port.\n\n   THREAD-FREE: Registers a periodic tick with the master timer instead\n   of creating a background thread.  All socket I/O is non-blocking."
+       "Start the TCP debug REPL on 127.0.0.1:port-num.\n   Optional second argument: token string for authentication.\n   Use port 0 for OS-assigned ephemeral port.\n   Returns the actual port number and writes ~/.jerboa-repl-port.\n\n   Runs in a dedicated background thread — independent of Qt event loop."
        (stop-debug-repl!)
        (let ([token (if (null? args) #f (car args))])
          (let-values ([(fd actual-port)
                        (repl-socket-listen "127.0.0.1" port-num)])
            (set! *repl-listen-fd* fd)
            (set! *repl-actual-port* actual-port)
-           (set! *repl-token* token)
-           (set! *repl-authed* (not token))
-           (set! *repl-client-fd* #f)
-           (set! *repl-line-buf* "")
-           (set! *repl-prompted* #f)
+           (set! *repl-running* #t)
            (write-repl-port-file! actual-port)
-           (schedule-periodic! 'debug-repl 100 debug-repl-tick!)
+           (set! *repl-thread*
+             (let ([t (make-thread
+                        (lambda ()
+                          (with-catch
+                            (lambda (e) (void))
+                            (lambda () (repl-accept-loop! token))))
+                        'debug-repl)])
+               (thread-start! t)
+               t))
            actual-port)))
   (def (stop-debug-repl!)
        "Stop the debug REPL server and clean up."
-       (repl-disconnect!)
+       (set! *repl-running* #f)
        (when *repl-listen-fd*
          (with-catch
            (lambda _ (void))
            (lambda () (repl-socket-close *repl-listen-fd*)))
          (set! *repl-listen-fd* #f)
          (set! *repl-actual-port* #f))
+       (when *repl-thread*
+         (with-catch
+           (lambda _ (void))
+           (lambda () (thread-sleep! 0.2)))
+         (set! *repl-thread* #f))
        (delete-repl-port-file!))
   (def (debug-repl-port)
        "Return the actual port number the debug REPL is listening on, or #f if stopped."
diff --git a/src/jerboa-emacs/debug-repl.ss b/src/jerboa-emacs/debug-repl.ss
index b222c7e..5c7a73b 100644
--- a/src/jerboa-emacs/debug-repl.ss
+++ b/src/jerboa-emacs/debug-repl.ss
@@ -2,10 +2,14 @@
 ;;; TCP REPL server for debugging a running jerboa-emacs instance.
 ;;; Connect with: nc 127.0.0.1 <port>
 ;;;
-;;; THREAD-FREE DESIGN: No Chez threads are created. All socket I/O uses
-;;; non-blocking FFI calls polled from the master timer (schedule-periodic!).
-;;; This prevents GC deadlocks caused by Chez threads blocking in foreign
-;;; calls (accept/read) that can't respond to stop-the-world GC signals.
+;;; DEDICATED THREAD DESIGN: The REPL runs in its own background thread,
+;;; independent of the Qt event loop and master timer. This means the REPL
+;;; stays responsive even when the UI is hung or the master timer is blocked.
+;;;
+;;; GC safety: Uses non-blocking sockets + thread-sleep! between polls.
+;;; thread-sleep! auto-deactivates the Chez thread (decrements active_threads),
+;;; allowing stop-the-world GC to proceed without waiting for this thread.
+;;; No foreign call ever blocks — accept/read return immediately (EAGAIN).
 ;;;
 ;;; API:
 ;;;   (start-debug-repl! port)          → actual-port
@@ -19,20 +23,16 @@
 
 (import :std/sugar
         :std/srfi/13
-        :jerboa/repl-socket
-        :jerboa-emacs/async)
+        :jerboa/repl-socket)
 
 ;;;============================================================================
 ;;; State
 ;;;============================================================================
 
 (def *repl-listen-fd* #f)       ;; listen socket fd (non-blocking)
-(def *repl-client-fd* #f)       ;; connected client fd (non-blocking), or #f
-(def *repl-line-buf*  "")       ;; partial line accumulator
 (def *repl-actual-port* #f)     ;; port number we're listening on
-(def *repl-token* #f)           ;; auth token (or #f for no auth)
-(def *repl-authed* #f)          ;; #t after successful auth (or if no token)
-(def *repl-prompted* #f)        ;; #t if we've sent the prompt for current line
+(def *repl-running* #f)         ;; #t while REPL thread should keep running
+(def *repl-thread* #f)          ;; the background thread handle
 (def *repl-port-file*
   (string-append (getenv "HOME") "/.jerboa-repl-port"))
 
@@ -54,27 +54,6 @@
       (lambda () (delete-file *repl-port-file*)))))
 
 ;;;============================================================================
-;;; Low-level I/O helpers
-;;;============================================================================
-
-(def (repl-send! str)
-  "Send a string to the connected client.  No-op if no client."
-  (when *repl-client-fd*
-    (unless (repl-socket-write *repl-client-fd* str)
-      ;; Write failed — client disconnected
-      (repl-disconnect!))))
-
-(def (repl-disconnect!)
-  "Close the client connection and reset state for next accept."
-  (when *repl-client-fd*
-    (with-catch (lambda _ (void))
-      (lambda () (repl-socket-close *repl-client-fd*))))
-  (set! *repl-client-fd* #f)
-  (set! *repl-line-buf* "")
-  (set! *repl-authed* #f)
-  (set! *repl-prompted* #f))
-
-;;;============================================================================
 ;;; Help text
 ;;;============================================================================
 
@@ -88,51 +67,47 @@
 ")
 
 ;;;============================================================================
-;;; Command processing
+;;; Command processing (runs in REPL thread)
 ;;;============================================================================
 
-(def (process-repl-line! line)
-  "Process one REPL command line.  Returns #t to continue, #f to disconnect."
+(def (process-repl-line line client-fd)
+  "Process one REPL command line. Returns #t to continue, #f to disconnect."
   (let ((cmd (string-trim-both line)))
     (cond
       ((string=? cmd "")
        #t)
       ((string=? cmd ",quit")
-       (repl-send! "Connection closed.\n")
+       (repl-fd-send! client-fd "Connection closed.\n")
        #f)
       ((string=? cmd ",help")
-       (repl-send! help-text)
+       (repl-fd-send! client-fd help-text)
        #t)
       ((string=? cmd ",threads")
        (with-catch
-         (lambda (e) (repl-send! "  (error listing threads)\n"))
+         (lambda (e) (repl-fd-send! client-fd "  (error listing threads)\n"))
          (lambda ()
-           (repl-send! "  master-timer (active)\n")))
+           (repl-fd-send! client-fd "  REPL thread (active)\n")))
        #t)
       ((string=? cmd ",state")
-       (repl-send!
+       (repl-fd-send! client-fd
          (string-append
            "  listen-fd: " (number->string (or *repl-listen-fd* -1))
-           "\n  client-fd: " (number->string (or *repl-client-fd* -1))
+           "\n  client-fd: " (number->string client-fd)
            "\n  bytes-allocated: " (number->string (bytes-allocated))
            "\n"))
        #t)
       ((string=? cmd ",gc")
-       ;; Safe to call (collect) now — no REPL thread is blocked in a
-       ;; foreign call (we run from the master timer, not a separate thread).
-       ;; NOTE: Other threads (IPC accept) may still be blocked.
-       ;; Use (collect 0) for a minor collection that's less likely to
-       ;; need all threads at rendezvous.
+       ;; (collect) requires all threads at rendezvous — can't safely call
+       ;; from a background thread. Just report stats instead.
        (with-catch
          (lambda (e)
-           (repl-send!
-             (string-append "  GC skipped (would deadlock). bytes-allocated: "
-               (number->string (bytes-allocated)) "\n")))
+           (repl-fd-send! client-fd "  (error reading GC stats)\n"))
          (lambda ()
-           (collect 0)
-           (repl-send!
-             (string-append "  GC done (gen 0). bytes-allocated: "
-               (number->string (bytes-allocated)) "\n"))))
+           (repl-fd-send! client-fd
+             (string-append
+               "  bytes-allocated: " (number->string (bytes-allocated))
+               "\n  collections: " (number->string (collections))
+               "\n  Note: (collect) not safe from REPL thread; GC runs automatically.\n"))))
        #t)
       (else
        ;; Evaluate as Chez Scheme expression
@@ -143,96 +118,22 @@
                           (with-output-to-string
                             (lambda ()
                               (display-condition e (current-output-port))))))))
-             (repl-send! (string-append "ERROR: " msg "\n"))))
+             (repl-fd-send! client-fd (string-append "ERROR: " msg "\n"))))
          (lambda ()
            (let* ((result (eval (read (open-input-string cmd))
                                 (interaction-environment)))
                   (out (open-output-string)))
              (write result out)
-             (repl-send! (string-append (get-output-string out) "\n")))))
+             (repl-fd-send! client-fd (string-append (get-output-string out) "\n")))))
        #t))))
 
 ;;;============================================================================
-;;; Tick — called from master timer every 100ms
+;;; I/O helpers
 ;;;============================================================================
 
-(def (debug-repl-tick!)
-  "Non-blocking REPL poll.  Called from the master timer.
-   Tries to accept a connection or read data from an existing client."
-  (when *repl-listen-fd*
-    (with-catch
-      (lambda (e) (void))  ;; swallow any unexpected errors in the tick
-      (lambda ()
-        (cond
-          ;; No client — try to accept one
-          ((not *repl-client-fd*)
-           (let ((cfd (repl-socket-accept *repl-listen-fd*)))
-             (when cfd
-               (set! *repl-client-fd* cfd)
-               (set! *repl-line-buf* "")
-               (set! *repl-prompted* #f)
-               ;; Check if token auth is needed
-               (if *repl-token*
-                 (begin
-                   (set! *repl-authed* #f)
-                   (repl-send! "token: "))
-                 (begin
-                   (set! *repl-authed* #t)
-                   (repl-send! "jerboa debug REPL — type ,help for commands\n"))))))
-
-          ;; Client connected — try to read data
-          (*repl-client-fd*
-           ;; Send prompt if needed
-           (when (and *repl-authed* (not *repl-prompted*))
-             (repl-send! "jerboa-dbg> \n")
-             (set! *repl-prompted* #t))
-
-           ;; Non-blocking read
-           (let ((data (repl-socket-read *repl-client-fd*)))
-             (cond
-               ((string? data)
-                ;; Got data — append to line buffer and process complete lines
-                (set! *repl-line-buf* (string-append *repl-line-buf* data))
-                (repl-process-lines!))
-               ((eq? data 'eof)
-                ;; Client disconnected
-                (repl-disconnect!))
-               ;; #f = EAGAIN, nothing to read — try again next tick
-               ))))))))
-
-(def (repl-process-lines!)
-  "Extract and process complete lines from *repl-line-buf*."
-  (let loop ()
-    (let ((nl (repl-string-index *repl-line-buf* #\newline)))
-      (when nl
-        (let ((line (substring *repl-line-buf* 0 nl))
-              (rest (substring *repl-line-buf* (+ nl 1)
-                               (string-length *repl-line-buf*))))
-          (set! *repl-line-buf* rest)
-          ;; Strip trailing CR (from telnet/nc on some systems)
-          (let ((line (if (and (> (string-length line) 0)
-                               (char=? (string-ref line (- (string-length line) 1)) #\return))
-                       (substring line 0 (- (string-length line) 1))
-                       line)))
-            (if *repl-authed*
-              ;; Normal REPL command
-              (let ((continue? (process-repl-line! line)))
-                (if continue?
-                  (begin
-                    (set! *repl-prompted* #f)  ;; need new prompt
-                    (loop))
-                  (repl-disconnect!)))
-              ;; Auth check
-              (let ((tok (string-trim-both line)))
-                (if (string=? tok *repl-token*)
-                  (begin
-                    (set! *repl-authed* #t)
-                    (repl-send! "jerboa debug REPL — type ,help for commands\n")
-                    (set! *repl-prompted* #f)
-                    (loop))
-                  (begin
-                    (repl-send! "Access denied.\n")
-                    (repl-disconnect!)))))))))))
+(def (repl-fd-send! fd str)
+  "Send string to fd. Returns #t on success, #f on error."
+  (repl-socket-write fd str))
 
 (def (repl-string-index str ch)
   "Return the index of the first occurrence of ch in str, or #f."
@@ -244,6 +145,91 @@
         (else (loop (+ i 1)))))))
 
 ;;;============================================================================
+;;; Client handler (runs in REPL thread)
+;;;============================================================================
+
+(def (handle-client! client-fd token)
+  "Handle one client connection. Blocks until client disconnects or ,quit.
+   Uses non-blocking reads + thread-sleep! for GC safety."
+  (with-catch
+    (lambda (e) (void))  ;; silently close on any error
+    (lambda ()
+      ;; Auth phase
+      (let ((authed (if token
+                      (begin
+                        (repl-fd-send! client-fd "token: ")
+                        (let-values (((tok-line rest) (repl-read-line client-fd "")))
+                          (and tok-line
+                               (string=? (string-trim-both tok-line) token))))
+                      #t)))
+        (when authed
+          (repl-fd-send! client-fd "jerboa debug REPL — type ,help for commands\n")
+          ;; Main REPL loop — carry leftover buffer between reads
+          (let loop ((buf ""))
+            (when *repl-running*
+              (repl-fd-send! client-fd "jerboa-dbg> ")
+              (let-values (((line rest) (repl-read-line client-fd buf)))
+                (when (and line *repl-running*)
+                  (when (process-repl-line line client-fd)
+                    (loop rest))))))))))
+  ;; Always close the client fd
+  (with-catch (lambda _ (void))
+    (lambda () (repl-socket-close client-fd))))
+
+(def (repl-read-line client-fd buf)
+  "Read one line from client-fd using non-blocking reads + thread-sleep!.
+   Returns (values line remaining-buffer) or (values #f \"\") on EOF/disconnect.
+   Carries over leftover data in buf from previous reads."
+  (let loop ((buf buf))
+    (if (not *repl-running*)
+      (values #f "")
+      (let ((nl (repl-string-index buf #\newline)))
+        (if nl
+          ;; Got a complete line — return it and the remaining buffer
+          (let ((line (substring buf 0 nl))
+                (rest (substring buf (+ nl 1) (string-length buf))))
+            ;; Strip trailing CR
+            (values
+              (if (and (> (string-length line) 0)
+                       (char=? (string-ref line (- (string-length line) 1)) #\return))
+                (substring line 0 (- (string-length line) 1))
+                line)
+              rest))
+          ;; Need more data — non-blocking read
+          (let ((data (repl-socket-read client-fd)))
+            (cond
+              ((string? data)
+               (loop (string-append buf data)))
+              ((eq? data 'eof)
+               (values #f ""))
+              (else
+               ;; EAGAIN — sleep briefly then retry
+               ;; thread-sleep! auto-deactivates for GC
+               (thread-sleep! 0.05)
+               (loop buf)))))))))
+
+;;;============================================================================
+;;; Accept loop (runs in REPL thread)
+;;;============================================================================
+
+(def (repl-accept-loop! token)
+  "Main accept loop. Runs in the dedicated REPL thread.
+   Accepts one client at a time (single-connection REPL)."
+  (let loop ()
+    (when *repl-running*
+      (let ((cfd (with-catch (lambda _ #f)
+                   (lambda () (repl-socket-accept *repl-listen-fd*)))))
+        (if cfd
+          (begin
+            (handle-client! cfd token)
+            (loop))
+          (begin
+            ;; No connection pending — sleep briefly then retry
+            ;; thread-sleep! auto-deactivates for GC
+            (thread-sleep! 0.1)
+            (loop)))))))
+
+;;;============================================================================
 ;;; Public API
 ;;;============================================================================
 
@@ -253,34 +239,42 @@
    Use port 0 for OS-assigned ephemeral port.
    Returns the actual port number and writes ~/.jerboa-repl-port.
 
-   THREAD-FREE: Registers a periodic tick with the master timer instead
-   of creating a background thread.  All socket I/O is non-blocking."
+   Runs in a dedicated background thread — independent of Qt event loop."
   ;; Stop any existing server
   (stop-debug-repl!)
   (let ((token (if (null? args) #f (car args))))
     (let-values (((fd actual-port) (repl-socket-listen "127.0.0.1" port-num)))
       (set! *repl-listen-fd* fd)
       (set! *repl-actual-port* actual-port)
-      (set! *repl-token* token)
-      (set! *repl-authed* (not token))
-      (set! *repl-client-fd* #f)
-      (set! *repl-line-buf* "")
-      (set! *repl-prompted* #f)
+      (set! *repl-running* #t)
       (write-repl-port-file! actual-port)
-      ;; Register periodic tick — polls every 100ms from master timer
-      (schedule-periodic! 'debug-repl 100 debug-repl-tick!)
+      ;; Spawn dedicated REPL thread
+      (set! *repl-thread*
+        (let ((t (make-thread
+                   (lambda ()
+                     (with-catch
+                       (lambda (e) (void))
+                       (lambda () (repl-accept-loop! token))))
+                   'debug-repl)))
+          (thread-start! t)
+          t))
       actual-port)))
 
 (def (stop-debug-repl!)
   "Stop the debug REPL server and clean up."
-  ;; Close client if connected
-  (repl-disconnect!)
-  ;; Close listen socket
+  ;; Signal thread to stop
+  (set! *repl-running* #f)
+  ;; Close listen socket (will cause accept to fail, unblocking the thread)
   (when *repl-listen-fd*
     (with-catch (lambda _ (void))
       (lambda () (repl-socket-close *repl-listen-fd*)))
     (set! *repl-listen-fd* #f)
     (set! *repl-actual-port* #f))
+  ;; Wait briefly for thread to notice
+  (when *repl-thread*
+    (with-catch (lambda _ (void))
+      (lambda () (thread-sleep! 0.2)))
+    (set! *repl-thread* #f))
   (delete-repl-port-file!))
 
 (def (debug-repl-port)