Three fewer things to remember
**The portal link puts itself back up.** It already retried a connection that raised, but a session that ended *cleanly* — a portal restarting, a proxy closing an idle socket — returned normally and went straight back round the loop with no wait at all, so an engine could spin against a portal that was merely saying goodbye politely. Every ending now reconnects on a delay, and the delay turns on whether the attempt got as far as attaching: one that stood up and dropped is a network event and retries at once, one that never stood up waits longer each time. Jittered, so a portal coming back is not met by every installation it serves in the same instant. Ping timeouts are named rather than defaulted, since they are what bounds how long a suspended laptop's dead socket looks alive, and the keepalive task is awaited so the reason a link went reaches the log instead of the garbage collector. **`fluksio enroll <code>`** is the whole command now; hub.fluksio.com is the default and `--portal` names another. The one command run before anything works should not need two flags. **`fluksio run` syncs first.** The reason a run exists is usually the edit before it, so remembering to sync was remembering to do something the computer could do — including the worker refresh, which is what makes an edit to your own package take effect at all. `--no-sync` opts out for a tight loop. That last one needed discovery fixed: it only ever looked at top-level `*.py`, so a repository whose code is in a package — the ordinary shape — found nothing from its own root. It now descends into the packages it holds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ue1tkFWB1bcGy3aWhCKpU
This commit is contained in:
@@ -19,6 +19,7 @@ import asyncio
|
||||
import base64
|
||||
import contextlib
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import replace
|
||||
@@ -36,7 +37,14 @@ logger = logging.getLogger(__name__)
|
||||
PROTOCOL = 1
|
||||
HEARTBEAT_S = 20.0
|
||||
STATUS_S = 60.0
|
||||
BASE_BACKOFF_S = 1.0
|
||||
MAX_BACKOFF_S = 30.0
|
||||
#: How long a silent socket is given before it is treated as gone. A laptop
|
||||
#: that suspended, or a wifi that changed underneath us, leaves a connection
|
||||
#: the operating system still believes in — nothing arrives and nothing fails.
|
||||
#: Only an unanswered ping tells us, so this is the worst case for noticing.
|
||||
PING_INTERVAL_S = 20.0
|
||||
PING_TIMEOUT_S = 20.0
|
||||
CHUNK_BYTES = 256 * 1024
|
||||
MAX_WS_MESSAGE = 1024 * 1024
|
||||
#: Only the versioned API is served over the tunnel. The MCP mount and the
|
||||
@@ -79,31 +87,53 @@ class CloudConnector:
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def serve_forever(self) -> None:
|
||||
backoff = 1.0
|
||||
"""Hold the link up, and put it back up when it goes down.
|
||||
|
||||
Every ending is the same ending here: a portal that closed the socket,
|
||||
a network that changed under it, a laptop that woke up somewhere else.
|
||||
The link is dialled again in all of them — the only question is how
|
||||
soon, and that turns on whether this attempt got as far as being
|
||||
attached. One that did and then dropped is a network event, so it
|
||||
retries at once; one that never stood up is met with a longer wait
|
||||
each time, because whatever is refusing is unlikely to stop within a
|
||||
second.
|
||||
"""
|
||||
delay = BASE_BACKOFF_S
|
||||
while True:
|
||||
config = cloud_config.load()
|
||||
if config is None:
|
||||
# Disconnected locally while we were running.
|
||||
return
|
||||
attached = False
|
||||
try:
|
||||
await self._session(config)
|
||||
backoff = 1.0
|
||||
attached = await self._session(config)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
self._last_error = str(exc)
|
||||
logger.warning("Portal link down: %s", exc)
|
||||
finally:
|
||||
self._connected = False
|
||||
self._connected_since = None
|
||||
self._last_error = str(exc)
|
||||
logger.warning("Portal link down: %s — retrying in %.0fs", exc, backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
backoff = min(MAX_BACKOFF_S, backoff * 2)
|
||||
|
||||
async def _session(self, config: cloud_config.CloudConfig) -> None:
|
||||
delay = BASE_BACKOFF_S if attached else min(MAX_BACKOFF_S, delay * 2)
|
||||
# Jittered, so a portal coming back up is not met by every
|
||||
# installation it serves in the same instant.
|
||||
wait = delay * (0.75 + random.random() * 0.5)
|
||||
logger.info("Reconnecting to the portal in %.0fs", wait)
|
||||
await asyncio.sleep(wait)
|
||||
|
||||
async def _session(self, config: cloud_config.CloudConfig) -> bool:
|
||||
"""One connection, from dial to close. True if it ever attached."""
|
||||
import websockets
|
||||
|
||||
attached = False
|
||||
url = f"{config.ws_url}?token={config.token}"
|
||||
async with websockets.connect(
|
||||
url, max_size=MAX_WS_MESSAGE, ping_interval=20
|
||||
url,
|
||||
max_size=MAX_WS_MESSAGE,
|
||||
ping_interval=PING_INTERVAL_S,
|
||||
ping_timeout=PING_TIMEOUT_S,
|
||||
) as socket:
|
||||
await socket.send(
|
||||
_dump({"op": "hello", "protocol": PROTOCOL, "app_version": _version()})
|
||||
@@ -116,6 +146,7 @@ class CloudConnector:
|
||||
|
||||
self._adopt_owner(config, welcome.get("owner"))
|
||||
|
||||
attached = True
|
||||
self._connected = True
|
||||
self._connected_since = time.time()
|
||||
self._last_error = None
|
||||
@@ -138,12 +169,18 @@ class CloudConnector:
|
||||
self._cancel_stream(str(frame.get("id") or ""))
|
||||
finally:
|
||||
keepalive.cancel()
|
||||
# Awaited, not just cancelled: a keepalive that died of its own
|
||||
# accord holds the reason the link went, and an un-awaited task
|
||||
# takes it to the garbage collector instead of the log.
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await keepalive
|
||||
self._connected = False
|
||||
self._connected_since = None
|
||||
for task in list(self._calls.values()) + list(self._streams.values()):
|
||||
task.cancel()
|
||||
self._calls.clear()
|
||||
self._streams.clear()
|
||||
return attached
|
||||
|
||||
@staticmethod
|
||||
def _adopt_owner(config: cloud_config.CloudConfig, owner: Any) -> None:
|
||||
|
||||
Reference in New Issue
Block a user