jambu-gpu 0.4.0__tar.gz → 0.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/PKG-INFO +1 -1
  2. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/docs/CONFIG.md +1 -0
  3. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/docs/INTEGRATION.md +1 -1
  4. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu.example.yaml +1 -0
  5. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/__init__.py +1 -1
  6. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/core/config.py +11 -0
  7. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/core/engine.py +33 -0
  8. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/pyproject.toml +1 -1
  9. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/tests/fakes.py +8 -1
  10. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/tests/test_config.py +8 -0
  11. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/tests/test_engine.py +72 -0
  12. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/.env.example +0 -0
  13. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/.gitignore +0 -0
  14. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/CONTRIBUTING.md +0 -0
  15. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/LICENSE +0 -0
  16. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/README.md +0 -0
  17. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/docs/ACCEPTANCE.md +0 -0
  18. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/docs/ADDING_A_PROVIDER.md +0 -0
  19. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/docs/AGENTS.md +0 -0
  20. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/docs/CHOOSING_A_MODEL.md +0 -0
  21. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/docs/LIFECYCLE.md +0 -0
  22. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/docs/USAGE.md +0 -0
  23. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/docs/assets/jambu-logo.jpg +0 -0
  24. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/examples/experiment.py +0 -0
  25. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/examples/jambu.yaml +0 -0
  26. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/examples/mastra-agent/README.md +0 -0
  27. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/examples/mastra-agent/package-lock.json +0 -0
  28. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/examples/mastra-agent/package.json +0 -0
  29. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/examples/mastra-agent/src/agent.ts +0 -0
  30. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/examples/mastra-agent/src/index.ts +0 -0
  31. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/examples/mastra-agent/src/tools/weather-tool.ts +0 -0
  32. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/examples/mastra-agent/tsconfig.json +0 -0
  33. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/examples/typescript-client/README.md +0 -0
  34. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/examples/typescript-client/package-lock.json +0 -0
  35. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/examples/typescript-client/package.json +0 -0
  36. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/examples/typescript-client/src/index.ts +0 -0
  37. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/examples/typescript-client/src/jambuGpu.ts +0 -0
  38. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/examples/typescript-client/tsconfig.json +0 -0
  39. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu.models.yaml +0 -0
  40. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu.yaml +0 -0
  41. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/__main__.py +0 -0
  42. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/agent/__init__.py +0 -0
  43. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/agent/install.py +0 -0
  44. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/agent/jobs.py +0 -0
  45. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/agent/mcp.py +0 -0
  46. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/agent/skill/SKILL.md +0 -0
  47. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/cli/__init__.py +0 -0
  48. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/cli/_common.py +0 -0
  49. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/cli/account.py +0 -0
  50. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/cli/agent.py +0 -0
  51. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/cli/inspect.py +0 -0
  52. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/cli/lifecycle.py +0 -0
  53. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/cli/main.py +0 -0
  54. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/cli/providers.py +0 -0
  55. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/cli/run.py +0 -0
  56. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/cli/setup.py +0 -0
  57. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/cli/status.py +0 -0
  58. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/cli/timer.py +0 -0
  59. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/cli/validate.py +0 -0
  60. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/core/__init__.py +0 -0
  61. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/core/beacon.py +0 -0
  62. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/core/credentials.py +0 -0
  63. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/core/durations.py +0 -0
  64. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/core/errors.py +0 -0
  65. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/core/events.py +0 -0
  66. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/core/execution.py +0 -0
  67. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/core/health.py +0 -0
  68. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/core/hosts.py +0 -0
  69. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/core/model_inspect.py +0 -0
  70. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/core/models.py +0 -0
  71. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/core/paths.py +0 -0
  72. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/core/policy.py +0 -0
  73. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/core/state.py +0 -0
  74. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/core/sweep.py +0 -0
  75. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/core/tool_parsers.py +0 -0
  76. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/providers/__init__.py +0 -0
  77. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/providers/base.py +0 -0
  78. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/providers/registry.py +0 -0
  79. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/providers/vast/__init__.py +0 -0
  80. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/providers/vast/client.py +0 -0
  81. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/providers/vast/config.py +0 -0
  82. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/providers/vast/mapper.py +0 -0
  83. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/providers/vast/provider.py +0 -0
  84. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/runtime/__init__.py +0 -0
  85. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/runtime/base.py +0 -0
  86. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/runtime/vllm.py +0 -0
  87. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/watchdog/__init__.py +0 -0
  88. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/watchdog/agent_body.py +0 -0
  89. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/jambu_gpu/watchdog/builder.py +0 -0
  90. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/tests/conftest.py +0 -0
  91. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/tests/test_agent.py +0 -0
  92. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/tests/test_cli.py +0 -0
  93. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/tests/test_engine_sessions.py +0 -0
  94. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/tests/test_integration.py +0 -0
  95. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/tests/test_model_inspect.py +0 -0
  96. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/tests/test_paths.py +0 -0
  97. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/tests/test_policy.py +0 -0
  98. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/tests/test_runtime_vllm.py +0 -0
  99. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/tests/test_state.py +0 -0
  100. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/tests/test_sweep.py +0 -0
  101. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/tests/test_vast_mapper.py +0 -0
  102. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/tests/test_vast_provider.py +0 -0
  103. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/tests/test_watchdog.py +0 -0
  104. {jambu_gpu-0.4.0 → jambu_gpu-0.4.1}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: jambu-gpu
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: Provider-agnostic CLI for provisioning and managing temporary GPU runtimes
5
5
  Project-URL: Homepage, https://github.com/jambuai/jambu-gpu
