cyclo-manager 1.1.0.dev4__tar.gz → 1.1.0.dev5__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.dev4 → cyclo_manager-1.1.0.dev5}/PKG-INFO +2 -2
  2. {cyclo_manager-1.1.0.dev4 → cyclo_manager-1.1.0.dev5}/README.md +1 -0
  3. {cyclo_manager-1.1.0.dev4 → cyclo_manager-1.1.0.dev5}/cyclo_host_agent/routers/repos.py +41 -20
  4. {cyclo_manager-1.1.0.dev4 → cyclo_manager-1.1.0.dev5}/cyclo_manager.egg-info/PKG-INFO +2 -2
  5. {cyclo_manager-1.1.0.dev4 → cyclo_manager-1.1.0.dev5}/cyclo_manager.egg-info/SOURCES.txt +1 -2
  6. {cyclo_manager-1.1.0.dev4 → cyclo_manager-1.1.0.dev5}/cyclo_manager.egg-info/requires.txt +0 -1
  7. {cyclo_manager-1.1.0.dev4 → cyclo_manager-1.1.0.dev5}/pyproject.toml +1 -2
  8. cyclo_manager-1.1.0.dev4/tests/test_cpu_usage.py +0 -169
  9. {cyclo_manager-1.1.0.dev4 → cyclo_manager-1.1.0.dev5}/cyclo_host_agent/__init__.py +0 -0
  10. {cyclo_manager-1.1.0.dev4 → cyclo_manager-1.1.0.dev5}/cyclo_host_agent/cpu_usage.py +0 -0
  11. {cyclo_manager-1.1.0.dev4 → cyclo_manager-1.1.0.dev5}/cyclo_host_agent/main.py +0 -0
  12. {cyclo_manager-1.1.0.dev4 → cyclo_manager-1.1.0.dev5}/cyclo_host_agent/models.py +0 -0
  13. {cyclo_manager-1.1.0.dev4 → cyclo_manager-1.1.0.dev5}/cyclo_host_agent/routers/__init__.py +0 -0
  14. {cyclo_manager-1.1.0.dev4 → cyclo_manager-1.1.0.dev5}/cyclo_host_agent/routers/files.py +0 -0
  15. {cyclo_manager-1.1.0.dev4 → cyclo_manager-1.1.0.dev5}/cyclo_host_agent/routers/system_stats.py +0 -0
  16. {cyclo_manager-1.1.0.dev4 → cyclo_manager-1.1.0.dev5}/cyclo_host_agent/routers/update.py +0 -0
  17. {cyclo_manager-1.1.0.dev4 → cyclo_manager-1.1.0.dev5}/cyclo_manager.egg-info/dependency_links.txt +0 -0
  18. {cyclo_manager-1.1.0.dev4 → cyclo_manager-1.1.0.dev5}/cyclo_manager.egg-info/entry_points.txt +0 -0
  19. {cyclo_manager-1.1.0.dev4 → cyclo_manager-1.1.0.dev5}/cyclo_manager.egg-info/top_level.txt +0 -0
  20. {cyclo_manager-1.1.0.dev4 → cyclo_manager-1.1.0.dev5}/cyclo_manager_cli/__init__.py +0 -0
  21. {cyclo_manager-1.1.0.dev4 → cyclo_manager-1.1.0.dev5}/cyclo_manager_cli/cli.py +0 -0
  22. {cyclo_manager-1.1.0.dev4 → cyclo_manager-1.1.0.dev5}/cyclo_manager_cli/config/config.yml +0 -0
  23. {cyclo_manager-1.1.0.dev4 → cyclo_manager-1.1.0.dev5}/cyclo_manager_cli/docker/docker-compose.yml +0 -0
  24. {cyclo_manager-1.1.0.dev4 → cyclo_manager-1.1.0.dev5}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cyclo-manager
3
- Version: 1.1.0.dev4
3
+ Version: 1.1.0.dev5
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
@@ -9,7 +9,6 @@ Requires-Dist: fastapi>=0.100.0
9
9
  Requires-Dist: uvicorn[standard]>=0.23.0
10
10
  Requires-Dist: psutil>=5.9.0
11
11
  Provides-Extra: dev
12
- Requires-Dist: pytest; extra == "dev"
13
12
  Requires-Dist: ruff; extra == "dev"
14
13
 
15
14
  # cyclo_manager CLI
@@ -134,6 +133,7 @@ The API container reaches it at `/agents/host/host_agent.sock` (see bundled `con
134
133
  - Check update availability from remote tags and local `package.xml` versions.
135
134
  - Update repos on allowed branches (`main`, `jazzy`) using stash or reset workflows.
136
135
  - Stop/start a repo's `docker/container.sh` helper during an update when requested by the UI.
136
+ - Run container starts as background jobs with a 10-minute timeout covering the helper and its output streams; timeout or cancellation terminates the helper's process group and ends the job.
137
137
  - Run Cyclo Manager package updates from the UI by delegating to `cyclo_manager update`.
138
138
  - Provide host CPU/memory/disk statistics and process information. CPU summaries
139
139
  share a background sampler's average of the latest three one-second samples.
@@ -120,6 +120,7 @@ The API container reaches it at `/agents/host/host_agent.sock` (see bundled `con
120
120
  - Check update availability from remote tags and local `package.xml` versions.
121
121
  - Update repos on allowed branches (`main`, `jazzy`) using stash or reset workflows.
122
122
  - Stop/start a repo's `docker/container.sh` helper during an update when requested by the UI.
123
+ - Run container starts as background jobs with a 10-minute timeout covering the helper and its output streams; timeout or cancellation terminates the helper's process group and ends the job.
123
124
  - Run Cyclo Manager package updates from the UI by delegating to `cyclo_manager update`.
124
125
  - Provide host CPU/memory/disk statistics and process information. CPU summaries
125
126
  share a background sampler's average of the latest three one-second samples.
@@ -22,6 +22,7 @@ import asyncio
22
22
  import os
23
23
  from pathlib import Path
24
24
  import re
25
+ import signal
25
26
  import time
26
27
  from typing import Optional
27
28
 
@@ -45,6 +46,7 @@ router = APIRouter(prefix='/repos', tags=['repos'])
45
46
  GIT_TIMEOUT = 120.0
46
47
  GIT_REMOTE_TIMEOUT = 5.0
47
48
  CONTAINER_SCRIPT_TIMEOUT = 600.0
49
+ CONTAINER_TERMINATE_TIMEOUT = 2.0
48
50
  CONTAINER_JOB_RETENTION_SECONDS = 3600.0
49
51
  CONTAINER_JOB_OUTPUT_LIMIT = 40000
50
52
 
@@ -429,13 +431,28 @@ async def _stop_container_start_process(
429
431
  proc: asyncio.subprocess.Process | None,
430
432
  readers: list[asyncio.Task],
431
433
  ) -> None:
432
- """Stop a container-start subprocess and consume its reader tasks."""
433
- if proc is not None and proc.returncode is None:
434
+ """Stop the isolated process group, with bounded pipe and process cleanup."""
435
+ if proc is not None:
436
+ def signal_group(sig):
437
+ try:
438
+ os.killpg(proc.pid, sig)
439
+ except ProcessLookupError:
440
+ pass
441
+
442
+ signal_group(signal.SIGTERM)
434
443
  try:
435
- proc.kill()
436
- except ProcessLookupError:
444
+ await asyncio.wait_for(proc.wait(), CONTAINER_TERMINATE_TIMEOUT)
445
+ except asyncio.TimeoutError:
437
446
  pass
438
- await proc.wait()
447
+ # Children may still exist even after their shell has exited.
448
+ signal_group(signal.SIGKILL)
449
+ try:
450
+ await asyncio.wait_for(proc.wait(), CONTAINER_TERMINATE_TIMEOUT)
451
+ except asyncio.TimeoutError:
452
+ pass
453
+ for reader in readers:
454
+ if not reader.done():
455
+ reader.cancel()
439
456
  await asyncio.gather(*readers, return_exceptions=True)
440
457
 
441
458
 
@@ -477,6 +494,7 @@ async def _run_container_start(repo_path: Path, name: str) -> None:
477
494
  'bash', str(script), 'start',
478
495
  cwd=str(repo_path / 'docker'),
479
496
  env=env,
497
+ start_new_session=True,
480
498
  stdin=asyncio.subprocess.PIPE,
481
499
  stdout=asyncio.subprocess.PIPE,
482
500
  stderr=asyncio.subprocess.PIPE,
@@ -490,28 +508,31 @@ async def _run_container_start(repo_path: Path, name: str) -> None:
490
508
  asyncio.create_task(_read_container_job_stream(proc.stdout, job)),
491
509
  asyncio.create_task(_read_container_job_stream(proc.stderr, job)),
492
510
  ]
