返回 DeepSeek-Reasonix
session_identity_busy_test.go
根目录 / internal / serve / session_identity_busy_test.go
1 package serve
2
3 import (
4 "context"
5 "io"
6 "net/http"
7 "net/http/httptest"
8 "net/url"
9 "path/filepath"
10 "strings"
11 "testing"
12
13 "reasonix/internal/agent"
14 "reasonix/internal/boot"
15 "reasonix/internal/config"
16 "reasonix/internal/control"
17 "reasonix/internal/event"
18 "reasonix/internal/session"
19 )
20
21 // newIdentityBusyServe wires an exclusive-session serve whose replacement
22 // controllers inherit the shared session service, mirroring what a production
23 // identity-capable host stores in its boot options.
24 func newIdentityBusyServe(t *testing.T) (*httptest.Server, *Server, *control.Controller, *session.Service, session.SessionRef) {
25 t.Helper()
26 root := filepath.Join(t.TempDir(), "sessions-v4")
27 service, err := session.NewService("serve-test", session.NewFilesystemPersistence(root))
28 if err != nil {
29 t.Fatal(err)
30 }
31 t.Cleanup(func() {
32 if err := service.Shutdown(context.Background()); err != nil {
33 t.Errorf("shutdown session service: %v", err)
34 }
35 })
36 exec := agent.New(nil, nil, agent.NewSession("system"), agent.Options{}, event.Discard)
37 opts := control.Options{
38 Runner: blockingRunner{}, Executor: exec, SessionDir: t.TempDir(),
39 SessionService: service, ExclusiveSession: true,
40 }
41 ctrl := control.New(opts)
42 ref, err := ctrl.BindFreshSession(t.Context(), "current")
43 if err != nil {
44 t.Fatal(err)
45 }
46 t.Cleanup(ctrl.Close)
47 t.Cleanup(func() { _ = service.CloseAll(context.Background()) })
48 bc := NewBroadcaster()
49 srv := New(ctrl, bc, config.ServeConfig{})
50 srv.buildControllerWithOptions = func(_ context.Context, _ string, bootOpts boot.Options) (*control.Controller, error) {
51 replacement := control.New(control.Options{
52 Runner: blockingRunner{}, Executor: agent.New(nil, nil, agent.NewSession("system"), agent.Options{}, event.Discard),
53 SessionDir: bootOpts.SessionDir, WorkspaceRoot: bootOpts.WorkspaceRoot,
54 SessionService: service, ExclusiveSession: true,
55 Sink: bootOpts.Sink, Label: "test",
56 })
57 return replacement, nil
58 }
59 tag := newSessionTagSink(bc)
60 if id, bound := ctrl.SessionRef(); bound {
61 tag.SetIdentity("", id.SessionID)
62 }
63 srv.RegisterSessionTag(ctrl, tag)
64 httpSrv := httptest.NewServer(operatorHandler(srv))
65 t.Cleanup(httpSrv.Close)
66 t.Cleanup(srv.CloseBackground)
67 return httpSrv, srv, ctrl, service, ref
68 }
69
70 func postResumeIdentity(t *testing.T, httpSrv *httptest.Server, sessionID string) *http.Response {
71 t.Helper()
72 resp, err := http.Post(httpSrv.URL+"/resume", "application/json",
73 strings.NewReader(`{"hostId":"serve-test","sessionId":"`+url.QueryEscape(sessionID)+`"}`))
74 if err != nil {
75 t.Fatal(err)
76 }
77 t.Cleanup(func() { resp.Body.Close() })
78 return resp
79 }
80
81 // The identity-route counterpart of TestBusyResumeDetachesAndReattachesRunningController:
82 // switching away from a running exclusive session must background it (turn
83 // keeps running, transcript stays readable) instead of refusing with 409, and
84 // switching back must re-attach the same live controller.
85 func TestBusyIdentityResumeDetachesAndReattachesRunningController(t *testing.T) {
86 httpSrv, srv, ctrlA, service, refA := newIdentityBusyServe(t)
87 target, err := service.Create(t.Context(), session.CreateOptions{SessionID: "target"})
88 if err != nil {
89 t.Fatal(err)
90 }
91 if _, err := target.Session().Flush(t.Context()); err != nil {
92 t.Fatal(err)
93 }
94 if err := service.Close(t.Context(), target.Ref()); err != nil {
95 t.Fatal(err)
96 }
97
98 ctrlA.Submit("keep running")
99 waitRunning(t, ctrlA)
100
101 resp := postResumeIdentity(t, httpSrv, "target")
102 if resp.StatusCode != http.StatusNoContent {
103 body, _ := io.ReadAll(resp.Body)
104 t.Fatalf("busy resume = %d: %s", resp.StatusCode, body)
105 }
106 foreground, ok := srv.ctl().(*control.Controller).SessionRef()
107 if !ok || foreground.SessionID != "target" {
108 t.Fatalf("foreground identity = %+v bound=%v, want target", foreground, ok)
109 }
110 if got := srv.ctl().SessionPath(); got != "" {
111 t.Fatalf("foreground path = %q, want empty for identity sessions", got)
112 }
113 if !ctrlA.Running() {
114 t.Fatal("switched-away session stopped instead of running in background")
115 }
116 // The backgrounded session stays readable through its identity route.
117 if resolved := srv.resolveReadControllerLocked(remoteSessionIDQueryPrefix + refA.SessionID); resolved != control.SessionAPI(ctrlA) {
118 t.Fatal("backgrounded running session is not readable through its identity route")
119 }
120
121 back := postResumeIdentity(t, httpSrv, refA.SessionID)
122 if back.StatusCode != http.StatusNoContent {
123 body, _ := io.ReadAll(back.Body)
124 t.Fatalf("reattach resume = %d: %s", back.StatusCode, body)
125 }
126 if srv.ctl() != control.SessionAPI(ctrlA) {
127 t.Fatal("reattach did not restore the original controller")
128 }
129 if !ctrlA.Running() {
130 t.Fatal("running turn was lost during reattach")
131 }
132 ctrlA.Cancel()
133 waitNotRunning(t, ctrlA)
134 }
135
136 // A host whose replacement builders cannot open identity sessions keeps the
137 // historical refusal instead of dropping the running controller.
138 func TestBusyIdentityResumeWithoutServiceRefusesLikeBefore(t *testing.T) {
139 httpSrv, srv, ctrlA, service, _ := newIdentityBusyServe(t)
140 target, err := service.Create(t.Context(), session.CreateOptions{SessionID: "target"})
141 if err != nil {
142 t.Fatal(err)
143 }
144 if _, err := target.Session().Flush(t.Context()); err != nil {
145 t.Fatal(err)
146 }
147 if err := service.Close(t.Context(), target.Ref()); err != nil {
148 t.Fatal(err)
149 }
150 // Strip the service from replacements: a legacy host that never wired the
151 // session service into its boot options.
152 srv.buildControllerWithOptions = func(_ context.Context, _ string, bootOpts boot.Options) (*control.Controller, error) {
153 return control.New(control.Options{
154 Runner: blockingRunner{}, SessionDir: bootOpts.SessionDir,
155 Sink: bootOpts.Sink, Label: "test",
156 }), nil
157 }
158
159 ctrlA.Submit("keep running")
160 waitRunning(t, ctrlA)
161
162 resp := postResumeIdentity(t, httpSrv, "target")
163 if resp.StatusCode != http.StatusConflict {
164 t.Fatalf("refusal resume = %d, want 409", resp.StatusCode)
165 }
166 if srv.ctl() != control.SessionAPI(ctrlA) {
167 t.Fatal("refused switch replaced the foreground controller")
168 }
169 if !ctrlA.Running() {
170 t.Fatal("refused switch stopped the running turn")
171 }
172 ctrlA.Cancel()
173 waitNotRunning(t, ctrlA)
174 }
175
176 // Re-selecting the running foreground session is a no-op, not a swap.
177 func TestBusyIdentityResumeOfCurrentSessionIsNoop(t *testing.T) {
178 httpSrv, srv, ctrlA, _, refA := newIdentityBusyServe(t)
179 ctrlA.Submit("keep running")
180 waitRunning(t, ctrlA)
181
182 resp := postResumeIdentity(t, httpSrv, refA.SessionID)
183 if resp.StatusCode != http.StatusNoContent {
184 body, _ := io.ReadAll(resp.Body)
185 t.Fatalf("same-session resume = %d: %s", resp.StatusCode, body)
186 }
187 if srv.ctl() != control.SessionAPI(ctrlA) {
188 t.Fatal("same-session resume replaced the foreground controller")
189 }
190 if !ctrlA.Running() {
191 t.Fatal("same-session resume stopped the running turn")
192 }
193 ctrlA.Cancel()
194 waitNotRunning(t, ctrlA)
195 }
196
196 lines GO