6
6
  Project-URL: Repository, https://github.com/jambuai/jambu-gpu
@@ -205,6 +205,7 @@ judged by the sweep's own `--orphan-max-age` and `--stopped-ttl` flags. See
205
205
  | --- | --- | --- |
206
206
  | `interval` | `60s` | Probe interval while waiting for the runtime. |
207
207
  | `boot_timeout` | `30m` | How long the machine may take to reach running, mostly the image pull. Past it the host counts as failed and `setup` tries another (`compute.host_attempts`). |
208
+ | `pull_stall_timeout` | `10m` | How long the image pull may go without progress (no new line from the provider, or only retries of a layer) before the host counts as failed and is replaced, without waiting out `boot_timeout`. Healthy pulls measured live showed a new line at least every 8 minutes; stalled ones never recovered. `off` disables it. |
208
209
  | `startup_timeout` | `10m` | Once the machine runs, how long the model server may take to answer. Raise it for very large models. |
209
210
  | `path` | `/health` | Health path on the model endpoint. |
210
211
 
@@ -117,7 +117,7 @@ gpu env --format dotenv > .env.gpu
117
117
 
118
118
  | Event | What the app sees | What it does |
119
119
  | --- | --- | --- |
120
- | first `setup` | blocks while a machine is rented, the image pulled and the model downloaded: usually 5–15 minutes. A bad host (stalled image pull, broken network) is destroyed and replaced automatically, each costing up to `health.boot_timeout` (30 min), at most `compute.host_attempts` (3) times | wait; do not put a short timeout on `setup`, it bounds itself |
120
+ | first `setup` | blocks while a machine is rented, the image pulled and the model downloaded: usually 5–15 minutes. A bad host (stalled image pull, broken network) is destroyed and replaced automatically, each costing up to `health.pull_stall_timeout` (10 min) when its pull stalls, or `health.boot_timeout` (30 min) when it crawls, at most `compute.host_attempts` (3) times | wait; do not put a short timeout on `setup`, it bounds itself |
121
121
  | idle for `idle_timeout` | the watchdog stops the machine; requests fail with connection refused or a timeout | call `setup` again and retry the request |
122
122
  | `setup` on a stopped machine | it is restarted: no new rent, no model download, under a minute for a small model (41 s measured) | wait, then use the **new** `base_url`: the provider may assign a new IP and port on restart |
123
123
  | stopped for `stopped_ttl` | the machine is destroyed to stop the storage bill | the next `setup` rents a new one (and a new key) |
