From 0d10dbf2f932a89a32e72d50c43eccd8cf0be59d Mon Sep 17 00:00:00 2001
From: Nikita Prokopov <niki@tonsky.me>
Date: Fri, 23 Aug 2024 18:54:28 +0200
Subject: [PATCH] Connect commands now accept `timeout` argument

---
 CHANGELOG.md           |  1 +
 cs_conn.py             | 30 +++++++++++++++++++++++++++++-
 cs_conn_nrepl_jvm.py   |  6 +++---
 cs_conn_nrepl_raw.py   | 11 ++++++-----
 cs_conn_shadow_cljs.py |  6 +++---
 cs_conn_socket_repl.py | 12 ++++++------
 6 files changed, 48 insertions(+), 18 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index ca78d7b..8636536 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,7 @@ Other changes:
 - Settings can now be specified in main `Preferences.sublime-settings` as well. Just prepend `clojure_sublimed_` to each setting’s name.
 - REPL can detect namespaces with meta on ns form
 - Detect `.shadow-cljs/nrepl.port` and `.shadow-cljs/socket-repl.port`
+- Connect commands now accept `timeout` argument for automation scenarios like “start clojure, start trying to connect to REPL until port is available”
 
 ### 3.8.0 - Aug 8, 2024
 
diff --git a/cs_conn.py b/cs_conn.py
index a9b7454..62e3a59 100644
--- a/cs_conn.py
+++ b/cs_conn.py
@@ -1,4 +1,4 @@
-import os, re, sublime, sublime_plugin
+import os, re, sublime, sublime_plugin, threading, time
 from . import cs_common, cs_eval, cs_eval_status, cs_parser, cs_warn
 
 status_key = 'clojure-sublimed-conn'
@@ -17,6 +17,9 @@ def __init__(self):
         self.disconnecting = False
         self.window = sublime.active_window()
 
+    def get_addr(self):
+        return self.addr() if callable(self.addr) else self.addr
+
     def connect_impl(self):
         pass
 
@@ -34,6 +37,31 @@ def connect(self):
             if window := sublime.active_window():
                 window.status_message(f'Connection failed')
 
+    def try_connect_impl(self, timeout):
+        state = cs_common.get_state(self.window)
+        t0 = time.time()
+        attempt = 1
+        while time.time() - t0 <= timeout:
+            time.sleep(0.25)
+            try:
+                cs_common.debug('Connection attempt #{} to {}', attempt, self.get_addr())
+                self.connect_impl()
+                state.conn = self
+                return
+            except Exception as e:
+                attempt += 1
+        cs_common.error('Giving up after {} sec connecting to {}', round(time.time() - t0, 2), self.get_addr())
+        self.disconnect()
+        if window := sublime.active_window():
+            window.status_message(f'Connection failed')
+
+    def try_connect(self, timeout = 0):
+        state = cs_common.get_state(self.window)
+        if timeout:
+            threading.Thread(target = self.try_connect_impl, args=(timeout,)).start()
+        else:
+            self.connect()
+
     def ready(self):
         return bool(self.status and self.status[0] == phases[4])
 
diff --git a/cs_conn_nrepl_jvm.py b/cs_conn_nrepl_jvm.py
index b13bc86..7b7195a 100644
--- a/cs_conn_nrepl_jvm.py
+++ b/cs_conn_nrepl_jvm.py
@@ -86,7 +86,7 @@ def handle_connect(self, msg):
             return True
 
         elif 5 == msg.get('id') and 'done' in msg.get('status', []):
-            self.set_status(4, self.addr)
+            self.set_status(4, self.get_addr())
             return True
 
     def handle_new_session(self, msg):
@@ -136,12 +136,12 @@ def handle_msg(self, msg):
         or self.handle_lookup(msg)
 
 class ClojureSublimedConnectNreplJvmCommand(sublime_plugin.WindowCommand):
-    def run(self, address):
+    def run(self, address, timeout = 0):
         state = cs_common.get_state(self.window)
         state.last_conn = ('clojure_sublimed_connect_nrepl_jvm', {'address': address})
         if address == 'auto':
             address = self.input({}).initial_text()
-        ConnectionNreplJvm(address).connect()
+        ConnectionNreplJvm(address).try_connect(timeout = timeout)
 
     def input(self, args):
         if 'address' not in args:
