// Package tasks runs long commands (apt upgrades, and later anything else the UI kicks // off) in the background, keeps their output, and lets any number of clients follow it // live. It is also the task history: what ran, when, and how it ended. package tasks import ( "bytes" "context" "crypto/rand" "encoding/hex" "errors" "os/exec" "sort" "sync" "time" ) const ( maxOutput = 1 << 20 // bytes kept per task maxHistory = 200 ) type Task struct { ID string Kind string // "updates", "cron", … Title string Command string Started time.Time Finished time.Time Running bool ExitCode int Err string mu sync.Mutex buf bytes.Buffer subs map[chan []byte]struct{} cancel context.CancelFunc } type Snapshot struct { ID string `json:"id"` Kind string `json:"kind"` Title string `json:"title"` Command string `json:"command"` Started time.Time `json:"started"` Finished *time.Time `json:"finished,omitempty"` Running bool `json:"running"` ExitCode int `json:"exit_code"` Err string `json:"error,omitempty"` Output string `json:"output,omitempty"` } type Manager struct { mu sync.Mutex tasks map[string]*Task } func New() *Manager { return &Manager{tasks: map[string]*Task{}} } func newID() string { b := make([]byte, 6) _, _ = rand.Read(b) return hex.EncodeToString(b) } // Start launches cmd with its output captured into a new task. cmd must not have // Stdout/Stderr set. The task ends when the process exits or Cancel is called. func (m *Manager) Start(kind, title string, cmd *exec.Cmd) (*Task, error) { ctx, cancel := context.WithCancel(context.Background()) t := &Task{ID: newID(), Kind: kind, Title: title, Command: cmd.String(), Started: time.Now(), Running: true, subs: map[chan []byte]struct{}{}, cancel: cancel} w := &writer{t: t} cmd.Stdout, cmd.Stderr = w, w if err := cmd.Start(); err != nil { cancel() return nil, err } m.mu.Lock() m.tasks[t.ID] = t m.prune() m.mu.Unlock() go func() { done := make(chan error, 1) go func() { done <- cmd.Wait() }() var err error select { case err = <-done: case <-ctx.Done(): _ = cmd.Process.Kill() err = <-done t.mu.Lock() t.Err = "cancelled" t.mu.Unlock() } t.mu.Lock() t.Running, t.Finished = false, time.Now() if err != nil { var ee *exec.ExitError if errors.As(err, &ee) { t.ExitCode = ee.ExitCode() } else { t.ExitCode = -1 if t.Err == "" { t.Err = err.Error() } } } for ch := range t.subs { close(ch) } t.subs = map[chan []byte]struct{}{} t.mu.Unlock() cancel() }() return t, nil } // prune keeps the newest maxHistory finished tasks. Caller holds m.mu. func (m *Manager) prune() { if len(m.tasks) <= maxHistory { return } var finished []*Task for _, t := range m.tasks { if !t.Running { finished = append(finished, t) } } sort.Slice(finished, func(i, j int) bool { return finished[i].Started.Before(finished[j].Started) }) for i := 0; i < len(finished) && len(m.tasks) > maxHistory; i++ { delete(m.tasks, finished[i].ID) } } func (m *Manager) Get(id string) (*Task, bool) { m.mu.Lock() defer m.mu.Unlock() t, ok := m.tasks[id] return t, ok } // List returns every task newest first, without output. func (m *Manager) List() []Snapshot { m.mu.Lock() all := make([]*Task, 0, len(m.tasks)) for _, t := range m.tasks { all = append(all, t) } m.mu.Unlock() sort.Slice(all, func(i, j int) bool { return all[i].Started.After(all[j].Started) }) out := make([]Snapshot, 0, len(all)) for _, t := range all { out = append(out, t.Snapshot(false)) } return out } // RunningOf reports whether a task of the given kind is in flight. func (m *Manager) RunningOf(kind string) (*Task, bool) { m.mu.Lock() defer m.mu.Unlock() for _, t := range m.tasks { if t.Kind == kind && t.Running { return t, true } } return nil, false } func (t *Task) Snapshot(withOutput bool) Snapshot { t.mu.Lock() defer t.mu.Unlock() s := Snapshot{ID: t.ID, Kind: t.Kind, Title: t.Title, Command: t.Command, Started: t.Started, Running: t.Running, ExitCode: t.ExitCode, Err: t.Err} if !t.Running { f := t.Finished s.Finished = &f } if withOutput { s.Output = t.buf.String() } return s } // Subscribe returns the output so far plus a channel of further chunks, closed when the // task ends. Call unsub when done following. func (t *Task) Subscribe() (backlog []byte, ch chan []byte, unsub func()) { t.mu.Lock() defer t.mu.Unlock() backlog = append([]byte(nil), t.buf.Bytes()...) if !t.Running { c := make(chan []byte) close(c) return backlog, c, func() {} } ch = make(chan []byte, 256) t.subs[ch] = struct{}{} return backlog, ch, func() { t.mu.Lock() if _, ok := t.subs[ch]; ok { delete(t.subs, ch) close(ch) } t.mu.Unlock() } } func (t *Task) Cancel() { t.cancel() } type writer struct{ t *Task } func (w *writer) Write(p []byte) (int, error) { t := w.t t.mu.Lock() defer t.mu.Unlock() t.buf.Write(p) if t.buf.Len() > maxOutput { b := t.buf.Bytes() t.buf = *bytes.NewBuffer(append([]byte("…\n"), b[len(b)-maxOutput*3/4:]...)) } chunk := append([]byte(nil), p...) for ch := range t.subs { select { case ch <- chunk: default: // slow follower: drop the chunk rather than stall the process } } return len(p), nil }