Skip to content

Commit e05bb73

Browse files
committed
feat: enhance agent SSE handling and worker restart logic
- Add sseRestartListener to handle vGPU restart events separately from config updates. - Update sseConfigListener to improve logging and connection handling. - Modify restartWorker to include actual worker info for better process management. - Introduce a retry mechanism for failed worker restarts to improve resilience. - Update settings.local.json to include additional Bash commands for enhanced functionality.
1 parent 337dd90 commit e05bb73

4 files changed

Lines changed: 159 additions & 41 deletions

File tree

.claude/settings.local.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@
44
"Bash(go build:*)",
55
"Bash(go env:*)",
66
"Bash(curl:*)",
7-
"Bash(echo exit: $?:*)"
7+
"Bash(echo exit: $?:*)",
8+
"Bash(npm install:*)",
9+
"Bash(where node:*)",
10+
"Bash(winget install:*)",
11+
"Bash(cmd:*)",
12+
"Bash(powershell:*)"
813
]
914
}
1015
}

internal/agent/agent.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,9 +255,10 @@ func (a *Agent) Start() error {
255255
}
256256

257257
// Start background tasks
258-
a.wg.Add(2)
258+
a.wg.Add(3)
259259
go a.statusReportLoop()
260260
go a.sseConfigListener()
261+
go a.sseRestartListener()
261262

262263
klog.Infof("Agent started: agent_id=%s pid=%d", a.agentID, os.Getpid())
263264

internal/agent/sse.go

Lines changed: 117 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,9 @@ const (
1919
sseVGPURestartTopicSuffix = "_vgpu_restart"
2020
)
2121

22-
// sseConfigListener connects to the SSE endpoint and triggers config re-fetch on new events.
23-
// It reconnects automatically with exponential backoff.
22+
// sseConfigListener connects to the SSE endpoint for config-update events and
23+
// triggers config re-fetch on new events. It reconnects automatically with
24+
// exponential backoff.
2425
func (a *Agent) sseConfigListener() {
2526
defer a.wg.Done()
2627

@@ -35,22 +36,52 @@ func (a *Agent) sseConfigListener() {
3536

3637
err := a.listenSSE()
3738
if err != nil {
38-
klog.Warningf("SSE connection error: %v", err)
39+
klog.Warningf("SSE config connection error: %v", err)
3940
}
4041

41-
// Check if context is done before reconnecting
4242
select {
4343
case <-a.ctx.Done():
4444
return
4545
case <-time.After(backoff):
4646
}
4747

48-
// Exponential backoff
4948
backoff = min(backoff*2, sseReconnectMax)
5049
}
5150
}
5251

