Compare commits

..

4 Commits

Author SHA1 Message Date
Hayden Hargreaves
7bcf8e8301 chore: forgot the file 2026-04-14 22:40:29 -07:00
Hayden Hargreaves
017c177b69 chore: renamed messages to events 2026-04-14 22:40:14 -07:00
Hayden Hargreaves
c1e06a9dfc chore: typo 2026-04-14 22:32:04 -07:00
Hayden Hargreaves
e5be0dd17b feat: process are actually dying right now :)
I did not need to spawn them in a go routine.
2026-04-14 22:31:10 -07:00
13 changed files with 300 additions and 161 deletions

126
doc/lifecycle-roadmap.md Normal file
View File

@ -0,0 +1,126 @@
# Process and Session Lifecycle Roadmap
##### Generated via AI on April 14th, 2026
## Why this exists
The current implementation now has a minimum-correct ownership model:
- `Session` owns both the proxy server and spawned process.
- `Session.Stop()` directly requests process shutdown and proxy shutdown.
- process exit status is emitted for both success (`ExitCode=0`) and failures.
This document captures the next iteration so lifecycle behavior remains predictable as the project grows.
## Current baseline (after the minimal fix)
- Process startup and waiting are split:
- startup returns a process handle immediately
- waiting happens in a background goroutine
- Unix signaling still targets process groups (`Setpgid` + negative PID) with TERM -> KILL escalation.
- Proxy shutdown is called explicitly by session stop.
- `http.ErrServerClosed` is treated as normal during proxy shutdown.
## Known limitations we should address next
1. Exit reason ambiguity
- We emit `ProcessSignaled` when stopping and `ProcessExited` when wait returns, but we do not explicitly encode whether exit was natural, requested by user, or forced by kill escalation.
2. Platform parity
- Non-Unix builds only signal the direct process and cannot reliably kill full process trees.
3. Shutdown ordering is not coordinated
- process stop and proxy shutdown are both requested, but there is no single orchestrated timeout policy across the whole session.
4. Session completion is implicit
- no explicit "session done" event or `Wait()` API to know when all workers have quiesced.
5. Message channel lifecycle
- channel is currently long-lived and not explicitly closed; this is safe for current flow, but not ideal for future composition/testing.
## Proposed future design
### 1) Introduce lifecycle controllers
Add small controller types with clear contracts:
- `ProcessController`
- `Start(cmd, env) error`
- `Stop(ctx) error` (TERM then KILL by deadline)
- `Wait() ProcessResult`
- `ProxyController`
- `Start(listener) error`
- `Stop(ctx) error`
- `Wait() error`
Reasoning:
- encapsulates resource ownership and synchronization
- avoids session-level ad hoc goroutines
- easier to unit-test
### 2) Move to context-driven shutdown
Use a parent context for a session and cancellation for coordinated stop.
Reasoning:
- one source of truth for shutdown intent
- natural propagation to future subcomponents
- easier timeout management than cross-goroutine signal channels
### 3) Add explicit process exit metadata
Define fields such as:
- `ExitReason`: `natural`, `signal`, `forced_kill`, `start_failed`, `runtime_error`
- `Signal`: optional signal value when applicable
Reasoning:
- accurate UI and logs
- better postmortem behavior for flaky commands
### 4) Add session-level graceful stop policy
Implement deterministic sequence with deadlines, for example:
1. request process graceful stop
2. wait up to `X` for process completion
3. force kill process group if needed
4. shutdown proxy with timeout `Y`
5. wait for goroutines/controllers to finish
6. emit `SessionStopped`
Reasoning:
- easier reasoning about final state
- avoids races between TUI exit and backend teardown
### 5) Improve non-Unix behavior
If Windows support becomes a requirement, evaluate job objects or equivalent process-tree control.
Reasoning:
- current direct-process signaling can leak descendants
- parity with Unix lifecycle expectations
## Implementation notes for the next pass
- Keep `internal/process/signal_unix.go` logic as the Unix baseline.
- Rename `Destory` to `Destroy` with a compatibility shim or bulk rename.
- Handle `net.ErrClosed` and `http.ErrServerClosed` as expected proxy shutdown outcomes.
- Add targeted tests:
- clean exit (`ExitCode=0`)
- non-zero exit
- TERM then forced KILL
- spawned child process cleanup on Unix process groups
- proxy shutdown does not emit fatal on normal close
## Suggested milestones
1. Controller extraction without behavior change
2. Exit reason metadata and event model updates
3. Context-based coordinated stop
4. Platform-specific process-tree improvements (if needed)

