返回 DeepSeek-Reasonix
race_regression_test.go
根目录 / desktop / race_regression_test.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "sync"
10 "testing"
11 "time"
12
13 "reasonix/internal/agent"
14 "reasonix/internal/boot"
15 "reasonix/internal/config"
16 "reasonix/internal/event"
17 )
18
19 // TestSessionLeaseHelpersConcurrentAccess hammers the sessionLeaseMu helpers
20 // (ensure/take/adopt/release/key) from concurrent goroutines. Run with -race:
21 // any residual raw access to tab.sessionLease shows up as a data race, and the
22 // final acquire asserts no lease leaked through an interleaving.
23 func TestSessionLeaseHelpersConcurrentAccess(t *testing.T) {
24 isolateDesktopUserDirs(t)
25 dir := config.SessionDir()
26 if err := os.MkdirAll(dir, 0o755); err != nil {
27 t.Fatalf("mkdir sessions: %v", err)
28 }
29 path := filepath.Join(dir, "lease-helper-hammer.jsonl")
30 key := sessionRuntimeKey(path)
31 tabA := &WorkspaceTab{ID: "a"}
32 tabB := &WorkspaceTab{ID: "b"}
33
34 const iterations = 300
35 var wg sync.WaitGroup
36 wg.Add(3)
37 go func() {
38 defer wg.Done()
39 for i := 0; i < iterations; i++ {
40 _ = tabA.ensureSessionLease(path)
41 _ = tabA.sessionLeaseRuntimeKey()
42 }
43 }()
44 go func() {
45 defer wg.Done()
46 for i := 0; i < iterations; i++ {
47 // The applyRuntimeTab transfer shape: move A's lease to B and back.
48 tabB.adoptSessionLease(tabA.takeSessionLease())
49 tabA.adoptSessionLease(tabB.takeSessionLease())
50 }
51 }()
52 go func() {
53 defer wg.Done()
54 for i := 0; i < iterations; i++ {
55 tabA.releaseSessionLease()
56 }
57 }()
58 wg.Wait()
59
60 tabA.releaseSessionLease()
61 tabB.releaseSessionLease()
62 lease, err := agent.TryAcquireSessionLease(key)
63 if err != nil {
64 t.Fatalf("lease leaked through concurrent helper interleavings: %v", err)
65 }
66 lease.Release()
67 }
68
69 // TestDetachRuntimeForReplacementTransfersLease asserts the detach clone takes
70 // lease ownership through the locked helpers and the visible tab keeps none.
71 func TestDetachRuntimeForReplacementTransfersLease(t *testing.T) {
72 isolateDesktopUserDirs(t)
73 dir := config.SessionDir()
74 if err := os.MkdirAll(dir, 0o755); err != nil {
75 t.Fatalf("mkdir sessions: %v", err)
76 }
77 path := filepath.Join(dir, "detach-transfer.jsonl")
78 key := sessionRuntimeKey(path)
79 tab := &WorkspaceTab{ID: "tab", Scope: "global", SessionPath: path}
80 app := &App{tabs: map[string]*WorkspaceTab{"tab": tab}}
81 if err := tab.ensureSessionLease(path); err != nil {
82 t.Fatalf("ensureSessionLease: %v", err)
83 }
84 t.Cleanup(func() {
85 tab.releaseSessionLease()
86 for _, d := range app.detachedSessions {
87 d.releaseSessionLease()
88 }
89 })
90
91 if !app.detachRuntimeForReplacement(tab) {
92 t.Fatal("detachRuntimeForReplacement failed for a live tab")
93 }
94 detached := app.detachedSessions[key]
95 if detached == nil {
96 t.Fatal("detached runtime was not registered")
97 }
98 if got := detached.sessionLeaseRuntimeKey(); got != key {
99 t.Fatalf("detached clone lease key = %q, want %q", got, key)
100 }
101 if got := tab.sessionLeaseRuntimeKey(); got != "" {
102 t.Fatalf("visible tab still holds lease key %q after detach", got)
103 }
104 }
105
106 // TestDetachRuntimeForReplacementSkipsRemovedTab: a tab that DeleteSession /
107 // CloseTab already unlinked must not be re-published into detachedSessions
108 // (the "session resurrects" class, #4384).
109 func TestDetachRuntimeForReplacementSkipsRemovedTab(t *testing.T) {
110 isolateDesktopUserDirs(t)
111 dir := config.SessionDir()
112 if err := os.MkdirAll(dir, 0o755); err != nil {
113 t.Fatalf("mkdir sessions: %v", err)
114 }
115 path := filepath.Join(dir, "detach-removed.jsonl")
116
117 removed := &WorkspaceTab{ID: "removed", SessionPath: path, removed: true}
118 app := &App{tabs: map[string]*WorkspaceTab{"removed": removed}}
119 if app.detachRuntimeForReplacement(removed) {
120 t.Fatal("removed tab was detached into the background registry")
121 }
122 if len(app.detachedSessions) != 0 {
123 t.Fatal("removed tab left an entry in detachedSessions")
124 }
125
126 orphan := &WorkspaceTab{ID: "orphan", SessionPath: path}
127 if app.detachRuntimeForReplacement(orphan) {
128 t.Fatal("tab absent from a.tabs was detached into the background registry")
129 }
130 if len(app.detachedSessions) != 0 {
131 t.Fatal("orphan tab left an entry in detachedSessions")
132 }
133 }
134
135 // TestTabEventSinkEmitConcurrentRebind: Emit keeps running on the controller
136 // goroutine while detach/reattach rebinds the sink's tab routing. Run with
137 // -race; before setBinding existed the tabID write raced every Emit.
138 func TestTabEventSinkEmitConcurrentRebind(t *testing.T) {
139 sink := &tabEventSink{tabID: "before"}
140 const iterations = 500
141 var wg sync.WaitGroup
142 wg.Add(2)
143 go func() {
144 defer wg.Done()
145 for i := 0; i < iterations; i++ {
146 sink.Emit(event.Event{Kind: event.Notice, Text: "hammer"})
147 }
148 }()
149 go func() {
150 defer wg.Done()
151 for i := 0; i < iterations; i++ {
152 sink.setBinding(fmt.Sprintf("tab-%d", i%2), nil)
153 sink.clearContext()
154 }
155 }()
156 wg.Wait()
157 if tabID, _ := sink.binding(); tabID == "" {
158 t.Fatal("sink lost its tab binding")
159 }
160 }
161
162 func TestModeRebuildAndABANavigationFenceLeaseFailureAndOldEpochEvent(t *testing.T) {
163 app, tab, oldCtrl, pathA, pathB, loadedB := newAtomicRebindTestApp(t)
164 oldSink := tab.sink
165 app.mu.RLock()
166 epochA := app.sessionRuntimeViewLocked(tab).Epoch
167 app.mu.RUnlock()
168
169 restored := make(chan struct{})
170 continueCommit := make(chan struct{})
171 oldEvent := make(chan wireEventTab, 1)
172 var capturedOldEvent wireEventTab
173 oldSink.runtimeEvents.emit = func(_ context.Context, name string, payload ...interface{}) {
174 if name != eventChannel || len(payload) != 1 {
175 return
176 }
177 if wire, ok := payload[0].(wireEventTab); ok && wire.Text == "retired runtime event" {
178 oldEvent <- wire
179 }
180 }
181 var blockOnce sync.Once
182 app.rebindCandidateHook = func(stage string) error {
183 switch stage {
184 case "restored":
185 blockOnce.Do(func() {
186 close(restored)
187 <-continueCommit
188 })
189 case "committed":
190 oldSink.Emit(event.Event{Kind: event.Notice, Text: "retired runtime event"})
191 select {
192 case capturedOldEvent = <-oldEvent:
193 case <-time.After(5 * time.Second):
194 return errors.New("retired runtime event was not emitted")
195 }
196 }
197 return nil
198 }
199
200 rebindDone := make(chan error, 1)
201 go func() {
202 rebindDone <- app.rebindTabToLoadedSessionPath(tab, pathB, loadedB)
203 }()
204 select {
205 case <-restored:
206 case <-time.After(10 * time.Second):
207 t.Fatal("A to B rebind did not reach restored candidate")
208 }
209
210 // This call observes A/economy, then waits behind the rebind transaction.
211 // Once B commits it must rebuild B to full, never interleave with the
212 // candidate or publish a half-updated profile.
213 modeDone := make(chan error, 1)
214 go func() {
215 modeDone <- app.SetTokenModeForTab(tab.ID, boot.TokenModeFull)
216 }()
217 close(continueCommit)
218 if err := <-rebindDone; err != nil {
219 t.Fatalf("A to B rebind: %v", err)
220 }
221 if err := <-modeDone; err != nil {
222 t.Fatalf("mode rebuild after A to B: %v", err)
223 }
224
225 stale := capturedOldEvent
226 app.mu.RLock()
227 epochB := app.sessionRuntimeViewLocked(tab).Epoch
228 app.mu.RUnlock()
229 if stale.RuntimeEpoch != epochA || epochB == "" || epochB == epochA {
230 t.Fatalf("epoch fence stale=%q source=%q target=%q", stale.RuntimeEpoch, epochA, epochB)
231 }
232 if app.controllerForTab(tab) == oldCtrl ||
233 sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(pathB) ||
234 currentTabTokenMode(tab) != boot.TokenModeFull {
235 t.Fatalf("A to B plus mode rebuild did not converge: ctrl=%p path=%q token=%q",
236 app.controllerForTab(tab), tab.currentSessionPath(), currentTabTokenMode(tab))
237 }
238
239 // A is now free. Hold it as an external target, then attempt B to A. The
240 // failed return leg must keep the rebuilt B controller, lease, path, epoch,
241 // and full token profile unchanged.
242 app.rebindCandidateHook = nil
243 holderA, err := agent.TryAcquireSessionLease(pathA)
244 if err != nil {
245 t.Fatalf("hold A before return navigation: %v", err)
246 }
247 defer holderA.Release()
248 loadedA, err := agent.LoadSession(pathA)
249 if err != nil {
250 t.Fatalf("load A: %v", err)
251 }
252 ctrlB := app.controllerForTab(tab)
253 err = app.rebindTabToLoadedSessionPath(tab, pathA, loadedA)
254 if !errors.Is(err, agent.ErrSessionLeaseHeld) {
255 t.Fatalf("B to A error = %v, want ErrSessionLeaseHeld", err)
256 }
257 if app.controllerForTab(tab) != ctrlB ||
258 sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(pathB) ||
259 tab.sessionLeaseRuntimeKey() != sessionRuntimeKey(pathB) ||
260 currentTabTokenMode(tab) != boot.TokenModeFull {
261 t.Fatalf("failed B to A changed B runtime: ctrl=%p path=%q lease=%q token=%q",
262 app.controllerForTab(tab), tab.currentSessionPath(), tab.sessionLeaseRuntimeKey(), currentTabTokenMode(tab))
263 }
264 app.mu.RLock()
265 finalView := app.sessionRuntimeViewLocked(tab)
266 targetAlias := app.runtimeBySessionKey[sessionRuntimeKey(pathA)]
267 app.mu.RUnlock()
268 if finalView.Phase != sessionRuntimeReady || finalView.Epoch != epochB || targetAlias != nil {
269 t.Fatalf("failed B to A runtime = phase %q epoch %q aliasA=%#v, want ready/%q/no alias",
270 finalView.Phase, finalView.Epoch, targetAlias, epochB)
271 }
272 }
273
274 // TestTabBuildSupersededByRebindGeneration: a session rebind bumps
275 // buildGeneration to strand any in-flight async build, so its swap (and every
276 // mid-build field write) is rejected; synchronous rebuilds pass generation 0
277 // and rely on runtimeRebuildMu instead.
278 func TestTabBuildSupersededByRebindGeneration(t *testing.T) {
279 tab := &WorkspaceTab{ID: "tab"}
280 app := &App{tabs: map[string]*WorkspaceTab{"tab": tab}}
281 app.mu.Lock()
282 tab.buildGeneration = 3
283 app.mu.Unlock()
284 if app.tabBuildSuperseded(tab, 3) {
285 t.Fatal("build with the current generation must not be superseded")
286 }
287 app.mu.Lock()
288 tab.buildGeneration++ // the rebind-side invalidation
289 app.mu.Unlock()
290 if !app.tabBuildSuperseded(tab, 3) {
291 t.Fatal("stale-generation build must be superseded after a rebind bump")
292 }
293 if app.tabBuildSuperseded(tab, 0) {
294 t.Fatal("synchronous rebuild (generation 0) must not be superseded by generation bumps")
295 }
296 app.mu.Lock()
297 tab.removed = true
298 app.mu.Unlock()
299 if !app.tabBuildSuperseded(tab, 0) {
300 t.Fatal("removed tab must supersede every build, including synchronous ones")
301 }
302 }
303
304 // TestReleaseSessionLeaseForKeyOnlyMatchesOwnKey: superseded builds may only
305 // release the lease bound to their own path; a mismatched key (the rebind's
306 // replacement session) must be left untouched.
307 func TestReleaseSessionLeaseForKeyOnlyMatchesOwnKey(t *testing.T) {
308 isolateDesktopUserDirs(t)
309 dir := config.SessionDir()
310 if err := os.MkdirAll(dir, 0o755); err != nil {
311 t.Fatalf("mkdir sessions: %v", err)
312 }
313 pathA := filepath.Join(dir, "lease-key-a.jsonl")
314 pathB := filepath.Join(dir, "lease-key-b.jsonl")
315 tab := &WorkspaceTab{ID: "tab"}
316 t.Cleanup(tab.releaseSessionLease)
317 if err := tab.ensureSessionLease(pathB); err != nil {
318 t.Fatalf("ensureSessionLease: %v", err)
319 }
320
321 tab.releaseSessionLeaseForKey(sessionRuntimeKey(pathA))
322 if _, err := agent.TryAcquireSessionLease(sessionRuntimeKey(pathB)); err == nil {
323 t.Fatal("mismatched key released the tab's live lease")
324 }
325
326 tab.releaseSessionLeaseForKey(sessionRuntimeKey(pathB))
327 lease, err := agent.TryAcquireSessionLease(sessionRuntimeKey(pathB))
328 if err != nil {
329 t.Fatalf("matching key did not release the lease: %v", err)
330 }
331 lease.Release()
332 }
333
334 // TestAbandonSupersededBuildPreservesNewBuildOwnership reproduces the rebind
335 // interleaving from the #5968 review: a stale async build cleans up after a
336 // rebind's replacement build already published its own SharedHostKey and
337 // session lease on the same live tab. The stale build must drop only its own
338 // host reference and lease, never the tab's.
339 func TestAbandonSupersededBuildPreservesNewBuildOwnership(t *testing.T) {
340 isolateDesktopUserDirs(t)
341 dir := config.SessionDir()
342 if err := os.MkdirAll(dir, 0o755); err != nil {
343 t.Fatalf("mkdir sessions: %v", err)
344 }
345 oldPath := filepath.Join(dir, "superseded-old.jsonl")
346 newPath := filepath.Join(dir, "superseded-new.jsonl")
347 newKey := sessionRuntimeKey(newPath)
348
349 app := &App{tabs: map[string]*WorkspaceTab{}}
350 // The stale build and the replacement build each hold one reference on
351 // the same workspace-root host (the common same-root rebind).
352 app.acquireSharedHost("root")
353 app.acquireSharedHost("root")
354
355 tab := &WorkspaceTab{ID: "tab", SharedHostKey: "root"}
356 app.tabs["tab"] = tab
357 t.Cleanup(tab.releaseSessionLease)
358 if err := tab.ensureSessionLease(newPath); err != nil {
359 t.Fatalf("ensureSessionLease(new): %v", err)
360 }
361
362 // Stale build abandons with ITS key material: the old lease path and the
363 // root key it acquired.
364 app.abandonSupersededBuild(tab, nil, "root", sessionRuntimeKey(oldPath))
365
366 app.mu.RLock()
367 hostKey := tab.SharedHostKey
368 app.mu.RUnlock()
369 if hostKey != "root" {
370 t.Fatalf("stale build cleared the tab's SharedHostKey: %q", hostKey)
371 }
372 app.sharedHostsMu.Lock()
373 entry := app.sharedHosts["root"]
374 refs := 0
375 if entry != nil {
376 refs = entry.refs
377 }
378 app.sharedHostsMu.Unlock()
379 if refs != 1 {
380 t.Fatalf("shared host refs = %d after stale-build cleanup, want 1 (the live build's reference)", refs)
381 }
382 if got := tab.sessionLeaseRuntimeKey(); got != newKey {
383 t.Fatalf("stale build released the replacement build's lease: key = %q, want %q", got, newKey)
384 }
385
386 // Removal shape: the abandoned build's own key still on the tab is the
387 // last reference and must be released.
388 app.abandonSupersededBuild(tab, nil, "", newKey)
389 lease, err := agent.TryAcquireSessionLease(newKey)
390 if err != nil {
391 t.Fatalf("matching-key abandon did not release the lease: %v", err)
392 }
393 lease.Release()
394 }
395
396 // TestRebindInvalidatesInFlightAsyncBuildBeforeSnapshot reproduces the #5968
397 // review scenario: an async startTabControllerBuild is stalled right after
398 // binding its session lease (holding its controller, lease, and shared-host
399 // reference), a rebind to a different session starts meanwhile, and the
400 // stalled build then tries to finish. The rebind must bump the generation
401 // BEFORE snapshotting tab.Ctrl, so the stale build can only fall into its
402 // superseded branches: the rebound controller must survive un-overwritten,
403 // the stale build's lease and host reference must be released, and the
404 // replacement build's ownership must stay intact.
405 func TestRebindInvalidatesInFlightAsyncBuildBeforeSnapshot(t *testing.T) {
406 isolateDesktopUserDirs(t)
407 root := globalTabWorkspaceRoot()
408 dir := desktopSessionDir(root)
409 if err := os.MkdirAll(dir, 0o755); err != nil {
410 t.Fatalf("mkdir session dir: %v", err)
411 }
412 oldPath := filepath.Join(dir, "rebind-race-old.jsonl")
413 newPath := filepath.Join(dir, "rebind-race-new.jsonl")
414 writeHistoryTestSession(t, oldPath, "old prompt")
415 writeHistoryTestSession(t, newPath, "new prompt")
416 oldKey := sessionRuntimeKey(oldPath)
417 newKey := sessionRuntimeKey(newPath)
418
419 app := NewApp()
420 app.readyHook = func() {}
421 tab := &WorkspaceTab{
422 ID: "tab",
423 Scope: "global",
424 WorkspaceRoot: root,
425 SessionPath: oldPath,
426 sink: &tabEventSink{tabID: "tab", app: app},
427 disabledMCP: map[string]ServerView{},
428 }
429 app.tabs[tab.ID] = tab
430 app.tabOrder = []string{tab.ID}
431 app.activeTabID = tab.ID
432 t.Cleanup(func() {
433 app.mu.RLock()
434 ctrl := tab.Ctrl
435 app.mu.RUnlock()
436 if ctrl != nil {
437 ctrl.Close()
438 }
439 tab.releaseSessionLease()
440 })
441
442 // Stall the async build inside its lease bind: at that point it already
443 // holds its controller, its lease, and its shared-host reference.
444 stalled := make(chan struct{})
445 releaseHook := make(chan struct{})
446 var once sync.Once
447 sessionLeaseAcquireHookForTest = func() {
448 once.Do(func() {
449 close(stalled)
450 <-releaseHook
451 })
452 }
453 t.Cleanup(func() { sessionLeaseAcquireHookForTest = nil })
454 t.Cleanup(func() {
455 select {
456 case <-releaseHook:
457 default:
458 close(releaseHook)
459 }
460 })
461
462 // startTabControllerBuild only backgrounds the build when a Wails context
463 // exists; expand its goroutine branch by hand so a.ctx can stay nil (the
464 // nil-ctx emit guards are what every other build test relies on too).
465 buildCtx, cancel := context.WithCancel(context.Background())
466 app.mu.Lock()
467 tab.buildGeneration++
468 generation := tab.buildGeneration
469 tab.buildCancel = cancel
470 app.mu.Unlock()
471 buildDone := make(chan struct{})
472 go func() {
473 defer close(buildDone)
474 app.buildTabControllerWithContext(tab, loadedTabSession{}, buildCtx, generation, cancel)
475 }()
476 select {
477 case <-stalled:
478 case <-time.After(15 * time.Second):
479 t.Fatal("async build did not reach the lease bind")
480 }
481
482 loaded, err := agent.LoadSession(newPath)
483 if err != nil {
484 t.Fatalf("LoadSession(new): %v", err)
485 }
486 rebindErr := make(chan error, 1)
487 go func() {
488 rebindErr <- app.rebindTabToLoadedSessionPath(tab, newPath, loaded)
489 }()
490
491 // Wait until the rebind's validation section has invalidated the async
492 // build (startTabControllerBuild set generation 1; the rebind bumps to 2)
493 // while the async build is still stalled pre-swap.
494 deadline := time.Now().Add(15 * time.Second)
495 for {
496 app.mu.RLock()
497 generation := tab.buildGeneration
498 app.mu.RUnlock()
499 if generation >= 2 {
500 break
501 }
502 if time.Now().After(deadline) {
503 t.Fatal("rebind did not bump the build generation")
504 }
505 time.Sleep(time.Millisecond)
506 }
507
508 close(releaseHook)
509 select {
510 case err := <-rebindErr:
511 if err != nil {
512 t.Fatalf("rebindTabToLoadedSessionPath: %v", err)
513 }
514 case <-time.After(30 * time.Second):
515 t.Fatal("rebind did not finish after releasing the stalled build")
516 }
517 select {
518 case <-buildDone:
519 case <-time.After(30 * time.Second):
520 t.Fatal("stale async build did not finish its superseded cleanup")
521 }
522
523 app.mu.RLock()
524 builtCtrl := tab.Ctrl
525 sessionPath := tab.SessionPath
526 app.mu.RUnlock()
527 if builtCtrl == nil {
528 t.Fatal("rebind left the tab without a controller")
529 }
530 if got := builtCtrl.SessionPath(); got != newPath {
531 t.Fatalf("stale async build overwrote the rebound controller: session path = %q, want %q", got, newPath)
532 }
533 if sessionPath != newPath {
534 t.Fatalf("tab.SessionPath = %q, want %q", sessionPath, newPath)
535 }
536 if got := tab.sessionLeaseRuntimeKey(); got != newKey {
537 t.Fatalf("tab lease key = %q, want the rebound session %q", got, newKey)
538 }
539 // The stale build's lease must be gone (released by its superseded
540 // cleanup or replaced by the rebound build's adopt) — the old session
541 // must be acquirable again.
542 lease, err := agent.TryAcquireSessionLease(oldKey)
543 if err != nil {
544 t.Fatalf("stale build leaked its session lease: %v", err)
545 }
546 lease.Release()
547 // Exactly one shared-host reference may remain: the rebound build's. The
548 // stale build must have released its own.
549 app.sharedHostsMu.Lock()
550 refs := 0
551 for _, entry := range app.sharedHosts {
552 refs += entry.refs
553 }
554 app.sharedHostsMu.Unlock()
555 if refs != 1 {
556 t.Fatalf("shared host refs = %d after stale-build cleanup, want 1 (the rebound build's)", refs)
557 }
558 }
559
560 // TestMetaForTabConcurrentWithBuildSwap polls MetaForTab (the frontend's boot
561 // probe) while a fake build goroutine flips Ready/Label/StartupErr/model under
562 // a.mu — the write pattern of buildTabControllerWithContext. Run with -race.
563 func TestMetaForTabConcurrentWithBuildSwap(t *testing.T) {
564 isolateDesktopUserDirs(t)
565 tab := &WorkspaceTab{ID: "tab", Scope: "project", WorkspaceRoot: t.TempDir()}
566 app := &App{
567 tabs: map[string]*WorkspaceTab{"tab": tab},
568 tabOrder: []string{"tab"},
569 activeTabID: "tab",
570 }
571
572 const iterations = 100
573 done := make(chan struct{})
574 go func() {
575 defer close(done)
576 for i := 0; i < iterations; i++ {
577 app.mu.Lock()
578 tab.Ready = !tab.Ready
579 tab.Label = fmt.Sprintf("model-%d", i)
580 tab.StartupErr = ""
581 tab.model = fmt.Sprintf("provider/m%d", i)
582 tab.goal = fmt.Sprintf("goal-%d", i)
583 tab.tokenMode = "full"
584 app.mu.Unlock()
585 }
586 }()
587 for i := 0; i < iterations; i++ {
588 meta := app.MetaForTab("tab")
589 if meta.EventChannel == "" {
590 t.Fatal("MetaForTab returned zero meta for a live tab")
591 }
592 }
593 <-done
594 }
595
595 lines GO