53-
// listenSSE opens a single SSE connection and processes events until disconnected.
52+
// sseRestartListener connects to the SSE endpoint for vGPU restart events and
53+
// routes them to the reconciler. It runs on a dedicated connection so that
54+
// messages can be attributed to the restart topic without relying on the
55+
// broker setting the SSE `event:` field (which sse.tensor-fusion.ai does not).
56+
func (a *Agent) sseRestartListener() {
57+
defer a.wg.Done()
58+
59+
backoff := sseReconnectMin
60+
61+
for {
62+
select {
63+
case <-a.ctx.Done():
64+
return
65+
default:
66+
}
67+
68+
err := a.listenSSERestart()
69+
if err != nil {
70+
klog.Warningf("SSE restart connection error: %v", err)
71+
}
72+
73+
select {
74+
case <-a.ctx.Done():
75+
return
76+
case <-time.After(backoff):
77+
}
78+
79+
backoff = min(backoff*2, sseReconnectMax)
80+
}
81+
}
82+
83+
// listenSSE opens a single SSE connection for config-update events (topic =
84+
// agentID) and triggers a debounced pullConfig on every received frame.
5485
func (a *Agent) listenSSE() error {
5586
ctx, cancel := context.WithCancel(a.ctx)
5687
defer cancel()
@@ -59,7 +90,11 @@ func (a *Agent) listenSSE() error {
5990
if err != nil {
6091
return err
6192
}
62-
req.Header.Set(sseTopicHeader, strings.Join([]string{a.agentID, a.vgpuRestartTopic()}, ","))
93+
// Subscribe only to the config-update topic.
94+
// Restart events are handled by a separate connection (listenSSERestart)
95+
// because the SSE broker does not include the topic name in the frame,
96+
// making it impossible to route events from a multi-topic subscription.
97+
req.Header.Set(sseTopicHeader, a.agentID)
6398
req.Header.Set("Accept", "text/event-stream")
6499
req.Header.Set("Cache-Control", "no-cache")
65100

@@ -71,25 +106,31 @@ func (a *Agent) listenSSE() error {
71106
defer func() { _ = resp.Body.Close() }()
72107

73108
if resp.StatusCode != http.StatusOK {
74-
klog.Warningf("SSE endpoint returned status %d", resp.StatusCode)
109+
klog.Warningf("SSE config endpoint returned status %d", resp.StatusCode)
75110
return nil
76111
}
77112

78-
klog.Infof("SSE connection established: topics=%s,%s", a.agentID, a.vgpuRestartTopic())
113+
klog.Infof("SSE config connection established: topic=%s", a.agentID)
79114

80115
scanner := bufio.NewScanner(resp.Body)
81116
var debounceTimer *time.Timer
82-
var eventType string
83117
var eventDataLines []string
84118

85119
flushEvent := func() {
86120
if len(eventDataLines) == 0 {
87-
eventType = ""
88121
return
89122
}
90-
a.handleSSEEvent(eventType, eventDataLines, &debounceTimer)
91-
eventType = ""
92123
eventDataLines = nil
124+
// Debounce config pull so event bursts result in one pullConfig call.
125+
if debounceTimer != nil {
126+
debounceTimer.Stop()
127+
}
128+
debounceTimer = time.AfterFunc(sseDebounceDelay, func() {
129+
klog.Infof("SSE config event received, triggering config re-fetch")
130+
if err := a.pullConfig(); err != nil {
131+
klog.Errorf("Failed to pull config after SSE event: %v", err)
132+
}
133+
})
93134
}
94135

95136
for scanner.Scan() {
@@ -103,12 +144,9 @@ func (a *Agent) listenSSE() error {
103144
}
104145

105146
line := scanner.Text()
106-
107147
switch {
108148
case line == "":
109149
flushEvent()
110-
case strings.HasPrefix(line, "event:"):
111-
eventType = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
112150
case strings.HasPrefix(line, "data:"):
113151
eventDataLines = append(eventDataLines, strings.TrimSpace(strings.TrimPrefix(line, "data:")))
114152
}
@@ -123,30 +161,77 @@ func (a *Agent) listenSSE() error {
123161
return err
124162
}
125163

126-
klog.Infof("SSE connection closed by server, will reconnect")
164+
klog.Infof("SSE config connection closed by server, will reconnect")
127165
return nil
128166
}
129167

130-
func (a *Agent) vgpuRestartTopic() string {
131-
return a.agentID + sseVGPURestartTopicSuffix
132-
}
168+
// listenSSERestart opens a single SSE connection for vGPU restart events
169+
// (topic = agentID + "_vgpu_restart"). Every received frame is forwarded
170+
// directly to handleVGPURestartEvent.
171+
func (a *Agent) listenSSERestart() error {
172+
ctx, cancel := context.WithCancel(a.ctx)
173+
defer cancel()
133174

134-
func (a *Agent) handleSSEEvent(eventType string, dataLines []string, debounceTimer **time.Timer) {
135-
if strings.TrimSpace(eventType) == a.vgpuRestartTopic() {
136-
a.handleVGPURestartEvent(dataLines)
137-
return
175+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sseEndpoint, nil)
176+
if err != nil {
177+
return err
138178
}
179+
req.Header.Set(sseTopicHeader, a.vgpuRestartTopic())
180+
req.Header.Set("Accept", "text/event-stream")
181+
req.Header.Set("Cache-Control", "no-cache")
139182

140-
// Debounce config pull so event bursts result in one pullConfig call.
141-
if *debounceTimer != nil {
142-
(*debounceTimer).Stop()
183+
client := &http.Client{}
184+
resp, err := client.Do(req)
185+
if err != nil {
186+
return err
143187
}
144-
*debounceTimer = time.AfterFunc(sseDebounceDelay, func() {
145-
klog.Infof("SSE event received, triggering config re-fetch")
146-
if err := a.pullConfig(); err != nil {
147-
klog.Errorf("Failed to pull config after SSE event: %v", err)
188+
defer func() { _ = resp.Body.Close() }()
189+
190+
if resp.StatusCode != http.StatusOK {
191+
klog.Warningf("SSE restart endpoint returned status %d", resp.StatusCode)
192+
return nil
193+
}
194+
195+
klog.Infof("SSE restart connection established: topic=%s", a.vgpuRestartTopic())
196+
197+
scanner := bufio.NewScanner(resp.Body)
198+
var eventDataLines []string
199+
200+
flushEvent := func() {
201+
if len(eventDataLines) == 0 {
202+
return
203+
}
204+
a.handleVGPURestartEvent(eventDataLines)
205+
eventDataLines = nil
206+
}
207+
208+
for scanner.Scan() {
209+
select {
210+
case <-ctx.Done():
211+
return nil
212+
default:
148213
}
149-
})
214+
215+
line := scanner.Text()
216+
switch {
217+
case line == "":
218+
flushEvent()
219+
case strings.HasPrefix(line, "data:"):
220+
eventDataLines = append(eventDataLines, strings.TrimSpace(strings.TrimPrefix(line, "data:")))
221+
}
222+
}
223+
flushEvent()
224+
225+
if err := scanner.Err(); err != nil {
226+
return err
227+
}
228+
229+
klog.Infof("SSE restart connection closed by server, will reconnect")
230+
return nil
231+
}
232+
233+
func (a *Agent) vgpuRestartTopic() string {
234+
return a.agentID + sseVGPURestartTopicSuffix
150235
}
151236

152237
func (a *Agent) handleVGPURestartEvent(dataLines []string) int {

internal/hypervisor/reconciler.go

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ func (r *Reconciler) reconcile() {
177177
klog.Infof("Force restart requested for worker: worker_id=%s", workerID)
178178
}
179179
// Structural change (GPU allocation, executable, args) requires restart
180-
if err := r.restartWorker(desiredInfo); err != nil {
180+
if err := r.restartWorker(desiredInfo, actualWorker); err != nil {
181181
klog.Errorf("Failed to restart worker: worker_id=%s error=%v", workerID, err)
182182
if forceRestart {
183183
retryRestarts[workerID] = struct{}{}
@@ -213,6 +213,12 @@ func (r *Reconciler) reconcile() {
213213
r.forceRestarts[workerID] = struct{}{}
214214
}
215215
r.mu.Unlock()
216+
// Schedule a reconcile soon so failed restarts are retried quickly
217+
// rather than waiting for the 30-second ticker.
218+
go func() {
219+
time.Sleep(5 * time.Second)
220+
r.TriggerReconcile()
221+
}()
216222
}
217223

218224
if added > 0 || removed > 0 || updated > 0 {
@@ -248,17 +254,38 @@ func (r *Reconciler) stopWorker(workerID string) error {
248254
return nil
249255
}
250256

251-
func (r *Reconciler) restartWorker(info *api.WorkerInfo) error {
257+
func (r *Reconciler) restartWorker(desired *api.WorkerInfo, actual *api.WorkerInfo) error {
252258
// Stop first
253-
if err := r.stopWorker(info.WorkerUID); err != nil {
254-
klog.Warningf("Failed to stop worker during restart: worker_id=%s error=%v", info.WorkerUID, err)
259+
if err := r.stopWorker(desired.WorkerUID); err != nil {
260+
klog.Warningf("Failed to stop worker during restart: worker_id=%s error=%v", desired.WorkerUID, err)
255261
}
256262

257-
// Small delay to ensure cleanup
258-
time.Sleep(100 * time.Millisecond)
263+
// StopWorker sends a signal but does not wait for the process to exit.
264+
// Poll until the old process releases the port, then start the new one.
265+
// Without this wait, StartWorker fails with "port already in use".
266+
if actual != nil && actual.WorkerRunningInfo != nil && actual.WorkerRunningInfo.PID > 0 {
267+
pid := int(actual.WorkerRunningInfo.PID)
268+
if isProcessRunning(pid) {
269+
deadline := time.Now().Add(10 * time.Second)
270+
for time.Now().Before(deadline) {
271+
if !isProcessRunning(pid) {
272+
break
273+
}
274+
time.Sleep(100 * time.Millisecond)
275+
}
276+
if isProcessRunning(pid) {
277+
klog.Warningf("Worker process did not exit within 10s, force killing: worker_id=%s pid=%d", desired.WorkerUID, pid)
278+
forceKillWorkerProcess(pid)
279+
time.Sleep(200 * time.Millisecond) // Allow OS to reclaim port after force kill
280+
}
281+
}
282+
} else {
283+
// PID unknown: short delay to let any in-flight socket closure finish
284+
time.Sleep(500 * time.Millisecond)
285+
}
259286

260287
// Start with new config
261-
return r.startWorker(info)
288+
return r.startWorker(desired)
262289
}
263290

264291
// needsRestart checks if structural config changed (GPU allocation, executable, args)

0 commit comments

Comments
 (0)