1package client
2
3import (
4 "context"
5 "errors"
6 "sync"
7
8 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/ports"
10 "github.com/bnema/vev/internal/protocol"
11)
12
13// Picker kill.
14//
15// `x` on a local session row kills it through the broker operations seam: one
16// independent, bounded control stream to the owning daemon, never the
17// attachment stream. A live session is torn down; a stopped session's history
18// record is deleted by the same typed Kill, which the daemon routes to its
19// stopped-session purge. The operation runs off the supervisor's run goroutine
20// so the picker keeps rendering; its typed outcome comes back as one notice and
21// a catalogue reconcile. An unknown outcome is reported and never replayed.
22
23// pickerKillResolver is the optional kill resolution of the real picker.
24type pickerKillResolver interface {
25 ResolveKill(key string) (pickerKillTarget, error)
26}
27
28// pickerCurrentHost is the optional cursor seam of the real picker.
29type pickerCurrentHost interface {
30 SetCurrent(pickerCurrent)
31}
32
33// pickerKillOutcome is one settled kill.
34type pickerKillOutcome struct {
35 target pickerKillTarget
36 result protocol.KillResult
37 err error
38}
39
40// pickerKills owns at most one in-flight picker kill. It is only touched from
41// the supervisor's run goroutine; the worker goroutine only sends on done.
42type pickerKills struct {
43 running bool
44 done chan pickerKillOutcome
45 cancel context.CancelFunc
46 wg sync.WaitGroup
47}
48
49// results is the settled-kill wake, nil while no kill runs.
50func (k *pickerKills) results() <-chan pickerKillOutcome {
51 if k == nil || !k.running {
52 return nil
53 }
54 return k.done
55}
56
57// setPickerCurrent names the attachment the picker is presented over.
58func (s *Supervisor) setPickerCurrent(current pickerCurrent) {
59 if host, ok := s.cfg.Picker.(pickerCurrentHost); ok {
60 host.SetCurrent(current)
61 }
62}
63
64// startPickerKill resolves one kill key against the latest catalogue and runs
65// the kill. A refusal is already a picker notice; a second `x` while one kill
66// runs is refused rather than queued.
67func (s *Supervisor) startPickerKill(service ports.BrokerNavigator, key string) {
68 resolver, ok := s.cfg.Picker.(pickerKillResolver)
69 if !ok || key == "" || supervisorNil(service) {
70 return
71 }
72 if s.picker.kills.running {
73 s.notifyPicker(pickerKillRefused("a kill is already in progress"))
74 return
75 }
76 target, err := resolver.ResolveKill(key)
77 if err != nil {
78 return
79 }
80 operations, err := NewBrokerOperations(service, s.cfg.Clock)
81 if err != nil {
82 s.notifyPicker(pickerKillRefused("couldn't kill " + target.name + ": broker unavailable"))
83 return
84 }
85 if s.picker.kills.done == nil {
86 s.picker.kills.done = make(chan pickerKillOutcome, 1)
87 }
88 killCtx, cancel := context.WithCancel(context.Background())
89 s.picker.kills.running = true
90 s.picker.kills.cancel = cancel
91 s.picker.kills.wg.Add(1)
92 go func() {
93 defer s.picker.kills.wg.Done()
94 result, err := operations.Kill(killCtx, target.route, target.name)
95 s.picker.kills.done <- pickerKillOutcome{target: target, result: result, err: err}
96 }()
97}
98
99// finishPickerKill reports one settled kill and asks the broker to re-observe
100// the local daemon, so the killed row leaves the catalogue promptly.
101func (s *Supervisor) finishPickerKill(service ports.BrokerNavigator, outcome pickerKillOutcome) {
102 s.settlePickerKill()
103 s.notifyPicker(pickerKillNotice(outcome))
104 if !supervisorNil(service) {
105 service.RequestReconcile("")
106 }
107 s.renderCurrent()
108}
109
110// retirePickerKill cancels and joins an in-flight kill before its borrowed
111// service closes. Its outcome is still reported, never replayed.
112func (s *Supervisor) retirePickerKill() {
113 if !s.picker.kills.running {
114 return
115 }
116 s.picker.kills.cancel()
117 s.picker.kills.wg.Wait()
118 outcome := <-s.picker.kills.done
119 s.settlePickerKill()
120 s.notifyPicker(pickerKillNotice(outcome))
121}
122
123func (s *Supervisor) settlePickerKill() {
124 if s.picker.kills.cancel != nil {
125 s.picker.kills.cancel()
126 }
127 s.picker.kills.running = false
128 s.picker.kills.cancel = nil
129}
130
131// pickerKillRefused is a kill that was never sent.
132func pickerKillRefused(message string) domain.Notification {
133 return domain.Notification{Code: domain.NoticeSessionKill, Severity: domain.NoticeWarn, Message: message}
134}
135
136// pickerKillNotice renders one typed kill outcome.
137func pickerKillNotice(outcome pickerKillOutcome) domain.Notification {
138 sev := domain.NoticeError
139 if outcome.err == nil && outcome.result.Outcome == protocol.KillSucceeded {
140 sev = domain.NoticeInfo
141 }
142 // Each target is its own subject, so kills in a row stack instead of
143 // replacing each other.
144 return domain.Notification{Code: domain.NoticeSessionKill, Severity: sev, Message: pickerKillText(outcome), Scope: outcome.target.name}
145}
146
147func pickerKillText(outcome pickerKillOutcome) string {
148 name := outcome.target.name
149 verb, done := "kill", "killed session "
150 if outcome.target.stopped {
151 verb, done = "delete stopped session", "deleted stopped session "
152 }
153 if outcome.err != nil {
154 if errors.Is(outcome.err, ErrBrokerOperationNotSent) {
155 return "couldn't " + verb + " " + name + ": not sent"
156 }
157 return "couldn't " + verb + " " + name
158 }
159 switch outcome.result.Outcome {
160 case protocol.KillSucceeded:
161 return done + name
162 case protocol.KillFailed:
163 if outcome.result.Text != "" {
164 return "couldn't " + verb + " " + name + ": " + outcome.result.Text
165 }
166 return "couldn't " + verb + " " + name
167 default:
168 // The request may have reached the daemon: never retried.
169 return verb + " " + name + ": outcome unknown"
170 }
171}