493
- await asyncio.wait_for(proc.wait(), timeout=CONTAINER_SCRIPT_TIMEOUT)
494
- await asyncio.gather(*readers)
511
+ await asyncio.wait_for(
512
+ asyncio.gather(proc.wait(), *readers), timeout=CONTAINER_SCRIPT_TIMEOUT)
495
513
  except asyncio.TimeoutError:
496
514
  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)
515
+ try:
516
+ await _stop_container_start_process(proc, readers)
517
+ finally:
518
+ _append_container_job_output(job, error)
519
+ _set_container_job_status(job, running=False, success=False, error=error)
500
520
  return
501
521
  except asyncio.CancelledError:
502
- await _stop_container_start_process(proc, readers)
503
- _set_container_job_status(
504
- job,
505
- running=False,
506
- success=False,
507
- error='Container start operation was cancelled',
508
- )
522
+ try:
523
+ await _stop_container_start_process(proc, readers)
524
+ finally:
525
+ _set_container_job_status(
526
+ job, running=False, success=False,
527
+ error='Container start operation was cancelled')
509
528
  raise
510
529
  except Exception as exc:
511
- await _stop_container_start_process(proc, readers)
512
530
  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)
531
+ try:
532
+ await _stop_container_start_process(proc, readers)
533
+ finally:
534
+ _append_container_job_output(job, error)
535
+ _set_container_job_status(job, running=False, success=False, error=error)
515
536
  return
516
537
 
517
538
  assert proc is not None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cyclo-manager
3
- Version: 1.1.0.dev4
3
+ Version: 1.1.0.dev5
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
@@ -9,7 +9,6 @@ Requires-Dist: fastapi>=0.100.0
9
9
  Requires-Dist: uvicorn[standard]>=0.23.0
10
10
  Requires-Dist: psutil>=5.9.0
11
11
  Provides-Extra: dev
12
- Requires-Dist: pytest; extra == "dev"
13
12
  Requires-Dist: ruff; extra == "dev"
14
13
 
15
14
  # cyclo_manager CLI
@@ -134,6 +133,7 @@ The API container reaches it at `/agents/host/host_agent.sock` (see bundled `con
134
133
  - Check update availability from remote tags and local `package.xml` versions.
135
134
  - Update repos on allowed branches (`main`, `jazzy`) using stash or reset workflows.
136
135
  - Stop/start a repo's `docker/container.sh` helper during an update when requested by the UI.
136
+ - Run container starts as background jobs with a 10-minute timeout covering the helper and its output streams; timeout or cancellation terminates the helper's process group and ends the job.
137
137
  - Run Cyclo Manager package updates from the UI by delegating to `cyclo_manager update`.
138
138
  - Provide host CPU/memory/disk statistics and process information. CPU summaries
139
139
  share a background sampler's average of the latest three one-second samples.
@@ -19,5 +19,4 @@ cyclo_manager.egg-info/top_level.txt
19
19
  cyclo_manager_cli/__init__.py
20
20
  cyclo_manager_cli/cli.py
21
21
  cyclo_manager_cli/config/config.yml
22
- cyclo_manager_cli/docker/docker-compose.yml
23
- tests/test_cpu_usage.py
22
+ cyclo_manager_cli/docker/docker-compose.yml
@@ -3,5 +3,4 @@ uvicorn[standard]>=0.23.0
3
3
  psutil>=5.9.0
4
4
 
5
5
  [dev]
6
- pytest
7
6
  ruff
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cyclo-manager"
7
- version = "1.1.0.dev4"
7
+ version = "1.1.0.dev5"
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"
@@ -17,7 +17,6 @@ dependencies = [
17
17
 
18
18
  [project.optional-dependencies]
19
19
  dev = [
20
- "pytest",
21
20
  "ruff",
22
21
  ]
23
22
 
@@ -1,169 +0,0 @@
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()