package client import ( "context" "errors" "sync" "github.com/bnema/vev/internal/domain" "github.com/bnema/vev/internal/ports" "github.com/bnema/vev/internal/protocol" ) // Picker kill. // // `x` on a local session row kills it through the broker operations seam: one // independent, bounded control stream to the owning daemon, never the // attachment stream. A live session is torn down; a stopped session's history // record is deleted by the same typed Kill, which the daemon routes to its // stopped-session purge. The operation runs off the supervisor's run goroutine // so the picker keeps rendering; its typed outcome comes back as one notice and // a catalogue reconcile. An unknown outcome is reported and never replayed. // pickerKillResolver is the optional kill resolution of the real picker. type pickerKillResolver interface { ResolveKill(key string) (pickerKillTarget, error) } // pickerCurrentHost is the optional cursor seam of the real picker. type pickerCurrentHost interface { SetCurrent(pickerCurrent) } // pickerKillOutcome is one settled kill. type pickerKillOutcome struct { target pickerKillTarget result protocol.KillResult err error } // pickerKills owns at most one in-flight picker kill. It is only touched from // the supervisor's run goroutine; the worker goroutine only sends on done. type pickerKills struct { running bool done chan pickerKillOutcome cancel context.CancelFunc wg sync.WaitGroup } // results is the settled-kill wake, nil while no kill runs. func (k *pickerKills) results() <-chan pickerKillOutcome { if k == nil || !k.running { return nil } return k.done } // setPickerCurrent names the attachment the picker is presented over. func (s *Supervisor) setPickerCurrent(current pickerCurrent) { if host, ok := s.cfg.Picker.(pickerCurrentHost); ok { host.SetCurrent(current) } } // startPickerKill resolves one kill key against the latest catalogue and runs // the kill. A refusal is already a picker notice; a second `x` while one kill // runs is refused rather than queued. func (s *Supervisor) startPickerKill(service ports.BrokerNavigator, key string) { resolver, ok := s.cfg.Picker.(pickerKillResolver) if !ok || key == "" || supervisorNil(service) { return } if s.picker.kills.running { s.notifyPicker(pickerKillRefused("a kill is already in progress")) return } target, err := resolver.ResolveKill(key) if err != nil { return } operations, err := NewBrokerOperations(service, s.cfg.Clock) if err != nil { s.notifyPicker(pickerKillRefused("couldn't kill " + target.name + ": broker unavailable")) return } if s.picker.kills.done == nil { s.picker.kills.done = make(chan pickerKillOutcome, 1) } killCtx, cancel := context.WithCancel(context.Background()) s.picker.kills.running = true s.picker.kills.cancel = cancel s.picker.kills.wg.Add(1) go func() { defer s.picker.kills.wg.Done() result, err := operations.Kill(killCtx, target.route, target.name) s.picker.kills.done <- pickerKillOutcome{target: target, result: result, err: err} }() } // finishPickerKill reports one settled kill and asks the broker to re-observe // the local daemon, so the killed row leaves the catalogue promptly. func (s *Supervisor) finishPickerKill(service ports.BrokerNavigator, outcome pickerKillOutcome) { s.settlePickerKill() s.notifyPicker(pickerKillNotice(outcome)) if !supervisorNil(service) { service.RequestReconcile("") } s.renderCurrent() } // retirePickerKill cancels and joins an in-flight kill before its borrowed // service closes. Its outcome is still reported, never replayed. func (s *Supervisor) retirePickerKill() { if !s.picker.kills.running { return } s.picker.kills.cancel() s.picker.kills.wg.Wait() outcome := <-s.picker.kills.done s.settlePickerKill() s.notifyPicker(pickerKillNotice(outcome)) } func (s *Supervisor) settlePickerKill() { if s.picker.kills.cancel != nil { s.picker.kills.cancel() } s.picker.kills.running = false s.picker.kills.cancel = nil } // pickerKillRefused is a kill that was never sent. func pickerKillRefused(message string) domain.Notification { return domain.Notification{Code: domain.NoticeSessionKill, Severity: domain.NoticeWarn, Message: message} } // pickerKillNotice renders one typed kill outcome. func pickerKillNotice(outcome pickerKillOutcome) domain.Notification { sev := domain.NoticeError if outcome.err == nil && outcome.result.Outcome == protocol.KillSucceeded { sev = domain.NoticeInfo } // Each target is its own subject, so kills in a row stack instead of // replacing each other. return domain.Notification{Code: domain.NoticeSessionKill, Severity: sev, Message: pickerKillText(outcome), Scope: outcome.target.name} } func pickerKillText(outcome pickerKillOutcome) string { name := outcome.target.name verb, done := "kill", "killed session " if outcome.target.stopped { verb, done = "delete stopped session", "deleted stopped session " } if outcome.err != nil { if errors.Is(outcome.err, ErrBrokerOperationNotSent) { return "couldn't " + verb + " " + name + ": not sent" } return "couldn't " + verb + " " + name } switch outcome.result.Outcome { case protocol.KillSucceeded: return done + name case protocol.KillFailed: if outcome.result.Text != "" { return "couldn't " + verb + " " + name + ": " + outcome.result.Text } return "couldn't " + verb + " " + name default: // The request may have reached the daemon: never retried. return verb + " " + name + ": outcome unknown" } }