iii-sdk 0.21.8.dev1__tar.gz → 0.21.8.dev2__tar.gz

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.
Files changed (76) hide show
  1. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/PKG-INFO +2 -2
  2. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/pyproject.toml +2 -2
  3. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/src/iii/__init__.py +4 -0
  4. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/src/iii/iii.py +93 -6
  5. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/src/iii/iii_types.py +1 -0
  6. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/src/iii/types.py +3 -0
  7. iii_sdk-0.21.8.dev2/tests/test_connection_state_listener.py +149 -0
  8. iii_sdk-0.21.8.dev2/tests/test_reconnect_sends_reattach.py +77 -0
  9. iii_sdk-0.21.8.dev2/tests/test_reconnect_survives_handshake_eof.py +106 -0
  10. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/.gitignore +0 -0
  11. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/README.md +0 -0
  12. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/src/iii/channel.py +0 -0
  13. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/src/iii/channels.py +0 -0
  14. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/src/iii/engine.py +0 -0
  15. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/src/iii/errors.py +0 -0
  16. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/src/iii/format_utils.py +0 -0
  17. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/src/iii/helpers.py +0 -0
  18. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/src/iii/iii_constants.py +0 -0
  19. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/src/iii/internal.py +0 -0
  20. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/src/iii/otel_worker_gauges.py +0 -0
  21. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/src/iii/protocol.py +0 -0
  22. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/src/iii/runtime.py +0 -0
  23. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/src/iii/state.py +0 -0
  24. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/src/iii/stream.py +0 -0
  25. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/src/iii/trigger.py +0 -0
  26. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/src/iii/triggers.py +0 -0
  27. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/src/iii/utils.py +0 -0
  28. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/src/iii/worker_metrics.py +0 -0
  29. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/conftest.py +0 -0
  30. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_api_triggers.py +0 -0
  31. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_async_api.py +0 -0
  32. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_baggage_span_processor.py +0 -0
  33. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_bridge.py +0 -0
  34. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_channel_close_delay.py +0 -0
  35. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_channel_submodule.py +0 -0
  36. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_context_propagation.py +0 -0
  37. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_data_channels.py +0 -0
  38. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_engine_constants.py +0 -0
  39. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_errors.py +0 -0
  40. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_format_utils.py +0 -0
  41. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_healthcheck.py +0 -0
  42. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_helpers.py +0 -0
  43. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_hold_process.py +0 -0
  44. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_http_external_functions_integration.py +0 -0
  45. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_iii_registration_dedup.py +0 -0
  46. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_init_api.py +0 -0
  47. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_invocation_exception.py +0 -0
  48. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_invocation_metadata.py +0 -0
  49. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_logger_function_ids.py +0 -0
  50. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_logger_otel.py +0 -0
  51. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_middleware.py +0 -0
  52. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_payload.py +0 -0
  53. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_pubsub.py +0 -0
  54. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_rbac_workers.py +0 -0
  55. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_register_function_args.py +0 -0
  56. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_runtime_submodule.py +0 -0
  57. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_span_ops.py +0 -0
  58. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_state.py +0 -0
  59. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_stream_models.py +0 -0
  60. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_stream_types.py +0 -0
  61. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_streams.py +0 -0
  62. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_streams_runtime_annotations.py +0 -0
  63. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_sync_api.py +0 -0
  64. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_telemetry.py +0 -0
  65. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_telemetry_exporters.py +0 -0
  66. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_telemetry_types.py +0 -0
  67. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_trace_helpers.py +0 -0
  68. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_trigger_action.py +0 -0
  69. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_trigger_metadata.py +0 -0
  70. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_trigger_registration_error.py +0 -0
  71. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_trigger_submodule.py +0 -0
  72. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_trigger_type_lifecycle.py +0 -0
  73. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_utils.py +0 -0
  74. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_worker_metadata.py +0 -0
  75. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/tests/test_worker_metrics.py +0 -0
  76. {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev2}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iii-sdk
3
- Version: 0.21.8.dev1
3
+ Version: 0.21.8.dev2
4
4
  Summary: III SDK for Python
5
5
  Project-URL: Homepage, https://github.com/iii-hq/iii
6
6
  Project-URL: Repository, https://github.com/iii-hq/iii
@@ -14,7 +14,7 @@ Classifier: Programming Language :: Python :: 3.10
14
14
  Classifier: Programming Language :: Python :: 3.11
15
15
  Classifier: Programming Language :: Python :: 3.12
16
16
  Requires-Python: >=3.10
17
- Requires-Dist: iii-helpers==0.21.8.dev1
17
+ Requires-Dist: iii-helpers==0.21.8.dev2
18
18
  Requires-Dist: opentelemetry-api>=1.25
19
19
  Requires-Dist: pydantic>=2.0
20
20
  Requires-Dist: websockets>=12.0
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "iii-sdk"
7
- version = "0.21.8.dev1"
7
+ version = "0.21.8.dev2"
8
8
  description = "III SDK for Python"
9
9
  authors = [{ name = "III" }]
10
10
  license = { text = "Apache-2.0" }
@@ -23,7 +23,7 @@ dependencies = [
23
23
  "websockets>=12.0",
24
24
  "pydantic>=2.0",
25
25
  "opentelemetry-api>=1.25",
26
- "iii-helpers==0.21.8.dev1",
26
+ "iii-helpers==0.21.8.dev2",
27
27
  ]
28
28
 
29
29
  [project.urls]
@@ -5,6 +5,8 @@ from iii_helpers.queue import EnqueueResult
5
5
  from .errors import InvocationError
6
6
  from .iii import TriggerAction, register_worker
7
7
  from .iii_constants import (
8
+ ConnectionStateCallback,
9
+ IIIConnectionState,
8
10
  InitOptions,
9
11
  TelemetryOptions,
10
12
  )