View File

@ -3,7 +3,6 @@ package app
import ( import (
"errors" "errors"
"fmt" "fmt"
"os"
"os/exec" "os/exec"
"syscall" "syscall"
"time" "time"
@ -12,34 +11,35 @@ import (
"termtap.dev/internal/process" "termtap.dev/internal/process"
) )
func StartProcess(cmd model.Command, addr string, ch chan<- model.Message, sigCh <-chan os.Signal) { func StartProcess(cmd model.Command, addr string, ch chan<- model.Event) (*model.Process, error) {
ch <- model.Message{ ch <- model.Event{
Type: model.MessageTypeProcessStarting, Type: model.EventTypeProcessStarting,
Body: fmt.Sprintf("spawning process '%s'", process.CommandString(cmd)), Body: fmt.Sprintf("spawning process '%s'", process.CommandString(cmd)),
} }
proc := process.NewProcess(cmd, addr, ch) proc := process.NewProcess(cmd, addr, ch)
if err := proc.Exec.Start(); err != nil { if err := proc.Exec.Start(); err != nil {
ch <- model.Message{ return nil, fmt.Errorf("start process: %w", err)
Type: model.MessageTypeProcessExited,
Body: fmt.Sprintf("%q", err),
}
return
} }
process.UpdateStatus(proc, true, ch) process.UpdateStatus(proc, true, ch)
// Listen for SIGTERM from main process go waitForProcessExit(proc, ch)
go func() {
sig := <-sigCh
ch <- model.Message{ return proc, nil
Type: model.MessageTypeProcessSignaled, }
func StopProcess(proc *model.Process, ch chan<- model.Event, sig syscall.Signal) {
if proc == nil || proc.Exec == nil || proc.Exec.Process == nil {
return
}
ch <- model.Event{
Type: model.EventTypeProcessSignaled,
Body: fmt.Sprintf("process with pid '%d' is being killed", proc.Exec.Process.Pid), Body: fmt.Sprintf("process with pid '%d' is being killed", proc.Exec.Process.Pid),
PID: proc.Exec.Process.Pid, PID: proc.Exec.Process.Pid,
} }
if proc.Exec != nil {
_ = process.SignalProcess(proc.Exec, sig) _ = process.SignalProcess(proc.Exec, sig)
go func() { go func() {
@ -48,16 +48,18 @@ func StartProcess(cmd model.Command, addr string, ch chan<- model.Message, sigCh
_ = process.SignalProcess(proc.Exec, syscall.SIGKILL) _ = process.SignalProcess(proc.Exec, syscall.SIGKILL)
} }
}() }()
}
process.UpdateStatus(proc, false, ch) func waitForProcessExit(proc *model.Process, ch chan<- model.Event) {
if proc == nil || proc.Exec == nil {
return
} }
}()
if err := proc.Exec.Wait(); err != nil { if err := proc.Exec.Wait(); err != nil {
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
ch <- model.Message{ ch <- model.Event{
Type: model.MessageTypeProcessExited, Type: model.EventTypeProcessExited,
Body: fmt.Sprintf("process pid '%d' exited by itself", proc.Exec.Process.Pid), Body: fmt.Sprintf("process pid '%d' exited", proc.Exec.Process.Pid),
PID: proc.Exec.Process.Pid, PID: proc.Exec.Process.Pid,
ExitCode: exitErr.ExitCode(), ExitCode: exitErr.ExitCode(),
} }
@ -65,12 +67,19 @@ func StartProcess(cmd model.Command, addr string, ch chan<- model.Message, sigCh
return return
} }
ch <- model.Message{ ch <- model.Event{
Type: model.MessageTypeFatal, Type: model.EventTypeFatal,
Body: fmt.Sprintf("%q", err), Body: fmt.Sprintf("%q", err),
} }
process.UpdateStatus(proc, false, ch) process.UpdateStatus(proc, false, ch)
return return
} }
ch <- model.Event{
Type: model.EventTypeProcessExited,
Body: fmt.Sprintf("process pid '%d' exited", proc.Exec.Process.Pid),
PID: proc.Exec.Process.Pid,
ExitCode: 0,
}
process.UpdateStatus(proc, false, ch)
} }

