Compare commits
No commits in common. "7bcf8e8301a131fe91f6f8a31dd82c2651b6d57d" and "58da1e3a6478493dd4a551ead2c97a0b2bb87e68" have entirely different histories.
7bcf8e8301
...
58da1e3a64
@ -1,126 +0,0 @@
|
||||
# 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)
|
||||
@ -3,6 +3,7 @@ package app
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"time"
|
||||
@ -11,55 +12,52 @@ import (
|
||||
"termtap.dev/internal/process"
|
||||
)
|
||||
|
||||
func StartProcess(cmd model.Command, addr string, ch chan<- model.Event) (*model.Process, error) {
|
||||
ch <- model.Event{
|
||||
Type: model.EventTypeProcessStarting,
|
||||
func StartProcess(cmd model.Command, addr string, ch chan<- model.Message, sigCh <-chan os.Signal) {
|
||||
ch <- model.Message{
|
||||
Type: model.MessageTypeProcessStarting,
|
||||
Body: fmt.Sprintf("spawning process '%s'", process.CommandString(cmd)),
|
||||
}
|
||||
|
||||
proc := process.NewProcess(cmd, addr, ch)
|
||||
|
||||
if err := proc.Exec.Start(); err != nil {
|
||||
return nil, fmt.Errorf("start process: %w", err)
|
||||
ch <- model.Message{
|
||||
Type: model.MessageTypeProcessExited,
|
||||
Body: fmt.Sprintf("%q", err),
|
||||
}
|
||||
return
|
||||
}
|
||||
process.UpdateStatus(proc, true, ch)
|
||||
|
||||
go waitForProcessExit(proc, ch)
|
||||
|
||||
return proc, nil
|
||||
}
|
||||
|
||||
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),
|
||||
PID: proc.Exec.Process.Pid,
|
||||
}
|
||||
|
||||
_ = process.SignalProcess(proc.Exec, sig)
|
||||
|
||||
// Listen for SIGTERM from main process
|
||||
go func() {
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
if process.ProcessAlive(proc.Exec) {
|
||||
_ = process.SignalProcess(proc.Exec, syscall.SIGKILL)
|
||||
sig := <-sigCh
|
||||
|
||||
ch <- model.Message{
|
||||
Type: model.MessageTypeProcessSignaled,
|
||||
Body: fmt.Sprintf("process with pid '%d' is being killed", proc.Exec.Process.Pid),
|
||||
PID: proc.Exec.Process.Pid,
|
||||
}
|
||||
|
||||
if proc.Exec != nil {
|
||||
_ = process.SignalProcess(proc.Exec, sig)
|
||||
|
||||
go func() {
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
if process.ProcessAlive(proc.Exec) {
|
||||
_ = 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 exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
|
||||
ch <- model.Event{
|
||||
Type: model.EventTypeProcessExited,
|
||||
Body: fmt.Sprintf("process pid '%d' exited", proc.Exec.Process.Pid),
|
||||
ch <- model.Message{
|
||||
Type: model.MessageTypeProcessExited,
|
||||
Body: fmt.Sprintf("process pid '%d' exited by itself", proc.Exec.Process.Pid),
|
||||
PID: proc.Exec.Process.Pid,
|
||||
ExitCode: exitErr.ExitCode(),
|
||||
}
|
||||
@ -67,19 +65,12 @@ func waitForProcessExit(proc *model.Process, ch chan<- model.Event) {
|
||||
return
|
||||
}
|
||||
|
||||
ch <- model.Event{
|
||||
Type: model.EventTypeFatal,
|
||||
ch <- model.Message{
|
||||
Type: model.MessageTypeFatal,
|
||||
Body: fmt.Sprintf("%q", err),
|
||||
}
|
||||
process.UpdateStatus(proc, false, ch)
|
||||
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)
|
||||
}
|
||||
|
||||
@ -1,31 +1,32 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"termtap.dev/internal/model"
|
||||
"termtap.dev/internal/proxy"
|
||||
)
|
||||
|
||||
func StartProxy(ps *model.ProxyServer, ch chan<- model.Event) {
|
||||
if ps == nil || ps.Server == nil || ps.Listener == nil {
|
||||
func StartProxy(addr string, ch chan<- model.Message) {
|
||||
ps, err := proxy.NewProxyServer(addr, ch)
|
||||
if err != nil {
|
||||
ch <- model.Message{
|
||||
Type: model.MessageTypeFatal,
|
||||
Body: fmt.Sprintf("%q", err),
|
||||
}
|
||||
return
|
||||
}
|
||||
defer proxy.Destory(ps, ch)
|
||||
|
||||
ch <- model.Event{
|
||||
Type: model.EventTypeProxyStarting,
|
||||
Body: fmt.Sprintf("proxy server started on %s", (*ps.Listener).Addr().String()),
|
||||
ch <- model.Message{
|
||||
Type: model.MessageTypeProxyStarting,
|
||||
Body: fmt.Sprintf("proxy server started on %s", addr),
|
||||
}
|
||||
|
||||
if err := ps.Server.Serve(*ps.Listener); err != nil {
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
ch <- model.Event{
|
||||
Type: model.EventTypeFatal,
|
||||
Body: fmt.Sprintf("fatal error in proxy server: %q", err),
|
||||
ch <- model.Message{
|
||||
Type: model.MessageTypeFatal,
|
||||
Body: fmt.Sprintf("%q", err),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@ -1,43 +1,32 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"termtap.dev/internal/model"
|
||||
"termtap.dev/internal/proxy"
|
||||
)
|
||||
|
||||
type Session struct {
|
||||
Events <-chan model.Event
|
||||
Messages <-chan model.Message
|
||||
|
||||
ch chan model.Event
|
||||
proxy *model.ProxyServer
|
||||
proc *model.Process
|
||||
once sync.Once
|
||||
sigCh chan os.Signal
|
||||
stopOnce sync.Once
|
||||
}
|
||||
|
||||
func StartSession(cmd model.Command, addr string) (*Session, error) {
|
||||
msgs := make(chan model.Event, 256)
|
||||
msgs := make(chan model.Message, 256)
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
ps, err := proxy.NewProxyServer(addr, msgs)
|
||||
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
|
||||
}
|
||||
go StartProxy(addr, msgs)
|
||||
go StartProcess(cmd, addr, msgs, sigCh)
|
||||
|
||||
return &Session{
|
||||
Events: msgs,
|
||||
ch: msgs,
|
||||
proxy: ps,
|
||||
proc: proc,
|
||||
Messages: msgs,
|
||||
sigCh: sigCh,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -46,8 +35,12 @@ func (s *Session) Stop() {
|
||||
return
|
||||
}
|
||||
|
||||
s.once.Do(func() {
|
||||
StopProcess(s.proc, s.ch, syscall.SIGTERM)
|
||||
proxy.Destroy(s.proxy, s.ch)
|
||||
s.stopOnce.Do(func() {
|
||||
signal.Stop(s.sigCh)
|
||||
|
||||
select {
|
||||
case s.sigCh <- syscall.SIGTERM:
|
||||
default:
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -26,7 +26,7 @@ func Run(args []string) {
|
||||
}
|
||||
defer session.Stop()
|
||||
|
||||
if err := tui.Run(session.Events); err != nil {
|
||||
if err := tui.Run(session.Messages); err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,34 +0,0 @@
|
||||
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
|
||||
}
|
||||
34
internal/model/message.go
Normal file
34
internal/model/message.go
Normal file
@ -0,0 +1,34 @@
|
||||
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
|
||||
}
|
||||
@ -15,7 +15,7 @@ func CommandString(c model.Command) string {
|
||||
return fmt.Sprintf("%s %s", c.Name, strings.Join(c.Args, " "))
|
||||
}
|
||||
|
||||
func NewProcess(cmd model.Command, addr string, ch chan<- model.Event) *model.Process {
|
||||
func NewProcess(cmd model.Command, addr string, ch chan<- model.Message) *model.Process {
|
||||
proc := exec.Command(cmd.Name, cmd.Args...)
|
||||
configureProcessForSignals(proc)
|
||||
|
||||
@ -23,24 +23,24 @@ func NewProcess(cmd model.Command, addr string, ch chan<- model.Event) *model.Pr
|
||||
|
||||
stdout, err := proc.StdoutPipe()
|
||||
if err != nil {
|
||||
ch <- model.Event{
|
||||
Type: model.EventTypeWarn,
|
||||
ch <- model.Message{
|
||||
Type: model.MessageTypeWarn,
|
||||
Body: fmt.Sprintf("could not open stdout pipe: %q", err),
|
||||
PID: proc.Process.Pid,
|
||||
}
|
||||
} else {
|
||||
go readPipe(stdout, model.EventTypeProcessStdout, ch)
|
||||
go readPipe(stdout, model.MessageTypeProcessStdout, ch)
|
||||
}
|
||||
|
||||
stderr, err := proc.StderrPipe()
|
||||
if err != nil {
|
||||
ch <- model.Event{
|
||||
Type: model.EventTypeWarn,
|
||||
ch <- model.Message{
|
||||
Type: model.MessageTypeWarn,
|
||||
Body: fmt.Sprintf("could not open stderr pipe: %q", err),
|
||||
PID: proc.Process.Pid,
|
||||
}
|
||||
} else {
|
||||
go readPipe(stderr, model.EventTypeProcessStderr, ch)
|
||||
go readPipe(stderr, model.MessageTypeProcessStderr, ch)
|
||||
}
|
||||
|
||||
return &model.Process{
|
||||
@ -66,17 +66,17 @@ func injectEnv(proc *exec.Cmd, addr string) {
|
||||
proc.Env = append(os.Environ(), injected...)
|
||||
}
|
||||
|
||||
func readPipe(pipe io.Reader, t model.EventType, ch chan<- model.Event) {
|
||||
func readPipe(pipe io.Reader, t model.MessageType, ch chan<- model.Message) {
|
||||
scanner := bufio.NewScanner(pipe)
|
||||
for scanner.Scan() {
|
||||
ch <- model.Event{
|
||||
ch <- model.Message{
|
||||
Type: t,
|
||||
Body: scanner.Text(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateStatus(proc *model.Process, running bool, ch chan<- model.Event) {
|
||||
func UpdateStatus(proc *model.Process, running bool, ch chan<- model.Message) {
|
||||
if proc == nil {
|
||||
return
|
||||
}
|
||||
@ -88,18 +88,18 @@ func UpdateStatus(proc *model.Process, running bool, ch chan<- model.Event) {
|
||||
proc.Running = running
|
||||
|
||||
var (
|
||||
t model.EventType
|
||||
t model.MessageType
|
||||
status string
|
||||
)
|
||||
if running {
|
||||
t = model.EventTypeProcessStarted
|
||||
t = model.MessageTypeProcessStarted
|
||||
status = "running"
|
||||
} else {
|
||||
t = model.EventTypeProcessExited
|
||||
t = model.MessageTypeProcessExited
|
||||
status = "stopped"
|
||||
}
|
||||
|
||||
ch <- model.Event{
|
||||
ch <- model.Message{
|
||||
Type: t,
|
||||
Body: fmt.Sprintf("Set process pid '%d' status to %s", proc.Exec.Process.Pid, status),
|
||||
PID: proc.Exec.Process.Pid,
|
||||
|
||||
@ -20,15 +20,15 @@ import (
|
||||
|
||||
const maxPreviewBytes = 1024
|
||||
|
||||
func proxyHandler(ch chan<- model.Event) http.Handler {
|
||||
func proxyHandler(ch chan<- model.Message) http.Handler {
|
||||
transport := http.DefaultTransport
|
||||
|
||||
// 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) {
|
||||
if req.Method == http.MethodConnect {
|
||||
http.Error(w, "CONNECT is not supported yet", http.StatusNotImplemented)
|
||||
ch <- model.Event{
|
||||
Type: model.EventTypeWarn,
|
||||
ch <- model.Message{
|
||||
Type: model.MessageTypeWarn,
|
||||
Body: fmt.Sprintf("CONNECT is not supported: %s", req.Host),
|
||||
}
|
||||
return
|
||||
@ -36,8 +36,8 @@ func proxyHandler(ch chan<- model.Event) http.Handler {
|
||||
|
||||
if req.URL.Scheme == "" || req.URL.Host == "" {
|
||||
http.Error(w, "request must use absolute-form URLs through the proxy", http.StatusBadRequest)
|
||||
ch <- model.Event{
|
||||
Type: model.EventTypeWarn,
|
||||
ch <- model.Message{
|
||||
Type: model.MessageTypeWarn,
|
||||
Body: fmt.Sprintf("rejected non-proxy request %s %s", req.Method, req.URL.String()),
|
||||
}
|
||||
return
|
||||
@ -60,8 +60,8 @@ func proxyHandler(ch chan<- model.Event) http.Handler {
|
||||
|
||||
requestPreview, err := readAndRestoreBody(&req.Body)
|
||||
if err != nil {
|
||||
ch <- model.Event{
|
||||
Type: model.EventTypeWarn,
|
||||
ch <- model.Message{
|
||||
Type: model.MessageTypeWarn,
|
||||
Body: fmt.Sprintf("(%s) failed to read request body", request.ID),
|
||||
Request: request,
|
||||
}
|
||||
@ -80,8 +80,8 @@ func proxyHandler(ch chan<- model.Event) http.Handler {
|
||||
request.RequestHeaders = outReq.Header
|
||||
request.RawURL = outReq.URL.String()
|
||||
|
||||
ch <- model.Event{
|
||||
Type: model.EventTypeRequestStarted,
|
||||
ch <- model.Message{
|
||||
Type: model.MessageTypeRequestStarted,
|
||||
Body: fmt.Sprintf("-> %+v", request),
|
||||
Request: request,
|
||||
}
|
||||
@ -96,8 +96,8 @@ func proxyHandler(ch chan<- model.Event) http.Handler {
|
||||
request.Duration = time.Since(start).Round(time.Microsecond)
|
||||
request.Status = status
|
||||
|
||||
ch <- model.Event{
|
||||
Type: model.EventTypeRequestFailed,
|
||||
ch <- model.Message{
|
||||
Type: model.MessageTypeRequestFailed,
|
||||
Body: fmt.Sprintf("upstream error for %s %s: %v", outReq.Method, outReq.URL.String(), err),
|
||||
Request: request,
|
||||
}
|
||||
@ -107,8 +107,8 @@ func proxyHandler(ch chan<- model.Event) http.Handler {
|
||||
|
||||
responsePreview, err := readAndRestoreBody(&resp.Body)
|
||||
if err != nil {
|
||||
ch <- model.Event{
|
||||
Type: model.EventTypeWarn,
|
||||
ch <- model.Message{
|
||||
Type: model.MessageTypeWarn,
|
||||
Body: fmt.Sprintf("(%s) failed to read response body", request.ID),
|
||||
Request: request,
|
||||
}
|
||||
@ -124,8 +124,8 @@ func proxyHandler(ch chan<- model.Event) http.Handler {
|
||||
request.Duration = time.Since(start).Round(time.Microsecond)
|
||||
request.Status = resp.StatusCode
|
||||
|
||||
ch <- model.Event{
|
||||
Type: model.EventTypeRequestFailed,
|
||||
ch <- model.Message{
|
||||
Type: model.MessageTypeRequestFailed,
|
||||
Body: fmt.Sprintf("write response body %s %s: %v", outReq.Method, outReq.URL.String(), err),
|
||||
}
|
||||
return
|
||||
@ -136,8 +136,8 @@ func proxyHandler(ch chan<- model.Event) http.Handler {
|
||||
request.ResponseHeaders = resp.Header
|
||||
request.Pending = false
|
||||
|
||||
ch <- model.Event{
|
||||
Type: model.EventTypeRequestFinished,
|
||||
ch <- model.Message{
|
||||
Type: model.MessageTypeRequestFinished,
|
||||
Body: fmt.Sprintf("<- %+v %s", request, formatHeaders(resp.Request.Header)),
|
||||
Request: request,
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@ import (
|
||||
"termtap.dev/internal/model"
|
||||
)
|
||||
|
||||
func NewProxyServer(addr string, ch chan<- model.Event) (*model.ProxyServer, error) {
|
||||
func NewProxyServer(addr string, ch chan<- model.Message) (*model.ProxyServer, error) {
|
||||
listener, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -28,14 +28,14 @@ func NewProxyServer(addr string, ch chan<- model.Event) (*model.ProxyServer, err
|
||||
}
|
||||
|
||||
// BUG: Not sure what all this does
|
||||
func Destroy(ps *model.ProxyServer, ch chan<- model.Event) {
|
||||
func Destory(ps *model.ProxyServer, ch chan<- model.Message) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if ps != nil && ps.Server != nil {
|
||||
_ = ps.Server.Shutdown(ctx)
|
||||
ch <- model.Event{
|
||||
Type: model.EventTypeProxyStarted,
|
||||
ch <- model.Message{
|
||||
Type: model.MessageTypeProxyStopped,
|
||||
Body: "proxy server was destroyed",
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,18 +13,18 @@ const (
|
||||
maxRequests = 200
|
||||
)
|
||||
|
||||
type EventMsg struct {
|
||||
value model.Event
|
||||
type appMsg struct {
|
||||
value model.Message
|
||||
}
|
||||
|
||||
type ErrMsg struct {
|
||||
type modelErrMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
msgCh <-chan model.Event
|
||||
msgCh <-chan model.Message
|
||||
|
||||
events []model.Event
|
||||
events []model.Message
|
||||
requestOrder []uuid.UUID
|
||||
requests map[uuid.UUID]model.Request
|
||||
|
||||
@ -32,10 +32,10 @@ type Model struct {
|
||||
height int
|
||||
}
|
||||
|
||||
func NewModel(msgCh <-chan model.Event) Model {
|
||||
func NewModel(msgCh <-chan model.Message) Model {
|
||||
return Model{
|
||||
msgCh: msgCh,
|
||||
events: make([]model.Event, 0, maxEvents),
|
||||
events: make([]model.Message, 0, maxEvents),
|
||||
requestOrder: make([]uuid.UUID, 0, maxRequests),
|
||||
requests: map[uuid.UUID]model.Request{},
|
||||
width: 100,
|
||||
@ -43,7 +43,7 @@ func NewModel(msgCh <-chan model.Event) Model {
|
||||
}
|
||||
}
|
||||
|
||||
func Run(msgCh <-chan model.Event) error {
|
||||
func Run(msgCh <-chan model.Message) error {
|
||||
p := tea.NewProgram(NewModel(msgCh), tea.WithAltScreen())
|
||||
_, err := p.Run()
|
||||
return err
|
||||
@ -53,13 +53,13 @@ func (m Model) Init() tea.Cmd {
|
||||
return waitForAppMessage(m.msgCh)
|
||||
}
|
||||
|
||||
func waitForAppMessage(msgCh <-chan model.Event) tea.Cmd {
|
||||
func waitForAppMessage(msgCh <-chan model.Message) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
msg, ok := <-msgCh
|
||||
if !ok {
|
||||
return ErrMsg{err: fmt.Errorf("event channel closed")}
|
||||
return modelErrMsg{err: fmt.Errorf("event channel closed")}
|
||||
}
|
||||
|
||||
return EventMsg{value: msg}
|
||||
return appMsg{value: msg}
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,14 +22,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case ErrMsg:
|
||||
m.events = append(m.events, model.Event{
|
||||
Type: model.EventTypeWarn,
|
||||
case modelErrMsg:
|
||||
m.events = append(m.events, model.Message{
|
||||
Type: model.MessageTypeWarn,
|
||||
Body: fmt.Sprintf("tui event stream closed: %v", msg.err),
|
||||
})
|
||||
return m, nil
|
||||
|
||||
case EventMsg:
|
||||
case appMsg:
|
||||
m.pushEvent(msg.value)
|
||||
m.applyMessage(msg.value)
|
||||
return m, waitForAppMessage(m.msgCh)
|
||||
@ -38,18 +38,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) pushEvent(msg model.Event) {
|
||||
func (m *Model) pushEvent(msg model.Message) {
|
||||
m.events = append(m.events, msg)
|
||||
if len(m.events) > maxEvents {
|
||||
m.events = m.events[len(m.events)-maxEvents:]
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) applyMessage(msg model.Event) {
|
||||
func (m *Model) applyMessage(msg model.Message) {
|
||||
switch msg.Type {
|
||||
case model.EventTypeRequestStarted:
|
||||
case model.MessageTypeRequestStarted:
|
||||
m.upsertRequest(msg.Request, true)
|
||||
case model.EventTypeRequestFinished, model.EventTypeRequestFailed:
|
||||
case model.MessageTypeRequestFinished, model.MessageTypeRequestFailed:
|
||||
m.upsertRequest(msg.Request, false)
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,7 +7,6 @@ import (
|
||||
"termtap.dev/internal/model"
|
||||
)
|
||||
|
||||
// TODO: This is all temporary
|
||||
func (m Model) View() string {
|
||||
eventLines := m.renderEvents(8)
|
||||
requestLines := m.renderRequests(12)
|
||||
@ -30,7 +29,10 @@ func (m Model) renderEvents(limit int) string {
|
||||
return " (none yet)"
|
||||
}
|
||||
|
||||
start := max(len(m.events)-limit, 0)
|
||||
start := len(m.events) - limit
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
|
||||
rows := make([]string, 0, len(m.events)-start)
|
||||
for i := start; i < len(m.events); i++ {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user