tenseleyflow/claudex / 73c634b

Browse files

perf: write-combine pty stdout chunks before ipc emit

Authored by espadonne
SHA
73c634b03a87a8ad18651af953b901cf63719ae7
Parents
8378ba8
Tree
06fd225

1 changed file

StatusFile+-
M src-tauri/src/commands.rs 53 15
src-tauri/src/commands.rsmodified
@@ -453,26 +453,63 @@ pub async fn spawn_pty(
453453
         },
454454
     );
455455
 
456
-    // Forwarder task: base64-encode each chunk off `data_rx` and emit
457
-    // a per-PTY event named `pty:data:<pty_id>`. Per-PTY events
458
-    // avoid the O(N) fan-out that the previous single `pty:data`
459
-    // channel had — every TerminalPane subscribes only to events
460
-    // for its own ptyId, so one PTY's stdout chunk doesn't wake up
461
-    // every listener in the webview.
456
+    // Forwarder task: write-combines stdout chunks off `data_rx`
457
+    // into ~frame-sized batches and emits them on a per-PTY event
458
+    // `pty:data:<pty_id>`. Write combining matters because:
462459
     //
463
-    // When `data_rx` closes (reader task hit EOF), wait on
464
-    // `exit_rx` for the final exit code and emit `pty:exit`
465
-    // (globally, since the store subscribes to it to clean up
466
-    // `ptyIds` and doesn't want to listen per-pty). Then remove
467
-    // our entry from active_ptys — if close_pty already removed
468
-    // it, the remove here is a harmless no-op.
460
+    //   * Each emit is a Tauri IPC call that dispatches a DOM
461
+    //     event on the webview's main thread.
462
+    //   * Each frontend callback does base64 decode + `term.write`
463
+    //     which is non-trivial main-thread work.
464
+    //
465
+    // Without coalescing, a claude TUI spinner + token stream
466
+    // easily pushed 30-60 discrete chunks per second through the
467
+    // bridge, stalling the main thread enough to beachball mouse
468
+    // input. We now buffer up to `MAX_COALESCE_BYTES` or until
469
+    // `MAX_COALESCE_MS` elapses since the first queued chunk,
470
+    // whichever comes first.
471
+    const MAX_COALESCE_BYTES: usize = 16 * 1024;
472
+    const MAX_COALESCE_MS: u64 = 16;
473
+
469474
     let app_clone = app.clone();
470475
     let active_ptys = state.active_ptys.clone();
471476
     let pid = pty_id.clone();
472477
     let data_event = format!("pty:data:{pid}");
473478
     tauri::async_runtime::spawn(async move {
474
-        while let Some(chunk) = data_rx.recv().await {
475
-            let b64 = BASE64_STANDARD.encode(&chunk);
479
+        use std::time::Duration;
480
+
481
+        let mut buffer: Vec<u8> = Vec::with_capacity(MAX_COALESCE_BYTES);
482
+        loop {
483
+            // Block for the first chunk of a batch. If the channel
484
+            // has closed AND the buffer is empty, we're done.
485
+            let first = match data_rx.recv().await {
486
+                Some(chunk) => chunk,
487
+                None => break,
488
+            };
489
+            buffer.extend_from_slice(&first);
490
+
491
+            // Pull additional chunks until we hit the byte or time
492
+            // budget. `try_recv` is non-blocking; `time::timeout`
493
+            // on `recv` bounds the wait.
494
+            let deadline = tokio::time::Instant::now()
495
+                + Duration::from_millis(MAX_COALESCE_MS);
496
+            while buffer.len() < MAX_COALESCE_BYTES {
497
+                let now = tokio::time::Instant::now();
498
+                if now >= deadline {
499
+                    break;
500
+                }
501
+                let remaining = deadline - now;
502
+                match tokio::time::timeout(remaining, data_rx.recv()).await {
503
+                    Ok(Some(chunk)) => buffer.extend_from_slice(&chunk),
504
+                    Ok(None) => {
505
+                        // Sender closed — flush and exit after emit.
506
+                        break;
507
+                    }
508
+                    Err(_) => break, // timeout elapsed
509
+                }
510
+            }
511
+
512
+            let b64 = BASE64_STANDARD.encode(&buffer);
476513
             let payload = PtyDataPayload {
477514
                 pty_id: pid.clone(),
478515
                 base64: b64,
@@ -480,9 +517,10 @@ pub async fn spawn_pty(
480517
             if let Err(err) = app_clone.emit(&data_event, &payload) {
481518
                 tracing::warn!(error = %err, "failed to emit pty:data");
482519
             }
520
+            buffer.clear();
483521
         }
484522
 
485
-        // data_rx closed — now block on the wait task's exit code.
523
+        // data_rx closed — wait on the exit code and emit pty:exit.
486524
         let exit_code = exit_rx.await.ok().flatten();
487525
         let payload = PtyExitPayload {
488526
             pty_id: pid.clone(),