返回 DeepSeek-Reasonix
reload_runtime_test.go
根目录 / desktop / reload_runtime_test.go
1 package main
2
3 import (
4 "context"
5 "io"
6 "os"
7 "path/filepath"
8 "sync"
9 "testing"
10 "time"
11
12 "reasonix/internal/agent"
13 "reasonix/internal/config"
14 "reasonix/internal/control"
15 "reasonix/internal/event"
16 "reasonix/internal/jobs"
17 )
18
19 // reloadRuntimeFixture writes the config the ReloadRuntime tests share (one
20 // configured provider) and returns the isolated session dir.
21 func reloadRuntimeFixture(t *testing.T) string {
22 t.Helper()
23 isolateDesktopUserDirs(t)
24 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
25
26 cfg := config.Default()
27 cfg.DefaultModel = "old/old-model"
28 cfg.Desktop.ProviderAccess = []string{"old"}
29 cfg.Providers = []config.ProviderEntry{
30 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
31 }
32 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
33 t.Fatalf("save config: %v", err)
34 }
35
36 dir := config.SessionDir()
37 if err := os.MkdirAll(dir, 0o755); err != nil {
38 t.Fatalf("mkdir session dir: %v", err)
39 }
40 return dir
41 }
42
43 func reloadRuntimeTab(t *testing.T, app *App, dir string, oldCtrl *control.Controller) *WorkspaceTab {
44 t.Helper()
45 tab := &WorkspaceTab{
46 ID: "tab_a",
47 Scope: "global",
48 Ready: true,
49 model: "old/old-model",
50 Ctrl: oldCtrl,
51 sink: &tabEventSink{tabID: "tab_a", app: app},
52 disabledMCP: map[string]ServerView{},
53 }
54 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
55 app.tabOrder = []string{tab.ID}
56 app.activeTabID = tab.ID
57 t.Cleanup(func() {
58 tab.releaseSessionLease()
59 if tab.Ctrl != nil {
60 tab.Ctrl.Close()
61 }
62 })
63 return tab
64 }
65
66 // TestReloadRuntimeSwapsAndClosesOldAfterSwap covers the success path through
67 // boot.Rebuild: the tab's controller is replaced, the session (grants, file)
68 // migrates, the outgoing controller is closed only after the swap published
69 // the replacement, and the frontend fence is emitted.
70 func TestReloadRuntimeSwapsAndClosesOldAfterSwap(t *testing.T) {
71 dir := reloadRuntimeFixture(t)
72
73 oldPath := filepath.Join(dir, "old.jsonl")
74 oldExec := agent.New(nil, nil, agent.NewSession("old system prompt"), agent.Options{}, event.Discard)
75 app := NewApp()
76 app.ctx = context.Background()
77 app.readyHook = func() {}
78 var fenceMu sync.Mutex
79 var fenceNames []string
80 app.runtimeEvents.emit = func(_ context.Context, name string, _ ...interface{}) {
81 fenceMu.Lock()
82 fenceNames = append(fenceNames, name)
83 fenceMu.Unlock()
84 }
85
86 closed := false
87 var ctrlAtClose control.SessionAPI
88 oldCtrl := control.New(control.Options{
89 Executor: oldExec,
90 SessionDir: dir,
91 SessionPath: oldPath,
92 Label: "old",
93 Sink: event.Discard,
94 Cleanup: func() {
95 closed = true
96 app.mu.RLock()
97 ctrlAtClose = app.tabs["tab_a"].Ctrl
98 app.mu.RUnlock()
99 },
100 })
101 oldCtrl.RestoreSessionAuthorizations(control.SessionAuthorizations{
102 Grants: []string{"bash|go test ./..."},
103 PlanModeReadOnlyCommands: []string{"go test ./..."},
104 })
105 tab := reloadRuntimeTab(t, app, dir, oldCtrl)
106
107 if err := app.ReloadRuntime(tab.ID); err != nil {
108 t.Fatalf("ReloadRuntime: %v", err)
109 }
110
111 newCtrl, ok := tab.Ctrl.(*control.Controller)
112 if !ok {
113 t.Fatalf("tab.Ctrl = %T, want *control.Controller", tab.Ctrl)
114 }
115 if newCtrl == oldCtrl {
116 t.Fatal("ReloadRuntime kept the outgoing controller installed")
117 }
118 if got := newCtrl.SessionPath(); got != oldPath {
119 t.Fatalf("session path = %q, want the carried session file %q", got, oldPath)
120 }
121 got := newCtrl.SessionAuthorizations()
122 if len(got.Grants) != 1 || got.Grants[0] != "bash|go test ./..." {
123 t.Fatalf("migrated grants = %+v, want [\"bash|go test ./...\"]", got.Grants)
124 }
125 if !closed {
126 t.Fatal("outgoing controller was not closed")
127 }
128 if ctrlAtClose != newCtrl {
129 t.Fatal("outgoing controller closed before the swap published the replacement")
130 }
131 deadline := time.Now().Add(2 * time.Second)
132 for {
133 fenceMu.Lock()
134 found := false
135 for _, name := range fenceNames {
136 if name == "runtime:rebuilt" {
137 found = true
138 break
139 }
140 }
141 fenceMu.Unlock()
142 if found {
143 break
144 }
145 if time.Now().After(deadline) {
146 t.Fatal("no runtime:rebuilt fence emitted")
147 }
148 time.Sleep(5 * time.Millisecond)
149 }
150 }
151
152 // TestReloadRuntimeFailureKeepsOldController: a failed build leaves the tab on
153 // the outgoing controller, which stays open.
154 func TestReloadRuntimeFailureKeepsOldController(t *testing.T) {
155 isolateDesktopUserDirs(t)
156
157 // No providers at all: the build cannot resolve the tab's model.
158 cfg := config.Default()
159 cfg.DefaultModel = ""
160 cfg.Providers = []config.ProviderEntry{}
161 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
162 t.Fatalf("save config: %v", err)
163 }
164 dir := config.SessionDir()
165 if err := os.MkdirAll(dir, 0o755); err != nil {
166 t.Fatalf("mkdir session dir: %v", err)
167 }
168
169 closed := false
170 oldCtrl := control.New(control.Options{
171 SessionDir: dir,
172 SessionPath: filepath.Join(dir, "old.jsonl"),
173 Label: "old",
174 Sink: event.Discard,
175 Cleanup: func() { closed = true },
176 })
177 app := NewApp()
178 app.ctx = context.Background()
179 app.readyHook = func() {}
180 tab := reloadRuntimeTab(t, app, dir, oldCtrl)
181
182 if err := app.ReloadRuntime(tab.ID); err == nil {
183 t.Fatal("ReloadRuntime with an unresolvable model returned nil error")
184 }
185 if tab.Ctrl != oldCtrl {
186 t.Fatal("failed reload replaced the tab controller")
187 }
188 if closed {
189 t.Fatal("failed reload closed the outgoing controller")
190 }
191 if app.deferredRebuildPending(tab.ID) {
192 t.Fatal("hard failure was queued for retry")
193 }
194 }
195
196 // TestReloadRuntimeBusyQueuesDeferred covers the busy contract: active work
197 // queues exactly one reload on the deferred-rebuild loop (coalesced), and the
198 // retry runs the boot.Rebuild reload once the tab is idle.
199 func TestReloadRuntimeBusyQueuesDeferred(t *testing.T) {
200 dir := reloadRuntimeFixture(t)
201
202 jm := jobs.NewManager(event.Discard)
203 releaseJob := make(chan struct{})
204 jm.Start("test", "blocking job", func(ctx context.Context, _ io.Writer) (string, error) {
205 select {
206 case <-releaseJob:
207 return "", nil
208 case <-ctx.Done():
209 return "", ctx.Err()
210 }
211 })
212 oldCtrl := control.New(control.Options{
213 SessionDir: dir,
214 SessionPath: filepath.Join(dir, "old.jsonl"),
215 Label: "old",
216 Sink: event.Discard,
217 Jobs: jm,
218 })
219 app := NewApp()
220 app.ctx = context.Background()
221 app.readyHook = func() {}
222 tab := reloadRuntimeTab(t, app, dir, oldCtrl)
223
224 if err := app.ReloadRuntime(tab.ID); err != nil {
225 t.Fatalf("busy ReloadRuntime returned %v, want nil (queued)", err)
226 }
227 if tab.Ctrl != oldCtrl {
228 t.Fatal("busy reload swapped the controller")
229 }
230 if !app.deferredRebuildPending(tab.ID) {
231 t.Fatal("busy reload was not queued on the deferred loop")
232 }
233 // A second request while busy coalesces into the same queued reload.
234 if err := app.ReloadRuntime(tab.ID); err != nil {
235 t.Fatalf("second busy ReloadRuntime returned %v, want nil", err)
236 }
237 app.deferredRebuild.mu.Lock()
238 label, ok := app.deferredRebuild.pending[tab.ID]
239 count := len(app.deferredRebuild.pending)
240 app.deferredRebuild.mu.Unlock()
241 if !ok || label != deferredRuntimeReloadLabel {
242 t.Fatalf("pending label = %q (present=%t), want %q", label, ok, deferredRuntimeReloadLabel)
243 }
244 if count != 1 {
245 t.Fatalf("pending entries = %d, want exactly 1 (coalesced)", count)
246 }
247
248 // The work finishes; the retry path reloads the now-idle tab.
249 close(releaseJob)
250 deadline := time.Now().Add(2 * time.Second)
251 for oldCtrl.RuntimeStatus().BackgroundJobs > 0 {
252 if time.Now().After(deadline) {
253 t.Fatal("background job did not finish")
254 }
255 time.Sleep(5 * time.Millisecond)
256 }
257 app.retryDeferredRuntimeReload(tab.ID, tab)
258 if tab.Ctrl == oldCtrl {
259 t.Fatal("deferred retry did not reload the idle tab")
260 }
261 if app.deferredRebuildPending(tab.ID) {
262 t.Fatal("deferred entry survived a successful retry")
263 }
264 }
265
265 lines GO