Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,9 @@ This file defines how coding agents should work in this repository.
`--interval`, `--busy-threshold`, `--gpu-ids`, `--host`, and `--port` before
auto-starting the service daemon or making RPC calls. CLI numeric options that
use shared validators must pass raw command-line values to those validators so
parse failures use KeepGPU's clean project errors instead of Typer usage text.
parse failures use KeepGPU's clean project errors instead of Typer usage text;
reject leading plus signs, non-ASCII digits, and underscore-separated numeric
spellings locally. Only documented negative sentinels such as `-1` are valid.
- If `keep-gpu start` auto-starts a service daemon and the following
`start_keep` RPC returns expected startup-unavailable JSON-RPC code
`-32000` before creating a session, the CLI must best-effort stop that
Expand Down
8 changes: 6 additions & 2 deletions docs/guides/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ value is invalid.
limit; fractional seconds such as `0.5` are accepted. `--vram` keeps integer
and digit-only values as bytes, accepts human units, and rejects
byte-equivalent requests above 1 PiB.
CLI numeric tokens use plain ASCII spellings; typo-like forms such as leading
plus signs, `1_000`, or full-width digits are rejected before daemon auto-start
or RPC. Only documented negative sentinels such as `--busy-threshold -1` are
accepted.

### Check status

Expand Down Expand Up @@ -162,10 +166,10 @@ summary averages and do not draw an idle-looking utilization fill.

| Option | Meaning | Default |
| --- | --- | --- |
| `--gpu-ids` | Comma-separated unique non-negative visible device ordinals. Omit to use all visible devices; explicit empty or whitespace-only values are invalid. Startup fails if all-visible resolution finds no GPUs or if an explicit ordinal is out of range. | all |
| `--gpu-ids` | Comma-separated unique non-negative visible device ordinals using plain ASCII digits. Omit to use all visible devices; explicit empty or whitespace-only values are invalid. Startup fails if all-visible resolution finds no GPUs or if an explicit ordinal is out of range. | all |
| `--vram` | Per-GPU memory target (`512MB`, `1GiB`, or bare bytes), capped at 1 PiB byte-equivalent. | `1GiB` |
| `--interval` | Finite positive seconds between keep-alive cycles, including fractional values, capped by the Python runtime wait limit. | `300` |
| `--busy-threshold` / `--util-threshold` | `0..100` backs off when utilization exceeds this value or telemetry is unavailable; `-1` disables utilization backoff. | `25` |
| `--busy-threshold` / `--util-threshold` | ASCII `0..100` backs off when utilization exceeds this value or telemetry is unavailable; `-1` disables utilization backoff. | `25` |
| `--job-id` | Optional URL-path-safe session id. Invalid IDs are rejected before service auto-start. | auto |

## Remote sessions
Expand Down
11 changes: 7 additions & 4 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ These options apply when you run `keep-gpu` without subcommands.
| Option | Type | Description |
| --- | --- | --- |
| `--interval NUMBER` | seconds | Finite positive sleep duration, including fractional values, between utilization checks and keep-alive batches; values above the Python runtime wait limit are rejected. |
| `--gpu-ids TEXT` | comma-separated unique non-negative ints | Subset of visible device ordinals to guard (for example, `0,2`). Omit to let the controller resolve all visible GPUs; explicit empty or whitespace-only values are invalid. Startup fails if all-visible resolution finds no GPUs or if an explicit ordinal is out of range. |
| `--gpu-ids TEXT` | comma-separated unique non-negative ints | Subset of visible device ordinals to guard (for example, `0,2`) using plain ASCII digits. Omit to let the controller resolve all visible GPUs; explicit empty or whitespace-only values are invalid. Startup fails if all-visible resolution finds no GPUs or if an explicit ordinal is out of range. |
| `--vram TEXT` | human size or bare bytes | Amount of memory each GPU controller allocates (`512MB`, `1GiB`, `1073741824`); byte-equivalent values above 1 PiB are rejected. |
| `--busy-threshold INTEGER` / `--util-threshold INTEGER` | percent | `0..100` backs off before allocation/compute when utilization is above this value or unavailable; `-1` disables utilization backoff. |
| `--busy-threshold INTEGER` / `--util-threshold INTEGER` | percent | ASCII `0..100` backs off before allocation/compute when utilization is above this value or unavailable; `-1` disables utilization backoff. |
| `--threshold TEXT` | deprecated | Legacy alias: numeric values map to busy-threshold, size strings map to vram. |

