Skip to content

Commit bcc6c33

Browse files
committed
Feature: add detached serve mode for background startup
- Add a shared serve background launcher helper for detached FastAPI startup - Add pypnm serve --run-background with optional log-file and pidfile overrides - Reject invalid run-background plus reload combinations in the CLI - Document detached serve usage and background log/pidfile behavior in PyPNM docs - Add focused CLI tests for detached launch and reload rejection behavior - 2026-03-31 12:27:15
1 parent 0a97b59 commit bcc6c33

6 files changed

Lines changed: 191 additions & 2 deletions

File tree

docs/system/pypnm-cli.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ Common options:
3838
- `--reload-dir` (repeatable)
3939
- `--reload-include` (repeatable)
4040
- `--reload-exclude` (repeatable)
41+
- `--run-background`
42+
- `--background-log-file`
43+
- `--background-pidfile`
4144
- `--mute-tags`
4245
- `--mute-tags-hard`
4346

@@ -48,6 +51,8 @@ pypnm serve
4851
pypnm serve --host 0.0.0.0 --port 8080
4952
pypnm serve --reload
5053
pypnm serve --workers 4 --limit-max-requests 2000
54+
pypnm serve --run-background
55+
pypnm serve --run-background --background-log-file /var/log/pypnm.log --background-pidfile /var/run/pypnm.pid
5156
pypnm serve --ssl --cert ./certs/cert.pem --key ./certs/key.pem
5257
pypnm serve --mute-tags "PNM Operations - Multi-Downstream OFDM RxMER"
5358
pypnm serve --mute-tags "Orchestrator,Operational" --mute-tags-hard
@@ -56,6 +61,8 @@ pypnm serve --mute-tags "Orchestrator,Operational" --mute-tags-hard
5661
Notes:
5762

5863
- When `--reload` is enabled, `--workers` is forced to `1`.
64+
- `--run-background` detaches the service, writes a pidfile, and redirects stdout/stderr to a log file.
65+
- `--run-background` cannot be used with `--reload`.
5966
- `--limit-max-requests` passes Uvicorn's worker recycle threshold through to the serve runtime.
6067
- For production memory safety, prefer multiple workers with a non-zero `--limit-max-requests` instead of `--reload`.
6168
- See [PyPNM Worker Sizing](worker-sizing.md) for CPU and memory-based defaults by hardware profile.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
66

77
[project]
88
name = "pypnm-docsis"
9-
version = "1.6.0.0"
9+
version = "1.6.0.1"
1010
description = "DOCSIS 3.x/4.0 Proactive Network Maintenance Toolkit"
1111
readme = "README.md"
1212
requires-python = ">=3.10"

src/pypnm/cli.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
import uvicorn
1414

1515
from pypnm.config.runtime_flags import ENV_MUTE_TAGS, ENV_MUTE_TAGS_HARD
16+
from pypnm.config.system_config_settings import SystemConfigSettings
17+
from pypnm.support.serve_background import launch_background_serve
1618
from pypnm.support.worker_profile import (
1719
WorkerProfile,
1820
default_profile_env_path,
@@ -192,6 +194,21 @@ def _build_parser() -> argparse.ArgumentParser:
192194
default=["*.pyc", "*__pycache__*", "*.tmp", "*.log"],
193195
help="Glob pattern(s) to exclude from reload (repeatable).",
194196
)
197+
serve_parser.add_argument(
198+
"--run-background",
199+
action="store_true",
200+
help="Detach the FastAPI service into the background and return the child PID.",
201+
)
202+
serve_parser.add_argument(
203+
"--background-log-file",
204+
default="",
205+
help="Optional log file path for --run-background.",
206+
)
207+
serve_parser.add_argument(
208+
"--background-pidfile",
209+
default="",
210+
help="Optional pidfile path for --run-background.",
211+
)
195212

196213
subparsers.add_parser("config-menu", help="Launch the interactive system.json configuration menu.")
197214

@@ -200,13 +217,31 @@ def _build_parser() -> argparse.ArgumentParser:
200217

201218