@@ -77,6 +77,7 @@ gpu_runtime:
77
77
  health:
78
78
  interval: 60s
79
79
  boot_timeout: 30m
80
+ pull_stall_timeout: 10m # a pull with no progress this long: replace the host
80
81
  startup_timeout: 10m
81
82
  path: /health
82
83
 
@@ -1,5 +1,5 @@
1
1
  """gpu: provider-agnostic GPU runtime provisioning and lifecycle CLI."""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.4.1"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -230,12 +230,17 @@ class HealthConfig(_Base):
230
230
  interval: str = "60s"
231
231
  # How long the machine may take to reach running (mostly the image pull).
232
232
  boot_timeout: str = "30m"
233
+ # How long that pull may go without progress before the host counts as
234
+ # stalled; a stalled pull never resumes, so waiting out boot_timeout
235
+ # only bills the dead host longer.
236
+ pull_stall_timeout: str = "10m"
233
237
  # How long the model server may then take to answer its health check.
234
238
  startup_timeout: str = "10m"
235
239
  path: str = "/health"
236
240
 
237
241
  _v_interval = _duration("interval")
238
242
  _v_boot = _duration("boot_timeout")
243
+ _v_stall = _duration("pull_stall_timeout")
239
244
  _v_startup = _duration("startup_timeout")
240
245
 
241
246
 
@@ -310,6 +315,10 @@ class RuntimeConfig(_Base):
310
315
  def boot_timeout_s(self) -> float:
311
316
  return parse_duration(self.health.boot_timeout) or 1800.0
312
317
 
318
+ @property
319
+ def pull_stall_timeout_s(self) -> Optional[float]:
320
+ return parse_duration(self.health.pull_stall_timeout)
321
+
313
322
  @property
314
323
  def startup_timeout_s(self) -> float:
315
324
  return parse_duration(self.health.startup_timeout) or 600.0
@@ -579,6 +588,7 @@ gpu_runtime:
579
588
  health:
580
589
  interval: 60s
581
590
  boot_timeout: 30m
591
+ pull_stall_timeout: 10m
582
592
  startup_timeout: 10m
583
593
 
584
594
  budget:
@@ -628,6 +638,7 @@ lifecycle:
628
638
  health:
629
639
  interval: 60s
630
640
  boot_timeout: 30m
641
+ pull_stall_timeout: 10m
631
642
  startup_timeout: 10m
632
643
 
633
644
  budget:
@@ -61,6 +61,9 @@ _HOST_NETWORK_FAILURE = re.compile(
61
61
  r"Temporary failure in name resolution|Network is unreachable"
62
62
  )
63
63
 
64
+ # A pull stuck retrying a layer: its countdown changes, but it is no progress.
65
+ _PULL_RETRY = re.compile(r"\bretrying\b", re.IGNORECASE)
66
+
64
67
  # Where `gpu logs --source` reads from: the watchdog serves the instance files.
65
68
  LOG_SOURCES = ("runtime", "onstart", "watchdog", "provider")
66
69
  # Runtime log lines quoted in the error when the model server dies at boot.
@@ -74,6 +77,19 @@ def _noop(_message: str) -> None:
74
77
  return None
75
78
 
76
79
 
80
+ def _pull_progress(message: Optional[str]) -> Optional[str]:
81
+ """The provider's boot message when it shows the pull moving, else None.
82
+
83
+ Vast reports the image pull's latest line (``<layer>: Download complete``,
84
+ ``<layer>: Pull complete``). A new line means a layer moved; the same line,
85
+ no line, or a retry countdown means it did not.
86
+ """
87
+ text = (message or "").strip()
88
+ if not text or _PULL_RETRY.search(text):
89
+ return None
90
+ return text
91
+
92
+
77
93
  class Engine:
