gdharness 0.5.8 → 0.5.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,11 +11,34 @@ signal tool_requested(request_id: String, tool_name: String, args: Dictionary)
11
11
  const DEFAULT_URL: String = "ws://127.0.0.1:6505/godot"
12
12
  ## Written beside the addon by the install, so it names the version this copy came from.
13
13
  const VERSION_MARKER: String = "res://addons/gdharness_editor/.gdharness-version"
14
+ ## Written by the server that serves this project, saying where its bridge actually is. Kept in
15
+ ## step with `announcementPath` in src/bridge-announce.ts.
16
+ const ANNOUNCEMENT: String = "res://.godot/gdharness-bridge.json"
17
+ const ANNOUNCE_PROTOCOL: int = 1
14
18
  const RECONNECT_DELAY: float = 3.0
15
19
  const MAX_RECONNECT_DELAY: float = 30.0
16
20
 
21
+ ## How long one attempt is given before the address is called a bad one.
22
+ ##
23
+ ## A socket pointed at a port nothing holds gives up by itself, in thirty seconds on Windows. One
24
+ ## pointed at something that accepts and then never speaks WebSocket does not give up at all:
25
+ ## measured here, still connecting after forty-five seconds, because the peer has no handshake
26
+ ## timeout of its own in 4.7. A leftover announcement can be either, and a port that has been
27
+ ## reused by some other program is the second.
28
+ const CONNECT_TIMEOUT: float = 10.0
29
+
30
+ ## How often the announcement is read again while connected, so a newer server is moved to
31
+ ## rather than waited for. A harness reconnect leaves the server it replaced running and holding
32
+ ## the old port, and an editor with no reason to look elsewhere stayed on it for the session.
33
+ const FOLLOW_INTERVAL: float = 5.0
34
+
17
35
  var socket: WebSocketPeer = WebSocketPeer.new()
18
36
  var server_url: String = DEFAULT_URL
37
+
38
+ ## What this copy was when it loaded, which is what the editor is running until it is restarted.
39
+ ## See [method _loaded_version] for why it is held rather than read when it is wanted.
40
+ var version_at_load: String = ""
41
+
19
42
  var _is_connected: bool = false
20
43
  var _reconnect_timer: Timer
21
44
  var _current_reconnect_delay: float = RECONNECT_DELAY
@@ -24,9 +47,21 @@ var _should_reconnect: bool = false
24
47
  var _project_path: String
25
48
  var _initialized: bool = false
26
49
 
50
+ ## Whether the address came from a caller rather than from the project, in which case it is not
51
+ ## this node's to change.
52
+ var _named_by_caller: bool = false
53
+ var _since_looked: float = 0.0
54
+
55
+ ## The announced address that did not answer, so the fallback gets a turn. Cleared the moment
56
+ ## anything connects or the project names a different one.
57
+ var _refused_url: String = ""
58
+ var _tried_announced: bool = false
59
+ var _connecting_for: float = 0.0
60
+
27
61
 
28
62
  func _ready() -> void:
29
63
  _project_path = ProjectSettings.globalize_path("res://")
64
+ version_at_load = _loaded_version()
30
65
 
31
66
  _reconnect_timer = Timer.new()
32
67
  _reconnect_timer.one_shot = true
@@ -48,6 +83,8 @@ func _process(_delta: float) -> void:
48
83
  # A connection refused never opened, so it reaches CLOSED without passing through
49
84
  # _handle_disconnect and nothing would ask again. An editor opened before the server
50
85
  # is the ordinary way that happens, and it then sat there for the rest of the day.
86
+ if _tried_announced:
87
+ _refused_url = server_url
51
88
  _schedule_reconnect()
52
89
  return
53
90
 
@@ -57,11 +94,17 @@ func _process(_delta: float) -> void:
57
94
  WebSocketPeer.STATE_OPEN:
58
95
  if not _is_connected:
59
96
  _handle_connect()