202219
def _run_serve(args: argparse.Namespace) -> int:
220+
run_background = bool(getattr(args, "run_background", False))
221+
background_log_file = str(getattr(args, "background_log_file", "")).strip()
222+
background_pidfile = str(getattr(args, "background_pidfile", "")).strip()
223+
224+
if run_background and bool(args.reload):
225+
print("[ERROR] --run-background cannot be used with --reload")
226+
return EXIT_CODE_USAGE
227+
203228
if args.ssl:
204229
print(f"🔒 Launching FastAPI with HTTPS on https://{args.host}:{args.port}")
205230
else:
206231
print(f"🌐 Launching FastAPI with HTTP on http://{args.host}:{args.port}")
207232

208233
_sanitize_pythonpath_for_serve()
209234

235+
if run_background:
236+
return launch_background_serve(
237+
module_name="pypnm.cli",
238+
app_slug="pypnm",
239+
runtime_dir=SystemConfigSettings.runtime_dir(),
240+
argv=sys.argv[1:],
241+
log_file=background_log_file,
242+
pidfile=background_pidfile,
243+
)
244+
210245
if str(args.mute_tags).strip() != "":
211246
os.environ[ENV_MUTE_TAGS] = str(args.mute_tags).strip()
212247
if bool(args.mute_tags_hard):
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
#!/usr/bin/env python3
2+
# SPDX-License-Identifier: Apache-2.0
3+
# Copyright (c) 2026 Maurice Garcia
4+
5+
from __future__ import annotations
6+
7+
import os
8+
import subprocess
9+
import sys
10+
from pathlib import Path
11+
12+
BACKGROUND_CHILD_ENV = "PYPNM_BACKGROUND_CHILD"
13+
RUN_BACKGROUND_FLAG = "--run-background"
14+
BACKGROUND_LOG_FILE_FLAG = "--background-log-file"
15+
BACKGROUND_PID_FILE_FLAG = "--background-pidfile"
16+
17+
18+
def background_log_path(runtime_dir: str, app_slug: str) -> Path:
19+
"""Return the default detached-serve log file path."""
20+
return Path(runtime_dir) / f"{app_slug}.serve.log"
21+
22+
23+
def background_pidfile_path(runtime_dir: str, app_slug: str) -> Path:
24+
"""Return the default detached-serve pidfile path."""
25+
return Path(runtime_dir) / f"{app_slug}.serve.pid"
26+
27+
28+
def launch_background_serve(
29+
*,
30+
module_name: str,
31+
app_slug: str,
32+
runtime_dir: str,
33+
argv: list[str],
34+
log_file: str | None,
35+
pidfile: str | None,
36+
) -> int:
37+
"""Detach a serve command into the background and return success."""
38+
runtime_path = Path(runtime_dir)
39+
runtime_path.mkdir(parents=True, exist_ok=True)
40+
41+
resolved_log_file = Path(log_file) if log_file is not None and log_file.strip() != "" else background_log_path(runtime_dir, app_slug)
42+
resolved_pidfile = Path(pidfile) if pidfile is not None and pidfile.strip() != "" else background_pidfile_path(runtime_dir, app_slug)
43+
resolved_log_file.parent.mkdir(parents=True, exist_ok=True)
44+
resolved_pidfile.parent.mkdir(parents=True, exist_ok=True)
45+
46+
child_argv = _strip_background_flags(argv)
47+
command = [sys.executable, "-m", module_name, *child_argv]
48+
child_env = os.environ.copy()
49+
child_env[BACKGROUND_CHILD_ENV] = "1"
50+
51+
with resolved_log_file.open("ab") as log_handle:
52+
process = subprocess.Popen(
53+
command,
54+
stdin=subprocess.DEVNULL,
55+
stdout=log_handle,
56+
stderr=subprocess.STDOUT,
57+
start_new_session=True,
58+
close_fds=True,
59+
env=child_env,
60+
)
61+
62+
resolved_pidfile.write_text(f"{int(process.pid)}\n", encoding="utf-8")
63+
print(f"[INFO] Background serve started: pid={int(process.pid)}")
64+
print(f"[INFO] Background serve log: {resolved_log_file}")
65+
print(f"[INFO] Background serve pidfile: {resolved_pidfile}")
66+
return 0
67+
68+
69+
def _strip_background_flags(argv: list[str]) -> list[str]:
70+
"""Return argv without background-launch flags and their values."""
71+
stripped: list[str] = []
72+
skip_next = False
73+
for token in argv:
74+
if skip_next:
75+
skip_next = False
76+
continue
77+
if token == RUN_BACKGROUND_FLAG:
78+
continue
79+
if token in (BACKGROUND_LOG_FILE_FLAG, BACKGROUND_PID_FILE_FLAG):
80+
skip_next = True
81+
continue
82+
if token.startswith(f"{BACKGROUND_LOG_FILE_FLAG}=") or token.startswith(f"{BACKGROUND_PID_FILE_FLAG}="):
83+
continue
84+
stripped.append(token)
85+
return stripped
86+
87+
88+
__all__ = [
89+
"BACKGROUND_CHILD_ENV",
90+
"RUN_BACKGROUND_FLAG",
91+
"BACKGROUND_LOG_FILE_FLAG",
92+
"BACKGROUND_PID_FILE_FLAG",
93+
"background_log_path",
94+
"background_pidfile_path",
95+
"launch_background_serve",
96+
]