diff --git a/cs_conn_nrepl_raw.py b/cs_conn_nrepl_raw.py
index 392d4f4..9bddfef 100644
--- a/cs_conn_nrepl_raw.py
+++ b/cs_conn_nrepl_raw.py
@@ -16,8 +16,8 @@ def __init__(self, addr):
         self.output_view = None
 
     def connect_impl(self):
-        self.set_status(0, 'Connecting to {}...', self.addr)
-        self.socket = cs_common.socket_connect(self.addr)
+        self.set_status(0, 'Connecting to {}...', self.get_addr())
+        self.socket = cs_common.socket_connect(self.get_addr())
         self.reader = threading.Thread(daemon=True, target=self.read_loop)
         self.reader.start()
 
@@ -84,7 +84,7 @@ def interrupt_impl(self, batch_id, id):
     def handle_connect(self, msg):
         if 1 == msg.get('id') and 'new-session' in msg:
             self.session = msg['new-session']
-            self.set_status(4, self.addr)
+            self.set_status(4, self.get_addr())
             return True
 
     def handle_disconnect(self, msg):
@@ -160,12 +160,13 @@ def handle_msg(self, msg):
         or self.handle_done(msg)
 
 class ClojureSublimedConnectNreplRawCommand(sublime_plugin.WindowCommand):
-    def run(self, address):
+    def run(self, address, timeout = 0):
         state = cs_common.get_state(self.window)
         state.last_conn = ('clojure_sublimed_connect_nrepl_raw', {'address': address})
         if address == 'auto':
             address = self.input({}).initial_text()
-        ConnectionNreplRaw(address).connect()
+        while not state.conn:
+            ConnectionNreplRaw(address).try_connect(timeout = timeout)
 
     def input(self, args):
         if 'address' not in args:
diff --git a/cs_conn_shadow_cljs.py b/cs_conn_shadow_cljs.py
index ac99c88..b156bc7 100644
--- a/cs_conn_shadow_cljs.py
+++ b/cs_conn_shadow_cljs.py
@@ -27,7 +27,7 @@ def handle_connect(self, msg):
 
             return True
         elif 2 == msg.get('id') and msg.get('status') == ['done']:
-            self.set_status(4, self.addr)
+            self.set_status(4, self.get_addr())
             return True
 
     def handle_value(self, msg):
@@ -85,10 +85,10 @@ def preview(self, text):
         """)
 
 class ClojureSublimedConnectShadowCljsCommand(sublime_plugin.WindowCommand):
-    def run(self, address, build):
+    def run(self, address, build, timeout = 0):
         state = cs_common.get_state(self.window)
         state.last_conn = ('clojure_sublimed_connect_shadow_cljs', {'address': address, 'build': build})
-        ConnectionShadowCljs(address, build).connect()
+        ConnectionShadowCljs(address, build).try_connect(timeout = timeout)
 
     def input(self, args):
         if 'address' in args and 'build' in args:
diff --git a/cs_conn_socket_repl.py b/cs_conn_socket_repl.py
index de7097f..6bf38f9 100644
--- a/cs_conn_socket_repl.py
+++ b/cs_conn_socket_repl.py
@@ -30,8 +30,8 @@ def __init__(self, addr):
         self.closing   = False
 
     def connect_impl(self):
-        self.set_status(0, 'Connecting to {}', self.addr)
-        self.socket = cs_common.socket_connect(self.addr)
+        self.set_status(0, 'Connecting to {}', self.get_addr())
+        self.socket = cs_common.socket_connect(self.get_addr())
         self.reader = threading.Thread(daemon=True, target=self.read_loop)
         self.reader.start()
 
@@ -57,7 +57,7 @@ def read_loop(self):
                     self.handle_msg(msg)
                 else:
                     if '{"tag" "started"}' in line:
-                        self.set_status(4, self.addr)
+                        self.set_status(4, self.get_addr())
                         started = True
         except OSError:
             pass
@@ -196,12 +196,12 @@ def handle_msg(self, msg):
         or self.handle_err(msg)
 
 class ClojureSublimedConnectSocketReplCommand(sublime_plugin.WindowCommand):
-    def run(self, address):
+    def run(self, address, timeout = 0):
         state = cs_common.get_state(self.window)
         state.last_conn = ('clojure_sublimed_connect_socket_repl', {'address': address})
         if address == 'auto':
-            address = self.input({}).initial_text()
-        ConnectionSocketRepl(address).connect()
+            address = lambda: self.input({}).initial_text()
+        ConnectionSocketRepl(address).try_connect(timeout = timeout)
 
     def input(self, args):
         if 'address' not in args: