Find a study wherever it is, and start on a port that is free
Docs / docs (push) Successful in 33s
Playwright Tests / test-playwright (1, 2) (push) Successful in 2m10s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m49s
pre-commit / pre-commit (push) Failing after 2m3s
Test Backend / test-backend (push) Successful in 2m32s
Compose Smoke Test / test-compose (push) Successful in 31s
Playwright Tests / merge-reports (push) Successful in 1m9s

Three things the one-folder-per-study layout ran into.

**Discovery walks down.** A plain directory is now walked all the way, so
`fluksio sync dev` finds `dev/s1_baseline/study.py` and naming each study is
no longer the price of the layout. Hidden directories, `__pycache__`,
`node_modules` and virtualenvs are left alone, and a package is taken whole.

Two files that would import under one module name are refused, naming both:
Python keeps one module per name, so the second would silently *be* the first
— and a node's generated body imports by that name, so a worker would run the
wrong study's code. The message says the fix, which is an `__init__.py` per
study directory. A module that raises while importing is now a sentence
naming the file rather than an importlib traceback.

**`run` and `sweep` sync downwards too**, so the flow is found from the
repository root without the sync-then-`--no-sync` two-step. A study that will
not import is a warning rather than a stopped run, since a walk meets every
study and a half-finished one two directories away is not this run's problem.
The upload was already a no-op for a flow nothing changed in, so what the walk
costs is import time — `--sync PATH` narrows it, and skipping unchanged
subtrees would need a cache keyed on file state that is deliberately not here.

**`serve` moves off a busy default port** — 8001, 8002, up to twenty — says
which it took, and writes that one into `client.json`. A port given with
`--port` still fails when it is taken, because naming one is asking for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9Hdrmf2cwNABCnE5x9UJa
This commit is contained in:
2026-08-28 08:59:14 +02:00
co-authored by Claude Opus 5
parent 9dc1fe0a84
commit 9387755e59
4 changed files with 259 additions and 36 deletions
+43 -3
View File
@@ -19,6 +19,7 @@ import argparse
import copy
import os
import secrets
import socket
import sys
from pathlib import Path
from typing import Any
@@ -230,6 +231,32 @@ CONCURRENCY_FLAGS = {
}
#: What `serve` listens on when nobody says. Taken often enough — another
#: engine, another framework's dev server — that dying on it is the first
#: thing a zero-config start would hit.
DEFAULT_PORT = 8000
#: How far up from it to look before giving up and letting the bind fail.
PORT_TRIES = 20
def _free_port(host: str, start: int) -> int:
"""The first port from ``start`` that nothing is listening on.
Probed with the same address and options uvicorn will bind with, so this
answers the question uvicorn is about to ask rather than a similar one.
"""
for port in range(start, start + PORT_TRIES):
with socket.socket() as probe:
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
probe.bind((host, port))
except OSError:
continue
return port
return start
def cmd_serve(args: argparse.Namespace) -> int:
data_dir = _data_dir(args.data_dir, args.shared)
for flag, name in CONCURRENCY_FLAGS.items():
@@ -267,10 +294,16 @@ def cmd_serve(args: argparse.Namespace) -> int:
from fluksio.flow import modules
from fluksio.main import app
port = args.port
if port is None:
port = _free_port(args.host, DEFAULT_PORT)
if port != DEFAULT_PORT:
_say(f"Port {DEFAULT_PORT} is in use; serving on {port} instead.")
# The client talks to this engine, and 0.0.0.0 is not an address to talk
# to — it is a statement about which interfaces to listen on.
reachable = "127.0.0.1" if args.host in ("0.0.0.0", "::", "") else args.host
url = f"http://{reachable}:{args.port}"
url = f"http://{reachable}:{port}"
token_path = _sign_in(admin_id, url, data_dir)
config = cloud_config.load()
@@ -303,7 +336,7 @@ def cmd_serve(args: argparse.Namespace) -> int:
uvicorn.run(
app,
host=args.host,
port=args.port,
port=port,
log_level=args.log_level,
log_config=_log_config(args.log_level),
)
@@ -374,7 +407,14 @@ def _parser() -> argparse.ArgumentParser:
serve = subparsers.add_parser("serve", help="run the engine")
with_data_dir(serve)
serve.add_argument("--host", default="127.0.0.1")
serve.add_argument("--port", type=int, default=8000)
# No default: a port nobody asked for may move when it is taken, and one
# that was asked for may not.
serve.add_argument(
"--port",
type=int,
default=None,
help=f"default {DEFAULT_PORT}, or the next free port when it is in use",
)
serve.add_argument("--log-level", default="info")
serve.add_argument("--admin-email", default=None)
serve.add_argument("--admin-password", default=None)