97
+ _follow_whoever_is_newest(_delta)
60
98
 
61
99
  while socket.get_available_packet_count() > 0:
62
100
  var packet: PackedByteArray = socket.get_packet()
63
101
  _handle_message(packet.get_string_from_utf8())
64
102
 
103
+ WebSocketPeer.STATE_CONNECTING:
104
+ _connecting_for += _delta
105
+ if _connecting_for >= CONNECT_TIMEOUT:
106
+ _give_up_on_this_address()
107
+
65
108
  WebSocketPeer.STATE_CLOSING:
66
109
  pass
67
110
 
@@ -70,7 +113,38 @@ func _process(_delta: float) -> void:
70
113
  _handle_disconnect()
71
114
 
72
115
 
116
+ ## Moves to the server the project now names, when that is not the one this is talking to.
117
+ ##
118
+ ## A harness reconnect leaves the server it replaced running, still holding the port it bound and
119
+ ## still answering: the editor has no reason to notice, and stayed on a server nothing was
120
+ ## speaking to for the rest of the session. The replacement announces where it landed, so the
121
+ ## editor can go to it rather than anybody ending a process.
122
+ ##
123
+ ## Only while connected by a URL this worked out for itself. A caller that named one is holding
124
+ ## this to that address, which is what every fixture does.
125
+ func _follow_whoever_is_newest(delta: float) -> void:
126
+ if _named_by_caller:
127
+ return
128
+ _since_looked += delta
129
+ if _since_looked < FOLLOW_INTERVAL:
130
+ return
131
+ _since_looked = 0.0
132
+
133
+ var announced: String = announced_url()
134
+ if announced == "" or announced == server_url or announced == _refused_url:
135
+ return
136
+ server_url = announced
137
+ # Put down before it is picked up again, because the arrival is what tells a server who this
138
+ # editor is: left standing, the open socket on the new address would never be greeted and the
139
+ # server would report no editor while holding one.
140
+ _is_connected = false
141
+ disconnected.emit()
142
+ _current_reconnect_delay = RECONNECT_DELAY
143
+ _attempt_connection()
144
+
145
+
73
146
  func connect_to_server(url: String = "") -> void:
147
+ _named_by_caller = url != ""
74
148
  server_url = _resolve_server_url(url)
75
149
  _should_reconnect = true
76
150
  _current_reconnect_delay = RECONNECT_DELAY
@@ -81,6 +155,12 @@ func _resolve_server_url(explicit_url: String) -> String:
81
155
  if explicit_url != "":
82
156
  return explicit_url
83
157
 
158
+ var announced: String = announced_url()
159
+ if announced != "" and announced != _refused_url:
160
+ _tried_announced = true
161
+ return announced
162
+ _tried_announced = false
163
+
84
164
  # The same variable the server reads, so the two agree on the port by construction.
85
165
  var raw: String = OS.get_environment("GDHARNESS_BRIDGE_PORT")
86
166
  if raw != "":
@@ -91,6 +171,48 @@ func _resolve_server_url(explicit_url: String) -> String:
91
171
  return DEFAULT_URL
92
172
 
93
173
 
