From 09241b3350d75e250bead0340565fb28f370a003 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:20:31 +0800 Subject: [PATCH] test: cover multi-subscriber fanout for process ws Co-Authored-By: Claude --- internal/api/process_ws_test.go | 112 ++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/internal/api/process_ws_test.go b/internal/api/process_ws_test.go index a84d2ab..a0d317f 100644 --- a/internal/api/process_ws_test.go +++ b/internal/api/process_ws_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" "time" @@ -88,3 +89,114 @@ func TestProcessWS(t *testing.T) { t.Fatal("did not receive exit banner frame") } } + +func TestProcessWSMultiSubscriber(t *testing.T) { + tmpDir := t.TempDir() + + opencodePath := filepath.Join(tmpDir, "opencode") + script := []byte("#!/bin/sh\nread line\nfor i in 1 2 3; do\n echo \"got: $line ($i)\"\n sleep 0.05\ndone\nexit 0\n") + if err := os.WriteFile(opencodePath, script, 0o755); err != nil { + t.Fatalf("write fake opencode: %v", err) + } + + wsRoot := filepath.Join(tmpDir, "workspaces") + wsMgr := workspace.NewLocalManager(wsRoot) + procMgr := process.NewManager(opencodePath) + wsSvc := service.NewWorkspaceService(wsMgr, procMgr, nil) + fileSvc := service.NewFileService(wsMgr, 1<<20) + procSvc := service.NewProcessService(wsMgr, procMgr, nil) + + r := NewRouter(wsSvc, fileSvc, procSvc, nil, gin.TestMode) + srv := httptest.NewServer(r) + defer srv.Close() + + ws, err := wsSvc.Create("wsmulti") + if err != nil { + t.Fatalf("create workspace: %v", err) + } + t.Cleanup(func() { + if err := wsSvc.Delete(ws.ID); err != nil { + t.Logf("cleanup delete workspace: %v", err) + } + }) + + if err := procSvc.Start(ws.ID); err != nil { + t.Fatalf("start process: %v", err) + } + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/api/workspaces/" + ws.ID + "/process/ws" + + ready := make(chan struct{}, 3) + start := make(chan struct{}) + frames := make([][]string, 3) + errs := make([]error, 3) + + var wg sync.WaitGroup + for i := 0; i < 3; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + errs[idx] = err + return + } + defer conn.Close() + + ready <- struct{}{} + <-start + + if err := conn.WriteMessage(websocket.TextMessage, []byte("hello\n")); err != nil { + errs[idx] = err + return + } + if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { + errs[idx] = err + return + } + + for { + _, data, err := conn.ReadMessage() + if err != nil { + errs[idx] = err + break + } + text := string(data) + frames[idx] = append(frames[idx], text) + if strings.Contains(text, "[process exited") { + break + } + } + }(i) + } + + for i := 0; i < 3; i++ { + <-ready + } + close(start) + wg.Wait() + + for i, ff := range frames { + var got1, got2, got3, gotExit bool + var got strings.Builder + for _, f := range ff { + got.WriteString(f) + if strings.Contains(f, "got: hello (1)") { + got1 = true + } + if strings.Contains(f, "got: hello (2)") { + got2 = true + } + if strings.Contains(f, "got: hello (3)") { + got3 = true + } + if strings.Contains(f, "[process exited") { + gotExit = true + } + } + if !got1 || !got2 || !got3 || !gotExit || errs[i] != nil { + t.Fatalf("subscriber #%d failed: err=%v, frames=%q", i+1, errs[i], got.String()) + } + } +}