src/pypnm/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@
66
__all__ = ["__version__"]
77

88
# MAJOR.MINOR.MAINTENANCE.BUILD
9-
__version__: str = "1.6.0.0"
9+
__version__: str = "1.6.0.1"

tests/test_cli.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ def _serve_args(**overrides: object) -> Namespace:
2626
"reload_dirs": [],
2727
"reload_includes": ["*.py"],
2828
"reload_excludes": ["*.pyc", "*__pycache__*", "*.tmp", "*.log"],
29+
"run_background": False,
30+
"background_log_file": "",
31+
"background_pidfile": "",
2932
}
3033
base.update(overrides)
3134
return Namespace(**base)
@@ -147,3 +150,51 @@ def fake_uvicorn_run(**kwargs: object) -> None:
147150
"[INFO] FastAPI runtime profile: workers=4 limit_max_requests=2000 source=hardware_auto"
148151
in captured.out
149152
)
153+
154+
155+
def test_run_serve_background_launches_detached_child(monkeypatch) -> None:
156+
monkeypatch.setattr(cli, "_sanitize_pythonpath_for_serve", lambda: None)
157+
called: dict[str, object] = {}
158+
159+
def _fake_launch_background_serve(**kwargs: object) -> int:
160+
called.update(kwargs)
161+
return cli.SUCCESS_EXIT_CODE
162+
163+
uvicorn_called = {"value": False}
164+
165+
def fake_uvicorn_run(**_kwargs: object) -> None:
166+
uvicorn_called["value"] = True
167+
168+
monkeypatch.setattr(cli, "launch_background_serve", _fake_launch_background_serve)
169+
monkeypatch.setattr(cli.uvicorn, "run", fake_uvicorn_run)
170+
monkeypatch.setattr(cli.SystemConfigSettings, "runtime_dir", classmethod(lambda cls: "/tmp/pypnm-runtime"))
171+
172+
exit_code = cli._run_serve(
173+
_serve_args(
174+
run_background=True,
175+
background_log_file="/tmp/pypnm.log",
176+
background_pidfile="/tmp/pypnm.pid",
177+
)
178+
)
179+
180+
assert exit_code == cli.SUCCESS_EXIT_CODE
181+
assert uvicorn_called["value"] is False
182+
assert called["module_name"] == "pypnm.cli"
183+
assert called["app_slug"] == "pypnm"
184+
assert called["runtime_dir"] == "/tmp/pypnm-runtime"
185+
assert called["log_file"] == "/tmp/pypnm.log"
186+
assert called["pidfile"] == "/tmp/pypnm.pid"
187+
188+
189+
def test_run_serve_background_rejects_reload(monkeypatch) -> None:
190+
uvicorn_called = {"value": False}
191+
192+
def fake_uvicorn_run(**_kwargs: object) -> None:
193+
uvicorn_called["value"] = True
194+
195+
monkeypatch.setattr(cli.uvicorn, "run", fake_uvicorn_run)
196+
197+
exit_code = cli._run_serve(_serve_args(run_background=True, reload=True))
198+
199+
assert exit_code == cli.EXIT_CODE_USAGE
200+
assert uvicorn_called["value"] is False

0 commit comments

Comments
 (0)