This commit is contained in:
2026-05-15 00:28:06 +01:00
parent 2f969fa215
commit 0d578d54f1
31 changed files with 3209 additions and 164 deletions

View File

@@ -1,6 +1,7 @@
package app
import (
"bytes"
"crypto/rand"
"encoding/hex"
"errors"
@@ -108,11 +109,15 @@ type Child struct {
// ringMu guards ring. The ring buffer carries the last `ringCap`
// bytes the PTY produced, used by SPEC §7 get_process_output stream
// mode and search_output scrollback.
// mode and search_output scrollback. The ring is a fixed-size byte
// array with a wrap-around write index — no per-chunk reslice or
// reallocation. StreamRead serves contiguous slices by copying out
// of the (possibly wrapped) ring into a fresh buffer.
ringMu sync.Mutex
ring []byte
ringStart int64 // absolute offset of ring[0]
ringWrites int64 // cumulative bytes written
ring []byte // length == ringCap once allocated
ringPos int // next byte to overwrite
ringFull bool // true once ringWrites ≥ ringCap
ringWrites int64 // cumulative bytes written
// portsMu guards ports. Best-effort port detection: regex on stream.
portsMu sync.Mutex
@@ -127,10 +132,36 @@ type Child struct {
// exits and calls Start to bring the entry back up. Cleared when the
// user explicitly kills the process from the palette.
autoRestart atomic.Bool
// persistFn is set by Session after Spawn registers the entry. The
// callback mirrors mutable bits (name, auto-restart) into the
// persist store so a restarted patterm can rebuild this entry. Nil
// when no persist store is attached (unit tests / non-command
// entries).
persistMu sync.Mutex
persistFn func(*Child)
}
func (c *Child) SetAutoRestart(v bool) { c.autoRestart.Store(v) }
func (c *Child) AutoRestart() bool { return c.autoRestart.Load() }
func (c *Child) SetAutoRestart(v bool) {
c.autoRestart.Store(v)
c.firePersist()
}
func (c *Child) AutoRestart() bool { return c.autoRestart.Load() }
func (c *Child) setPersistFn(fn func(*Child)) {
c.persistMu.Lock()
c.persistFn = fn
c.persistMu.Unlock()
}
func (c *Child) firePersist() {
c.persistMu.Lock()
fn := c.persistFn
c.persistMu.Unlock()
if fn != nil {
fn(c)
}
}
// PortSighting is one entry returned by get_process_ports.
type PortSighting struct {
@@ -152,7 +183,7 @@ func newChildEntry(id, name string, kind ChildKind, argv, env []string, parentID
Kind: kind,
ParentID: parentID,
PresetRef: presetRef,
ring: make([]byte, 0, ringCap),
ring: make([]byte, ringCap),
}
st := StatusStopped
c.status.Store(&st)
@@ -254,6 +285,7 @@ func (c *Child) SetName(name string) {
c.nameMu.Lock()
c.Name = name
c.nameMu.Unlock()
c.firePersist()
}
// ScreenVersion returns the current emulator snapshot version, bumped
@@ -302,13 +334,22 @@ func (c *Child) recordWrite(chunk []byte) {
c.lastWriteNS.Store(time.Now().UnixNano())
c.screenVersion.Add(1)
c.ringMu.Lock()
c.ring = append(c.ring, chunk...)
c.ringWrites += int64(len(chunk))
if len(c.ring) > ringCap {
drop := len(c.ring) - ringCap
c.ring = c.ring[drop:]
c.ringStart += int64(drop)
// Chunks larger than ringCap are tail-truncated — only the last
// ringCap bytes of the chunk can survive.
src := chunk
if len(src) > ringCap {
src = src[len(src)-ringCap:]
}
for written := 0; written < len(src); {
n := copy(c.ring[c.ringPos:], src[written:])
c.ringPos += n
if c.ringPos >= ringCap {
c.ringPos = 0
c.ringFull = true
}
written += n
}
c.ringWrites += int64(len(chunk))
c.ringMu.Unlock()
c.scanPortsFromChunk(chunk)
}
@@ -316,6 +357,11 @@ func (c *Child) recordWrite(chunk []byte) {
// scanPortsFromChunk does best-effort port detection on a PTY chunk.
// SPEC §7 get_process_ports — no probing, just stream scanning.
func (c *Child) scanPortsFromChunk(chunk []byte) {
// Cheap prefix check: most chunks don't contain a URL. Bail before
// running the regex DFA over the whole chunk.
if !bytes.Contains(chunk, []byte("http")) {
return
}
matches := portRegex.FindAllSubmatch(chunk, -1)
if len(matches) == 0 {
return
@@ -364,16 +410,38 @@ func (c *Child) Ports() []PortSighting {
func (c *Child) StreamRead(since int64) ([]byte, int64) {
c.ringMu.Lock()
defer c.ringMu.Unlock()
if since < c.ringStart {
since = c.ringStart
end := c.ringWrites
var ringStart int64
if c.ringFull {
ringStart = end - int64(ringCap)
}
if since < ringStart {
since = ringStart
}
end := c.ringStart + int64(len(c.ring))
if since >= end {
return nil, end
}
start := int(since - c.ringStart)
out := make([]byte, end-since)
copy(out, c.ring[start:])
n := int(end - since)
out := make([]byte, n)
// Locate `since` in the ring. When the buffer hasn't wrapped yet,
// bytes 0..ringPos hold writes 0..ringPos. After wrap, ringPos
// points at the oldest byte, and the freshest byte is at
// (ringPos - 1) mod ringCap.
var pos int
if c.ringFull {
skip := int(since - ringStart) // bytes after the oldest
pos = (c.ringPos + skip) % ringCap
} else {
pos = int(since)
}
first := ringCap - pos
if first > n {
first = n
}
copy(out, c.ring[pos:pos+first])
if first < n {
copy(out[first:], c.ring[:n-first])
}
return out, end
}
@@ -395,19 +463,17 @@ func (c *Child) signal(sig syscall.Signal) error {
// NudgeRedraw asks the child to throw away any diff-based render state
// and emit a full frame on the next tick. Used after a focus switch so
// ratatui/ink TUIs re-render coherently against the snapshot we just
// replayed. We toggle the PTY size by one row so the kernel reliably
// emits SIGWINCH (TIOCSWINSZ skips the signal if the size didn't
// change), then send SIGWINCH explicitly for TUIs that miss or coalesce
// the size-toggled signal. The emulator is left alone — it already
// matches our intended size and the brief mismatch only affects what the
// child writes during the second redraw.
// replayed. Sends an explicit SIGWINCH; TIOCSWINSZ with the same size
// is a no-op in the kernel, so an explicit signal is what most TUIs
// actually act on anyway. Avoid resize-toggles here — under a drag-
// resize the kernel still emits intermediate SIGWINCHes against the
// host PTY and toggling our child's size on top produces inconsistent
// grid state.
func (c *Child) NudgeRedraw(cols, rows uint16) {
pty := c.PTY()
if pty == nil || rows < 2 {
return
}
_ = pty.Resize(cols, rows-1)
_ = pty.Resize(cols, rows)
_ = c.signal(syscall.SIGWINCH)
}