174
+ ## Stops waiting on an address that is not answering, and asks again elsewhere.
175
+ func _give_up_on_this_address() -> void:
176
+ if _tried_announced:
177
+ _refused_url = server_url
178
+ _connecting_for = 0.0
179
+ socket.close()
180
+ _schedule_reconnect()
181
+
182
+
183
+ ## Where the server says its bridge is, or "" when nothing has said.
184
+ ##
185
+ ## Written by a server that knows which project it serves, inside that project, so the two sides
186
+ ## agree by construction rather than by deriving a temporary directory the same way: they do not
187
+ ## share an environment, and the runtime's own announcement cost a session learning that.
188
+ ##
189
+ ## A leftover is found out by trying it rather than by asking whether its process is still there.
190
+ ## `OS.is_process_running` answers that only for a child of the caller on Unix, where it prints
191
+ ## "does not exist or is not a child of the calling process" and says no about every server there
192
+ ## is: it works on Windows and quietly disables the whole thing everywhere else. So an
193
+ ## announcement that does not answer is set aside, the fallback gets the next turn, and a
194
+ ## different announcement puts it back in play.
195
+ func announced_url() -> String:
196
+ if not FileAccess.file_exists(ANNOUNCEMENT):
197
+ return ""
198
+ var file: FileAccess = FileAccess.open(ANNOUNCEMENT, FileAccess.READ)
199
+ if file == null:
200
+ return ""
201
+ var said: Variant = JSON.parse_string(file.get_as_text())
202
+ file.close()
203
+ if not said is Dictionary:
204
+ return ""
205
+
206
+ var announcement: Dictionary = said
207
+ if int(announcement.get("protocol", 0)) != ANNOUNCE_PROTOCOL:
208
+ return ""
209
+ var port: int = int(announcement.get("port", 0))
210
+ if port < 1 or port > 65535:
211
+ return ""
212
+ var host: String = str(announcement.get("host", "127.0.0.1"))
213
+ return "ws://%s:%d/godot" % [host, port]
214
+
215
+
94
216
  func disconnect_from_server() -> void:
95
217
  _should_reconnect = false
96
218
  if _reconnect_timer:
@@ -104,6 +226,7 @@ func _attempt_connection() -> void:
104
226
  if socket.get_ready_state() != WebSocketPeer.STATE_CLOSED:
105
227
  socket.close()
106
228
 
229
+ _connecting_for = 0.0
107
230
  var err: Error = socket.connect_to_url(server_url)
108
231
  if err != OK:
109
232
  push_error("[gdharness] Failed to connect to %s: %s" % [server_url, error_string(err)])
@@ -113,6 +236,7 @@ func _attempt_connection() -> void:
113
236
  func _handle_connect() -> void:
114
237
  _is_connected = true
115
238
  _current_reconnect_delay = RECONNECT_DELAY
239
+ _refused_url = ""
116
240
 
117
241
  # The version reported is the one this editor loaded at startup, not the one on disk: an
118
242
  # upgrade replaces the files under a running editor, which goes on serving the old code until
@@ -123,7 +247,7 @@ func _handle_connect() -> void:
123
247
  {
124
248
  "type": "godot_ready",
125
249
  "project_path": _project_path,
126
- "addon_version": _loaded_version(),
250
+ "addon_version": version_at_load,
127
251
  "editor_pid": OS.get_process_id()
128
252
  }
129
253
  )
@@ -132,6 +256,13 @@ func _handle_connect() -> void:
132
256
 
133
257
 
134
258
  ## The version marker beside this addon, or "" when the copy was not installed by gdharness.
259
+ ##
260
+ ## Read once, when this copy loads, and never again: an upgrade replaces the files under a
261
+ ## running editor and rewrites the marker with them, so reading it at connect time answers with
262
+ ## the version on disk rather than the one in memory. That is the wrong answer at the one moment
263
+ ## it matters. An upgrade ends with the harness reconnecting, the editor reconnecting behind it,
264
+ ## and `addonIsStale` reporting false over an editor still running the old code, which is exactly
265
+ ## what it exists to catch.
135
266
  func _loaded_version() -> String:
136
267
  if not FileAccess.file_exists(VERSION_MARKER):
137
268
  return ""
@@ -159,6 +290,10 @@ func _schedule_reconnect() -> void:
159
290
 
160
291
 
161
292
  func _on_reconnect_timer() -> void:
293
+ # Asked again rather than remembered: the reason a connection dropped is often that its server
294
+ # did, and the one that replaced it has said where it is since.
295
+ if not _named_by_caller:
296
+ server_url = _resolve_server_url("")
162
297
  _attempt_connection()
163
298
 
164
299