View File

@ -1,32 +1,31 @@
package app package app
import ( import (
"errors"
"fmt" "fmt"
"net/http"
"termtap.dev/internal/model" "termtap.dev/internal/model"
"termtap.dev/internal/proxy"
) )
func StartProxy(addr string, ch chan<- model.Message) { func StartProxy(ps *model.ProxyServer, ch chan<- model.Event) {
ps, err := proxy.NewProxyServer(addr, ch) if ps == nil || ps.Server == nil || ps.Listener == nil {
if err != nil {
ch <- model.Message{
Type: model.MessageTypeFatal,
Body: fmt.Sprintf("%q", err),
}
return return
} }
defer proxy.Destory(ps, ch)
ch <- model.Message{ ch <- model.Event{
Type: model.MessageTypeProxyStarting, Type: model.EventTypeProxyStarting,
Body: fmt.Sprintf("proxy server started on %s", addr), Body: fmt.Sprintf("proxy server started on %s", (*ps.Listener).Addr().String()),
} }
if err := ps.Server.Serve(*ps.Listener); err != nil { if err := ps.Server.Serve(*ps.Listener); err != nil {
ch <- model.Message{ if errors.Is(err, http.ErrServerClosed) {
Type: model.MessageTypeFatal, return
Body: fmt.Sprintf("%q", err), }
ch <- model.Event{
Type: model.EventTypeFatal,
Body: fmt.Sprintf("fatal error in proxy server: %q", err),
} }
return return
} }

View File

@ -1,32 +1,43 @@
package app package app
import ( import (
"os"
"os/signal"
"sync" "sync"
"syscall" "syscall"
"termtap.dev/internal/model" "termtap.dev/internal/model"
"termtap.dev/internal/proxy"
) )
type Session struct { type Session struct {
Messages <-chan model.Message Events <-chan model.Event
sigCh chan os.Signal ch chan model.Event
stopOnce sync.Once proxy *model.ProxyServer
proc *model.Process
once sync.Once
} }
func StartSession(cmd model.Command, addr string) (*Session, error) { func StartSession(cmd model.Command, addr string) (*Session, error) {
msgs := make(chan model.Message, 256) msgs := make(chan model.Event, 256)
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
go StartProxy(addr, msgs) ps, err := proxy.NewProxyServer(addr, msgs)
go StartProcess(cmd, addr, msgs, sigCh) if err != nil {
return nil, err
}
go StartProxy(ps, msgs)
proc, err := StartProcess(cmd, addr, msgs)
if err != nil {
proxy.Destroy(ps, msgs)
return nil, err
}
return &Session{ return &Session{
Messages: msgs, Events: msgs,
sigCh: sigCh, ch: msgs,
proxy: ps,
proc: proc,
}, nil }, nil
} }
@ -35,12 +46,8 @@ func (s *Session) Stop() {
return return
} }
s.stopOnce.Do(func() { s.once.Do(func() {
signal.Stop(s.sigCh) StopProcess(s.proc, s.ch, syscall.SIGTERM)
proxy.Destroy(s.proxy, s.ch)
select {
case s.sigCh <- syscall.SIGTERM:
default:
}
}) })
} }

View File

@ -26,7 +26,7 @@ func Run(args []string) {
} }
defer session.Stop() defer session.Stop()
if err := tui.Run(session.Messages); err != nil { if err := tui.Run(session.Events); err != nil {
log.Fatalln(err) log.Fatalln(err)
} }
} }

34
internal/model/event.go Normal file
View File