78
94
  def __init__(
79
95
  self,
@@ -779,8 +795,13 @@ class Engine:
779
795
  instance = state.instance
780
796
  assert instance is not None
781
797
  boot_timeout = self.config.boot_timeout_s
798
+ stall_timeout = self.config.pull_stall_timeout_s
782
799
  deadline = time.time() + boot_timeout
783
800
  announced = False
801
+ # Live, healthy pulls printed a new line every 1-8 minutes; stalled ones
802
+ # froze (or retried one layer) until boot_timeout, 30 minutes per host.
803
+ progress: Optional[str] = None
804
+ progress_at = time.time()
784
805
  while time.time() < deadline:
785
806
  status = self.provider.status(instance)
786
807
  if not status.exists:
@@ -810,6 +831,18 @@ class Engine:
810
831
  InstanceState.IDLE,
811
832
  ) and (have_ports or not ready_ports):
812
833
  return current
834
+ moved = _pull_progress(status.message)
835
+ if moved is not None and moved != progress:
836
+ progress, progress_at = moved, time.time()
837
+ elif stall_timeout and time.time() - progress_at >= stall_timeout:
838
+ last = (status.message or "").strip().splitlines()
839
+ raise HostFailure(
840
+ f"instance {instance.id}'s image pull made no progress for "
841
+ f"{format_duration(stall_timeout)} (health.pull_stall_timeout)"
842
+ + (f"; last: {last[-1][:160]}" if last else ""),
843
+ reason=f"image pull stalled for {format_duration(stall_timeout)}",
844
+ instance=current,
845
+ )
813
846
  if not announced:
814
847
  announced = True
815
848
  self.echo(" waiting for the machine to come up ...")
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "jambu-gpu"
7
- version = "0.4.0"
7
+ version = "0.4.1"
8
8
  description = "Provider-agnostic CLI for provisioning and managing temporary GPU runtimes"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import time
6
- from typing import Optional
6
+ from typing import Callable, Optional
7
7
 
8
8
  from jambu_gpu.core.errors import CapacityUnavailableError, ProvisioningError
