**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
96 lines
3.3 KiB
Python
96 lines
3.3 KiB
Python
"""The portal link goes back up on its own, whatever took it down.
|
|
|
|
A laptop that suspends, a wifi that changes, a portal that restarts: all of
|
|
them end the same session, and none of them should need anybody to notice.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from fluksio.cloud import config as cloud_config
|
|
from fluksio.cloud.connector import BASE_BACKOFF_S, MAX_BACKOFF_S, CloudConnector
|
|
|
|
|
|
class _Link:
|
|
"""A connector whose sessions do whatever a test says, instantly."""
|
|
|
|
def __init__(self, connector: CloudConnector) -> None:
|
|
self.connector = connector
|
|
self.waits: list[float] = []
|
|
self.sessions = 0
|
|
|
|
def run(self, outcomes: list[Any]) -> list[float]:
|
|
"""Drive `serve_forever` through `outcomes`, then let it return."""
|
|
|
|
async def session(_config: Any) -> bool:
|
|
outcome = outcomes[self.sessions]
|
|
self.sessions += 1
|
|
if self.sessions >= len(outcomes):
|
|
# The next iteration reads the config and stops.
|
|
cloud_config.load = lambda: None # type: ignore[assignment]
|
|
if isinstance(outcome, Exception):
|
|
raise outcome
|
|
return bool(outcome)
|
|
|
|
async def sleep(seconds: float) -> None:
|
|
self.waits.append(seconds)
|
|
|
|
self.connector._session = session # type: ignore[assignment]
|
|
with_patched = asyncio.sleep
|
|
asyncio.sleep = sleep # type: ignore[assignment]
|
|
try:
|
|
asyncio.run(self.connector.serve_forever())
|
|
finally:
|
|
asyncio.sleep = with_patched # type: ignore[assignment]
|
|
return self.waits
|
|
|
|
|
|
@pytest.fixture
|
|
def link(monkeypatch: pytest.MonkeyPatch) -> _Link:
|
|
monkeypatch.setattr(cloud_config, "load", lambda: object())
|
|
return _Link(CloudConnector(app=None)) # type: ignore[arg-type]
|
|
|
|
|
|
def test_a_portal_that_closes_the_socket_is_not_hammered(link: _Link):
|
|
"""A clean close used to return normally and reconnect with no wait."""
|
|
waits = link.run([True, True, True])
|
|
|
|
# One wait per session. Returning normally from a session used to skip the
|
|
# sleep entirely, so a portal that accepted and closed span the loop.
|
|
assert link.sessions == 3
|
|
assert len(waits) == 3, waits
|
|
assert all(wait > 0 for wait in waits), waits
|
|
|
|
|
|
def test_a_link_that_stood_up_retries_at_once(link: _Link):
|
|
"""A dropped connection is a network event, not a refusal."""
|
|
waits = link.run([True, True])
|
|
|
|
# Jittered around the base delay rather than backed off.
|
|
assert len(waits) == 2, waits
|
|
assert all(BASE_BACKOFF_S * 0.7 <= w <= BASE_BACKOFF_S * 1.3 for w in waits), waits
|
|
|
|
|
|
def test_a_link_that_never_attaches_backs_off(link: _Link):
|
|
waits = link.run([OSError("no route to host")] * 6)
|
|
|
|
assert waits[0] < waits[1] < waits[2], waits
|
|
assert max(waits) <= MAX_BACKOFF_S * 1.3
|
|
|
|
|
|
def test_backing_off_stops_at_the_ceiling(link: _Link):
|
|
waits = link.run([OSError("refused")] * 12)
|
|
|
|
assert max(waits) <= MAX_BACKOFF_S * 1.3, waits
|
|
|
|
|
|
def test_a_failure_after_a_good_session_starts_over_from_the_base(link: _Link):
|
|
"""Ten minutes of uptime should not inherit the last outage's delay."""
|
|
waits = link.run([OSError("x"), OSError("x"), OSError("x"), True, True])
|
|
|
|
assert waits[-1] <= BASE_BACKOFF_S * 1.3, waits
|