@ -0,0 +1,34 @@
package model
type EventType string
const (
EventTypeSessionStarted EventType = "SessionStarted"
EventTypeSessionStopped EventType = "SessionStopped"
EventTypeProxyStarting EventType = "ProxyStarting"
EventTypeProxyStarted EventType = "ProxyStarted"
EventTypeProxyStopped EventType = "ProxyStopped"
EventTypeRequestStarted EventType = "RequestStarted"
EventTypeRequestFinished EventType = "RequestFinished"
EventTypeRequestFailed EventType = "RequestFailed"
EventTypeProcessStarting EventType = "ProcessStarting"
EventTypeProcessStarted EventType = "ProcessStarted"
EventTypeProcessExited EventType = "ProcessExited"
EventTypeProcessSignaled EventType = "ProcessSignaled"
EventTypeProcessStdout EventType = "ProcessStdout"
EventTypeProcessStderr EventType = "ProcessStderr"
EventTypeFatal EventType = "Fatal"
EventTypeWarn EventType = "Warn"
)
type Event struct {
Type EventType
Body string
PID int
ExitCode int
Request Request
}

View File

@ -1,34 +0,0 @@
package model
type MessageType string
const (
MessageTypeSessionStarted MessageType = "SessionStarted"
MessageTypeSessionStopped MessageType = "SessionStopped"
MessageTypeProxyStarting MessageType = "ProxyStarting"
MessageTypeProxyStarted MessageType = "ProxyStarted"
MessageTypeProxyStopped MessageType = "ProxyStopped"
MessageTypeRequestStarted MessageType = "RequestStarted"
MessageTypeRequestFinished MessageType = "RequestFinished"
MessageTypeRequestFailed MessageType = "RequestFailed"
MessageTypeProcessStarting MessageType = "ProcessStarting"
MessageTypeProcessStarted MessageType = "ProcessStarted"
MessageTypeProcessExited MessageType = "ProcessExited"
MessageTypeProcessSignaled MessageType = "ProcessSignaled"
MessageTypeProcessStdout MessageType = "ProcessStdout"
MessageTypeProcessStderr MessageType = "ProcessStderr"
MessageTypeFatal MessageType = "Fatal"
MessageTypeWarn MessageType = "Warn"
)
type Message struct {
Type MessageType
Body string
PID int
ExitCode int
Request Request
}

View File

@ -15,7 +15,7 @@ func CommandString(c model.Command) string {
return fmt.Sprintf("%s %s", c.Name, strings.Join(c.Args, " ")) return fmt.Sprintf("%s %s", c.Name, strings.Join(c.Args, " "))
} }
func NewProcess(cmd model.Command, addr string, ch chan<- model.Message) *model.Process { func NewProcess(cmd model.Command, addr string, ch chan<- model.Event) *model.Process {
proc := exec.Command(cmd.Name, cmd.Args...) proc := exec.Command(cmd.Name, cmd.Args...)
configureProcessForSignals(proc) configureProcessForSignals(proc)
@ -23,24 +23,24 @@ func NewProcess(cmd model.Command, addr string, ch chan<- model.Message) *model.
stdout, err := proc.StdoutPipe() stdout, err := proc.StdoutPipe()
if err != nil { if err != nil {
ch <- model.Message{ ch <- model.Event{
Type: model.MessageTypeWarn, Type: model.EventTypeWarn,
Body: fmt.Sprintf("could not open stdout pipe: %q", err), Body: fmt.Sprintf("could not open stdout pipe: %q", err),
PID: proc.Process.Pid, PID: proc.Process.Pid,
} }
} else { } else {
go readPipe(stdout, model.MessageTypeProcessStdout, ch) go readPipe(stdout, model.EventTypeProcessStdout, ch)
} }
stderr, err := proc.StderrPipe() stderr, err := proc.StderrPipe()
if err != nil { if err != nil {
ch <- model.Message{ ch <- model.Event{
Type: model.MessageTypeWarn, Type: model.EventTypeWarn,
Body: fmt.Sprintf("could not open stderr pipe: %q", err), Body: fmt.Sprintf("could not open stderr pipe: %q", err),
PID: proc.Process.Pid, PID: proc.Process.Pid,
} }
} else { } else {
go readPipe(stderr, model.MessageTypeProcessStderr, ch) go readPipe(stderr, model.EventTypeProcessStderr, ch)
} }
return &model.Process{ return &model.Process{
@ -66,17 +66,17 @@ func injectEnv(proc *exec.Cmd, addr string) {
proc.Env = append(os.Environ(), injected...) proc.Env = append(os.Environ(), injected...)
} }
func readPipe(pipe io.Reader, t model.MessageType, ch chan<- model.Message) { func readPipe(pipe io.Reader, t model.EventType, ch chan<- model.Event) {
scanner := bufio.NewScanner(pipe) scanner := bufio.NewScanner(pipe)
for scanner.Scan() { for scanner.Scan() {
ch <- model.Message{ ch <- model.Event{
Type: t, Type: t,
Body: scanner.Text(), Body: scanner.Text(),
} }
} }
} }
func UpdateStatus(proc *model.Process, running bool, ch chan<- model.Message) { func UpdateStatus(proc *model.Process, running bool, ch chan<- model.Event) {
if proc == nil { if proc == nil {
return return
} }
@ -88,18 +88,18 @@ func UpdateStatus(proc *model.Process, running bool, ch chan<- model.Message) {
proc.Running = running proc.Running = running
var ( var (
t model.MessageType t model.EventType
status string status string
) )
if running { if running {
t = model.MessageTypeProcessStarted t = model.EventTypeProcessStarted
status = "running" status = "running"
} else { } else {
t = model.MessageTypeProcessExited t = model.EventTypeProcessExited
status = "stopped" status = "stopped"
} }
ch <- model.Message{ ch <- model.Event{
Type: t, Type: t,
Body: fmt.Sprintf("Set process pid '%d' status to %s", proc.Exec.Process.Pid, status), Body: fmt.Sprintf("Set process pid '%d' status to %s", proc.Exec.Process.Pid, status),
PID: proc.Exec.Process.Pid, PID: proc.Exec.Process.Pid,

View File

@ -20,15 +20,15 @@ import (
const maxPreviewBytes = 1024 const maxPreviewBytes = 1024
func proxyHandler(ch chan<- model.Message) http.Handler { func proxyHandler(ch chan<- model.Event) http.Handler {
transport := http.DefaultTransport transport := http.DefaultTransport
// TODO: This should be wired into the main channel, but that will require a model package // TODO: This should be wired into the main channel, but that will require a model package
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodConnect { if req.Method == http.MethodConnect {
http.Error(w, "CONNECT is not supported yet", http.StatusNotImplemented) http.Error(w, "CONNECT is not supported yet", http.StatusNotImplemented)
ch <- model.Message{ ch <- model.Event{
Type: model.MessageTypeWarn, Type: model.EventTypeWarn,
Body: fmt.Sprintf("CONNECT is not supported: %s", req.Host), Body: fmt.Sprintf("CONNECT is not supported: %s", req.Host),
} }
return return
@ -36,8 +36,8 @@ func proxyHandler(ch chan<- model.Message) http.Handler {
if req.URL.Scheme == "" || req.URL.Host == "" { if req.URL.Scheme == "" || req.URL.Host == "" {
http.Error(w, "request must use absolute-form URLs through the proxy", http.StatusBadRequest) http.Error(w, "request must use absolute-form URLs through the proxy", http.StatusBadRequest)
ch <- model.Message{ ch <- model.Event{
Type: model.MessageTypeWarn, Type: model.EventTypeWarn,
Body: fmt.Sprintf("rejected non-proxy request %s %s", req.Method, req.URL.String()), Body: fmt.Sprintf("rejected non-proxy request %s %s", req.Method, req.URL.String()),
} }
return return
@ -60,8 +60,8 @@ func proxyHandler(ch chan<- model.Message) http.Handler {
requestPreview, err := readAndRestoreBody(&req.Body) requestPreview, err := readAndRestoreBody(&req.Body)
if err != nil { if err != nil {
ch <- model.Message{ ch <- model.Event{
Type: model.MessageTypeWarn, Type: model.EventTypeWarn,
Body: fmt.Sprintf("(%s) failed to read request body", request.ID), Body: fmt.Sprintf("(%s) failed to read request body", request.ID),
Request: request, Request: request,
} }
@ -80,8 +80,8 @@ func proxyHandler(ch chan<- model.Message) http.Handler {
request.RequestHeaders = outReq.Header request.RequestHeaders = outReq.Header
request.RawURL = outReq.URL.String() request.RawURL = outReq.URL.String()
ch <- model.Message{ ch <- model.Event{
Type: model.MessageTypeRequestStarted, Type: model.EventTypeRequestStarted,
Body: fmt.Sprintf("-> %+v", request), Body: fmt.Sprintf("-> %+v", request),
Request: request, Request: request,
} }
@ -96,8 +96,8 @@ func proxyHandler(ch chan<- model.Message) http.Handler {
request.Duration = time.Since(start).Round(time.Microsecond) request.Duration = time.Since(start).Round(time.Microsecond)
request.Status = status request.Status = status
ch <- model.Message{ ch <- model.Event{
Type: model.MessageTypeRequestFailed, Type: model.EventTypeRequestFailed,
Body: fmt.Sprintf("upstream error for %s %s: %v", outReq.Method, outReq.URL.String(), err), Body: fmt.Sprintf("upstream error for %s %s: %v", outReq.Method, outReq.URL.String(), err),
Request: request, Request: request,
} }
@ -107,8 +107,8 @@ func proxyHandler(ch chan<- model.Message) http.Handler {
responsePreview, err := readAndRestoreBody(&resp.Body) responsePreview, err := readAndRestoreBody(&resp.Body)
if err != nil { if err != nil {
ch <- model.Message{ ch <- model.Event{
Type: model.MessageTypeWarn, Type: model.EventTypeWarn,
Body: fmt.Sprintf("(%s) failed to read response body", request.ID), Body: fmt.Sprintf("(%s) failed to read response body", request.ID),
Request: request, Request: request,
} }
@ -124,8 +124,8 @@ func proxyHandler(ch chan<- model.Message) http.Handler {
request.Duration = time.Since(start).Round(time.Microsecond) request.Duration = time.Since(start).Round(time.Microsecond)
request.Status = resp.StatusCode request.Status = resp.StatusCode
ch <- model.Message{ ch <- model.Event{
Type: model.MessageTypeRequestFailed, Type: model.EventTypeRequestFailed,
Body: fmt.Sprintf("write response body %s %s: %v", outReq.Method, outReq.URL.String(), err), Body: fmt.Sprintf("write response body %s %s: %v", outReq.Method, outReq.URL.String(), err),
} }
return return
@ -136,8 +136,8 @@ func proxyHandler(ch chan<- model.Message) http.Handler {
request.ResponseHeaders = resp.Header request.ResponseHeaders = resp.Header
request.Pending = false request.Pending = false
ch <- model.Message{ ch <- model.Event{
Type: model.MessageTypeRequestFinished, Type: model.EventTypeRequestFinished,
Body: fmt.Sprintf("<- %+v %s", request, formatHeaders(resp.Request.Header)), Body: fmt.Sprintf("<- %+v %s", request, formatHeaders(resp.Request.Header)),
Request: request, Request: request,
} }

View File

@ -10,7 +10,7 @@ import (
"termtap.dev/internal/model" "termtap.dev/internal/model"
) )
func NewProxyServer(addr string, ch chan<- model.Message) (*model.ProxyServer, error) { func NewProxyServer(addr string, ch chan<- model.Event) (*model.ProxyServer, error) {
listener, err := net.Listen("tcp", addr) listener, err := net.Listen("tcp", addr)
if err != nil { if err != nil {
return nil, err return nil, err
@ -28,14 +28,14 @@ func NewProxyServer(addr string, ch chan<- model.Message) (*model.ProxyServer, e
} }
// BUG: Not sure what all this does // BUG: Not sure what all this does
func Destory(ps *model.ProxyServer, ch chan<- model.Message) { func Destroy(ps *model.ProxyServer, ch chan<- model.Event) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() defer cancel()
if ps != nil && ps.Server != nil { if ps != nil && ps.Server != nil {
_ = ps.Server.Shutdown(ctx) _ = ps.Server.Shutdown(ctx)
ch <- model.Message{ ch <- model.Event{
Type: model.MessageTypeProxyStopped, Type: model.EventTypeProxyStarted,
Body: "proxy server was destroyed", Body: "proxy server was destroyed",
} }
} }

View File

@ -13,18 +13,18 @@ const (
maxRequests = 200 maxRequests = 200
) )
type appMsg struct { type EventMsg struct {
value model.Message value model.Event
} }
type modelErrMsg struct { type ErrMsg struct {
err error err error
} }
type Model struct { type Model struct {
msgCh <-chan model.Message msgCh <-chan model.Event
events []model.Message events []model.Event
requestOrder []uuid.UUID requestOrder []uuid.UUID
requests map[uuid.UUID]model.Request requests map[uuid.UUID]model.Request
@ -32,10 +32,10 @@ type Model struct {
height int height int
} }
func NewModel(msgCh <-chan model.Message) Model { func NewModel(msgCh <-chan model.Event) Model {
return Model{ return Model{
msgCh: msgCh, msgCh: msgCh,
events: make([]model.Message, 0, maxEvents), events: make([]model.Event, 0, maxEvents),
requestOrder: make([]uuid.UUID, 0, maxRequests), requestOrder: make([]uuid.UUID, 0, maxRequests),
requests: map[uuid.UUID]model.Request{}, requests: map[uuid.UUID]model.Request{},
width: 100, width: 100,
@ -43,7 +43,7 @@ func NewModel(msgCh <-chan model.Message) Model {
} }
} }
func Run(msgCh <-chan model.Message) error { func Run(msgCh <-chan model.Event) error {
p := tea.NewProgram(NewModel(msgCh), tea.WithAltScreen()) p := tea.NewProgram(NewModel(msgCh), tea.WithAltScreen())
_, err := p.Run() _, err := p.Run()
return err return err
@ -53,13 +53,13 @@ func (m Model) Init() tea.Cmd {
return waitForAppMessage(m.msgCh) return waitForAppMessage(m.msgCh)
} }
func waitForAppMessage(msgCh <-chan model.Message) tea.Cmd { func waitForAppMessage(msgCh <-chan model.Event) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
msg, ok := <-msgCh msg, ok := <-msgCh
if !ok { if !ok {
return modelErrMsg{err: fmt.Errorf("event channel closed")} return ErrMsg{err: fmt.Errorf("event channel closed")}
} }
return appMsg{value: msg} return EventMsg{value: msg}
} }
} }

View File

@ -22,14 +22,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
return m, nil return m, nil
case modelErrMsg: case ErrMsg:
m.events = append(m.events, model.Message{ m.events = append(m.events, model.Event{
Type: model.MessageTypeWarn, Type: model.EventTypeWarn,
Body: fmt.Sprintf("tui event stream closed: %v", msg.err), Body: fmt.Sprintf("tui event stream closed: %v", msg.err),
}) })
return m, nil return m, nil
case appMsg: case EventMsg:
m.pushEvent(msg.value) m.pushEvent(msg.value)
m.applyMessage(msg.value) m.applyMessage(msg.value)
return m, waitForAppMessage(m.msgCh) return m, waitForAppMessage(m.msgCh)
@ -38,18 +38,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
func (m *Model) pushEvent(msg model.Message) { func (m *Model) pushEvent(msg model.Event) {
m.events = append(m.events, msg) m.events = append(m.events, msg)
if len(m.events) > maxEvents { if len(m.events) > maxEvents {
m.events = m.events[len(m.events)-maxEvents:] m.events = m.events[len(m.events)-maxEvents:]
} }
} }
func (m *Model) applyMessage(msg model.Message) { func (m *Model) applyMessage(msg model.Event) {
switch msg.Type { switch msg.Type {
case model.MessageTypeRequestStarted: case model.EventTypeRequestStarted:
m.upsertRequest(msg.Request, true) m.upsertRequest(msg.Request, true)
case model.MessageTypeRequestFinished, model.MessageTypeRequestFailed: case model.EventTypeRequestFinished, model.EventTypeRequestFailed:
m.upsertRequest(msg.Request, false) m.upsertRequest(msg.Request, false)
} }
} }

View File

@ -7,6 +7,7 @@ import (
"termtap.dev/internal/model" "termtap.dev/internal/model"
) )
// TODO: This is all temporary
func (m Model) View() string { func (m Model) View() string {
eventLines := m.renderEvents(8) eventLines := m.renderEvents(8)
requestLines := m.renderRequests(12) requestLines := m.renderRequests(12)
@ -29,10 +30,7 @@ func (m Model) renderEvents(limit int) string {
return " (none yet)" return " (none yet)"
} }
start := len(m.events) - limit start := max(len(m.events)-limit, 0)
if start < 0 {
start = 0
}
rows := make([]string, 0, len(m.events)-start) rows := make([]string, 0, len(m.events)-start)
for i := start; i < len(m.events); i++ { for i := start; i < len(m.events); i++ {