9
9
  from jambu_gpu.core.models import (
@@ -66,6 +66,10 @@ class FakeProvider(GPUProvider):
66
66
  # reaches running).
67
67
  self.failing_hosts: set[str] = set()
68
68
  self.host_failure = "failed"
69
+ # What a "loading" machine reports on its Nth status poll, as Vast
70
+ # reports the image pull's latest line.
71
+ self.boot_message: Optional[Callable[[int], str]] = None
72
+ self.boot_polls = 0
69
73
  self.rented_hosts: list[str] = []
70
74
  self.searches: list[list[str]] = []
71
75
 
@@ -169,6 +173,9 @@ class FakeProvider(GPUProvider):
169
173
  if stored is None:
170
174
  return InstanceStatus(state=InstanceState.DESTROYED, exists=False)
171
175
  message = ""
176
+ if stored.state is InstanceState.STARTING and self.boot_message is not None:
177
+ message = self.boot_message(self.boot_polls)
178
+ self.boot_polls += 1
172
179
  if stored.state is InstanceState.FAILED:
173
180
  message = (
174
181
  'Error response from daemon: Get "https://registry-1.docker.io/v2/": '
@@ -388,3 +388,11 @@ def test_host_attempts_and_boot_timeout_keep_the_instance_compatible(write_confi
388
388
  base = write_config().fingerprint()
389
389
  assert write_config(compute={"host_attempts": 5}).fingerprint() == base
390
390
  assert write_config(health={"boot_timeout": "40m"}).fingerprint() == base
391
+ assert write_config(health={"pull_stall_timeout": "5m"}).fingerprint() == base
392
+
393
+
394
+ def test_pull_stall_timeout_defaults_to_ten_minutes_and_can_be_off(write_config):
395
+ assert write_config().pull_stall_timeout_s == 10 * 60
396
+ assert write_config(health={"pull_stall_timeout": "off"}).pull_stall_timeout_s is None
397
+ with pytest.raises(ConfigError, match="pull_stall_timeout"):
398
+ write_config(health={"pull_stall_timeout": "soon"})
@@ -720,6 +720,78 @@ def test_a_stuck_image_pull_times_out_on_boot_timeout_not_startup_timeout(
720
720
  assert health_waits == [420.0]
721
721
 
722
722
 
723
+ def _with_clock(monkeypatch, start=1_000_000.0):
724
+ """Make the boot wait's sleeps advance a fake clock: minutes in no time."""
725
+ clock = [start]
726
+ monkeypatch.setattr("jambu_gpu.core.engine.time.time", lambda: clock[0])
727
+ monkeypatch.setattr(
728
+ "jambu_gpu.core.engine.time.sleep",
729
+ lambda seconds: clock.__setitem__(0, clock[0] + seconds),
730
+ )
731
+ return clock
732
+
733
+
734
+ def test_a_frozen_image_pull_is_given_up_long_before_boot_timeout(write_config, monkeypatch):
735
+ """Live: three hosts froze on one pull line until the 30-minute boot_timeout."""
736
+ engine, lines, _ = _booting(write_config, monkeypatch)
737
+ clock = _with_clock(monkeypatch)
738
+ engine.provider.fail_first_hosts(1, how="loading")
739
+ engine.provider.boot_message = lambda poll: (
740
+ "995efd0b979d: Verifying Checksum" if poll < 20 else "067869a57bf4: Pull complete"
741
+ )
742
+ started = clock[0]
743
+
744
+ state = engine.setup()
745
+
746
+ assert state.instance.host_id == "m2"
747
+ assert "! host m1 failed (image pull stalled for 10m); trying another machine (2/3)" in lines
748
+ # Two minutes of progress, then ten without: replaced at about 12, not 30.
749
+ assert 12 * 60 <= clock[0] - started < 13 * 60
750
+ assert engine.provider.searches[-1] == ["m1"], "the stalled host is not rented again"
751
+
752
+
753
+ def test_a_pull_retrying_one_layer_counts_as_stalled(write_config, monkeypatch):
754
+ engine, lines, _ = _booting(write_config, monkeypatch)
755
+ clock = _with_clock(monkeypatch)
756
+ engine.provider.fail_first_hosts(1, how="loading")
757
+ engine.provider.boot_message = lambda poll: (
758
+ f"60c68e5ca95a: Retrying in {1 + poll % 3} second{'s' if poll % 3 else ''}"
759
+ )
760
+ started = clock[0]
761
+
762
+ engine.setup()
763
+
764
+ assert "! host m1 failed (image pull stalled for 10m); trying another machine (2/3)" in lines
765
+ assert 10 * 60 <= clock[0] - started < 11 * 60
766
+
767
+
768
+ def test_a_slow_pull_that_keeps_moving_waits_for_boot_timeout(write_config, monkeypatch):
769
+ """A new layer every 8 minutes is slow, not stuck: only boot_timeout ends it."""
770
+ engine, lines, _ = _booting(write_config, monkeypatch)
771
+ clock = _with_clock(monkeypatch)
772
+ engine.provider.fail_first_hosts(1, how="loading")
773
+ engine.provider.boot_message = lambda poll: f"layer{poll * 6 // 480}: Pull complete"
774
+ started = clock[0]
775
+
776
+ engine.setup()
777
+
778
+ assert "! host m1 failed (image pull timed out after 30m); trying another machine (2/3)" in lines
779
+ assert clock[0] - started >= 30 * 60
780
+
781
+
782
+ def test_pull_stall_detection_can_be_turned_off(write_config, monkeypatch):
783
+ engine, lines, _ = _booting(
784
+ write_config, monkeypatch, health={"pull_stall_timeout": "off", "boot_timeout": "20m"}
785
+ )
786
+ _with_clock(monkeypatch)
787
+ engine.provider.fail_first_hosts(1, how="loading")
788
+ engine.provider.boot_message = lambda poll: "067869a57bf4: Pull complete"
789
+
790
+ engine.setup()
791
+
792
+ assert "! host m1 failed (image pull timed out after 20m); trying another machine (2/3)" in lines
793
+
794
+
723
795
  def test_a_model_server_that_dies_is_not_blamed_on_the_host(engine, monkeypatch):
724
796
  from jambu_gpu.core.errors import HostFailure, RuntimeStartupError
725
797
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes