reflex-sdk 0.3.2__tar.gz → 0.3.4__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.
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/PKG-INFO +1 -1
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/pyproject.toml +1 -1
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/auth_runner.py +84 -1
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/connect_runner.py +25 -1
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/transports/_webrtc_client.py +71 -24
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/transports/webrtc.py +6 -1
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex_sdk.egg-info/PKG-INFO +1 -1
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/README.md +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/__init__.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/__main__.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/_convex.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/_region_probe.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/_transport.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/_version.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/actions.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/cameras/__init__.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/cameras/base.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/cameras/realsense.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/cameras/shm.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/cameras/v4l2.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/cli.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/client.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/connectors/__init__.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/connectors/base.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/connectors/shell.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/connectors/yam_bimanual.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/datasets.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/deployments.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/instances.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/keys.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/models.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/product.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/receipts.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/robot_runtime.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/robots.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/sessions.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/so101.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/training.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/transports/__init__.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/transports/_webrtc_streaming_client.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/transports/base.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/transports/edge_http.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/transports/platform.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex_sdk.egg-info/SOURCES.txt +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex_sdk.egg-info/dependency_links.txt +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex_sdk.egg-info/entry_points.txt +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex_sdk.egg-info/requires.txt +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex_sdk.egg-info/top_level.txt +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/setup.cfg +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/tests/test_connect_runner.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/tests/test_prod1_smoke.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/tests/test_product_cli.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/tests/test_public_sdk.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/tests/test_region_probe.py +0 -0
- {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/tests/test_so101_actions.py +0 -0
|
@@ -75,6 +75,76 @@ def _resolve_api_key() -> str | None:
|
|
|
75
75
|
return None
|
|
76
76
|
|
|
77
77
|
|
|
78
|
+
# NET.3: known Modal regional endpoints we can probe to find the nearest.
|
|
79
|
+
# Each entry: (region_key, probe_url). Probe must be cheap (HEAD or short GET).
|
|
80
|
+
# Added regions appear here once their primeNode is registered with Convex.
|
|
81
|
+
KNOWN_REGIONS = (
|
|
82
|
+
(
|
|
83
|
+
"us-west",
|
|
84
|
+
"https://reflex-inc--reflex-inference-molmoact-webrtc-baseline-mo-8e694d.us-west.modal.direct/health",
|
|
85
|
+
),
|
|
86
|
+
(
|
|
87
|
+
"us-east",
|
|
88
|
+
"https://reflex-inc--reflex-inference-molmoact-webrtc-baseline-us-e787db.us-east.modal.direct/health",
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
REGION_PROBE_TIMEOUT_S = 2.5
|
|
92
|
+
REGION_PROBE_SAMPLES = 2
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _probe_nearest_region() -> str | None:
|
|
96
|
+
"""Probe known regions; return the lowest-latency region key.
|
|
97
|
+
|
|
98
|
+
Costs one HEAD-equivalent per region per sample. Cached for the lifetime
|
|
99
|
+
of the process via REFLEX_PINNED_REGION env var; subsequent calls skip
|
|
100
|
+
the probe. Returns None on total failure (no region pickable, authorize
|
|
101
|
+
proceeds with no region hint).
|
|
102
|
+
"""
|
|
103
|
+
pinned = (os.environ.get("REFLEX_PINNED_REGION") or "").strip().lower()
|
|
104
|
+
if pinned:
|
|
105
|
+
return pinned
|
|
106
|
+
|
|
107
|
+
import concurrent.futures as _cf
|
|
108
|
+
|
|
109
|
+
def _probe_one(url: str) -> float:
|
|
110
|
+
latencies = []
|
|
111
|
+
for _ in range(REGION_PROBE_SAMPLES):
|
|
112
|
+
t0 = time.perf_counter()
|
|
113
|
+
try:
|
|
114
|
+
req = urllib.request.Request(
|
|
115
|
+
url, method="GET",
|
|
116
|
+
headers={"user-agent": USER_AGENT},
|
|
117
|
+
)
|
|
118
|
+
with urllib.request.urlopen(req, timeout=REGION_PROBE_TIMEOUT_S) as r:
|
|
119
|
+
r.read(64)
|
|
120
|
+
latencies.append(time.perf_counter() - t0)
|
|
121
|
+
except Exception:
|
|
122
|
+
return float("inf")
|
|
123
|
+
return min(latencies)
|
|
124
|
+
|
|
125
|
+
results: dict[str, float] = {}
|
|
126
|
+
with _cf.ThreadPoolExecutor(max_workers=len(KNOWN_REGIONS)) as ex:
|
|
127
|
+
future_to_region = {
|
|
128
|
+
ex.submit(_probe_one, url): region for region, url in KNOWN_REGIONS
|
|
129
|
+
}
|
|
130
|
+
for fut in _cf.as_completed(future_to_region):
|
|
131
|
+
region = future_to_region[fut]
|
|
132
|
+
results[region] = fut.result()
|
|
133
|
+
|
|
134
|
+
if not results or all(v == float("inf") for v in results.values()):
|
|
135
|
+
return None
|
|
136
|
+
best = min(results, key=lambda k: results[k])
|
|
137
|
+
best_ms = results[best] * 1000
|
|
138
|
+
print(
|
|
139
|
+
f"[reflex] region probe: chose {best!r} ({best_ms:.0f}ms) — "
|
|
140
|
+
f"{ {k: f'{v*1000:.0f}ms' for k, v in results.items()} }",
|
|
141
|
+
file=sys.stderr, flush=True,
|
|
142
|
+
)
|
|
143
|
+
# Cache the pick for the rest of the process (avoids re-probing per call).
|
|
144
|
+
os.environ["REFLEX_PINNED_REGION"] = best
|
|
145
|
+
return best
|
|
146
|
+
|
|
147
|
+
|
|
78
148
|
def _convex_mutation(convex_url: str, path: str, args: dict, *, timeout: float = 30.0) -> dict:
|
|
79
149
|
body = json.dumps({"path": path, "format": "json", "args": args}).encode("utf-8")
|
|
80
150
|
req = urllib.request.Request(
|
|
@@ -116,11 +186,24 @@ def maybe_authorize(
|
|
|
116
186
|
or CONVEX_URL_DEFAULT
|
|
117
187
|
)
|
|
118
188
|
|
|
189
|
+
# NET.3: probe nearest region (cached after first call). Skipped via
|
|
190
|
+
# REFLEX_SKIP_REGION_PROBE=1 if the caller already knows their region.
|
|
191
|
+
client_region: str | None = None
|
|
192
|
+
if not os.environ.get("REFLEX_SKIP_REGION_PROBE"):
|
|
193
|
+
try:
|
|
194
|
+
client_region = _probe_nearest_region()
|
|
195
|
+
except Exception as exc:
|
|
196
|
+
print(f"[reflex] region probe failed ({exc}); proceeding without hint", file=sys.stderr, flush=True)
|
|
197
|
+
|
|
198
|
+
auth_args = {"apiKey": api_key, "baseModel": base_model, "robotType": robot_type}
|
|
199
|
+
if client_region:
|
|
200
|
+
auth_args["clientRegion"] = client_region
|
|
201
|
+
|
|
119
202
|
t0 = time.perf_counter()
|
|
120
203
|
result = _convex_mutation(
|
|
121
204
|
convex_url,
|
|
122
205
|
"publicApi:authorizeSession",
|
|
123
|
-
|
|
206
|
+
auth_args,
|
|
124
207
|
)
|
|
125
208
|
dt_ms = (time.perf_counter() - t0) * 1000
|
|
126
209
|
|
|
@@ -176,11 +176,35 @@ def run_session(
|
|
|
176
176
|
started_cameras: list[str] = []
|
|
177
177
|
last_heartbeat = 0.0
|
|
178
178
|
interrupted = False
|
|
179
|
+
sigint_count = 0
|
|
179
180
|
executor: concurrent.futures.ThreadPoolExecutor | None = None
|
|
180
181
|
|
|
181
182
|
def _on_signal(signum, _frame): # noqa: ANN001 - signal callback signature
|
|
182
|
-
nonlocal interrupted
|
|
183
|
+
nonlocal interrupted, sigint_count
|
|
183
184
|
interrupted = True
|
|
185
|
+
sigint_count += 1
|
|
186
|
+
# Before the transport has connected there's no graceful work pending,
|
|
187
|
+
# so unwind immediately — otherwise the user's ^C only sets a flag while
|
|
188
|
+
# transport.start() keeps blocking in `fut.result(timeout=...)`.
|
|
189
|
+
if not transport_started:
|
|
190
|
+
log.warning(
|
|
191
|
+
"received signal %s before transport connected — aborting", signum
|
|
192
|
+
)
|
|
193
|
+
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
|
194
|
+
signal.signal(signal.SIGTERM, signal.SIG_DFL)
|
|
195
|
+
raise KeyboardInterrupt(f"signal {signum}")
|
|
196
|
+
# During an active session the first signal triggers graceful homing.
|
|
197
|
+
# A second signal escalates to force-exit so a stuck shutdown can't
|
|
198
|
+
# trap the user — restore the default handler and re-raise.
|
|
199
|
+
if sigint_count >= 2:
|
|
200
|
+
log.warning(
|
|
201
|
+
"received signal %s a second time — forcing exit", signum
|
|
202
|
+
)
|
|
203
|
+
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
|
204
|
+
signal.signal(signal.SIGTERM, signal.SIG_DFL)
|
|
205
|
+
raise KeyboardInterrupt(
|
|
206
|
+
f"forced exit (signal {signum} x{sigint_count})"
|
|
207
|
+
)
|
|
184
208
|
log.warning("received signal %s; ending session and homing arms", signum)
|
|
185
209
|
|
|
186
210
|
prev_sigint = signal.signal(signal.SIGINT, _on_signal)
|
|
@@ -415,33 +415,70 @@ class WebRTCClient:
|
|
|
415
415
|
method="POST",
|
|
416
416
|
headers=signaling_headers,
|
|
417
417
|
)
|
|
418
|
-
# Auto-wake cold Modal worker
|
|
419
|
-
#
|
|
420
|
-
#
|
|
418
|
+
# Auto-wake cold Modal worker. Modal scales workers to zero after idle;
|
|
419
|
+
# first request takes 30-90s to cold-start. Both the signaling POST and
|
|
420
|
+
# the ICE wait below share a single deadline = `timeout` seconds from
|
|
421
|
+
# start, so callers tune one number to govern the whole connect.
|
|
422
|
+
deadline = time.monotonic() + float(timeout)
|
|
423
|
+
loop = asyncio.get_event_loop()
|
|
424
|
+
|
|
425
|
+
def _do_post(per_attempt_timeout: float) -> dict:
|
|
426
|
+
with urllib.request.urlopen(req, timeout=per_attempt_timeout) as r:
|
|
427
|
+
return json.loads(r.read())
|
|
428
|
+
|
|
421
429
|
t0 = time.perf_counter()
|
|
422
|
-
|
|
423
|
-
|
|
430
|
+
attempt = 0
|
|
431
|
+
last_err: BaseException | None = None
|
|
432
|
+
warned = False
|
|
433
|
+
resp = None
|
|
434
|
+
while True:
|
|
435
|
+
remaining = deadline - time.monotonic()
|
|
436
|
+
if remaining <= 0:
|
|
437
|
+
msg = (
|
|
438
|
+
f"WebRTC signaling timeout after {time.perf_counter()-t0:.1f}s "
|
|
439
|
+
f"(budget {timeout:.0f}s)"
|
|
440
|
+
)
|
|
441
|
+
if last_err is not None:
|
|
442
|
+
raise RuntimeError(f"{msg} — last error: {last_err!r}") from last_err
|
|
443
|
+
raise RuntimeError(f"{msg} — no response from {self.signaling_url}")
|
|
444
|
+
attempt += 1
|
|
445
|
+
# Cap each POST at 30s so a stalled connect can't burn the whole
|
|
446
|
+
# budget on one attempt — we'd rather retry sooner with feedback.
|
|
447
|
+
per_attempt = min(remaining, 30.0)
|
|
424
448
|
try:
|
|
425
|
-
|
|
426
|
-
resp = json.loads(r.read())
|
|
449
|
+
resp = await loop.run_in_executor(None, _do_post, per_attempt)
|
|
427
450
|
last_err = None
|
|
428
451
|
break
|
|
429
452
|
except urllib.error.HTTPError as exc:
|
|
430
453
|
last_err = exc
|
|
431
|
-
if exc.code in (502, 503, 504):
|
|
432
|
-
|
|
433
|
-
print(f"[webrtc-client] worker cold (HTTP {exc.code}) — waking...", flush=True)
|
|
434
|
-
time.sleep(10)
|
|
435
|
-
continue
|
|
436
|
-
raise
|
|
454
|
+
if exc.code not in (502, 503, 504):
|
|
455
|
+
raise
|
|
437
456
|
except (urllib.error.URLError, TimeoutError) as exc:
|
|
438
457
|
last_err = exc
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
458
|
+
|
|
459
|
+
if not warned:
|
|
460
|
+
warned = True
|
|
461
|
+
if isinstance(last_err, urllib.error.HTTPError):
|
|
462
|
+
print(
|
|
463
|
+
f"[webrtc-client] worker cold (HTTP {last_err.code}) — "
|
|
464
|
+
f"waking, budget {timeout:.0f}s...",
|
|
465
|
+
flush=True,
|
|
466
|
+
)
|
|
467
|
+
else:
|
|
468
|
+
print(
|
|
469
|
+
f"[webrtc-client] worker unreachable ({last_err!r}) — "
|
|
470
|
+
f"retrying, budget {timeout:.0f}s...",
|
|
471
|
+
flush=True,
|
|
472
|
+
)
|
|
473
|
+
elapsed = time.perf_counter() - t0
|
|
474
|
+
print(
|
|
475
|
+
f"[webrtc-client] cold-wake attempt {attempt} failed at "
|
|
476
|
+
f"{elapsed:.0f}s of {timeout:.0f}s — sleeping before retry",
|
|
477
|
+
flush=True,
|
|
478
|
+
)
|
|
479
|
+
sleep_for = min(10.0, max(0.0, deadline - time.monotonic()))
|
|
480
|
+
if sleep_for > 0:
|
|
481
|
+
await asyncio.sleep(sleep_for)
|
|
445
482
|
print(f"[webrtc-client] signaling roundtrip: {(time.perf_counter()-t0)*1000:.0f}ms", flush=True)
|
|
446
483
|
|
|
447
484
|
await self.pc.setRemoteDescription(RTCSessionDescription(
|
|
@@ -449,13 +486,23 @@ class WebRTCClient:
|
|
|
449
486
|
))
|
|
450
487
|
self.session_id = resp.get("session_id")
|
|
451
488
|
|
|
452
|
-
# Wait for ICE + DTLS + DataChannel
|
|
453
|
-
|
|
489
|
+
# Wait for ICE + DTLS + DataChannel using whatever time is left in the
|
|
490
|
+
# shared deadline. Floor at 1s so a near-exhausted budget still gives
|
|
491
|
+
# ICE a meaningful chance instead of insta-timing-out.
|
|
492
|
+
ice_remaining = max(1.0, deadline - time.monotonic())
|
|
493
|
+
ice_t0 = time.perf_counter()
|
|
454
494
|
try:
|
|
455
|
-
await asyncio.wait_for(self._connected.wait(), timeout=
|
|
456
|
-
print(
|
|
495
|
+
await asyncio.wait_for(self._connected.wait(), timeout=ice_remaining)
|
|
496
|
+
print(
|
|
497
|
+
f"[webrtc-client] connected in {(time.perf_counter()-ice_t0)*1000:.0f}ms "
|
|
498
|
+
f"session={self.session_id}",
|
|
499
|
+
flush=True,
|
|
500
|
+
)
|
|
457
501
|
except asyncio.TimeoutError:
|
|
458
|
-
raise RuntimeError(
|
|
502
|
+
raise RuntimeError(
|
|
503
|
+
f"WebRTC ICE/DataChannel timeout after {ice_remaining:.1f}s — "
|
|
504
|
+
"likely NAT/firewall blocked direct UDP"
|
|
505
|
+
)
|
|
459
506
|
|
|
460
507
|
# H.264 keyframes can take ~500ms-1s to arrive at the server after
|
|
461
508
|
# negotiation. Sleep briefly so the first measured infer() finds a
|
|
@@ -148,7 +148,12 @@ class WebRTCTransport(Transport):
|
|
|
148
148
|
fut = asyncio.run_coroutine_threadsafe(
|
|
149
149
|
self._client.connect(timeout=self._connect_timeout), self._loop
|
|
150
150
|
)
|
|
151
|
-
|
|
151
|
+
# The client owns the deadline (signaling cold-wake + ICE share one
|
|
152
|
+
# budget = connect_timeout). +30s slack here lets an in-flight urllib
|
|
153
|
+
# POST in an executor thread unwind its TCP timeout naturally after
|
|
154
|
+
# the inner coroutine cancels — avoids a bare TimeoutError leaking up
|
|
155
|
+
# while the inner is in the middle of raising its own diagnostic one.
|
|
156
|
+
fut.result(timeout=self._connect_timeout + 30.0)
|
|
152
157
|
|
|
153
158
|
def stop(self) -> None:
|
|
154
159
|
if self._client is not None and self._loop is not None:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|