Skip to content

memory.dashboard.launcher_core

memory.dashboard.launcher_core

Shared launcher core for the memory and zettelkasten dashboard stacks.

Both dashboards spawn the same shape of process tree — a FastAPI backend and (in a source checkout) a Vite dev server — and historically each had its own near-identical __main__.py. This module holds that logic once; each dashboard builds a :class:LauncherConfig and calls :func:run. The chat agent now runs in-process in the backend (cursor-sdk's hermetic bridge ships a bundled node binary), so there is no longer a separate Node sidecar to launch.

Beyond de-duplication it adds three pieces of robustness over the original copy-paste launchers:

  • Health-confirmed port skip. A busy port is no longer assumed to be our dashboard. The launcher hits /api/health and only skips when the live server identifies itself as our service; an unrelated process holding the port is reported as a conflict instead of silently masking a broken stack.
  • Wait-for-health. After spawning the backend the launcher polls its health endpoint and only prints the "open this URL" line once it actually answers, so users don't click through to a connection-refused page.
  • Restart-with-backoff. A component that dies is respawned with exponential backoff (resetting after a stable run) instead of just being declared dead, so a transient crash self-heals without a manual relaunch.

LauncherConfig dataclass

Everything that differs between the two dashboards.

backend_port / frontend_port are the base defaults. The actual ports are resolved per-workspace in :func:resolve_ports: an explicit env override (backend_port_env / frontend_port_env) always wins, else the base is shifted by a deterministic per-workspace offset so two checkouts of the same dashboard (e.g. angelo and a second clone) land on different ports and don't fight over one.

Source code in memory/dashboard/launcher_core.py
@dataclass(frozen=True)
class LauncherConfig:
    """Everything that differs between the two dashboards.

    ``backend_port`` / ``frontend_port`` are the *base* defaults. The actual
    ports are resolved per-workspace in :func:`resolve_ports`: an explicit env
    override (``backend_port_env`` / ``frontend_port_env``) always wins, else the
    base is shifted by a deterministic per-workspace offset so two checkouts of
    the same dashboard (e.g. angelo and a second clone) land on different ports
    and don't fight over one.
    """

    dashboard_dir: Path
    backend_app: str          # uvicorn import path, e.g. "memory.dashboard.backend.main:app"
    service_name: str         # base value returned by /api/health, e.g. "memory-dashboard"
    backend_port: int
    frontend_port: int
    workspace: Path
    install_hint: str = 'pip install -e ".[zettelkasten,dashboard]"'
    backend_port_env: str = "DASHBOARD_BACKEND_PORT"
    frontend_port_env: str = "DASHBOARD_FRONTEND_PORT"

    @property
    def frontend_dir(self) -> Path:
        return self.dashboard_dir / "frontend"

workspace_instance_id

workspace_instance_id(workspace: Path | str) -> str

Stable short id for a workspace, derived from its resolved path.

Deterministic (not random) on purpose: the launcher and the backend each compute it independently, and a relaunch must recognise its own already running dashboard rather than spawning a duplicate. Two different checkouts therefore get two different ids and never claim each other's port.

Source code in memory/dashboard/launcher_core.py
def workspace_instance_id(workspace: Path | str) -> str:
    """Stable short id for a workspace, derived from its resolved path.

    Deterministic (not random) on purpose: the launcher and the backend each
    compute it independently, and a relaunch must recognise its *own* already
    running dashboard rather than spawning a duplicate. Two different checkouts
    therefore get two different ids and never claim each other's port.
    """
    resolved = str(Path(workspace).expanduser().resolve())
    return hashlib.sha1(resolved.encode("utf-8")).hexdigest()[:8]

current_instance_id

current_instance_id() -> str

The instance id for the running process.

Prefers the value the launcher exported (:data:INSTANCE_ENV); falls back to deriving it from ANGELO_WORKSPACE (or cwd) so a backend started directly by uvicorn — outside the launcher — still reports a consistent identity.

Source code in memory/dashboard/launcher_core.py
def current_instance_id() -> str:
    """The instance id for the running process.

    Prefers the value the launcher exported (:data:`INSTANCE_ENV`); falls back to
    deriving it from ``ANGELO_WORKSPACE`` (or cwd) so a backend started directly
    by uvicorn — outside the launcher — still reports a consistent identity.
    """
    pre = os.environ.get(INSTANCE_ENV)
    if pre:
        return pre
    ws = os.environ.get("ANGELO_WORKSPACE", os.getcwd())
    return workspace_instance_id(ws)

service_identity

service_identity(base_service: str, instance_id: str | None = None) -> str

Workspace-scoped service name, e.g. zettelkasten-dashboard@a1b2c3d4.

This is what the health endpoint reports and what the launcher matches on, so a sibling workspace's dashboard reads as foreign instead of being adopted.

Source code in memory/dashboard/launcher_core.py
def service_identity(base_service: str, instance_id: str | None = None) -> str:
    """Workspace-scoped service name, e.g. ``zettelkasten-dashboard@a1b2c3d4``.

    This is what the health endpoint reports and what the launcher matches on, so
    a sibling workspace's dashboard reads as *foreign* instead of being adopted.
    """
    return f"{base_service}@{instance_id or current_instance_id()}"

port_offset

port_offset(instance_id: str, span: int = PORT_OFFSET_SPAN) -> int

Deterministic 0..span-1 port shift for a workspace instance id.

Source code in memory/dashboard/launcher_core.py
def port_offset(instance_id: str, span: int = PORT_OFFSET_SPAN) -> int:
    """Deterministic 0..span-1 port shift for a workspace instance id."""
    return int(instance_id, 16) % max(1, span)

resolve_ports

resolve_ports(config: LauncherConfig, instance_id: str) -> tuple[int, int]

Resolve (backend, frontend) ports: explicit env override else base+offset.

Source code in memory/dashboard/launcher_core.py
def resolve_ports(config: LauncherConfig, instance_id: str) -> tuple[int, int]:
    """Resolve (backend, frontend) ports: explicit env override else base+offset."""
    offset = port_offset(instance_id)
    backend = _env_port(config.backend_port_env)
    if backend is None:
        backend = config.backend_port + offset
    frontend = _env_port(config.frontend_port_env)
    if frontend is None:
        frontend = config.frontend_port + offset
    return backend, frontend

resolved_ports

resolved_ports(workspace: Path | str, base_backend: int, base_frontend: int, backend_port_env: str, frontend_port_env: str) -> tuple[int, int]

Resolve a workspace's (backend, frontend) ports without a LauncherConfig.

Same rule as :func:resolve_ports (explicit env override else a deterministic per-workspace offset), but callable from places that don't build a full :class:LauncherConfig — notably the launch_dashboard MCP tools and the angelo CLI. Sharing this is what keeps the python -m launcher, the MCP tool, and angelo kill agreeing on which port a given workspace's dashboard actually uses (a past divergence spawned duplicate backends).

Source code in memory/dashboard/launcher_core.py
def resolved_ports(
    workspace: Path | str,
    base_backend: int,
    base_frontend: int,
    backend_port_env: str,
    frontend_port_env: str,
) -> tuple[int, int]:
    """Resolve a workspace's (backend, frontend) ports without a LauncherConfig.

    Same rule as :func:`resolve_ports` (explicit env override else a deterministic
    per-workspace offset), but callable from places that don't build a full
    :class:`LauncherConfig` — notably the ``launch_dashboard`` MCP tools and the
    ``angelo`` CLI. Sharing this is what keeps the ``python -m`` launcher, the MCP
    tool, and ``angelo kill`` agreeing on *which* port a given workspace's
    dashboard actually uses (a past divergence spawned duplicate backends).
    """
    instance_id = workspace_instance_id(workspace)
    offset = port_offset(instance_id)
    backend = _env_port(backend_port_env)
    if backend is None:
        backend = base_backend + offset
    frontend = _env_port(frontend_port_env)
    if frontend is None:
        frontend = base_frontend + offset
    return backend, frontend

package_version

package_version() -> str

Installed angelo package version, or a sentinel when unresolvable.

Source code in memory/dashboard/launcher_core.py
def package_version() -> str:
    """Installed ``angelo`` package version, or a sentinel when unresolvable."""
    try:
        return importlib.metadata.version("angelo")
    except Exception:
        return "0.0.0+unknown"

build_id

build_id() -> str

Stable per-process identifier of the running code: <version>+<hash>.

Two processes from the same install report the same id; an upgrade (version bump) or any source edit (fingerprint change) yields a different id. Cached so the (cheap) fingerprint scan runs at most once per process.

Source code in memory/dashboard/launcher_core.py
def build_id() -> str:
    """Stable per-process identifier of the running code: ``<version>+<hash>``.

    Two processes from the same install report the same id; an upgrade (version
    bump) or any source edit (fingerprint change) yields a different id. Cached so
    the (cheap) fingerprint scan runs at most once per process.
    """
    global _BUILD_ID_CACHE
    if _BUILD_ID_CACHE is None:
        _BUILD_ID_CACHE = f"{package_version()}+{_code_fingerprint()}"
    return _BUILD_ID_CACHE

ensure_node_deps

ensure_node_deps(name: str, comp_dir: Path, npm: str | None) -> bool

Install a Node component's dependencies if missing. Returns readiness.

Source code in memory/dashboard/launcher_core.py
def ensure_node_deps(name: str, comp_dir: Path, npm: str | None) -> bool:
    """Install a Node component's dependencies if missing. Returns readiness."""
    if (comp_dir / "node_modules").is_dir():
        return True
    if npm is None:
        print(f"[launcher] {name}: npm not found on PATH — skipping "
              "(install Node.js to enable it).", file=sys.stderr)
        return False
    print(f"[launcher] installing {name} dependencies (first run)...")
    if subprocess.run([npm, "install"], cwd=str(comp_dir)).returncode != 0:
        print(f"[launcher] {name}: `npm install` failed — skipping.", file=sys.stderr)
        return False
    return True

preflight

preflight(config: LauncherConfig) -> dict[str, bool]

Decide which components can run for this install, prepping Node deps.

The backend always runs (and now hosts the chat agent in-process — no Node sidecar). Vite is a dev-only hot-reload layer that needs the frontend source plus a Node runtime. A plain pip install ships the built UI (frontend/dist) but not the frontend source — so installed users get a backend-served UI, while a source checkout gets the full live stack. Returns {"dev", "vite"} flags.

Source code in memory/dashboard/launcher_core.py
def preflight(config: LauncherConfig) -> dict[str, bool]:
    """Decide which components can run for this install, prepping Node deps.

    The backend always runs (and now hosts the chat agent in-process — no Node
    sidecar). Vite is a dev-only hot-reload layer that needs the frontend
    *source* plus a Node runtime. A plain `pip install` ships the built UI
    (frontend/dist) but not the frontend source — so installed users get a
    backend-served UI, while a source checkout gets the full live stack.
    Returns {"dev", "vite"} flags.
    """
    frontend_dir = config.frontend_dir
    have_built_ui = (frontend_dir / "dist" / "index.html").is_file()
    have_frontend_src = (frontend_dir / "package.json").is_file()

    if not have_built_ui and not have_frontend_src:
        print(
            "[launcher] ERROR: no dashboard UI found — neither a built bundle at\n"
            f"    {frontend_dir / 'dist'}\n"
            f"  nor frontend source at {frontend_dir}.\n"
            "  Reinstall angelo; for a source checkout use:\n"
            f"    {config.install_hint}\n",
            file=sys.stderr,
        )
        sys.exit(1)

    npm = shutil.which("npm")

    run_vite = False
    if have_frontend_src:
        run_vite = ensure_node_deps("frontend", frontend_dir, npm)
        if not run_vite and not have_built_ui:
            print("[launcher] ERROR: frontend dependencies could not be installed "
                  "and no built bundle exists to serve.", file=sys.stderr)
            sys.exit(1)

    return {"dev": have_frontend_src, "vite": run_vite}

dashboard_url

dashboard_url(port: int) -> str

Browser-facing dashboard URL. Uses 127.0.0.1 (not localhost) to match the address uvicorn actually binds — localhost may resolve to IPv6 ::1 first and incur a failed hop before falling back to the IPv4-only backend.

Source code in memory/dashboard/launcher_core.py
def dashboard_url(port: int) -> str:
    """Browser-facing dashboard URL. Uses 127.0.0.1 (not ``localhost``) to match
    the address uvicorn actually binds — ``localhost`` may resolve to IPv6 ``::1``
    first and incur a failed hop before falling back to the IPv4-only backend."""
    return f"http://127.0.0.1:{port}"

http_ok

http_ok(url: str, timeout: float = 1.0, expect_service: str | None = None) -> bool

GET url; True on HTTP 200 (and matching service, if requested).

Any connection/parse failure is False — a non-HTTP process squatting on the port, a different app, or a backend that hasn't finished booting all read as "not (our) healthy service".

Source code in memory/dashboard/launcher_core.py
def http_ok(url: str, timeout: float = 1.0, expect_service: str | None = None) -> bool:
    """GET ``url``; True on HTTP 200 (and matching service, if requested).

    Any connection/parse failure is False — a non-HTTP process squatting on the
    port, a different app, or a backend that hasn't finished booting all read as
    "not (our) healthy service".
    """
    try:
        with urlopen(url, timeout=timeout) as resp:  # noqa: S310 (localhost only)
            if resp.status != 200:
                return False
            if expect_service is None:
                return True
            try:
                payload = json.loads(resp.read().decode("utf-8"))
            except (ValueError, UnicodeDecodeError):
                return False
            return isinstance(payload, dict) and payload.get("service") == expect_service
    except (URLError, OSError, ValueError):
        return False

fetch_health

fetch_health(url: str, timeout: float = 1.0) -> dict | None

GET url and return the parsed health JSON, or None on any failure.

Unlike :func:http_ok (a bool), this surfaces the whole payload so callers can read the service/build fields and decide ours-vs-stale-vs-foreign. A non-200, non-JSON, or non-object response reads as None.

Source code in memory/dashboard/launcher_core.py
def fetch_health(url: str, timeout: float = 1.0) -> dict | None:
    """GET ``url`` and return the parsed health JSON, or ``None`` on any failure.

    Unlike :func:`http_ok` (a bool), this surfaces the whole payload so callers
    can read the ``service``/``build`` fields and decide ours-vs-stale-vs-foreign.
    A non-200, non-JSON, or non-object response reads as ``None``.
    """
    try:
        with urlopen(url, timeout=timeout) as resp:  # noqa: S310 (localhost only)
            if resp.status != 200:
                return None
            payload = json.loads(resp.read().decode("utf-8"))
    except (URLError, OSError, ValueError, UnicodeDecodeError):
        return None
    return payload if isinstance(payload, dict) else None

classify_port

classify_port(port: int, *, url: str | None = None, expect_service: str | None = None, expect_build: str | None = None, probe_in_use=None, probe_health=None, probe_payload=None) -> str

Return 'free', 'ours', 'stale', or 'foreign' for a component's port.

  • 'free' — nothing is listening.
  • 'ours' — a healthy backend whose identity (expect_service) matches and, when expect_build is given, whose reported build matches.
  • 'stale' — our backend by identity, but running a different build than expect_build (old code after an upgrade or source edit). Only returned when expect_build is provided.
  • 'foreign' — a busy port with no/failed health check, or a different service.

Components with no health endpoint (url is None) can only be 'free' or 'foreign'. When expect_build is omitted the legacy http_ok bool path is used, so existing callers/tests keep their two-way ours/foreign behaviour.

Probes default to the module-level helpers, resolved at call time so tests (and any future swap) can monkeypatch them.

Source code in memory/dashboard/launcher_core.py
def classify_port(
    port: int,
    *,
    url: str | None = None,
    expect_service: str | None = None,
    expect_build: str | None = None,
    probe_in_use=None,
    probe_health=None,
    probe_payload=None,
) -> str:
    """Return 'free', 'ours', 'stale', or 'foreign' for a component's port.

    - 'free'    — nothing is listening.
    - 'ours'    — a healthy backend whose identity (``expect_service``) matches and,
                  when ``expect_build`` is given, whose reported ``build`` matches.
    - 'stale'   — our backend by identity, but running a *different* build than
                  ``expect_build`` (old code after an upgrade or source edit). Only
                  returned when ``expect_build`` is provided.
    - 'foreign' — a busy port with no/failed health check, or a different service.

    Components with no health endpoint (``url is None``) can only be 'free' or
    'foreign'. When ``expect_build`` is omitted the legacy ``http_ok`` bool path
    is used, so existing callers/tests keep their two-way ours/foreign behaviour.

    Probes default to the module-level helpers, resolved at call time so tests
    (and any future swap) can monkeypatch them.
    """
    probe_in_use = probe_in_use or port_in_use
    if not probe_in_use(port):
        return "free"
    if not url:
        return "foreign"

    if expect_build is not None:
        probe_payload = probe_payload or fetch_health
        payload = probe_payload(url)
        if not isinstance(payload, dict):
            return "foreign"
        if expect_service is not None and payload.get("service") != expect_service:
            return "foreign"
        if payload.get("build") != expect_build:
            return "stale"
        return "ours"

    probe_health = probe_health or http_ok
    if probe_health(url, expect_service=expect_service):
        return "ours"
    return "foreign"

kill_listeners

kill_listeners(port: int) -> int

SIGTERM every process LISTENing on a TCP port. Returns the count signalled.

Cross-platform (netstat on Windows, lsof elsewhere) and best-effort: any enumeration failure or unkillable pid is swallowed so callers can treat a 0 return as "nothing recycled" rather than an error.

Source code in memory/dashboard/launcher_core.py
def kill_listeners(port: int) -> int:
    """SIGTERM every process LISTENing on a TCP ``port``. Returns the count signalled.

    Cross-platform (netstat on Windows, lsof elsewhere) and best-effort: any
    enumeration failure or unkillable pid is swallowed so callers can treat a 0
    return as "nothing recycled" rather than an error.
    """
    pids: set[int] = set()
    try:
        if sys.platform == "win32":
            out = subprocess.run(
                ["netstat", "-ano", "-p", "TCP"],
                capture_output=True, text=True, timeout=5,
            ).stdout
            for line in out.splitlines():
                parts = line.split()
                if (len(parts) >= 5 and parts[3] == "LISTENING"
                        and parts[1].endswith(f":{port}")):
                    try:
                        pids.add(int(parts[4]))
                    except ValueError:
                        pass
        else:
            out = subprocess.run(
                ["lsof", "-ti", f"tcp:{port}", "-sTCP:LISTEN"],
                capture_output=True, text=True, timeout=5,
            ).stdout
            for tok in out.split():
                try:
                    pids.add(int(tok))
                except ValueError:
                    pass
    except (OSError, subprocess.SubprocessError):
        return 0

    killed = 0
    for pid in pids:
        try:
            os.kill(pid, signal.SIGTERM)
            killed += 1
        except OSError:
            pass
    return killed

wait_for_free

wait_for_free(port: int, timeout: float = 5.0, interval: float = 0.25, *, probe=None, sleep=sleep, clock=monotonic) -> bool

Poll until port is no longer listening (e.g. after a recycle kill).

Source code in memory/dashboard/launcher_core.py
def wait_for_free(port: int, timeout: float = 5.0, interval: float = 0.25,
                  *, probe=None, sleep=time.sleep, clock=time.monotonic) -> bool:
    """Poll until ``port`` is no longer listening (e.g. after a recycle kill)."""
    probe = probe or port_in_use
    deadline = clock() + timeout
    while True:
        if not probe(port):
            return True
        if clock() >= deadline:
            return False
        sleep(interval)

wait_for_health

wait_for_health(url: str, timeout: float = HEALTH_WAIT_SECONDS, interval: float = 0.25, expect_service: str | None = None, *, probe=None, sleep=sleep, clock=monotonic) -> bool

Poll url until it answers healthy or timeout elapses.

Source code in memory/dashboard/launcher_core.py
def wait_for_health(
    url: str,
    timeout: float = HEALTH_WAIT_SECONDS,
    interval: float = 0.25,
    expect_service: str | None = None,
    *,
    probe=None,
    sleep=time.sleep,
    clock=time.monotonic,
) -> bool:
    """Poll ``url`` until it answers healthy or ``timeout`` elapses."""
    probe = probe or http_ok
    deadline = clock() + timeout
    while True:
        if probe(url, expect_service=expect_service):
            return True
        if clock() >= deadline:
            return False
        sleep(interval)

backoff_delay

backoff_delay(attempt: int, base: float = RESTART_BASE_SECONDS, cap: float = RESTART_CAP_SECONDS) -> float

Exponential backoff for restart attempt (1-based), capped.

Source code in memory/dashboard/launcher_core.py
def backoff_delay(attempt: int, base: float = RESTART_BASE_SECONDS,
                  cap: float = RESTART_CAP_SECONDS) -> float:
    """Exponential backoff for restart ``attempt`` (1-based), capped."""
    if attempt < 1:
        attempt = 1
    return min(cap, base * (2 ** (attempt - 1)))

next_restart

next_restart(attempts: int, uptime: float, *, max_restarts: int = MAX_RESTARTS, stable_reset: float = STABLE_RESET_SECONDS, base: float = RESTART_BASE_SECONDS, cap: float = RESTART_CAP_SECONDS) -> tuple[str, float, int]

Decide what to do when a component exits.

Returns (action, delay_seconds, new_attempts) where action is "restart" or "giveup". A component that had been up at least stable_reset seconds has its failure counter reset first, so an occasional late crash self-heals rather than counting toward the flap limit.

Source code in memory/dashboard/launcher_core.py
def next_restart(
    attempts: int,
    uptime: float,
    *,
    max_restarts: int = MAX_RESTARTS,
    stable_reset: float = STABLE_RESET_SECONDS,
    base: float = RESTART_BASE_SECONDS,
    cap: float = RESTART_CAP_SECONDS,
) -> tuple[str, float, int]:
    """Decide what to do when a component exits.

    Returns ``(action, delay_seconds, new_attempts)`` where action is "restart"
    or "giveup". A component that had been up at least ``stable_reset`` seconds
    has its failure counter reset first, so an occasional late crash self-heals
    rather than counting toward the flap limit.
    """
    effective = 0 if uptime >= stable_reset else attempts
    if effective >= max_restarts:
        return ("giveup", 0.0, effective)
    return ("restart", backoff_delay(effective + 1, base, cap), effective + 1)

run

run(config: LauncherConfig) -> None

Preflight, start everything, wait for health, then supervise.

Source code in memory/dashboard/launcher_core.py
def run(config: LauncherConfig) -> None:
    """Preflight, start everything, wait for health, then supervise."""
    # Windows consoles default to cp1252, which can't render Vite's arrows etc.
    if hasattr(sys.stdout, "reconfigure"):
        sys.stdout.reconfigure(encoding="utf-8", errors="replace")

    # Hand the resolved project root to every child (notably the backend, whose
    # in-process chat agent reads the Cursor API key and graph relative to it;
    # otherwise a pip install would resolve them under site-packages).
    os.environ["ANGELO_WORKSPACE"] = str(config.workspace)

    # Per-workspace identity + ports. Exporting these into our own environment
    # propagates them to every child: the backend reports the workspace-scoped
    # service id from its health endpoint, and the Vite child reads the port envs
    # (see frontend/vite.config.ts) so its dev server and proxy match the backend
    # this launcher actually started.
    instance_id = workspace_instance_id(config.workspace)
    backend_port, frontend_port = resolve_ports(config, instance_id)
    identity = service_identity(config.service_name, instance_id)
    os.environ[INSTANCE_ENV] = instance_id
    os.environ[config.backend_port_env] = str(backend_port)
    os.environ[config.frontend_port_env] = str(frontend_port)
    print(f"[launcher] workspace {config.workspace} -> instance {instance_id} "
          f"(backend :{backend_port}, frontend :{frontend_port})")

    caps = preflight(config)
    components = _build_components(
        config, caps,
        backend_port=backend_port,
        frontend_port=frontend_port,
        expect_service=identity,
    )

    # In a source checkout the live Vite server is the UI; otherwise the backend
    # serves the bundled build, so that's where users should point their browser.
    ui_port = frontend_port if caps["vite"] else backend_port

    supervised = [comp for comp in components if _start_component(comp)]

    if not supervised:
        print("[launcher] everything already running, nothing to do")
        print(f"[launcher] dashboard: {dashboard_url(ui_port)}")
        return

    # Wait for the backend to actually answer before advertising the URL.
    backend = next((c for c in supervised if c.name == "backend"), None)
    if backend is not None and backend.url is not None:
        if wait_for_health(backend.url, expect_service=backend.expect_service):
            print("[launcher] backend healthy")
        else:
            print(f"[launcher] WARNING: backend did not answer {backend.url} within "
                  f"{HEALTH_WAIT_SECONDS:.0f}s — it may still be starting; check the "
                  "[backend] logs above.", file=sys.stderr)

    print(f"[launcher] dashboard: {dashboard_url(ui_port)}  (Ctrl+C to stop)")

    stop = threading.Event()
    try:
        _supervise(supervised, stop)
    except KeyboardInterrupt:
        stop.set()
        _shutdown(supervised)