cyclo-manager 1.1.0.dev2__tar.gz → 1.1.0.dev3__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 (24) hide show
  1. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev3}/PKG-INFO +3 -3
  2. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev3}/README.md +2 -2
  3. cyclo_manager-1.1.0.dev3/cyclo_host_agent/cpu_usage.py +84 -0
  4. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev3}/cyclo_host_agent/main.py +14 -0
  5. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev3}/cyclo_host_agent/routers/files.py +47 -25
  6. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev3}/cyclo_host_agent/routers/repos.py +59 -36
  7. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev3}/cyclo_host_agent/routers/system_stats.py +17 -7
  8. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev3}/cyclo_manager.egg-info/PKG-INFO +3 -3
  9. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev3}/cyclo_manager.egg-info/SOURCES.txt +3 -1
  10. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev3}/cyclo_manager_cli/__init__.py +1 -1
  11. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev3}/pyproject.toml +1 -1
  12. cyclo_manager-1.1.0.dev3/tests/test_cpu_usage.py +169 -0
  13. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev3}/cyclo_host_agent/__init__.py +0 -0
  14. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev3}/cyclo_host_agent/models.py +0 -0
  15. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev3}/cyclo_host_agent/routers/__init__.py +0 -0
  16. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev3}/cyclo_host_agent/routers/update.py +0 -0
  17. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev3}/cyclo_manager.egg-info/dependency_links.txt +0 -0
  18. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev3}/cyclo_manager.egg-info/entry_points.txt +0 -0
  19. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev3}/cyclo_manager.egg-info/requires.txt +0 -0
  20. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev3}/cyclo_manager.egg-info/top_level.txt +0 -0
  21. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev3}/cyclo_manager_cli/cli.py +0 -0
  22. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev3}/cyclo_manager_cli/config/config.yml +0 -0
  23. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev3}/cyclo_manager_cli/docker/docker-compose.yml +0 -0
  24. {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cyclo-manager
3
- Version: 1.1.0.dev2
3
+ Version: 1.1.0.dev3
4
4
  Summary: cyclo_manager CLI: pip-installable launcher for cyclo_manager server and UI containers. Run 'cyclo_manager up' to start Docker stack.
5
5
  License-Expression: Apache-2.0
6
6
  Requires-Python: >=3.10
@@ -147,8 +147,8 @@ Defined in [`cyclo_manager_cli/docker/docker-compose.yml`](cyclo_manager_cli/doc
147
147
 
148
148
  | Compose service | Container name | `cyclo_manager up` | Image (example) |
149
149
  |-----------------|----------------|--------------------|-----------------|
150
- | `cyclo_manager` | `cyclo_manager` | **Started** | `robotis/cyclo-manager:1.0.0` |
151
- | `ui` | `cyclo_manager_ui` | **Started** | `robotis/cyclo-manager-ui:1.0.0` |
150
+ | `cyclo_manager` | `cyclo_manager` | **Started** | `robotis/cyclo-manager:1.0.1` |
151
+ | `ui` | `cyclo_manager_ui` | **Started** | `robotis/cyclo-manager-ui:1.0.1` |
152
152
  | `rmw_zenoh` | `zenoh_daemon` | **Created only** | `robotis/zenoh-daemon:latest` |
153
153
  | `novnc-server` | `novnc-server` | **Created only** | `robotis/novnc-server:latest` |
154
154
 
@@ -133,8 +133,8 @@ Defined in [`cyclo_manager_cli/docker/docker-compose.yml`](cyclo_manager_cli/doc
133
133
 
134
134
  | Compose service | Container name | `cyclo_manager up` | Image (example) |
135
135
  |-----------------|----------------|--------------------|-----------------|
136
- | `cyclo_manager` | `cyclo_manager` | **Started** | `robotis/cyclo-manager:1.0.0` |
137
- | `ui` | `cyclo_manager_ui` | **Started** | `robotis/cyclo-manager-ui:1.0.0` |
136
+ | `cyclo_manager` | `cyclo_manager` | **Started** | `robotis/cyclo-manager:1.0.1` |
137
+ | `ui` | `cyclo_manager_ui` | **Started** | `robotis/cyclo-manager-ui:1.0.1` |
138
138
  | `rmw_zenoh` | `zenoh_daemon` | **Created only** | `robotis/zenoh-daemon:latest` |
139
139
  | `novnc-server` | `novnc-server` | **Created only** | `robotis/novnc-server:latest` |
140
140
 
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env python3
2
+ #
3
+ # Copyright 2026 ROBOTIS CO., LTD.
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+ # Author: Hyungyu Kim
18
+
19
+ """Continuous host CPU sampling, independent of HTTP requests."""
20
+
21
+ from collections import deque
22
+ import logging
23
+ import threading
24
+
25
+ import psutil
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ CPU_SAMPLE_INTERVAL_SECONDS = 1.0
30
+ CPU_AVERAGE_SAMPLES = 3
31
+
32
+
33
+ class CpuUsageSampler:
34
+ """Keep the mean of the latest three consecutive one-second CPU samples."""
35
+
36
+ def __init__(self, interval: float = CPU_SAMPLE_INTERVAL_SECONDS) -> None:
37
+ """Initialize the sampler without starting a background thread."""
38
+ self._interval = interval
39
+ self._samples: deque[float] = deque(maxlen=CPU_AVERAGE_SAMPLES)
40
+ self._lock = threading.Lock()
41
+ self._stop = threading.Event()
42
+ self._thread: threading.Thread | None = None
43
+
44
+ def start(self) -> None:
45
+ """Start one sampling thread and discard samples from any previous run."""
46
+ if self._thread is not None and self._thread.is_alive():
47
+ return
48
+ with self._lock:
49
+ self._samples.clear()
50
+ self._stop.clear()
51
+ self._thread = threading.Thread(
52
+ target=self._run, name='host-cpu-sampler', daemon=True,
53
+ )
54
+ self._thread.start()
55
+
56
+ def stop(self) -> None:
57
+ """Wake and join the sampler on application shutdown."""
58
+ self._stop.set()
59
+ if self._thread is not None:
60
+ self._thread.join()
61
+ self._thread = None
62
+
63
+ def average(self) -> float | None:
64
+ """Return a cached average, or None before the first complete interval."""
65
+ with self._lock:
66
+ if not self._samples:
67
+ return None
68
+ return round(sum(self._samples) / len(self._samples), 1)
69
+
70
+ def _run(self) -> None:
71
+ while not self._stop.is_set():
72
+ try:
73
+ # Prime and read on the same thread. The initial value is not a sample.
74
+ psutil.cpu_percent(interval=None)
75
+ while not self._stop.wait(self._interval):
76
+ sample = psutil.cpu_percent(interval=None)
77
+ with self._lock:
78
+ self._samples.append(float(sample))
79
+ except Exception:
80
+ # Re-prime after a failure instead of mixing stale measurement intervals.
81
+ with self._lock:
82
+ self._samples.clear()
83
+ logger.exception('Failed to sample host CPU usage')
84
+ self._stop.wait(self._interval)
@@ -18,6 +18,8 @@
18
18
 
19
19
  """Cyclo host agent: FastAPI server on Unix Domain Socket for host-level operations."""
20
20
 
21
+ import asyncio
22
+ from contextlib import asynccontextmanager
21
23
  import logging
22
24
  from pathlib import Path
23
25
 
@@ -34,10 +36,22 @@ logger = logging.getLogger(__name__)
34
36
 
35
37
  SOCKET_PATH = '/var/run/robotis/agent_sockets/host/host_agent.sock'
36
38
 
39
+
40
+ @asynccontextmanager
41
+ async def lifespan(app: FastAPI):
42
+ """Run the shared CPU sampler for the lifetime of the host agent."""
43
+ system_stats.cpu_sampler.start()
44
+ try:
45
+ yield
46
+ finally:
47
+ await asyncio.to_thread(system_stats.cpu_sampler.stop)
48
+
49
+
37
50
  app = FastAPI(
38
51
  title='cyclo_host_agent',
39
52
  description='Host agent for Cyclo Manager: repo management.',
40
53
  version=__version__,
54
+ lifespan=lifespan,
41
55
  )
42
56
 
43
57
  app.include_router(repos.router)
@@ -18,6 +18,7 @@
18
18
 
19
19
  """Host file browser and editor endpoints."""
20
20
 
21
+ import asyncio
21
22
  import os
22
23
  import shutil
23
24
  import subprocess
@@ -71,6 +72,21 @@ def _safe_path(relative_path: str = '') -> tuple[Path, str]:
71
72
  return target, rel
72
73
 
73
74
 
75
+ def _safe_nofollow_path(relative_path: str = '') -> tuple[Path, str]:
76
+ raw_path = Path(relative_path)
77
+ if raw_path.is_absolute() or '..' in raw_path.parts:
78
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path escapes file root')
79
+
80
+ lexical_target = FILE_ROOT_PATH / raw_path
81
+ parent = lexical_target.parent.resolve()
82
+ if parent != FILE_ROOT_PATH and FILE_ROOT_PATH not in parent.parents:
83
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path escapes file root')
84
+
85
+ target = parent / lexical_target.name
86
+ rel = '' if target == FILE_ROOT_PATH else target.relative_to(FILE_ROOT_PATH).as_posix()
87
+ return target, rel
88
+
89
+
74
90
  def _safe_filename(filename: str) -> str:
75
91
  name = Path(filename).name
76
92
  if name in {'', '.', '..'} or name != filename or '/' in filename or '\\' in filename:
@@ -80,7 +96,8 @@ def _safe_filename(filename: str) -> str:
80
96
 
81
97
  def _is_binary(path: Path) -> bool:
82
98
  try:
83
- return b'\x00' in path.read_bytes()[:8192]
99
+ with path.open('rb') as file:
100
+ return b'\x00' in file.read(8192)
84
101
  except OSError:
85
102
  return True
86
103
 
@@ -262,7 +279,7 @@ def _git_head_content(repo_root: Path, repo_path: str) -> str:
262
279
 
263
280
 
264
281
  @router.get('/tree', response_model=FileTreeResponse)
265
- async def list_directory(
282
+ def list_directory(
266
283
  path: str = '',
267
284
  show_hidden: bool = Query(False),
268
285
  ) -> FileTreeResponse:
@@ -286,7 +303,7 @@ async def list_directory(
286
303
 
287
304
 
288
305
  @router.get('/search', response_model=FileSearchResponse)
289
- async def search_files(
306
+ def search_files(
290
307
  query: str,
291
308
  path: str = '',
292
309
  show_hidden: bool = Query(False),
@@ -340,7 +357,7 @@ async def search_files(
340
357
 
341
358
 
342
359
  @router.get('/read', response_model=FileReadResponse)
343
- async def read_file(
360
+ def read_file(
344
361
  path: str,
345
362
  ) -> FileReadResponse:
346
363
  """Read a UTF-8 text file under the configured file root."""
@@ -366,7 +383,7 @@ async def read_file(
366
383
 
367
384
 
368
385
  @router.get('/diff', response_model=FileDiffResponse)
369
- async def diff_file(
386
+ def diff_file(
370
387
  path: str,
371
388
  ) -> FileDiffResponse:
372
389
  """Return original and current file content for a git diff view."""
@@ -397,7 +414,7 @@ async def diff_file(
397
414
 
398
415
 
399
416
  @router.post('/write', response_model=FileOperationResponse)
400
- async def write_file(req: FileWriteRequest) -> FileOperationResponse:
417
+ def write_file(req: FileWriteRequest) -> FileOperationResponse:
401
418
  """Write a UTF-8 text file under the configured file root."""
402
419
  target, rel = _safe_path(req.path)
403
420
  if target.exists() and not target.is_file():
@@ -417,10 +434,10 @@ async def write_file(req: FileWriteRequest) -> FileOperationResponse:
417
434
 
418
435
 
419
436
  @router.post('/create', response_model=FileOperationResponse)
420
- async def create_path(req: FileCreateRequest) -> FileOperationResponse:
437
+ def create_path(req: FileCreateRequest) -> FileOperationResponse:
421
438
  """Create a file or folder under the configured file root."""
422
- target, rel = _safe_path(req.path)
423
- if target.exists():
439
+ target, rel = _safe_nofollow_path(req.path)
440
+ if target.exists() or target.is_symlink():
424
441
  raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='Path already exists')
425
442
  try:
426
443
  if req.type == 'directory':
@@ -440,17 +457,17 @@ async def create_path(req: FileCreateRequest) -> FileOperationResponse:
440
457
 
441
458
 
442
459
  @router.post('/rename', response_model=FileOperationResponse)
443
- async def rename_path(req: FileRenameRequest) -> FileOperationResponse:
460
+ def rename_path(req: FileRenameRequest) -> FileOperationResponse:
444
461
  """Rename a file or folder under the configured file root."""
445
- target, _ = _safe_path(req.path)
446
- if not target.exists():
462
+ target, _ = _safe_nofollow_path(req.path)
463
+ if not target.exists() and not target.is_symlink():
447
464
  raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Path not found')
448
465
  if '/' in req.new_name or '\\' in req.new_name or req.new_name in {'', '.', '..'}:
449
466
  raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid name')
450
- destination = (target.parent / req.new_name).resolve()
467
+ destination = target.parent / req.new_name
451
468
  if destination != FILE_ROOT_PATH and FILE_ROOT_PATH not in destination.parents:
452
469
  raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path escapes file root')
453
- if destination.exists():
470
+ if destination.exists() or destination.is_symlink():
454
471
  raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='Destination already exists')
455
472
  try:
456
473
  target.rename(destination)
@@ -473,34 +490,37 @@ async def upload_file(
473
490
  raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path is not a directory')
474
491
 
475
492
  safe_name = _safe_filename(filename)
476
- destination = (target_dir / safe_name).resolve()
493
+ destination = target_dir / safe_name
477
494
  if destination != FILE_ROOT_PATH and FILE_ROOT_PATH not in destination.parents:
478
495
  raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path escapes file root')
479
496
  if destination.exists() and destination.is_dir():
480
497
  raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='A folder with that name already exists')
481
- overwritten = destination.exists()
498
+ overwritten = destination.exists() or destination.is_symlink()
482
499
  if overwritten and not overwrite:
483
500
  raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='File already exists')
484
501
 
485
502
  temp_path = target_dir / f'.{safe_name}.{uuid.uuid4().hex}.uploading'
486
503
  size_bytes = 0
487
504
  try:
488
- with temp_path.open('wb') as output:
505
+ output = await asyncio.to_thread(temp_path.open, 'wb')
506
+ try:
489
507
  async for chunk in request.stream():
490
508
  size_bytes += len(chunk)
491
509
  if size_bytes > MAX_UPLOAD_BYTES:
492
510
  raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail='File is too large to upload')
493
- output.write(chunk)
494
- os.replace(temp_path, destination)
511
+ await asyncio.to_thread(output.write, chunk)
512
+ finally:
513
+ await asyncio.to_thread(output.close)
514
+ await asyncio.to_thread(os.replace, temp_path, destination)
495
515
  except HTTPException:
496
516
  try:
497
- temp_path.unlink(missing_ok=True)
517
+ await asyncio.to_thread(temp_path.unlink, missing_ok=True)
498
518
  except OSError:
499
519
  pass
500
520
  raise
501
521
  except OSError as exc:
502
522
  try:
503
- temp_path.unlink(missing_ok=True)
523
+ await asyncio.to_thread(temp_path.unlink, missing_ok=True)
504
524
  except OSError:
505
525
  pass
506
526
  raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc))
@@ -517,18 +537,20 @@ async def upload_file(
517
537
 
518
538
 
519
539
  @router.delete('', response_model=FileOperationResponse)
520
- async def delete_path(
540
+ def delete_path(
521
541
  path: str,
522
542
  recursive: bool = Query(False),
523
543
  ) -> FileOperationResponse:
524
544
  """Delete a file or folder under the configured file root."""
525
- target, rel = _safe_path(path)
545
+ target, rel = _safe_nofollow_path(path)
526
546
  if target == FILE_ROOT_PATH:
527
547
  raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Cannot delete file root')
528
- if not target.exists():
548
+ if not target.exists() and not target.is_symlink():
529
549
  raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Path not found')
530
550
  try:
531
- if target.is_dir():
551
+ if target.is_symlink():
552
+ target.unlink()
553
+ elif target.is_dir():
532
554
  if recursive:
533
555
  shutil.rmtree(target)
534
556
  else:
@@ -343,6 +343,7 @@ async def _update_reset(repo_path: Path, preserve_files: list[str]) -> UpdateRes
343
343
  # ── container helper ───────────────────────────────────────────────────────────
344
344
 
345
345
  _container_start_status_by_repo: dict[str, dict] = {}
346
+ _container_start_tasks: set[asyncio.Task[None]] = set()
346
347
 
347
348
 
348
349
  def _prune_container_start_jobs() -> None:
@@ -424,6 +425,20 @@ async def _read_container_job_stream(stream: asyncio.StreamReader, job: dict) ->
424
425
  _update_container_job_from_line(job, line)
425
426
 
426
427
 
428
+ async def _stop_container_start_process(
429
+ proc: asyncio.subprocess.Process | None,
430
+ readers: list[asyncio.Task],
431
+ ) -> None:
432
+ """Stop a container-start subprocess and consume its reader tasks."""
433
+ if proc is not None and proc.returncode is None:
434
+ try:
435
+ proc.kill()
436
+ except ProcessLookupError:
437
+ pass
438
+ await proc.wait()
439
+ await asyncio.gather(*readers, return_exceptions=True)
440
+
441
+
427
442
  async def _run_container_sh(repo_path: Path, action: str) -> tuple[bool, str]:
428
443
  script = repo_path / 'docker' / 'container.sh'
429
444
  if not script.exists():
@@ -448,52 +463,58 @@ async def _run_container_start(repo_path: Path, name: str) -> None:
448
463
  job = _container_start_status_by_repo.get(name)
449
464
  if job is None:
450
465
  return
451
- script = repo_path / 'docker' / 'container.sh'
452
- if not script.exists():
453
- _set_container_job_status(
454
- job,
455
- running=False,
456
- success=False,
457
- error=f'container.sh not found at {script}',
458
- )
459
- return
460
-
461
- env = os.environ.copy()
462
- env['COMPOSE_PROGRESS'] = 'plain'
463
- env['COMPOSE_ANSI'] = 'never'
464
- proc = await asyncio.create_subprocess_exec(
465
- 'bash', str(script), 'start',
466
- cwd=str(repo_path / 'docker'),
467
- env=env,
468
- stdin=asyncio.subprocess.PIPE,
469
- stdout=asyncio.subprocess.PIPE,
470
- stderr=asyncio.subprocess.PIPE,
471
- )
472
- if proc.stdin is not None:
473
- proc.stdin.write(b'y\n')
474
- await proc.stdin.drain()
475
- proc.stdin.close()
476
-
477
- readers = [
478
- asyncio.create_task(_read_container_job_stream(proc.stdout, job)),
479
- asyncio.create_task(_read_container_job_stream(proc.stderr, job)),
480
- ]
466
+ proc: asyncio.subprocess.Process | None = None
467
+ readers: list[asyncio.Task] = []
481
468
  try:
469
+ script = repo_path / 'docker' / 'container.sh'
470
+ if not script.exists():
471
+ raise FileNotFoundError(f'container.sh not found at {script}')
472
+
473
+ env = os.environ.copy()
474
+ env['COMPOSE_PROGRESS'] = 'plain'
475
+ env['COMPOSE_ANSI'] = 'never'
476
+ proc = await asyncio.create_subprocess_exec(
477
+ 'bash', str(script), 'start',
478
+ cwd=str(repo_path / 'docker'),
479
+ env=env,
480
+ stdin=asyncio.subprocess.PIPE,
481
+ stdout=asyncio.subprocess.PIPE,
482
+ stderr=asyncio.subprocess.PIPE,
483
+ )
484
+ if proc.stdin is not None:
485
+ proc.stdin.write(b'y\n')
486
+ await proc.stdin.drain()
487
+ proc.stdin.close()
488
+
489
+ readers = [
490
+ asyncio.create_task(_read_container_job_stream(proc.stdout, job)),
491
+ asyncio.create_task(_read_container_job_stream(proc.stderr, job)),
492
+ ]
482
493
  await asyncio.wait_for(proc.wait(), timeout=CONTAINER_SCRIPT_TIMEOUT)
483
494
  await asyncio.gather(*readers)
484
495
  except asyncio.TimeoutError:
485
- proc.kill()
486
- await proc.wait()
487
- await asyncio.gather(*readers, return_exceptions=True)
488
- _append_container_job_output(job, 'Timeout waiting for container.sh')
496
+ error = 'Timeout waiting for container.sh'
497
+ await _stop_container_start_process(proc, readers)
498
+ _append_container_job_output(job, error)
499
+ _set_container_job_status(job, running=False, success=False, error=error)
500
+ return
501
+ except asyncio.CancelledError:
502
+ await _stop_container_start_process(proc, readers)
489
503
  _set_container_job_status(
490
504
  job,
491
505
  running=False,
492
506
  success=False,
493
- error='Timeout waiting for container.sh',
507
+ error='Container start operation was cancelled',
494
508
  )
509
+ raise
510
+ except Exception as exc:
511
+ await _stop_container_start_process(proc, readers)
512
+ error = str(exc) or type(exc).__name__
513
+ _append_container_job_output(job, error)
514
+ _set_container_job_status(job, running=False, success=False, error=error)
495
515
  return
496
516
 
517
+ assert proc is not None
497
518
  if proc.returncode == 0:
498
519
  _set_container_job_status(
499
520
  job,
@@ -569,7 +590,9 @@ async def start_repo_container(name: str) -> ContainerStartStatusResponse:
569
590
  return _build_container_start_status_response(existing)
570
591
 
571
592
  job = _new_container_start_status(name)
572
- asyncio.create_task(_run_container_start(repo_path, name))
593
+ task = asyncio.create_task(_run_container_start(repo_path, name))
594
+ _container_start_tasks.add(task)
595
+ task.add_done_callback(_container_start_tasks.discard)
573
596
  return _build_container_start_status_response(job)
574
597
 
575
598
 
@@ -21,21 +21,30 @@
21
21
  import time
22
22
  from pathlib import Path
23
23
 
24
+ from cyclo_host_agent.cpu_usage import CpuUsageSampler
24
25
  from cyclo_host_agent.models import (
25
26
  HostProcessesResponse,
26
27
  HostProcessInfo,
27
28
  HostSystemStatsResponse,
28
29
  )
29
- from fastapi import APIRouter, Query
30
+ from fastapi import APIRouter, HTTPException, Query
30
31
  import psutil
31
32
 
32
33
  router = APIRouter(prefix='/system', tags=['system'])
34
+ cpu_sampler = CpuUsageSampler()
33
35
 
34
36
  PROCESS_SAMPLE_INTERVAL_SECONDS = 0.2
35
37
  PROCESS_DEFAULT_LIMIT = 80
36
38
  EXTRA_STORAGE_MOUNT_PATHS = ('/mnt/ssd', '/data')
37
39
 
38
40
 
41
+ def _cpu_average() -> float:
42
+ value = cpu_sampler.average()
43
+ if value is None:
44
+ raise HTTPException(status_code=503, detail='CPU measurement is warming up')
45
+ return value
46
+
47
+
39
48
  def _temperature() -> float | None:
40
49
  try:
41
50
  sensors = psutil.sensors_temperatures()
@@ -84,7 +93,8 @@ def _sample_processes(limit: int = PROCESS_DEFAULT_LIMIT) -> HostProcessesRespon
84
93
  except (psutil.NoSuchProcess, psutil.AccessDenied):
85
94
  continue
86
95
 
87
- overall_cpu_percent = psutil.cpu_percent(interval=PROCESS_SAMPLE_INTERVAL_SECONDS)
96
+ # Process usage keeps its short measurement window; total CPU uses the shared average.
97
+ time.sleep(PROCESS_SAMPLE_INTERVAL_SECONDS)
88
98
  mem = psutil.virtual_memory()
89
99
 
90
100
  processes: list[HostProcessInfo] = []
@@ -111,7 +121,7 @@ def _sample_processes(limit: int = PROCESS_DEFAULT_LIMIT) -> HostProcessesRespon
111
121
 
112
122
  processes.sort(key=lambda item: item.cpu_percent, reverse=True)
113
123
  return HostProcessesResponse(
114
- cpu_percent=round(float(overall_cpu_percent), 1),
124
+ cpu_percent=_cpu_average(),
115
125
  memory_used_mb=int(mem.used // (1024 * 1024)),
116
126
  memory_total_mb=int(mem.total // (1024 * 1024)),
117
127
  processes=processes[:max(1, min(limit, 500))],
@@ -119,9 +129,9 @@ def _sample_processes(limit: int = PROCESS_DEFAULT_LIMIT) -> HostProcessesRespon
119
129
 
120
130
 
121
131
  @router.get('/status', response_model=HostSystemStatsResponse)
122
- async def get_system_stats() -> HostSystemStatsResponse:
123
- """Return host CPU, memory, disk, and uptime stats."""
124
- cpu_percent = psutil.cpu_percent(interval=PROCESS_SAMPLE_INTERVAL_SECONDS)
132
+ def get_system_stats() -> HostSystemStatsResponse:
133
+ """Return host stats with CPU averaged over the latest three one-second samples."""
134
+ cpu_percent = _cpu_average()
125
135
  mem = psutil.virtual_memory()
126
136
  disk = psutil.disk_usage('/')
127
137
  extra_storage_path, extra_storage_disk = _extra_storage_disk_usage()
@@ -149,7 +159,7 @@ async def get_system_stats() -> HostSystemStatsResponse:
149
159
 
150
160
 
151
161
  @router.get('/processes', response_model=HostProcessesResponse)
152
- async def get_system_processes(
162
+ def get_system_processes(
153
163
  limit: int = Query(PROCESS_DEFAULT_LIMIT, ge=1, le=500),
154
164
  ) -> HostProcessesResponse:
155
165
  """Return host process CPU/memory usage sorted by CPU usage."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cyclo-manager
3
- Version: 1.1.0.dev2
3
+ Version: 1.1.0.dev3
4
4
  Summary: cyclo_manager CLI: pip-installable launcher for cyclo_manager server and UI containers. Run 'cyclo_manager up' to start Docker stack.
5
5
  License-Expression: Apache-2.0
6
6
  Requires-Python: >=3.10
@@ -147,8 +147,8 @@ Defined in [`cyclo_manager_cli/docker/docker-compose.yml`](cyclo_manager_cli/doc
147
147
 
148
148
  | Compose service | Container name | `cyclo_manager up` | Image (example) |
149
149
  |-----------------|----------------|--------------------|-----------------|
150
- | `cyclo_manager` | `cyclo_manager` | **Started** | `robotis/cyclo-manager:1.0.0` |
151
- | `ui` | `cyclo_manager_ui` | **Started** | `robotis/cyclo-manager-ui:1.0.0` |
150
+ | `cyclo_manager` | `cyclo_manager` | **Started** | `robotis/cyclo-manager:1.0.1` |
151
+ | `ui` | `cyclo_manager_ui` | **Started** | `robotis/cyclo-manager-ui:1.0.1` |
152
152
  | `rmw_zenoh` | `zenoh_daemon` | **Created only** | `robotis/zenoh-daemon:latest` |
153
153
  | `novnc-server` | `novnc-server` | **Created only** | `robotis/novnc-server:latest` |
154
154
 
@@ -2,6 +2,7 @@ README.md
2
2
  pyproject.toml
3
3
  setup.cfg
4
4
  cyclo_host_agent/__init__.py
5
+ cyclo_host_agent/cpu_usage.py
5
6
  cyclo_host_agent/main.py
6
7
  cyclo_host_agent/models.py
7
8
  cyclo_host_agent/routers/__init__.py
@@ -18,4 +19,5 @@ cyclo_manager.egg-info/top_level.txt
18
19
  cyclo_manager_cli/__init__.py
19
20
  cyclo_manager_cli/cli.py
20
21
  cyclo_manager_cli/config/config.yml
21
- cyclo_manager_cli/docker/docker-compose.yml
22
+ cyclo_manager_cli/docker/docker-compose.yml
23
+ tests/test_cpu_usage.py
@@ -18,4 +18,4 @@
18
18
 
19
19
  """cyclo_manager CLI - pip-installable launcher for cyclo_manager server and UI containers."""
20
20
 
21
- __version__ = '1.0.0'
21
+ __version__ = '1.1.0'
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cyclo-manager"
7
- version = "1.1.0.dev2"
7
+ version = "1.1.0.dev3"
8
8
  description = "cyclo_manager CLI: pip-installable launcher for cyclo_manager server and UI containers. Run 'cyclo_manager up' to start Docker stack."
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env python3
2
+ #
3
+ # Copyright 2026 ROBOTIS CO., LTD.
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+ # Author: Hyungyu Kim
18
+
19
+ """Regression tests for continuous CPU sampling and shared API summaries."""
20
+
21
+ from types import SimpleNamespace
22
+ import threading
23
+ import time
24
+ import unittest
25
+ from unittest.mock import Mock, call, patch
26
+
27
+ from cyclo_host_agent.cpu_usage import CpuUsageSampler
28
+ from cyclo_host_agent.main import app, lifespan
29
+ from cyclo_host_agent.routers import system_stats
30
+ from fastapi import HTTPException
31
+
32
+
33
+ class CpuUsageTests(unittest.TestCase):
34
+ """Check measurement windows, startup behavior, and sampling ownership."""
35
+
36
+ def test_rolling_window_discards_prime_and_oldest_sample(self):
37
+ """Average available samples at startup and only the last three afterward."""
38
+ sampler = CpuUsageSampler()
39
+ observed = []
40
+
41
+ def next_interval(interval):
42
+ self.assertEqual(interval, 1.0)
43
+ observed.append(sampler.average())
44
+ if len(observed) == 5:
45
+ sampler._stop.set()
46
+ return True
47
+ return False
48
+
49
+ with patch('cyclo_host_agent.cpu_usage.psutil.cpu_percent',
50
+ side_effect=[99.0, 10.0, 20.0, 60.0, 90.0]) as cpu:
51
+ with patch.object(sampler._stop, 'wait', side_effect=next_interval):
52
+ sampler._run()
53
+ self.assertEqual(observed, [None, 10.0, 15.0, 30.0, 56.7])
54
+ self.assertEqual(cpu.call_args_list, [call(interval=None)] * 5)
55
+
56
+ def test_background_sampling_is_independent_of_readers(self):
57
+ """Prime and sample on one thread, even without API requests."""
58
+ sampler = CpuUsageSampler(interval=0.01)
59
+ thread_ids = []
60
+
61
+ def cpu_percent(interval):
62
+ thread_ids.append(threading.get_ident())
63
+ return 30.0
64
+
65
+ with patch('cyclo_host_agent.cpu_usage.psutil.cpu_percent',
66
+ side_effect=cpu_percent):
67
+ sampler.start()
68
+ worker = sampler._thread
69
+ try:
70
+ sampler.start()
71
+ self.assertIs(sampler._thread, worker)
72
+ deadline = time.monotonic() + 2
73
+ while sampler.average() is None and time.monotonic() < deadline:
74
+ time.sleep(0.005)
75
+ self.assertEqual(sampler.average(), 30.0)
76
+ finally:
77
+ sampler.stop()
78
+ self.assertFalse(worker.is_alive())
79
+ count = len(thread_ids)
80
+ for _ in range(50):
81
+ self.assertEqual(sampler.average(), 30.0)
82
+ self.assertEqual(len(thread_ids), count)
83
+ self.assertGreaterEqual(count, 2)
84
+ self.assertEqual(len(set(thread_ids)), 1)
85
+ self.assertNotEqual(thread_ids[0], threading.get_ident())
86
+ sampler._interval = 1.0
87
+ sampler.start()
88
+ try:
89
+ self.assertIsNone(sampler.average())
90
+ finally:
91
+ sampler.stop()
92
+
93
+ def test_warmup_does_not_report_a_fake_zero(self):
94
+ """Return a retryable response before a complete sample exists."""
95
+ with patch.object(system_stats, 'cpu_sampler', CpuUsageSampler()):
96
+ with self.assertRaises(HTTPException) as result:
97
+ system_stats.get_system_stats()
98
+ self.assertEqual(result.exception.status_code, 503)
99
+
100
+ def test_sampling_failure_clears_history_and_primes_again(self):
101
+ """Do not keep old CPU values when a measurement fails."""
102
+ sampler = CpuUsageSampler()
103
+ observed = []
104
+
105
+ def next_interval(interval):
106
+ observed.append(sampler.average())
107
+ if len(observed) == 5:
108
+ sampler._stop.set()
109
+ return True
110
+ return False
111
+
112
+ with patch('cyclo_host_agent.cpu_usage.psutil.cpu_percent',
113
+ side_effect=[99.0, 10.0, OSError('CPU read failed'), 99.0, 60.0]), \
114
+ patch('cyclo_host_agent.cpu_usage.logger.exception') as log, \
115
+ patch.object(sampler._stop, 'wait', side_effect=next_interval):
116
+ sampler._run()
117
+ self.assertEqual(observed, [None, 10.0, None, None, 60.0])
118
+ log.assert_called_once()
119
+
120
+ def test_status_and_process_summary_read_the_same_average(self):
121
+ """Total CPU is shared while process rows retain their own measurements."""
122
+ process = Mock()
123
+ process.as_dict.return_value = {
124
+ 'pid': 42, 'username': 'robot', 'cpu_percent': 80.0,
125
+ 'memory_percent': 1.0, 'memory_info': SimpleNamespace(rss=1024),
126
+ 'cmdline': ['robot-node'], 'name': 'robot-node',
127
+ }
128
+ with patch.object(system_stats.cpu_sampler, 'average', return_value=12.3), \
129
+ patch.object(system_stats.psutil, 'cpu_percent',
130
+ side_effect=AssertionError('API must not resample total CPU')), \
131
+ patch.object(system_stats.psutil, 'virtual_memory',
132
+ return_value=SimpleNamespace(used=1024**3, total=8*1024**3)), \
133
+ patch.object(system_stats.psutil, 'disk_usage',
134
+ return_value=SimpleNamespace(used=10*1024**3, total=100*1024**3)), \
135
+ patch.object(system_stats, '_extra_storage_disk_usage', return_value=(None, None)), \
136
+ patch.object(system_stats, '_temperature', return_value=None), \
137
+ patch.object(system_stats.psutil, 'process_iter', return_value=[process]), \
138
+ patch.object(system_stats.psutil, 'cpu_count', return_value=4), \
139
+ patch.object(system_stats.time, 'sleep') as sleep:
140
+ status = system_stats.get_system_stats()
141
+ processes = system_stats._sample_processes()
142
+ self.assertEqual(status.cpu_percent, 12.3)
143
+ self.assertEqual(processes.cpu_percent, status.cpu_percent)
144
+ self.assertEqual(processes.processes[0].cpu_percent, 20.0)
145
+ process.cpu_percent.assert_called_once_with(None)
146
+ sleep.assert_called_once_with(0.2)
147
+
148
+
149
+ class CpuLifespanTests(unittest.IsolatedAsyncioTestCase):
150
+ """Check sampler cleanup when the API exits."""
151
+
152
+ async def test_lifespan_stops_sampler_even_on_failure(self):
153
+ """Stop the background worker on both normal and exceptional shutdown."""
154
+ sampler = Mock()
155
+ with patch.object(system_stats, 'cpu_sampler', sampler):
156
+ async with lifespan(app):
157
+ sampler.start.assert_called_once_with()
158
+ sampler.stop.assert_not_called()
159
+ sampler.stop.assert_called_once_with()
160
+ sampler.reset_mock()
161
+ with self.assertRaises(RuntimeError):
162
+ async with lifespan(app):
163
+ raise RuntimeError('shutdown')
164
+ sampler.start.assert_called_once_with()
165
+ sampler.stop.assert_called_once_with()
166
+
167
+
168
+ if __name__ == '__main__':
169
+ unittest.main()