返回 DeepSeek-Reasonix
workspace_watcher_darwin_test.go
根目录 / desktop / workspace_watcher_darwin_test.go
1 //go:build darwin && cgo
2
3 package main
4
5 import (
6 "errors"
7 "fmt"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "runtime"
12 "strings"
13 "sync"
14 "testing"
15 "time"
16
17 "github.com/fsnotify/fsnotify"
18 "golang.org/x/sys/unix"
19 "reasonix/internal/agent"
20 "reasonix/internal/config"
21 "reasonix/internal/provider"
22 )
23
24 const darwinLowNoFileChild = "REASONIX_DARWIN_LOW_NOFILE_CHILD"
25
26 func TestDarwinWorkspaceWatcherReportsDeepFileOperations(t *testing.T) {
27 root := canonicalWorkspaceRoot(t.TempDir())
28 w := newDarwinWatcherForTest(t)
29 if err := w.Add(root, true); err != nil {
30 t.Fatal(err)
31 }
32
33 deep := filepath.Join(root, "目录 with spaces", "nested")
34 if err := os.MkdirAll(deep, 0o700); err != nil {
35 t.Fatal(err)
36 }
37 path := filepath.Join(deep, "文件.txt")
38 if err := os.WriteFile(path, []byte("created"), 0o600); err != nil {
39 t.Fatal(err)
40 }
41 waitForDarwinWorkspaceEvent(t, w, func(event fsnotify.Event) bool {
42 return filepath.Clean(event.Name) == path && event.Op&fsnotify.Create != 0
43 })
44
45 if err := os.WriteFile(path, []byte("modified"), 0o600); err != nil {
46 t.Fatal(err)
47 }
48 waitForDarwinWorkspaceEvent(t, w, func(event fsnotify.Event) bool {
49 return filepath.Clean(event.Name) == path && event.Op&fsnotify.Write != 0
50 })
51
52 renamed := filepath.Join(deep, "renamed 文件.txt")
53 if err := os.Rename(path, renamed); err != nil {
54 t.Fatal(err)
55 }
56 waitForDarwinWorkspaceEvent(t, w, func(event fsnotify.Event) bool {
57 name := filepath.Clean(event.Name)
58 return (name == path || name == renamed) && event.Op&fsnotify.Rename != 0
59 })
60
61 if err := os.Remove(renamed); err != nil {
62 t.Fatal(err)
63 }
64 waitForDarwinWorkspaceEvent(t, w, func(event fsnotify.Event) bool {
65 return filepath.Clean(event.Name) == renamed && event.Op&fsnotify.Remove != 0
66 })
67 }
68
69 func TestDarwinWorkspaceWatcherHonorsRecursiveModeAndRemove(t *testing.T) {
70 root := canonicalWorkspaceRoot(t.TempDir())
71 nested := filepath.Join(root, "child", "grandchild")
72 if err := os.MkdirAll(nested, 0o700); err != nil {
73 t.Fatal(err)
74 }
75 w := newDarwinWatcherForTest(t)
76 if err := w.Add(root, false); err != nil {
77 t.Fatal(err)
78 }
79
80 direct := filepath.Join(root, "direct.txt")
81 if err := os.WriteFile(direct, []byte("direct"), 0o600); err != nil {
82 t.Fatal(err)
83 }
84 waitForDarwinWorkspaceEvent(t, w, func(event fsnotify.Event) bool {
85 return filepath.Clean(event.Name) == direct
86 })
87
88 deep := filepath.Join(nested, "deep.txt")
89 if err := os.WriteFile(deep, []byte("ignored"), 0o600); err != nil {
90 t.Fatal(err)
91 }
92 assertNoDarwinWorkspaceEvent(t, w, deep, 400*time.Millisecond)
93
94 if err := w.Remove(root); err != nil {
95 t.Fatal(err)
96 }
97 removedWatchPath := filepath.Join(root, "after-remove.txt")
98 if err := os.WriteFile(removedWatchPath, []byte("ignored"), 0o600); err != nil {
99 t.Fatal(err)
100 }
101 assertNoDarwinWorkspaceEvent(t, w, removedWatchPath, 400*time.Millisecond)
102
103 if err := w.Add(root, true); err != nil {
104 t.Fatal(err)
105 }
106 if err := os.WriteFile(deep, []byte("recursive"), 0o600); err != nil {
107 t.Fatal(err)
108 }
109 waitForDarwinWorkspaceEvent(t, w, func(event fsnotify.Event) bool {
110 return filepath.Clean(event.Name) == deep
111 })
112 }
113
114 func TestDarwinWorkspaceWatcherMapsFlagsAndDegradesHub(t *testing.T) {
115 op, overflow := darwinWorkspaceEvent(darwinFSEventItemRemoved | darwinFSEventItemRenamed | darwinFSEventItemCreated | darwinFSEventItemModified | darwinFSEventItemXattr)
116 if overflow || op != fsnotify.Remove|fsnotify.Rename|fsnotify.Create|fsnotify.Write {
117 t.Fatalf("mapped event = (%v, %v), want all operation bits without overflow", op, overflow)
118 }
119 if op, overflow = darwinWorkspaceEvent(darwinFSEventHistoryDone); op != 0 || overflow {
120 t.Fatalf("HistoryDone mapped to (%v, %v), want ignored", op, overflow)
121 }
122 for name, flags := range map[string]uint32{
123 "must-scan": darwinFSEventMustScanSubDirs,
124 "user-dropped": darwinFSEventUserDropped,
125 "kernel-dropped": darwinFSEventKernelDropped,
126 "ids-wrapped": darwinFSEventEventIDsWrapped,
127 "mount": darwinFSEventMount,
128 "unmount": darwinFSEventUnmount,
129 } {
130 t.Run(name, func(t *testing.T) {
131 if op, overflow := darwinWorkspaceEvent(flags); op != 0 || !overflow {
132 t.Fatalf("mapped event = (%v, %v), want overflow only", op, overflow)
133 }
134 })
135 }
136 if op, overflow = darwinWorkspaceEvent(darwinFSEventRootChanged); op&fsnotify.Rename == 0 || !overflow {
137 t.Fatalf("RootChanged mapped to (%v, %v), want rename and overflow", op, overflow)
138 }
139
140 root := t.TempDir()
141 app := &App{tabs: map[string]*WorkspaceTab{"a": {ID: "a", WorkspaceRoot: root}}}
142 app.workspaceHub = newWorkspaceChangeHub(app)
143 t.Cleanup(func() { app.workspaceHub.close() })
144 if state := app.WorkspaceRevisionForTab("a").WatchState; state != "active" {
145 t.Fatalf("initial watch state = %s, want active", state)
146 }
147 key := canonicalWorkspaceRoot(root)
148 app.workspaceHub.mu.Lock()
149 r := app.workspaceHub.roots[key]
150 watcher, watcherOK := r.watcher.(*darwinWorkspaceWatcher)
151 app.workspaceHub.mu.Unlock()
152 if !watcherOK {
153 t.Fatalf("watcher type = %T, want Darwin FSEvents watcher", r.watcher)
154 }
155 watcher.mu.Lock()
156 sub := watcher.watches[key]
157 watcher.mu.Unlock()
158 if sub == nil {
159 t.Fatal("workspace root subscription is missing")
160 }
161 // RootChanged mapping above proves the combined Rename+overflow contract.
162 // Inject an overflow-only native flag here so the Hub degradation assertion
163 // is deterministic and does not also remove the watched root.
164 sub.publish(key, darwinFSEventMustScanSubDirs)
165
166 deadline := time.Now().Add(time.Second)
167 for time.Now().Before(deadline) {
168 app.workspaceHub.mu.Lock()
169 degraded := r.state == "degraded" && r.allPaths
170 app.workspaceHub.mu.Unlock()
171 if degraded {
172 return
173 }
174 time.Sleep(5 * time.Millisecond)
175 }
176 t.Fatal("dropped FSEvents notification did not degrade the hub with allPaths invalidation")
177 }
178
179 func TestDarwinWorkspaceWatcherCoalescesChannelOverflow(t *testing.T) {
180 w := &darwinWorkspaceWatcher{
181 events: make(chan fsnotify.Event, 1),
182 errors: make(chan error, 1),
183 watches: make(map[string]*darwinWorkspaceSubscription),
184 }
185 event := fsnotify.Event{Name: "/tmp/file", Op: fsnotify.Write}
186 w.sendEvent(event)
187 w.sendEvent(event)
188 w.sendEvent(event)
189 if err := <-w.errors; !errors.Is(err, fsnotify.ErrEventOverflow) {
190 t.Fatalf("overflow error = %v", err)
191 }
192 select {
193 case err := <-w.errors:
194 t.Fatalf("duplicate overflow before recovery: %v", err)
195 default:
196 }
197 <-w.events
198 w.sendEvent(event)
199 <-w.events
200 w.sendEvent(event)
201 w.sendEvent(event)
202 if err := <-w.errors; !errors.Is(err, fsnotify.ErrEventOverflow) {
203 t.Fatalf("overflow after successful recovery = %v", err)
204 }
205 w.closed.Store(true)
206 close(w.events)
207 close(w.errors)
208 w.sendEvent(event)
209 w.sendOverflow()
210 }
211
212 func TestDarwinWorkspaceWatcherCatchUpPreservesLaterWrites(t *testing.T) {
213 w := newDarwinWatcherForTest(t)
214 root := canonicalWorkspaceRoot(t.TempDir())
215 if err := w.Add(root, false); err != nil {
216 t.Fatal(err)
217 }
218 barrier := w.(interface{ CatchUp() })
219 barrier.CatchUp()
220 path := filepath.Join(root, "after-barrier.jsonl")
221 if err := os.WriteFile(path, []byte("new source"), 0600); err != nil {
222 t.Fatal(err)
223 }
224 waitForDarwinWorkspaceEvent(t, w, func(event fsnotify.Event) bool {
225 return filepath.Clean(event.Name) == path && event.Op&fsnotify.Create != 0
226 })
227 if err := w.Close(); err != nil {
228 t.Fatal(err)
229 }
230 // A late lifecycle request cannot flush a released native subscription.
231 barrier.CatchUp()
232 }
233
234 func TestDarwinWorkspaceWatcherKeepsWorkspaceAndExternalGitScope(t *testing.T) {
235 base := t.TempDir()
236 root := filepath.Join(base, "workspace")
237 gitDir := filepath.Join(base, "external git")
238 if out, err := exec.Command("git", "init", "--separate-git-dir", gitDir, root).CombinedOutput(); err != nil {
239 t.Fatalf("git init --separate-git-dir: %v: %s", err, out)
240 }
241 generated := filepath.Join(root, "node_modules", "pkg")
242 if err := os.MkdirAll(generated, 0o700); err != nil {
243 t.Fatal(err)
244 }
245
246 app := &App{tabs: map[string]*WorkspaceTab{"a": {ID: "a", WorkspaceRoot: root}}}
247 app.workspaceHub = newWorkspaceChangeHub(app)
248 t.Cleanup(func() { app.workspaceHub.close() })
249 view := app.WorkspaceRevisionForTab("a")
250 if view.WatchState != "active" {
251 t.Fatalf("watch state = %s, want active", view.WatchState)
252 }
253 beforeContent := view.Revisions.Content
254 for index := range 64 {
255 path := filepath.Join(generated, fmt.Sprintf("generated-%03d.js", index))
256 if err := os.WriteFile(path, []byte("generated"), 0o600); err != nil {
257 t.Fatal(err)
258 }
259 }
260 time.Sleep(300 * time.Millisecond)
261 view = app.WorkspaceRevisionForTab("a")
262 if view.Revisions.Content != beforeContent || view.WatchState != "active" {
263 t.Fatalf("generated churn changed view: before=%d after=%+v", beforeContent, view)
264 }
265
266 beforeGit := view.Revisions.GitMeta
267 ref := filepath.Join(gitDir, "refs", "heads", "watch-test")
268 if err := os.WriteFile(ref, []byte("0000000000000000000000000000000000000000\n"), 0o600); err != nil {
269 t.Fatal(err)
270 }
271 afterGit := waitForDarwinGitRevision(t, app, beforeGit)
272 afterGit = waitForDarwinGitRevisionToSettle(t, app, afterGit)
273 objectDir := filepath.Join(gitDir, "objects", "ff")
274 if err := os.MkdirAll(objectDir, 0o700); err != nil {
275 t.Fatal(err)
276 }
277 if err := os.WriteFile(filepath.Join(objectDir, "ignored"), []byte("object"), 0o600); err != nil {
278 t.Fatal(err)
279 }
280 time.Sleep(300 * time.Millisecond)
281 if got := app.WorkspaceRevisionForTab("a").Revisions.GitMeta; got != afterGit {
282 t.Fatalf("Git objects churn advanced metadata revision: before=%d after=%d", afterGit, got)
283 }
284 }
285
286 func TestDarwinWorkspaceWatcherConcurrentLifecycle(t *testing.T) {
287 base := t.TempDir()
288 paths := make([]string, 24)
289 for index := range paths {
290 paths[index] = filepath.Join(base, fmt.Sprintf("root-%02d", index))
291 if err := os.Mkdir(paths[index], 0o700); err != nil {
292 t.Fatal(err)
293 }
294 }
295 w, err := newWorkspaceWatcher()
296 if err != nil {
297 t.Fatal(err)
298 }
299 for _, path := range paths[:8] {
300 if err := w.Add(path, true); err != nil {
301 t.Fatal(err)
302 }
303 }
304 start := make(chan struct{})
305 errCh := make(chan error, len(paths))
306 var wg sync.WaitGroup
307 for index, path := range paths {
308 wg.Add(1)
309 go func(index int, path string) {
310 defer wg.Done()
311 <-start
312 if err := w.Add(path, index%2 == 0); err != nil && !errors.Is(err, fsnotify.ErrClosed) {
313 errCh <- err
314 return
315 }
316 if err := w.Remove(path); err != nil {
317 errCh <- err
318 }
319 }(index, path)
320 }
321 close(start)
322 if err := w.Close(); err != nil {
323 t.Fatal(err)
324 }
325 wg.Wait()
326 close(errCh)
327 for err := range errCh {
328 t.Errorf("concurrent watcher lifecycle: %v", err)
329 }
330 if err := w.Close(); err != nil {
331 t.Fatalf("second Close: %v", err)
332 }
333 darwin := w.(*darwinWorkspaceWatcher)
334 darwin.mu.Lock()
335 remaining := len(darwin.watches)
336 darwin.mu.Unlock()
337 if remaining != 0 {
338 t.Fatalf("remaining subscriptions after Close = %d", remaining)
339 }
340 }
341
342 func TestDarwinWorkspaceWatcherCloseWaitsForConcurrentRemove(t *testing.T) {
343 watcher, err := newWorkspaceWatcher()
344 if err != nil {
345 t.Fatal(err)
346 }
347 w := watcher.(*darwinWorkspaceWatcher)
348 root := canonicalWorkspaceRoot(t.TempDir())
349 stopEntered := make(chan struct{})
350 allowStop := make(chan struct{})
351 sub := &darwinWorkspaceSubscription{
352 watcher: w,
353 path: root,
354 stopNative: func() {
355 close(stopEntered)
356 <-allowStop
357 },
358 }
359 w.mu.Lock()
360 w.watches[root] = sub
361 w.mu.Unlock()
362
363 removeDone := make(chan error, 1)
364 go func() { removeDone <- w.Remove(root) }()
365 <-stopEntered
366 closeDone := make(chan error, 1)
367 go func() { closeDone <- w.Close() }()
368 deadline := time.Now().Add(time.Second)
369 for !w.closed.Load() && time.Now().Before(deadline) {
370 runtime.Gosched()
371 }
372 if !w.closed.Load() {
373 t.Fatal("Close did not start")
374 }
375 select {
376 case err := <-closeDone:
377 t.Fatalf("Close returned before concurrent Remove stopped: %v", err)
378 case <-time.After(50 * time.Millisecond):
379 }
380
381 close(allowStop)
382 if err := <-removeDone; err != nil {
383 t.Fatal(err)
384 }
385 if err := <-closeDone; err != nil {
386 t.Fatal(err)
387 }
388 if _, ok := <-w.Events(); ok {
389 t.Fatal("event channel remained open after Close")
390 }
391 if _, ok := <-w.Errors(); ok {
392 t.Fatal("error channel remained open after Close")
393 }
394 if err := w.Close(); err != nil {
395 t.Fatalf("idempotent Close: %v", err)
396 }
397 }
398
399 func TestDarwinWorkspaceWatcherLowFileDescriptorLimit(t *testing.T) {
400 if os.Getenv(darwinLowNoFileChild) == "1" {
401 runDarwinLowNoFileChild(t)
402 return
403 }
404 cmd := exec.Command(os.Args[0], "-test.run=^TestDarwinWorkspaceWatcherLowFileDescriptorLimit$", "-test.count=1", "-test.v")
405 cmd.Env = append(os.Environ(), darwinLowNoFileChild+"=1")
406 output, err := cmd.CombinedOutput()
407 if err != nil {
408 t.Fatalf("low RLIMIT_NOFILE child failed: %v\n%s", err, output)
409 }
410 }
411
412 func runDarwinLowNoFileChild(t *testing.T) {
413 base := t.TempDir()
414 root := filepath.Join(base, "large workspace")
415 deep := filepath.Join(root, "one", "two", "three")
416 if err := os.MkdirAll(deep, 0o700); err != nil {
417 t.Fatal(err)
418 }
419 for index := range 512 {
420 path := filepath.Join(root, fmt.Sprintf("root-file-%03d.txt", index))
421 if err := os.WriteFile(path, []byte("fixture"), 0o600); err != nil {
422 t.Fatal(err)
423 }
424 }
425 projectConfig := filepath.Join(root, "reasonix.toml")
426 if err := os.WriteFile(projectConfig, []byte("[agent]\nmemory_compiler = { enabled = true, verbosity = \"compact\" }\n"), 0o600); err != nil {
427 t.Fatal(err)
428 }
429 userConfig := config.UserConfigPath()
430 if err := os.MkdirAll(filepath.Dir(userConfig), 0o700); err != nil {
431 t.Fatal(err)
432 }
433 if err := os.WriteFile(userConfig, []byte("[agent]\nmemory_compiler = \"compact\"\n"), 0o600); err != nil {
434 t.Fatal(err)
435 }
436
437 var oldLimit unix.Rlimit
438 if err := unix.Getrlimit(unix.RLIMIT_NOFILE, &oldLimit); err != nil {
439 t.Fatal(err)
440 }
441 limit := oldLimit
442 if limit.Max < 256 {
443 t.Fatalf("hard RLIMIT_NOFILE = %d, need at least 256", limit.Max)
444 }
445 limit.Cur = 256
446 if err := unix.Setrlimit(unix.RLIMIT_NOFILE, &limit); err != nil {
447 t.Fatal(err)
448 }
449 t.Cleanup(func() { _ = unix.Setrlimit(unix.RLIMIT_NOFILE, &oldLimit) })
450
451 app := &App{tabs: map[string]*WorkspaceTab{"a": {ID: "a", WorkspaceRoot: root}}}
452 app.workspaceHub = newWorkspaceChangeHub(app)
453 t.Cleanup(func() { app.workspaceHub.close() })
454 view := app.WorkspaceRevisionForTab("a")
455 if view.WatchState != "active" {
456 t.Fatalf("watch state under RLIMIT_NOFILE=256 = %s", view.WatchState)
457 }
458 if fds := darwinOpenFDCount(t); fds >= 128 {
459 t.Fatalf("FSEvents watcher opened too many descriptors: %d", fds)
460 }
461
462 deepFile := filepath.Join(deep, "external-write.txt")
463 beforeContent := view.Revisions.Content
464 if err := os.WriteFile(deepFile, []byte("external"), 0o600); err != nil {
465 t.Fatal(err)
466 }
467 waitForDarwinContentRevision(t, app, beforeContent)
468
469 changed, err := config.MigrateLegacyMemoryCompilerForRoot(root)
470 if err != nil || !changed {
471 t.Fatalf("config migration changed=%v err=%v", changed, err)
472 }
473 for _, path := range []string{userConfig, projectConfig} {
474 raw, err := os.ReadFile(path)
475 if err != nil {
476 t.Fatal(err)
477 }
478 if strings.Contains(string(raw), "memory_compiler") {
479 t.Fatalf("legacy memory_compiler remains in %s", path)
480 }
481 }
482
483 subagents := filepath.Join(base, "sessions", "subagents")
484 if err := os.MkdirAll(subagents, 0o700); err != nil {
485 t.Fatal(err)
486 }
487 if cleaned, err := agent.NewSubagentStore(subagents).CleanupStaleRunning(); err != nil || cleaned != 0 {
488 t.Fatalf("subagent cleanup cleaned=%d err=%v", cleaned, err)
489 }
490
491 session := agent.NewSession("system")
492 session.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
493 sessionPath := filepath.Join(base, "sessions", "low-limit.jsonl")
494 if err := session.SaveSnapshot(sessionPath); err != nil {
495 t.Fatalf("save session snapshot: %v", err)
496 }
497 }
498
499 func newDarwinWatcherForTest(t *testing.T) workspaceWatcher {
500 t.Helper()
501 w, err := newWorkspaceWatcher()
502 if err != nil {
503 t.Fatal(err)
504 }
505 t.Cleanup(func() { _ = w.Close() })
506 return w
507 }
508
509 func waitForDarwinWorkspaceEvent(t *testing.T, watcher workspaceWatcher, match func(fsnotify.Event) bool) fsnotify.Event {
510 t.Helper()
511 timer := time.NewTimer(5 * time.Second)
512 defer timer.Stop()
513 for {
514 select {
515 case event, ok := <-watcher.Events():
516 if !ok {
517 t.Fatal("watcher event channel closed")
518 }
519 if match(event) {
520 return event
521 }
522 case err, ok := <-watcher.Errors():
523 if !ok {
524 t.Fatal("watcher error channel closed")
525 }
526 t.Fatalf("watcher error: %v", err)
527 case <-timer.C:
528 t.Fatal("timed out waiting for FSEvents notification")
529 }
530 }
531 }
532
533 func assertNoDarwinWorkspaceEvent(t *testing.T, watcher workspaceWatcher, path string, duration time.Duration) {
534 t.Helper()
535 timer := time.NewTimer(duration)
536 defer timer.Stop()
537 for {
538 select {
539 case event, ok := <-watcher.Events():
540 if !ok {
541 return
542 }
543 if filepath.Clean(event.Name) == path {
544 t.Fatalf("unexpected event after non-recursive filter/removal: %v", event)
545 }
546 case err, ok := <-watcher.Errors():
547 if ok {
548 t.Fatalf("watcher error: %v", err)
549 }
550 case <-timer.C:
551 return
552 }
553 }
554 }
555
556 func waitForDarwinContentRevision(t *testing.T, app *App, before uint64) uint64 {
557 t.Helper()
558 deadline := time.Now().Add(5 * time.Second)
559 for time.Now().Before(deadline) {
560 if got := app.WorkspaceRevisionForTab("a").Revisions.Content; got > before {
561 return got
562 }
563 time.Sleep(10 * time.Millisecond)
564 }
565 t.Fatalf("content revision did not advance beyond %d", before)
566 return before
567 }
568
569 func waitForDarwinGitRevision(t *testing.T, app *App, before uint64) uint64 {
570 t.Helper()
571 deadline := time.Now().Add(5 * time.Second)
572 for time.Now().Before(deadline) {
573 if got := app.WorkspaceRevisionForTab("a").Revisions.GitMeta; got > before {
574 return got
575 }
576 time.Sleep(10 * time.Millisecond)
577 }
578 t.Fatalf("Git metadata revision did not advance beyond %d", before)
579 return before
580 }
581
582 func waitForDarwinGitRevisionToSettle(t *testing.T, app *App, last uint64) uint64 {
583 t.Helper()
584 stableSince := time.Now()
585 deadline := time.Now().Add(3 * time.Second)
586 for time.Now().Before(deadline) {
587 time.Sleep(10 * time.Millisecond)
588 got := app.WorkspaceRevisionForTab("a").Revisions.GitMeta
589 if got != last {
590 last = got
591 stableSince = time.Now()
592 continue
593 }
594 if time.Since(stableSince) >= 150*time.Millisecond {
595 return got
596 }
597 }
598 t.Fatal("Git metadata revision did not settle")
599 return last
600 }
601
602 func darwinOpenFDCount(t *testing.T) int {
603 t.Helper()
604 var limit unix.Rlimit
605 if err := unix.Getrlimit(unix.RLIMIT_NOFILE, &limit); err != nil {
606 t.Fatal(err)
607 }
608 maxFD := min(limit.Cur, 4096)
609 open := 0
610 for fd := range maxFD {
611 if _, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0); err == nil {
612 open++
613 }
614 }
615 return open
616 }
617
617 lines GO