Skip to content

Commit 70dd15a

Browse files
committed
refactor(sse): redesign with session model and type-safe events
Introduce a Session-based API that encapsulates topic management inside the package, removing the exported TopicKey and context.Value pattern from consumers. EventType is now a typed enum (Stdout, Stderr, Done) replacing loose string constants and hardcoded "done". Server is no longer an http.Handler — sessions handle the upgrade via Session.ServeHTTP. Publish returns errors instead of silently swallowing them; the management layer logs failures via a publish() helper. OnSession now writes 403 Forbidden on rejection instead of returning a misleading 200. SSE Shutdown is wired into the management server lifecycle after Serve returns. The logrus dependency moves from the sse package to the management package where logging actually happens.
1 parent 779479d commit 70dd15a

3 files changed

Lines changed: 82 additions & 35 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ CLAUDE.md
99
task.md
1010
ANALYSIS.md
1111
test_20runs.sh
12+
.qoder

pkg/service/management/server.go

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ import (
1313
ssev2 "linuxvm/pkg/sse"
1414
"net/http"
1515
"sync"
16+
"time"
1617

17-
"github.com/google/uuid"
18+
"github.com/sirupsen/logrus"
1819
)
1920

2021
type Server struct {
@@ -51,19 +52,24 @@ func NewServer(machine Machine) (*Server, error) {
5152
return &Server{
5253
machine: machine,
5354
srv: httpv2.NewUnixSockHTTPServer("management-api", config.Endpoints.ManagementAPI),
54-
sse: ssev2.NewSSEServer(),
55+
sse: ssev2.NewServer(),
5556
}, nil
5657
}
5758

5859
func (s *Server) Start(ctx context.Context) error {
59-
// new management api
6060
s.srv.Mux.HandleFunc("/v2/healthz", s.handleHealth)
6161
s.srv.Mux.HandleFunc("/v2/vmconfig", s.handleVMConfig)
6262
s.srv.Mux.HandleFunc("/v2/attach", s.handleAttach)
6363
s.srv.Mux.HandleFunc("/v2/exec", s.handleExec)
6464
s.srv.Mux.HandleFunc("/v2/stop", s.handleRequestVMStop)
6565

66-
return s.srv.Serve(ctx)
66+
err := s.srv.Serve(ctx)
67+
68+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
69+
defer cancel()
70+
_ = s.sse.Shutdown(shutdownCtx)
71+
72+
return err
6773
}
6874

6975
type Info struct {
@@ -122,18 +128,18 @@ func (s *Server) handleExec(w http.ResponseWriter, r *http.Request) {
122128
http.Error(w, "invalid json", http.StatusBadRequest)
123129
return
124130
}
125-
topic := "sess-" + uuid.NewString()
126-
ctx, cancel := context.WithCancel(context.WithValue(r.Context(), ssev2.TopicKey, topic)) //nolint:staticcheck
131+
sess := s.sse.BeginSession()
132+
ctx, cancel := context.WithCancel(r.Context())
127133
defer cancel()
128-
go s.executeCommand(ctx, cancel, topic, req)
129-
s.sse.ServeHTTP(w, r.WithContext(ctx))
134+
go s.executeCommand(ctx, cancel, sess, req)
135+
sess.ServeHTTP(w, r.WithContext(ctx))
130136
}
131137

132-
func (s *Server) executeCommand(ctx context.Context, cancel context.CancelFunc, topic string, req execRequest) {
138+
func (s *Server) executeCommand(ctx context.Context, cancel context.CancelFunc, sess *ssev2.Session, req execRequest) {
133139
defer cancel()
134140
proc, err := sshsvc.GuestExec(ctx, s.machine.SSHTarget(), req.Bin, req.Args...)
135141
if err != nil {
136-
s.sse.Publish(topic, ssev2.TypeErr, "guest exec failed: "+err.Error())
142+
publish(sess, ssev2.Stderr, "guest exec failed: "+err.Error())
137143
return
138144
}
139145
var wg sync.WaitGroup
@@ -143,21 +149,27 @@ func (s *Server) executeCommand(ctx context.Context, cancel context.CancelFunc,
143149
sc := bufio.NewScanner(proc.StdoutPipeReader)
144150
sc.Buffer(make([]byte, 64*1024), 1<<20)
145151
for sc.Scan() {
146-
s.sse.Publish(topic, ssev2.TypeOut, sc.Text())
152+
publish(sess, ssev2.Stdout, sc.Text())
147153
}
148154
}()
149155
go func() {
150156
defer wg.Done()
151157
sc := bufio.NewScanner(proc.StderrPipeReader)
152158
sc.Buffer(make([]byte, 64*1024), 1<<20)
153159
for sc.Scan() {
154-
s.sse.Publish(topic, ssev2.TypeErr, sc.Text())
160+
publish(sess, ssev2.Stderr, sc.Text())
155161
}
156162
}()
157163
wg.Wait()
158164
if err := <-proc.ErrChan; err != nil {
159-
s.sse.Publish(topic, ssev2.TypeErr, "wait: "+err.Error())
165+
publish(sess, ssev2.Stderr, "wait: "+err.Error())
160166
return
161167
}
162-
s.sse.Publish(topic, "done", "done")
168+
publish(sess, ssev2.Done, "done")
163169
}
170+
171+
func publish(sess *ssev2.Session, typ ssev2.EventType, data string) {
172+
if err := sess.Publish(typ, data); err != nil {
173+
logrus.Warnf("sse: publish failed on session %s: %v", sess.Topic(), err)
174+
}
175+
}

pkg/sse/sse_server.go

Lines changed: 55 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,32 +3,37 @@
33
package sse
44

55
import (
6+
"context"
67
"net/http"
78

8-
"github.com/sirupsen/logrus"
9-
"github.com/tmaxmax/go-sse"
9+
"github.com/google/uuid"
10+
gosse "github.com/tmaxmax/go-sse"
1011
)
1112

12-
// SSE message types
13-
const (
14-
TypeOut = "out"
15-
TypeErr = "error"
13+
// EventType identifies the kind of SSE event sent to clients.
14+
type EventType string
1615

17-
TopicKey = "sseTopicKey"
16+
const (
17+
Stdout EventType = "out"
18+
Stderr EventType = "error"
19+
Done EventType = "done"
1820
)
1921

20-
// Server wraps the SSE server with helper methods.
22+
// Server creates and manages SSE sessions. It owns the underlying
23+
// pub-sub provider and is responsible for shutting it down on exit.
2124
type Server struct {
22-
server *sse.Server
25+
inner *gosse.Server
2326
}
2427

25-
func NewSSEServer() *Server {
28+
// NewServer creates an SSE server. Sessions are created via BeginSession;
29+
// the server itself is not an http.Handler.
30+
func NewServer() *Server {
2631
return &Server{
27-
server: &sse.Server{
32+
inner: &gosse.Server{
2833
OnSession: func(w http.ResponseWriter, r *http.Request) ([]string, bool) {
29-
topic, ok := r.Context().Value(TopicKey).(string)
34+
topic, ok := r.Context().Value(internalTopicKey).(string)
3035
if !ok || topic == "" {
31-
logrus.Warn("sse: empty topic in session")
36+
w.WriteHeader(http.StatusForbidden)
3237
return nil, false
3338
}
3439
return []string{topic}, true
@@ -37,16 +42,45 @@ func NewSSEServer() *Server {
3742
}
3843
}
3944

40-
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
41-
s.server.ServeHTTP(w, r)
45+
// Shutdown closes the SSE provider, stopping all active subscriptions.
46+
func (s *Server) Shutdown(ctx context.Context) error {
47+
return s.inner.Shutdown(ctx)
4248
}
4349

44-
func (s *Server) Publish(topic, msgType, data string) {
45-
msg := &sse.Message{}
46-
msg.AppendData(data)
47-
msg.Type = sse.Type(msgType)
50+
// Session represents a single SSE client connection tied to a unique topic.
51+
// Each session publishes events on its own topic, isolating streams between clients.
52+
type Session struct {
53+
server *Server
54+
topic string
55+
}
4856

49-
if err := s.server.Publish(msg, topic); err != nil {
50-
logrus.Warnf("sse: failed to publish message: %v", err)
57+
// BeginSession creates a new SSE session with a unique topic.
58+
func (s *Server) BeginSession() *Session {
59+
return &Session{
60+
server: s,
61+
topic: "sess-" + uuid.NewString(),
5162
}
5263
}
64+
65+
// Topic returns the session's unique identifier, useful for logging.
66+
func (sess *Session) Topic() string {
67+
return sess.topic
68+
}
69+
70+
// ServeHTTP upgrades the HTTP request into an SSE connection for this session.
71+
func (sess *Session) ServeHTTP(w http.ResponseWriter, r *http.Request) {
72+
ctx := context.WithValue(r.Context(), internalTopicKey, sess.topic) //nolint:staticcheck
73+
sess.server.inner.ServeHTTP(w, r.WithContext(ctx))
74+
}
75+
76+
// Publish sends an event of the given type with the provided data to this session's clients.
77+
func (sess *Session) Publish(typ EventType, data string) error {
78+
msg := &gosse.Message{}
79+
msg.AppendData(data)
80+
msg.Type = gosse.Type(string(typ))
81+
return sess.server.inner.Publish(msg, sess.topic)
82+
}
83+
84+
type contextKeyType struct{}
85+
86+
var internalTopicKey = contextKeyType{}

0 commit comments

Comments
 (0)