@@ -24,6 +26,8 @@ __all__ = [
24
26
  # Errors
25
27
  "InvocationError",
26
28
  # Core
29
+ "ConnectionStateCallback",
30
+ "IIIConnectionState",
27
31
  "InitOptions",
28
32
  "register_worker",
29
33
  "TelemetryOptions",
@@ -32,6 +32,7 @@ from .format_utils import extract_request_format, extract_response_format
32
32
  from .iii_constants import (
33
33
  DEFAULT_RECONNECTION_CONFIG,
34
34
  MAX_QUEUE_SIZE,
35
+ ConnectionStateCallback,
35
36
  FunctionRef,
36
37
  IIIConnectionState,
37
38
  InitOptions,
@@ -202,7 +203,11 @@ class III:
202
203
  )
203
204
  self._reconnect_attempt = 0
204
205
  self._connection_state: IIIConnectionState = "disconnected"
206
+ # Must exist before the auto-connect below: the loop thread calls
207
+ # _set_connection_state as soon as connect_async starts.
208
+ self._connection_listeners: list[ConnectionStateCallback] = []
205
209
  self._worker_id: str | None = None
210
+ self._reattach_token: str | None = None
206
211
 
207
212
  # Background event loop thread
208
213
  self._loop = asyncio.new_event_loop()
@@ -232,11 +237,16 @@ class III:
232
237
  return
233
238
  if self._connection_state == "failed":
234
239
  raise ConnectionError(f"Connection to {self._address} failed")
235
- self._connected_event.wait(timeout=30)
240
+ connected = self._connected_event.wait(timeout=30)
236
241
  if cast(IIIConnectionState, self._connection_state) == "failed":
237
242
  raise ConnectionError(
238
243
  f"Connection to {self._address} failed after max retries"
239
244
  )
245
+ if not connected:
246
+ log.warning(
247
+ f"Timed out after 30s waiting for connection to {self._address}; "
248
+ "continuing to retry in background"
249
+ )
240
250
 
241
251
  def shutdown(self) -> None:
242
252
  """Gracefully shut down the client, releasing all resources.
@@ -337,8 +347,18 @@ class III:
337
347
  )
338
348
  log.info(f"Connected to {self._address}")
339
349
  await self._on_connected()
340
- except (ConnectionError, OSError, TimeoutError, asyncio.TimeoutError) as e:
341
- log.warning(f"Connection failed: {e}")
350
+ except Exception as e:
351
+ # Catch everything: a connect-time failure must never kill the
352
+ # reconnect loop. Enumerating exception types here has failed
353
+ # twice — ConnectionError/OSError missed websockets'
354
+ # InvalidHandshake (MOT-3931), and that in turn missed
355
+ # InvalidMessage, raised when the engine-boot listener race
356
+ # surfaces through iii-network's accept-then-dial as an EOF
357
+ # between TCP connect and the WS 101 (MOT-3966): the exception
358
+ # escaped, the connect task died silently, and the worker stayed
359
+ # a zombie with zero retries. CancelledError is BaseException,
360
+ # so shutdown cancellation still propagates.
361
+ log.warning(f"Connection failed: {type(e).__name__}: {e}")
342
362
  if self._running:
343
363
  self._schedule_reconnect()
344
364
 
@@ -378,6 +398,18 @@ class III:
378
398
  async def _on_connected(self) -> None:
379
399
  self._reconnect_attempt = 0
380
400
  self._set_connection_state("connected")
401
+ # Reconnect: present the previous engine-assigned identity BEFORE the
402
+ # registration replay so the engine retires the old connection and the
403
+ # replay lands on a clean slate instead of racing its cleanup. The
404
+ # token proves we ARE that worker (ids alone are publicly listable).
405
+ if self._worker_id:
406
+ reattach: dict[str, Any] = {
407
+ "type": MessageType.REATTACH.value,
408
+ "previous_worker_id": self._worker_id,
409
+ }
410
+ if self._reattach_token:
411
+ reattach["reattach_token"] = self._reattach_token
412
+ await self._send(reattach)
381
413
  # Re-register all (snapshot to avoid mutation from caller thread)
382
414
  for trigger_type_data in list(self._trigger_types.values()):
383
415
  await self._send(trigger_type_data.message)
@@ -425,7 +457,12 @@ class III:
425
457
  async def _send(self, msg: Any) -> None:
426
458
  data = self._to_dict(msg)
427
459
  if self._ws and self._ws.state.name == "OPEN":
428
- log.debug(f"Send: {json.dumps(data)[:200]}")
460
+ # Redact the reattach secret from debug logs; it authorizes
461
+ # evicting this worker's previous connection.
462
+ log_safe = {
463
+ k: ("***" if k == "reattach_token" else v) for k, v in data.items()
464
+ }
465
+ log.debug(f"Send: {json.dumps(log_safe)[:200]}")
429
466
  await self._ws.send(json.dumps(data))
430
467
  else:
431
468
  if len(self._queue) >= MAX_QUEUE_SIZE:
@@ -486,6 +523,7 @@ class III:
486
523
  elif msg_type == MessageType.WORKER_REGISTERED.value:
487
524
  worker_id = data.get("worker_id", "")
488
525
  self._worker_id = worker_id
526
+ self._reattach_token = data.get("reattach_token")
489
527
  log.debug(f"Worker registered with ID: {worker_id}")
490
528
 
491
529
  def _handle_result(self, invocation_id: str, result: Any, error: Any) -> None:
@@ -807,6 +845,50 @@ class III:
807
845
  self._connected_event.set()
808
846
  else:
809
847
  self._connected_event.clear()
848
+ for handler in list(self._connection_listeners):
849
+ try:
850
+ handler(state)
851
+ except Exception:
852
+ log.exception("Connection state listener raised")
853
+
854
+ def add_connection_state_listener(
855
+ self, handler: ConnectionStateCallback
856
+ ) -> Callable[[], None]:
857
+ """Subscribe to connection-state transitions.
858
+
859
+ The handler is fired immediately with the current state (on the
860
+ caller's thread), then once per transition. Transitions fire on the
861
+ SDK's background event-loop thread: keep handlers fast and do not
862
+ call sync SDK methods from them (they would raise ``RuntimeError``).
863
+ Treat calls as state notifications, not edges — a state may rarely
864
+ be observed twice around subscription. Registering the same handler
865
+ twice fires it twice. Returns an idempotent unsubscribe function
866
+ that removes only its own registration.
867
+
868
+ Examples:
869
+ >>> unsubscribe = worker.add_connection_state_listener(
870
+ ... lambda state: print(f"engine link: {state}")
871
+ ... )
872
+ """
873
+ self._connection_listeners.append(handler)
874
+ try:
875
+ handler(self._connection_state)
876
+ except Exception:
877
+ log.exception("Connection state listener raised on initial fire")
878
+
879
+ unsubscribed = False
880
+
881
+ def unsubscribe() -> None:
882
+ nonlocal unsubscribed
883
+ if unsubscribed:
884
+ return
885
+ unsubscribed = True
886
+ try:
887
+ self._connection_listeners.remove(handler)
888
+ except ValueError:
889
+ pass
890
+
891
+ return unsubscribe
810
892
 
811
893
  def get_connection_state(self) -> IIIConnectionState:
812
894
  """Return the current WebSocket connection state.
@@ -1417,14 +1499,19 @@ class TriggerAction:
1417
1499
  def register_worker(address: str, options: InitOptions | None = None) -> III:
1418
1500
  """Register the worker with a iii instance, returns a connected worker client.
1419
1501
 
1420
- Blocks until the WebSocket connection is established and ready.
1502
+ Blocks up to 30 seconds for the WebSocket connection to be established.
1503
+ If the engine is not reachable in time, a warning is logged and the
1504
+ client is returned anyway — it keeps retrying in the background and
1505
+ flushes registrations once connected. Use
1506
+ ``add_connection_state_listener`` to observe the actual transition.
1421
1507
 
1422
1508
  Args:
1423
1509
  address: WebSocket URL of the III engine (e.g. ``ws://localhost:49134``).
1424
1510
  options: Optional configuration for worker name, timeouts, reconnection, and OTel.
1425
1511
 
1426
1512
  Returns:
1427
- A connected III client instance ready to use.
1513
+ An III client instance (connected, or still connecting after the
1514
+ 30s wait timed out).
1428
1515
 
1429
1516
  Raises:
1430
1517
  ConnectionError: If the connection fails or exceeds max retries.
@@ -21,6 +21,7 @@ class MessageType(str, Enum):
21
21
  UNREGISTER_TRIGGER_TYPE = "unregistertriggertype"
22
22
  TRIGGER_REGISTRATION_RESULT = "triggerregistrationresult"
23
23
  WORKER_REGISTERED = "workerregistered"
24
+ REATTACH = "reattach"
24
25
 
25
26
 
26
27
  class RegisterTriggerTypeMessage(BaseModel):
@@ -21,6 +21,7 @@ from typing import (
21
21
  from iii_helpers.http import HttpInvocationConfig
22
22
  from pydantic import BaseModel, ConfigDict
23
23
 
24
+ from .iii_constants import ConnectionStateCallback
24
25
  from .iii_types import (
25
26
  RegisterFunctionMessage,
26
27
  RegisterTriggerInput,
@@ -111,6 +112,8 @@ class IIIClient(Protocol):
111
112
 
112
113
  def unregister_trigger_type(self, trigger_type: RegisterTriggerTypeInput | dict[str, Any]) -> None: ...
113
114
 
115
+ def add_connection_state_listener(self, handler: ConnectionStateCallback) -> Callable[[], None]: ...
116
+
114
117
  def shutdown(self) -> None: ...
115
118
 
116
119
 
@@ -0,0 +1,149 @@
1
+ """Unit tests for III.add_connection_state_listener (no live engine needed)."""
2
+
3
+ import time
4
+ from types import SimpleNamespace
5
+ from typing import Any
6
+
7
+ import pytest
8
+ from iii_helpers.observability import ReconnectionConfig
9
+ from websockets.exceptions import InvalidMessage
10
+
11
+ import iii.iii as iii_module
12
+ from iii import InitOptions
13
+ from iii.iii import III
14
+
15
+
16
+ class FakeWebSocket:
17
+ def __init__(self) -> None:
18
+ self.state = SimpleNamespace(name="OPEN")
19
+
20
+ async def send(self, payload: str) -> None:
21
+ pass
22
+
23
+ async def close(self) -> None:
24
+ self.state = SimpleNamespace(name="CLOSED")
25
+
26
+ def __aiter__(self) -> "FakeWebSocket":
27
+ return self
28
+
29
+ async def __anext__(self) -> Any:
30
+ raise StopAsyncIteration
31
+
32
+
33
+ def _patch_transport(monkeypatch: pytest.MonkeyPatch, connect) -> None:
34
+ monkeypatch.setattr(iii_module.websockets, "connect", connect)
35
+ monkeypatch.setattr("iii_helpers.observability.telemetry.init_otel", lambda **kwargs: None)
36
+ monkeypatch.setattr("iii_helpers.observability.telemetry.attach_event_loop", lambda loop: None)
37
+ monkeypatch.setattr(iii_module.III, "_register_worker_metadata", lambda self: None)
38
+
39
+
40
+ def _connected_client(monkeypatch: pytest.MonkeyPatch) -> III:
41
+ async def fake_connect(_addr: str, **kwargs: object) -> FakeWebSocket:
42
+ return FakeWebSocket()
43
+
44
+ _patch_transport(monkeypatch, fake_connect)
45
+
46
+ client = III("ws://fake")
47
+ client._wait_until_connected()
48
+ time.sleep(0.05)
49
+ assert client.get_connection_state() == "connected"
50
+ return client
51
+
52
+
53
+ def test_immediate_fire_transitions_dedup_and_shutdown(
54
+ monkeypatch: pytest.MonkeyPatch,
55
+ ) -> None:
56
+ client = _connected_client(monkeypatch)
57
+ events: list[str] = []
58
+
59
+ client.add_connection_state_listener(events.append)
60
+ assert events == ["connected"] # fired immediately with current state
61
+
62
+ client._set_connection_state("reconnecting")
63
+ client._set_connection_state("connected")
64
+ client._set_connection_state("connected") # same state -> no fire
65
+ assert events == ["connected", "reconnecting", "connected"]
66
+
67
+ client.shutdown()
68
+ assert events == ["connected", "reconnecting", "connected", "disconnected"]
69
+
70
+
71
+ def test_raising_listener_isolated_and_unsubscribe_idempotent(
72
+ monkeypatch: pytest.MonkeyPatch,
73
+ ) -> None:
74
+ client = _connected_client(monkeypatch)
75
+ events: list[str] = []
76
+
77
+ def bad(state: str) -> None:
78
+ raise RuntimeError("boom")
79
+
80
+ client.add_connection_state_listener(bad) # immediate fire raises, is swallowed
81
+ unsubscribe = client.add_connection_state_listener(events.append)
82
+ assert events == ["connected"]
83
+
84
+ client._set_connection_state("reconnecting")
85
+ assert events == ["connected", "reconnecting"] # bad didn't block delivery
86
+
87
+ unsubscribe()
88
+ unsubscribe() # idempotent
89
+ client._set_connection_state("connected")
90
+ assert events == ["connected", "reconnecting"]
91
+
92
+ client.shutdown() # fires "disconnected" into bad -> swallowed, no crash
93
+ assert events == ["connected", "reconnecting"]
94
+
95
+
96
+ def test_duplicate_subscription_unsubscribe_removes_only_its_own(
97
+ monkeypatch: pytest.MonkeyPatch,
98
+ ) -> None:
99
+ client = _connected_client(monkeypatch)
100
+ events: list[str] = []
101
+
102
+ unsub1 = client.add_connection_state_listener(events.append)
103
+ unsub2 = client.add_connection_state_listener(events.append)
104
+ assert events == ["connected", "connected"]
105
+
106
+ unsub1()
107
+ unsub1() # second call must not remove the other registration
108
+ events.clear()
109
+ client._set_connection_state("reconnecting")
110
+ assert events == ["reconnecting"]
111
+
112
+ unsub2()
113
+ client._set_connection_state("connected")
114
+ assert events == ["reconnecting"]
115
+
116
+ client.shutdown()
117
+
118
+
119
+ def test_handshake_failure_keeps_retrying(monkeypatch: pytest.MonkeyPatch) -> None:
120
+ attempts = 0
121
+
122
+ async def flaky_connect(_addr: str, **kwargs: object) -> FakeWebSocket:
123
+ nonlocal attempts
124
+ attempts += 1
125
+ if attempts <= 2:
126
+ # Peer accepted TCP but the WS upgrade died (not an OSError):
127
+ # must not kill the connect/reconnect task.
128
+ raise InvalidMessage("did not receive a valid HTTP response")
129
+ return FakeWebSocket()
130
+
131
+ _patch_transport(monkeypatch, flaky_connect)
132
+
133
+ client = III(
134
+ "ws://fake",
135
+ InitOptions(reconnection_config=ReconnectionConfig(initial_delay_ms=5, max_delay_ms=10)),
136
+ )
137
+ states: list[str] = []
138
+ client.add_connection_state_listener(states.append)
139
+ client._wait_until_connected()
140
+ assert client.get_connection_state() == "connected"
141
+ assert attempts == 3
142
+ # _set_connection_state sets the connected event before dispatching
143
+ # listeners, so give the loop thread a bounded window to deliver.
144
+ deadline = time.time() + 2
145
+ while "connected" not in states and time.time() < deadline:
146
+ time.sleep(0.01)
147
+ assert "connected" in states
148
+
149
+ client.shutdown()
@@ -0,0 +1,77 @@
1
+ """iii-hq/iii#1975 regression: on reconnect the SDK must present the previous
2
+ engine-assigned worker id via a ``reattach`` frame BEFORE replaying its
3
+ registration batch, so the engine retires the old connection and the replay
4
+ lands on a clean slate instead of racing its cleanup. On the first connect,
5
+ with no prior id, no reattach is sent.
6
+
7
+ Driven directly against ``_on_connected`` (no socket needed): the reattach is
8
+ the first thing sent, gated on a stored ``_worker_id``.
9
+ """
10
+
11
+ import asyncio
12
+
13
+ from iii.iii import III
14
+ from iii.iii_constants import InitOptions
15
+ from iii.iii_types import MessageType
16
+
17
+
18
+ def test_reconnect_sends_reattach_first(monkeypatch) -> None:
19
+ monkeypatch.setattr(
20
+ "iii_helpers.observability.telemetry.init_otel", lambda **kwargs: None
21
+ )
22
+ monkeypatch.setattr(
23
+ "iii_helpers.observability.telemetry.attach_event_loop", lambda loop: None
24
+ )
25
+
26
+ # The constructor auto-connects on a background thread; it never reaches a
27
+ # real engine here (unused.test fails to resolve), so it never calls
28
+ # _on_connected on its own — we drive that directly below. shutdown() in
29
+ # the finally stops the (non-daemon) loop thread.
30
+ client = III("ws://unused.test", InitOptions())
31
+
32
+ sent: list = []
33
+
34
+ async def fake_send(msg) -> None:
35
+ sent.append(msg)
36
+
37
+ monkeypatch.setattr(client, "_send", fake_send)
38
+ monkeypatch.setattr(client, "_register_worker_metadata", lambda: None)
39
+ client._ws = None # _receive_loop returns immediately
40
+
41
+ # One binding to replay, so ordering (reattach before replay) is observable.
42
+ client._triggers["t1"] = {"type": "registertrigger", "id": "t1"}
43
+
44
+ async def drive() -> None:
45
+ # First connect: no stored id yet -> no reattach.
46
+ await client._on_connected()
47
+ assert not any(
48
+ isinstance(m, dict) and m.get("type") == MessageType.REATTACH.value
49
+ for m in sent
50
+ ), f"no reattach must be sent on first connect: {sent}"
51
+
52
+ # Engine assigns an identity + secret; the socket then drops and
53
+ # reconnects.
54
+ client._worker_id = "w-old"
55
+ client._reattach_token = "tok-old"
56
+ sent.clear()
57
+ await client._on_connected()
58
+
59
+ loop = asyncio.new_event_loop()
60
+ try:
61
+ loop.run_until_complete(drive())
62
+ finally:
63
+ loop.close()
64
+ client.shutdown()
65
+
66
+ assert sent, "reconnect must send frames"
67
+ first = sent[0]
68
+ assert isinstance(first, dict) and first.get("type") == MessageType.REATTACH.value, (
69
+ f"reattach must be the first frame on reconnect, got: {sent}"
70
+ )
71
+ assert first.get("previous_worker_id") == "w-old"
72
+ assert first.get("reattach_token") == "tok-old"
73
+ # And it must precede the replayed registration.
74
+ assert any(
75
+ (m.get("type") if isinstance(m, dict) else getattr(m, "type", None)) == "registertrigger"
76
+ for m in sent[1:]
77
+ ), f"registration replay must follow the reattach: {sent}"
@@ -0,0 +1,106 @@
1
+ """MOT-3966 regression: the reconnect loop must survive handshake-level failures.
2
+
3
+ First-boot conditions: the engine autostarts bundle workers before its worker
4
+ listener is accepting, and the VM's userspace network stack (iii-network)
5
+ accepts the guest's TCP connection before dialing the host — so "nothing
6
+ listening yet" surfaces to the client as an EOF between TCP connect and the
7
+ WS 101. The websockets library raises that as ``InvalidMessage`` (an
8
+ ``InvalidHandshake``, NOT a ``ConnectionError``/``OSError``). The SDK's old
9
+ narrow ``except`` let it escape ``_do_connect``, silently killing the connect
10
+ task with zero retries: the worker stayed a zombie for the VM's lifetime.
11
+ """
12
+
13
+ import base64
14
+ import hashlib
15
+ import socket
16
+ import threading
17
+
18
+ from iii.iii import III
19
+ from iii.iii_constants import InitOptions
20
+ from iii_helpers.observability import ReconnectionConfig
21
+
22
+ WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
23
+
24
+
25
+ def _minimal_ws_server(listener: socket.socket, eof_first_n: int) -> None:
26
+ """Accept loop: the first ``eof_first_n`` connections are closed without
27
+ sending a byte (EOF during the client's handshake — the engine-boot race
28
+ as seen through iii-network's accept-then-dial). Later connections get a
29
+ real RFC 6455 101 reply and are held open."""
30
+ held = []
31
+ accepted = 0
32
+ while True:
33
+ try:
34
+ conn, _ = listener.accept()
35
+ except OSError:
36
+ return # listener closed: test over
37
+ accepted += 1
38
+ if accepted <= eof_first_n:
39
+ conn.close()
40
+ continue
41
+
42
+ data = b""
43
+ try:
44
+ while b"\r\n\r\n" not in data:
45
+ chunk = conn.recv(4096)
46
+ if not chunk:
47
+ break
48
+ data += chunk
49
+ key = ""
50
+ for line in data.decode("latin1").split("\r\n"):
51
+ if line.lower().startswith("sec-websocket-key:"):
52
+ key = line.split(":", 1)[1].strip()
53
+ accept = base64.b64encode(
54
+ hashlib.sha1((key + WS_GUID).encode()).digest()
55
+ ).decode()
56
+ conn.sendall(
57
+ b"HTTP/1.1 101 Switching Protocols\r\n"
58
+ b"Upgrade: websocket\r\n"
59
+ b"Connection: Upgrade\r\n"
60
+ b"Sec-WebSocket-Accept: " + accept.encode() + b"\r\n\r\n"
61
+ )
62
+ except OSError:
63
+ conn.close()
64
+ continue
65
+ held.append(conn) # keep open; the client streams registrations
66
+
67
+
68
+ def test_reconnect_survives_handshake_eof(monkeypatch) -> None:
69
+ monkeypatch.setattr(
70
+ "iii_helpers.observability.telemetry.init_otel", lambda **kwargs: None
71
+ )
72
+ monkeypatch.setattr(
73
+ "iii_helpers.observability.telemetry.attach_event_loop", lambda loop: None
74
+ )
75
+
76
+ listener = socket.socket()
77
+ listener.bind(("127.0.0.1", 0))
78
+ listener.listen(8)
79
+ port = listener.getsockname()[1]
80
+ server = threading.Thread(
81
+ target=_minimal_ws_server, args=(listener, 1), daemon=True
82
+ )
83
+ server.start()
84
+
85
+ client = III(
86
+ f"ws://127.0.0.1:{port}",
87
+ InitOptions(
88
+ reconnection_config=ReconnectionConfig(
89
+ initial_delay_ms=100,
90
+ max_delay_ms=200,
91
+ jitter_factor=0.0,
92
+ ),
93
+ ),
94
+ )
95
+ try:
96
+ # Pre-fix: the first connection's EOF raised InvalidMessage out of
97
+ # _do_connect, the connect task died silently, no retry ever ran,
98
+ # and this wait timed out with the client stuck "connecting".
99
+ client._connected_event.wait(timeout=10)
100
+ assert client._connection_state == "connected", (
101
+ f"client never recovered from the handshake EOF "
102
+ f"(state={client._connection_state!r}; reconnect loop died)"
103
+ )
104
+ finally:
105
+ client.shutdown()
106
+ listener.close()
File without changes
File without changes
File without changes