## Service mode
Expand All @@ -45,6 +45,9 @@ Local input validation runs before service auto-start. Invalid `--vram`,
`--job-id`, `--interval`, `--busy-threshold`, `--gpu-ids`, `--host`, or `--port`
values fail before daemon startup or RPC. Omit `--gpu-ids` to use all visible
GPUs; explicit empty or whitespace-only values are invalid.
CLI numeric tokens use plain ASCII spellings; typo-like forms such as leading
plus signs, `1_000`, or full-width digits are rejected locally. Only documented
negative sentinels such as `--busy-threshold -1` are accepted.
If `start` auto-starts the service and the service then reports expected
startup unavailability before creating a session, the CLI best-effort stops the
just-created daemon instead of leaving it idle.
Expand All @@ -62,10 +65,10 @@ the service log at

| Option | Default | Description |
| --- | --- | --- |
| `--gpu-ids` | all | Comma-separated unique visible device ordinals in the service process environment. Omit for all visible GPUs; empty or whitespace-only values are invalid. |
| `--gpu-ids` | all | Comma-separated unique visible device ordinals using plain ASCII digits in the service process environment. Omit for all visible GPUs; empty or whitespace-only values are invalid. |
| `--vram` | `1GiB` | Per-GPU keep memory target; byte-equivalent values above 1 PiB are rejected. |
| `--interval` | `300` | Finite positive keep cycle interval in seconds, including fractional values, capped by the Python runtime wait limit. |
| `--busy-threshold` / `--util-threshold` | `25` | `0..100` backs off when utilization is above this value or telemetry is unavailable; `-1` disables utilization backoff. |
| `--busy-threshold` / `--util-threshold` | `25` | ASCII `0..100` backs off when utilization is above this value or telemetry is unavailable; `-1` disables utilization backoff. |
| `--job-id` | auto | Optional URL-path-safe custom id. Invalid IDs are rejected locally before service auto-start; valid IDs must be unique across active and starting sessions. |
| `--host` | `127.0.0.1` | Service host to contact; invalid values are rejected before auto-start. |
| `--port` | `8765` | Service port to contact; must be a plain ASCII decimal integer in `1..65535`. |
Expand Down
64 changes: 57 additions & 7 deletions src/keep_gpu/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import json
import os
import re
import shlex
import signal
import subprocess
Expand Down Expand Up @@ -54,6 +55,10 @@
"busy_threshold": "--busy-threshold/--util-threshold",
"interval": "--interval",
}
_CLI_INTEGER_TOKEN_RE = re.compile(r"-?[0-9]+")
_CLI_NUMBER_TOKEN_RE = re.compile(
r"-?(?:(?:[0-9]+(?:\.[0-9]*)?)|(?:\.[0-9]+))(?:[eE][+-]?[0-9]+)?"
)

app = typer.Typer(
context_settings={"help_option_names": ["-h", "--help"]},
Expand All @@ -63,6 +68,29 @@
logger = setup_logger(__name__)


def _is_signed_zero_integer_token(token: str) -> bool:
return (
token.startswith("-")
and _CLI_INTEGER_TOKEN_RE.fullmatch(token) is not None
and int(token) == 0
)


def _is_invalid_gpu_id_token(token: str) -> bool:
if not _CLI_INTEGER_TOKEN_RE.fullmatch(token):
return True
return _is_signed_zero_integer_token(token)


def _looks_like_numeric_cli_token(token: str) -> bool:
return bool(token) and (
token[0] in "+-"
or any(ch.isdigit() for ch in token)
or "_" in token
or "." in token
)


class ServiceUnreachableError(RuntimeError):
"""Raised when the local service cannot be reached."""

Expand Down Expand Up @@ -311,11 +339,21 @@ def _apply_legacy_threshold(
if legacy_threshold is None:
return vram_value, busy_threshold, None

normalized = legacy_threshold.strip()
if _CLI_INTEGER_TOKEN_RE.fullmatch(
normalized
) and not _is_signed_zero_integer_token(normalized):
return vram_value, int(normalized), "busy"
try:
parsed_threshold = int(legacy_threshold)
except ValueError:
return legacy_threshold, busy_threshold, "vram"
return vram_value, parsed_threshold, "busy"
parse_vram_to_elements(normalized)
except (TypeError, ValueError) as exc:
if any(ch.isalpha() for ch in normalized):
raise typer.BadParameter(str(exc)) from exc
if _looks_like_numeric_cli_token(normalized):
raise typer.BadParameter(
"threshold must be an integer utilization value or a VRAM size"
) from exc
return legacy_threshold, busy_threshold, "vram"


def _parse_gpu_ids(gpu_ids: Optional[str]) -> Optional[List[int]]:
Expand All @@ -326,7 +364,10 @@ def _parse_gpu_ids(gpu_ids: Optional[str]) -> Optional[List[int]]:
"gpu_ids must not be empty; omit --gpu-ids to use all visible GPUs"
)
try:
parsed = [int(i.strip()) for i in gpu_ids.split(",")]
tokens = [i.strip() for i in gpu_ids.split(",")]
if any(_is_invalid_gpu_id_token(token) for token in tokens):
raise ValueError
parsed = [int(token) for token in tokens]
except ValueError as exc:
raise typer.BadParameter(
f"Invalid characters in --gpu-ids '{gpu_ids}'. "
Expand All @@ -343,7 +384,7 @@ def _validate_cli_interval(interval: Any) -> Union[int, float]:
raise typer.BadParameter("interval must be finite and positive")
if isinstance(interval, str):
normalized = interval.strip()
if not normalized:
if not normalized or not _CLI_NUMBER_TOKEN_RE.fullmatch(normalized):
raise typer.BadParameter("interval must be finite and positive")
try:
try:
Expand Down Expand Up @@ -371,7 +412,11 @@ def _validate_cli_vram(vram: str) -> str:
def _validate_cli_busy_threshold(busy_threshold: Any) -> int:
if isinstance(busy_threshold, str):
normalized = busy_threshold.strip()
if not normalized:
if (
not normalized
or not _CLI_INTEGER_TOKEN_RE.fullmatch(normalized)
or _is_signed_zero_integer_token(normalized)
):
raise typer.BadParameter(
"busy_threshold must be -1 or an integer between 0 and 100"
)
Expand Down Expand Up @@ -1137,6 +1182,11 @@ def main(
interval = _validate_cli_interval(interval)
busy_threshold = _validate_cli_busy_threshold(busy_threshold)
_parse_gpu_ids(gpu_ids)
legacy_vram, legacy_busy_threshold, _ = _apply_legacy_threshold(
vram, legacy_threshold, busy_threshold
)
_validate_cli_vram(legacy_vram)
_validate_cli_busy_threshold(legacy_busy_threshold)
_run_blocking(interval, gpu_ids, vram, legacy_threshold, busy_threshold)
except typer.BadParameter as exc:
console.print(f"[bold red]Error: {exc}[/bold red]")
Expand Down
68 changes: 67 additions & 1 deletion tests/test_cli_service_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,9 +395,30 @@ def fake_rpc(*args, **kwargs):
(["--vram", "not-a-size"], "invalid format"),
(["--vram", ("9" * 500) + "GiB"], "vram must be no more than"),
(["--interval", str(10**1000)], "interval must be no more than"),
(["--interval", f"+{10**1000}"], "interval must be no more than"),
(["--interval", f"+{10**1000}"], "interval must be finite and positive"),
(["--interval", "NaN"], "interval must be finite and positive"),
(["--interval", "Infinity"], "interval must be finite and positive"),
(["--interval", "1_000"], "interval must be finite and positive"),
(
["--busy-threshold", "12"],
"busy_threshold must be -1 or an integer between 0 and 100",
),
(
["--busy-threshold", "1_0"],
"busy_threshold must be -1 or an integer between 0 and 100",
),
(
["--gpu-ids", "123"],
"Invalid characters in --gpu-ids '123'",
),
(
["--gpu-ids", "1_000"],
"Invalid characters in --gpu-ids '1_000'",
),
(
["--gpu-ids", "-0"],
"Invalid characters in --gpu-ids '-0'",
),
],
)
def test_start_command_rejects_local_inputs_before_auto_start(
Expand Down Expand Up @@ -2225,6 +2246,51 @@ def test_blocking_mode_rejects_non_integer_busy_threshold_without_usage(monkeypa
assert "Usage:" not in result.output


@pytest.mark.parametrize(
("args", "message"),
[
(["--gpu-ids", "1_000"], "Invalid characters in --gpu-ids '1_000'"),
(["--gpu-ids", "123"], "Invalid characters in --gpu-ids '123'"),
(["--gpu-ids", "+0"], "Invalid characters in --gpu-ids '+0'"),
(["--interval", "1_000"], "interval must be finite and positive"),
(["--interval", "+1"], "interval must be finite and positive"),
(
["--busy-threshold", "1_0"],
"busy_threshold must be -1 or an integer between 0 and 100",
),
(
["--busy-threshold", "+25"],
"busy_threshold must be -1 or an integer between 0 and 100",
),
(
["--threshold", "1_0"],
"threshold must be an integer utilization value or a VRAM size",
),
(
["--threshold", "+25"],
"threshold must be an integer utilization value or a VRAM size",
),
(
["--threshold", "12"],
"threshold must be an integer utilization value or a VRAM size",
),
],
)
def test_blocking_mode_rejects_non_canonical_numeric_tokens(monkeypatch, args, message):
monkeypatch.setattr(
cli,
"_run_blocking",
lambda *args, **kwargs: (_ for _ in ()).throw(
AssertionError("blocking runner should not be called")
),
)

result = runner.invoke(cli.app, args)

assert result.exit_code == 1
assert message in result.output


def test_invalid_root_interval_before_subcommand_is_rejected(monkeypatch):
monkeypatch.setattr(
cli,
Expand Down
40 changes: 40 additions & 0 deletions tests/test_cli_thresholds.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,35 @@ def test_parse_gpu_ids_rejects_duplicate_values():
cli._parse_gpu_ids("0,1,0")


@pytest.mark.parametrize("gpu_ids", ["1_000", "123", "+0", "-0", "-00"])
def test_parse_gpu_ids_rejects_non_canonical_numeric_tokens(gpu_ids):
with pytest.raises(typer.BadParameter, match="Invalid characters in --gpu-ids"):
cli._parse_gpu_ids(gpu_ids)


@pytest.mark.parametrize("interval", ["1_000", "123", "+1"])
def test_validate_cli_interval_rejects_non_canonical_numeric_tokens(interval):
with pytest.raises(
typer.BadParameter, match="interval must be finite and positive"
):
cli._validate_cli_interval(interval)


def test_validate_cli_interval_preserves_exponent_plus_sign():
assert cli._validate_cli_interval("1e+3") == 1000


@pytest.mark.parametrize("busy_threshold", ["1_0", "12", "+25", "-0"])
def test_validate_cli_busy_threshold_rejects_non_canonical_numeric_tokens(
busy_threshold,
):
with pytest.raises(
typer.BadParameter,
match="busy_threshold must be -1 or an integer between 0 and 100",
):
cli._validate_cli_busy_threshold(busy_threshold)


def test_apply_legacy_threshold_none():
vram, threshold, mode = cli._apply_legacy_threshold("1GiB", None, -1)
assert vram == "1GiB"
Expand All @@ -44,6 +73,17 @@ def test_apply_legacy_threshold_numeric():
assert mode == "busy"


@pytest.mark.parametrize("legacy_threshold", ["1_0", "12", "+25", "-0"])
def test_apply_legacy_threshold_rejects_non_canonical_numeric_tokens(
legacy_threshold,
):
with pytest.raises(
typer.BadParameter,
match="threshold must be an integer utilization value or a VRAM size",
):
cli._apply_legacy_threshold("1GiB", legacy_threshold, -1)


def test_apply_legacy_threshold_memory_string():
vram, threshold, mode = cli._apply_legacy_threshold("1GiB", "2GiB", -1)
assert vram == "2GiB"
Expand Down
Loading