ScuttleBot

fix: PTY mirror never blocks terminal — async line processing Move line processing to a separate goroutine with a buffered channel. Terminal writes (stdout) are always immediate. If the IRC post is slow or the channel buffer fills, PTY output is dropped rather than blocking the interactive terminal. Fixes input lag when relay API is slow.

lmata 2026-04-05 22:53 trunk
Commit 694a9494139b7625059346ca5c3ccadd8efcfa9043a14261c06d1e5843c531bf
--- cmd/claude-relay/main.go
+++ cmd/claude-relay/main.go
@@ -216,11 +216,11 @@
216216
)
217217
// Create PTY mirror early so session file loop can dedup against it.
218218
var ptyMirror *relaymirror.PTYMirror
219219
if relayActive {
220220
ptyMirror = relaymirror.NewPTYMirror(defaultMirrorLineMax, 500*time.Millisecond, func(line string) {
221
- _ = relay.Post(ctx, line)
221
+ go func() { _ = relay.Post(ctx, line) }()
222222
})
223223
go mirrorSessionLoop(ctx, relay, cfg, startedAt, ptyMirror)
224224
go presenceLoopPtr(ctx, &relay, cfg.HeartbeatInterval)
225225
}
226226
227227
--- cmd/claude-relay/main.go
+++ cmd/claude-relay/main.go
@@ -216,11 +216,11 @@
216 )
217 // Create PTY mirror early so session file loop can dedup against it.
218 var ptyMirror *relaymirror.PTYMirror
219 if relayActive {
220 ptyMirror = relaymirror.NewPTYMirror(defaultMirrorLineMax, 500*time.Millisecond, func(line string) {
221 _ = relay.Post(ctx, line)
222 })
223 go mirrorSessionLoop(ctx, relay, cfg, startedAt, ptyMirror)
224 go presenceLoopPtr(ctx, &relay, cfg.HeartbeatInterval)
225 }
226
227
--- cmd/claude-relay/main.go
+++ cmd/claude-relay/main.go
@@ -216,11 +216,11 @@
216 )
217 // Create PTY mirror early so session file loop can dedup against it.
218 var ptyMirror *relaymirror.PTYMirror
219 if relayActive {
220 ptyMirror = relaymirror.NewPTYMirror(defaultMirrorLineMax, 500*time.Millisecond, func(line string) {
221 go func() { _ = relay.Post(ctx, line) }()
222 })
223 go mirrorSessionLoop(ctx, relay, cfg, startedAt, ptyMirror)
224 go presenceLoopPtr(ctx, &relay, cfg.HeartbeatInterval)
225 }
226
227
--- cmd/codex-relay/main.go
+++ cmd/codex-relay/main.go
@@ -231,11 +231,11 @@
231231
"SCUTTLEBOT_ACTIVITY_VIA_BROKER="+boolString(relayActive),
232232
)
233233
var ptyMirror *relaymirror.PTYMirror
234234
if relayActive {
235235
ptyMirror = relaymirror.NewPTYMirror(defaultMirrorLineMax, 500*time.Millisecond, func(line string) {
236
- _ = relay.Post(ctx, line)
236
+ go func() { _ = relay.Post(ctx, line) }()
237237
})
238238
go mirrorSessionLoop(ctx, relay, cfg, startedAt, preExisting, ptyMirror)
239239
go presenceLoopPtr(ctx, &relay, cfg.HeartbeatInterval)
240240
}
241241
242242
--- cmd/codex-relay/main.go
+++ cmd/codex-relay/main.go
@@ -231,11 +231,11 @@
231 "SCUTTLEBOT_ACTIVITY_VIA_BROKER="+boolString(relayActive),
232 )
233 var ptyMirror *relaymirror.PTYMirror
234 if relayActive {
235 ptyMirror = relaymirror.NewPTYMirror(defaultMirrorLineMax, 500*time.Millisecond, func(line string) {
236 _ = relay.Post(ctx, line)
237 })
238 go mirrorSessionLoop(ctx, relay, cfg, startedAt, preExisting, ptyMirror)
239 go presenceLoopPtr(ctx, &relay, cfg.HeartbeatInterval)
240 }
241
242
--- cmd/codex-relay/main.go
+++ cmd/codex-relay/main.go
@@ -231,11 +231,11 @@
231 "SCUTTLEBOT_ACTIVITY_VIA_BROKER="+boolString(relayActive),
232 )
233 var ptyMirror *relaymirror.PTYMirror
234 if relayActive {
235 ptyMirror = relaymirror.NewPTYMirror(defaultMirrorLineMax, 500*time.Millisecond, func(line string) {
236 go func() { _ = relay.Post(ctx, line) }()
237 })
238 go mirrorSessionLoop(ctx, relay, cfg, startedAt, preExisting, ptyMirror)
239 go presenceLoopPtr(ctx, &relay, cfg.HeartbeatInterval)
240 }
241
242
--- cmd/gemini-relay/main.go
+++ cmd/gemini-relay/main.go
@@ -222,11 +222,11 @@
222222
_, _ = io.Copy(ptmx, os.Stdin)
223223
}()
224224
// Dual-path mirroring: PTY for real-time text + session file for metadata.
225225
ptyMirror := relaymirror.NewPTYMirror(defaultMirrorLineMax, 500*time.Millisecond, func(line string) {
226226
if relayActive {
227
- _ = relay.Post(ctx, line)
227
+ go func() { _ = relay.Post(ctx, line) }()
228228
}
229229
})
230230
ptyMirror.BusyCallback = func(now time.Time) {
231231
state.mu.Lock()
232232
state.lastBusy = now
233233
--- cmd/gemini-relay/main.go
+++ cmd/gemini-relay/main.go
@@ -222,11 +222,11 @@
222 _, _ = io.Copy(ptmx, os.Stdin)
223 }()
224 // Dual-path mirroring: PTY for real-time text + session file for metadata.
225 ptyMirror := relaymirror.NewPTYMirror(defaultMirrorLineMax, 500*time.Millisecond, func(line string) {
226 if relayActive {
227 _ = relay.Post(ctx, line)
228 }
229 })
230 ptyMirror.BusyCallback = func(now time.Time) {
231 state.mu.Lock()
232 state.lastBusy = now
233
--- cmd/gemini-relay/main.go
+++ cmd/gemini-relay/main.go
@@ -222,11 +222,11 @@
222 _, _ = io.Copy(ptmx, os.Stdin)
223 }()
224 // Dual-path mirroring: PTY for real-time text + session file for metadata.
225 ptyMirror := relaymirror.NewPTYMirror(defaultMirrorLineMax, 500*time.Millisecond, func(line string) {
226 if relayActive {
227 go func() { _ = relay.Post(ctx, line) }()
228 }
229 })
230 ptyMirror.BusyCallback = func(now time.Time) {
231 state.mu.Lock()
232 state.lastBusy = now
233
--- pkg/relaymirror/pty.go
+++ pkg/relaymirror/pty.go
@@ -48,11 +48,25 @@
4848
// Copy reads from r (typically a PTY fd) and also writes to w (typically
4949
// os.Stdout for the interactive terminal). Lines are emitted via onLine.
5050
// Blocks until r returns EOF or error.
5151
func (m *PTYMirror) Copy(r io.Reader, w io.Writer) error {
5252
buf := make([]byte, 4096)
53
- var lineBuf bytes.Buffer
53
+ lineCh := make(chan []byte, 64) // buffered channel for async line processing
54
+ done := make(chan struct{})
55
+
56
+ // Process lines in a separate goroutine so terminal is never blocked.
57
+ go func() {
58
+ defer close(done)
59
+ var lineBuf bytes.Buffer
60
+ for chunk := range lineCh {
61
+ lineBuf.Write(chunk)
62
+ m.emitLines(&lineBuf)
63
+ }
64
+ if lineBuf.Len() > 0 {
65
+ m.emitLine(lineBuf.String())
66
+ }
67
+ }()
5468
5569
for {
5670
n, err := r.Read(buf)
5771
if n > 0 {
5872
// Detect busy signals for interrupt logic.
@@ -60,23 +74,26 @@
6074
lower := strings.ToLower(string(buf[:n]))
6175
if strings.Contains(lower, "esc to interrupt") || strings.Contains(lower, "working...") {
6276
m.BusyCallback(time.Now())
6377
}
6478
}
65
- // Pass through to terminal.
79
+ // Pass through to terminal — ALWAYS immediate, never blocked.
6680
if w != nil {
6781
_, _ = w.Write(buf[:n])
6882
}
69
- // Buffer and emit lines.
70
- lineBuf.Write(buf[:n])
71
- m.emitLines(&lineBuf)
83
+ // Send to line processor (non-blocking with buffered channel).
84
+ chunk := make([]byte, n)
85
+ copy(chunk, buf[:n])
86
+ select {
87
+ case lineCh <- chunk:
88
+ default:
89
+ // Channel full — drop this chunk rather than block terminal.
90
+ }
7291
}
7392
if err != nil {
74
- // Flush remaining buffer.
75
- if lineBuf.Len() > 0 {
76
- m.emitLine(lineBuf.String())
77
- }
93
+ close(lineCh)
94
+ <-done
7895
if err == io.EOF {
7996
return nil
8097
}
8198
return err
8299
}
83100
--- pkg/relaymirror/pty.go
+++ pkg/relaymirror/pty.go
@@ -48,11 +48,25 @@
48 // Copy reads from r (typically a PTY fd) and also writes to w (typically
49 // os.Stdout for the interactive terminal). Lines are emitted via onLine.
50 // Blocks until r returns EOF or error.
51 func (m *PTYMirror) Copy(r io.Reader, w io.Writer) error {
52 buf := make([]byte, 4096)
53 var lineBuf bytes.Buffer
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
55 for {
56 n, err := r.Read(buf)
57 if n > 0 {
58 // Detect busy signals for interrupt logic.
@@ -60,23 +74,26 @@
60 lower := strings.ToLower(string(buf[:n]))
61 if strings.Contains(lower, "esc to interrupt") || strings.Contains(lower, "working...") {
62 m.BusyCallback(time.Now())
63 }
64 }
65 // Pass through to terminal.
66 if w != nil {
67 _, _ = w.Write(buf[:n])
68 }
69 // Buffer and emit lines.
70 lineBuf.Write(buf[:n])
71 m.emitLines(&lineBuf)
 
 
 
 
 
72 }
73 if err != nil {
74 // Flush remaining buffer.
75 if lineBuf.Len() > 0 {
76 m.emitLine(lineBuf.String())
77 }
78 if err == io.EOF {
79 return nil
80 }
81 return err
82 }
83
--- pkg/relaymirror/pty.go
+++ pkg/relaymirror/pty.go
@@ -48,11 +48,25 @@
48 // Copy reads from r (typically a PTY fd) and also writes to w (typically
49 // os.Stdout for the interactive terminal). Lines are emitted via onLine.
50 // Blocks until r returns EOF or error.
51 func (m *PTYMirror) Copy(r io.Reader, w io.Writer) error {
52 buf := make([]byte, 4096)
53 lineCh := make(chan []byte, 64) // buffered channel for async line processing
54 done := make(chan struct{})
55
56 // Process lines in a separate goroutine so terminal is never blocked.
57 go func() {
58 defer close(done)
59 var lineBuf bytes.Buffer
60 for chunk := range lineCh {
61 lineBuf.Write(chunk)
62 m.emitLines(&lineBuf)
63 }
64 if lineBuf.Len() > 0 {
65 m.emitLine(lineBuf.String())
66 }
67 }()
68
69 for {
70 n, err := r.Read(buf)
71 if n > 0 {
72 // Detect busy signals for interrupt logic.
@@ -60,23 +74,26 @@
74 lower := strings.ToLower(string(buf[:n]))
75 if strings.Contains(lower, "esc to interrupt") || strings.Contains(lower, "working...") {
76 m.BusyCallback(time.Now())
77 }
78 }
79 // Pass through to terminal — ALWAYS immediate, never blocked.
80 if w != nil {
81 _, _ = w.Write(buf[:n])
82 }
83 // Send to line processor (non-blocking with buffered channel).
84 chunk := make([]byte, n)
85 copy(chunk, buf[:n])
86 select {
87 case lineCh <- chunk:
88 default:
89 // Channel full — drop this chunk rather than block terminal.
90 }
91 }
92 if err != nil {
93 close(lineCh)
94 <-done
 
 
95 if err == io.EOF {
96 return nil
97 }
98 return err
99 }
100

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button