[PATCH 08/36] tuna: Add comprehensive test suite for cpuset module
John Kacur <[email protected]> Fri, 10 Jul 2026 10:14:46 -0400
| Newsgroups | org.kernel.vger.linux-rt-users |
|---|---|
| Message-ID | <[email protected]> |
Add unit tests and utility script for the cpuset module to ensure proper functionality and discover edge cases. Tests added: - tests/test_cpuset.py: 27 unit tests across 7 test classes - TestCpusetsInit: cgroup v2 detection and initialization - TestCpusetCreation: creating/destroying cpusets - TestCpusetConfiguration: CPU/memory assignment - TestContextManager: auto_destroy behavior - TestTaskMigration: process migration - TestDiscoveryFunctions: cpuset discovery - TestCleanupFunctions: cleanup operations Utility script: - cleanup_test_cpusets.sh: Clean up leftover test cpusets Tests handle both root and non-root execution, properly clean up nested cpuset hierarchies, and validate cgroup v2 requirements. Assisted-by: Claude:claude-sonnet-4-5 Signed-off-by: John Kacur <[email protected]> --- tests/test_cpuset.py | 616 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 616 insertions(+) create mode 100644 tests/test_cpuset.py diff --git a/tests/test_cpuset.py b/tests/test_cpuset.py new file mode 100644 index 000000000000..6ac4fe0c9c40 --- /dev/null +++ b/tests/test_cpuset.py @@ -0,0 +1,616 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# SPDX-License-Identifier: GPL-2.0-only +""" +Comprehensive test suite for tuna.cpuset module + +Tests cover: +- Core cpuset functionality (rteval-compatible) +- Tuna-specific enhancements (discovery, cleanup, persistence) +- Edge cases and error handling +- Nested cpuset hierarchies + +Requires root permissions to run tests that create/destroy cgroups. +""" + +import unittest +import os +import sys +import time +import tempfile +from pathlib import Path + +# Add tuna to path if running from tests directory +if Path(__file__).parent.name == 'tests': + sys.path.insert(0, str(Path(__file__).parent.parent)) + +from tuna import cpuset + + +class TestCpusetsInit(unittest.TestCase): + """Test CpusetsInit class and cgroup v2 detection""" + + def test_initialization(self): + """Test that CpusetsInit initializes correctly""" + ci = cpuset.CpusetsInit() + self.assertIsInstance(ci, cpuset.CpusetsInit) + self.assertIsInstance(ci.supported, bool) + self.assertIsInstance(ci.numa_nodes, int) + + def test_cpuset_path(self): + """Test that cpuset_path is set correctly for root cgroup""" + ci = cpuset.CpusetsInit() + if ci.supported: + self.assertEqual(ci.cpuset_path, '/sys/fs/cgroup') + + def test_cgroup_v2_detection(self): + """Test cgroup v2 detection logic""" + supported = cpuset.CpusetsInit._cpuset_supported() + self.assertIsInstance(supported, bool) + + # If /sys/fs/cgroup exists and has cgroup.controllers, should be supported + if os.path.exists('/sys/fs/cgroup/cgroup.controllers'): + # Read controllers to verify cpuset is available + with open('/sys/fs/cgroup/cgroup.controllers') as f: + controllers = f.read().strip().split() + if 'cpuset' in controllers: + self.assertTrue(supported) + + def test_numa_nodes(self): + """Test NUMA node detection""" + ci = cpuset.CpusetsInit() + if ci.supported: + # Should have at least 1 NUMA node + self.assertGreaterEqual(ci.numa_nodes, 1) + + # Verify against actual /sys/devices/system/node + from glob import glob + actual_nodes = len(glob('/sys/devices/system/node/node*')) + self.assertEqual(ci.numa_nodes, actual_nodes) + + def test_auto_destroy_is_false(self): + """Test that root cgroup never has auto_destroy enabled""" + ci = cpuset.CpusetsInit() + if ci.supported: + self.assertFalse(ci.auto_destroy) + + [email protected](os.geteuid() == 0, "Requires root permissions") [email protected](cpuset.CpusetsInit().supported, "Requires cgroup v2 support") +class TestCpusetCreation(unittest.TestCase): + """Test Cpuset creation and destruction""" + + def setUp(self): + """Set up test fixtures""" + self.test_cpusets = [] + self.ci = cpuset.CpusetsInit() + + def tearDown(self): + """Clean up any cpusets created during tests""" + for cs in self.test_cpusets: + try: + if hasattr(cs, 'cpuset_path') and os.path.exists(cs.cpuset_path): + # Move any tasks back to root + tm = cpuset.TaskMigrate(cs, self.ci) + tm.migrate() + cs.destroy() + except: + pass + + def test_create_cpuset(self): + """Test basic cpuset creation""" + cs = cpuset.Cpuset('test_create') + self.test_cpusets.append(cs) + + self.assertEqual(cs.cpuset_name, 'test_create') + self.assertEqual(cs.cpuset_path, '/sys/fs/cgroup/test_create') + self.assertTrue(os.path.exists(cs.cpuset_path)) + + def test_create_with_auto_destroy_false(self): + """Test cpuset creation with auto_destroy=False (default)""" + cs = cpuset.Cpuset('test_no_auto') + self.test_cpusets.append(cs) + + self.assertFalse(cs.auto_destroy) + + def test_create_with_auto_destroy_true(self): + """Test cpuset creation with auto_destroy=True""" + cs = cpuset.Cpuset('test_auto', auto_destroy=True) + self.test_cpusets.append(cs) + + self.assertTrue(cs.auto_destroy) + + def test_destroy_cpuset(self): + """Test manual cpuset destruction""" + cs = cpuset.Cpuset('test_destroy') + path = cs.cpuset_path + + self.assertTrue(os.path.exists(path)) + cs.destroy() + self.assertFalse(os.path.exists(path)) + + def test_create_existing_cpuset(self): + """Test creating a cpuset that already exists""" + cs1 = cpuset.Cpuset('test_existing') + self.test_cpusets.append(cs1) + path1 = cs1.cpuset_path + + # Create again with same name + cs2 = cpuset.Cpuset('test_existing') + + # Should reference the same path + self.assertEqual(cs1.cpuset_path, cs2.cpuset_path) + self.assertTrue(os.path.exists(path1)) + + def test_cpuset_name_none_raises_error(self): + """Test that creating cpuset with None name raises ValueError""" + with self.assertRaises(ValueError): + cpuset.Cpuset(None) + + [email protected](os.geteuid() == 0, "Requires root permissions") [email protected](cpuset.CpusetsInit().supported, "Requires cgroup v2 support") +class TestCpusetConfiguration(unittest.TestCase): + """Test Cpuset configuration (CPUs, memory, etc.)""" + + def setUp(self): + """Set up test fixtures""" + self.test_cpusets = [] + self.ci = cpuset.CpusetsInit() + + def tearDown(self): + """Clean up any cpusets created during tests""" + for cs in self.test_cpusets: + try: + if hasattr(cs, 'cpuset_path') and os.path.exists(cs.cpuset_path): + tm = cpuset.TaskMigrate(cs, self.ci) + tm.migrate() + cs.destroy() + except: + pass + + def test_assign_cpus(self): + """Test CPU assignment to cpuset""" + cs = cpuset.Cpuset('test_cpus') + self.test_cpusets.append(cs) + + cs.write_memnode('0') + cs.assign_cpus('0-1') + + # Read back the assignment + with open(os.path.join(cs.cpuset_path, 'cpuset.cpus')) as f: + assigned = f.read().strip() + + # Should be '0-1' or '0,1' depending on kernel + self.assertIn('0', assigned) + self.assertIn('1', assigned) + + def test_write_memnode(self): + """Test memory node assignment""" + cs = cpuset.Cpuset('test_memnode') + self.test_cpusets.append(cs) + + cs.write_memnode('0') + + # Read back the assignment + with open(os.path.join(cs.cpuset_path, 'cpuset.mems')) as f: + assigned = f.read().strip() + + self.assertEqual(assigned, '0') + + def test_cpu_exclusive(self): + """Test CPU exclusive (partition) setting""" + cs = cpuset.Cpuset('test_exclusive') + self.test_cpusets.append(cs) + + cs.write_memnode('0') + cs.assign_cpus('0') + + # Try to set exclusive (may not be supported on all kernels) + cs.set_cpu_exclusive() + + partition_file = os.path.join(cs.cpuset_path, 'cpuset.cpus.partition') + if os.path.exists(partition_file): + with open(partition_file) as f: + partition = f.read().strip() + # Should be 'isolated' or 'root' (if not supported) + self.assertIn(partition, ['isolated', 'root', 'member']) + + [email protected](os.geteuid() == 0, "Requires root permissions") [email protected](cpuset.CpusetsInit().supported, "Requires cgroup v2 support") +class TestContextManager(unittest.TestCase): + """Test context manager behavior with auto_destroy""" + + def setUp(self): + """Set up test fixtures""" + self.ci = cpuset.CpusetsInit() + + def test_context_manager_auto_destroy_true(self): + """Test that cpuset is destroyed when auto_destroy=True""" + path = None + + with cpuset.Cpuset('test_context_auto', auto_destroy=True) as cs: + path = cs.cpuset_path + cs.write_memnode('0') + cs.assign_cpus('0') + self.assertTrue(os.path.exists(path)) + + # After exiting context, should be destroyed + self.assertFalse(os.path.exists(path)) + + def test_context_manager_auto_destroy_false(self): + """Test that cpuset persists when auto_destroy=False""" + path = None + + with cpuset.Cpuset('test_context_no_auto', auto_destroy=False) as cs: + path = cs.cpuset_path + cs.write_memnode('0') + cs.assign_cpus('0') + self.assertTrue(os.path.exists(path)) + + # After exiting context, should still exist + self.assertTrue(os.path.exists(path)) + + # Clean up manually + os.rmdir(path) + + [email protected](os.geteuid() == 0, "Requires root permissions") [email protected](cpuset.CpusetsInit().supported, "Requires cgroup v2 support") +class TestTaskMigration(unittest.TestCase): + """Test task migration between cpusets""" + + def setUp(self): + """Set up test fixtures""" + self.test_cpusets = [] + self.ci = cpuset.CpusetsInit() + + def tearDown(self): + """Clean up any cpusets created during tests""" + for cs in self.test_cpusets: + try: + if hasattr(cs, 'cpuset_path') and os.path.exists(cs.cpuset_path): + tm = cpuset.TaskMigrate(cs, self.ci) + tm.migrate() + cs.destroy() + except: + pass + + def test_write_pid(self): + """Test writing a PID to a cpuset""" + cs = cpuset.Cpuset('test_pid') + self.test_cpusets.append(cs) + + cs.write_memnode('0') + cs.assign_cpus('0') + + # Try to write our own PID + pid = os.getpid() + result = cs.write_pid(pid) + + # Should succeed (we can move ourselves) + self.assertTrue(result) + + # Verify PID is in the cpuset + tasks = cs.get_tasks() + self.assertIn(str(pid), tasks) + + # Move back to root + self.ci.write_pid(pid) + + def test_task_migrate(self): + """Test TaskMigrate class""" + cs = cpuset.Cpuset('test_migrate') + self.test_cpusets.append(cs) + + cs.write_memnode('0') + cs.assign_cpus('0') + + # Move our PID to the cpuset + pid = os.getpid() + cs.write_pid(pid) + + # Migrate back to root + tm = cpuset.TaskMigrate(cs, self.ci) + migrated, failed = tm.migrate() + + self.assertGreaterEqual(migrated, 1) # At least our process + self.assertGreaterEqual(failed, 0) # Some failures are OK + + [email protected](os.geteuid() == 0, "Requires root permissions") [email protected](cpuset.CpusetsInit().supported, "Requires cgroup v2 support") +class TestDiscoveryFunctions(unittest.TestCase): + """Test tuna-specific discovery functions""" + + def setUp(self): + """Set up test fixtures""" + self.test_cpusets = [] + self.ci = cpuset.CpusetsInit() + + def tearDown(self): + """Clean up any cpusets created during tests""" + for cs in self.test_cpusets: + try: + if hasattr(cs, 'cpuset_path') and os.path.exists(cs.cpuset_path): + # Use recursive destroy to handle any nested cpusets + cpuset.destroy_cpuset_recursive(cs.cpuset_path, force=True) + except: + pass + + # Clean up specific test cpusets that might have nested children + for name in ['test_parent', 'test_list_1', 'test_list_2', + 'test_pattern_1', 'test_pattern_2', 'other_cpuset']: + try: + cpuset.destroy_cpuset_recursive(name, force=True) + except: + pass + + def test_list_cpusets_no_pattern(self): + """Test listing all cpusets without pattern""" + # Create some test cpusets + cs1 = cpuset.Cpuset('test_list_1') + cs2 = cpuset.Cpuset('test_list_2') + self.test_cpusets.extend([cs1, cs2]) + + cs1.write_memnode('0') + cs2.write_memnode('0') + + # List all cpusets + all_cpusets = cpuset.list_cpusets(recursive=False) + + # Should include our test cpusets + self.assertIn('test_list_1', all_cpusets) + self.assertIn('test_list_2', all_cpusets) + + def test_list_cpusets_with_pattern(self): + """Test listing cpusets with pattern matching""" + # Create test cpusets with different names + cs1 = cpuset.Cpuset('test_pattern_1') + cs2 = cpuset.Cpuset('test_pattern_2') + cs3 = cpuset.Cpuset('other_cpuset') + self.test_cpusets.extend([cs1, cs2, cs3]) + + cs1.write_memnode('0') + cs2.write_memnode('0') + cs3.write_memnode('0') + + # List with pattern + pattern_cpusets = cpuset.list_cpusets(pattern='test_pattern_*', recursive=False) + + # Should only include matching cpusets + self.assertIn('test_pattern_1', pattern_cpusets) + self.assertIn('test_pattern_2', pattern_cpusets) + self.assertNotIn('other_cpuset', pattern_cpusets) + + def test_list_cpusets_recursive(self): + """Test recursive cpuset listing""" + # Clean up first in case of previous failed run + cpuset.destroy_cpuset_recursive('test_parent', force=True) + + # Create nested cpusets using Cpuset class + # Note: In cgroup v2, creating nested cpusets requires proper setup + parent = cpuset.Cpuset('test_parent') + self.test_cpusets.append(parent) + parent.write_memnode('0') + parent.assign_cpus('0') + + # Create child as a proper cpuset (not just mkdir) + # First enable cpuset controller in parent + try: + with open(os.path.join(parent.cpuset_path, 'cgroup.subtree_control'), 'w') as f: + f.write('+cpuset') + except (OSError, PermissionError): + pass + + # Create child directory and enable it as a cpuset + child_path = os.path.join(parent.cpuset_path, 'test_child') + if not os.path.exists(child_path): + os.mkdir(child_path) + # Write to cpuset.cpus to make it a valid cpuset + try: + with open(os.path.join(child_path, 'cpuset.cpus'), 'w') as f: + f.write('0') + with open(os.path.join(child_path, 'cpuset.mems'), 'w') as f: + f.write('0') + except (OSError, PermissionError): + pass + + # List recursively + all_cpusets = cpuset.list_cpusets(recursive=True) + + # Should find both parent and child + self.assertIn('test_parent', all_cpusets) + # Child should be found if it's a valid cpuset + has_child = os.path.exists(os.path.join(child_path, 'cpuset.cpus')) + if has_child: + self.assertIn('test_parent/test_child', all_cpusets) + + # Clean up child first (must destroy children before parents) + if os.path.exists(child_path): + try: + os.rmdir(child_path) + except OSError: + pass # May have processes, tearDown will handle it + + [email protected](os.geteuid() == 0, "Requires root permissions") [email protected](cpuset.CpusetsInit().supported, "Requires cgroup v2 support") +class TestCleanupFunctions(unittest.TestCase): + """Test tuna-specific cleanup functions""" + + def setUp(self): + """Set up test fixtures""" + self.test_cpusets = [] + self.ci = cpuset.CpusetsInit() + + def tearDown(self): + """Clean up any cpusets that might still exist""" + # Clean up any test_cleanup_* cpusets (including nested ones) + try: + # First try to clean with recursive pattern matching + cpuset.cleanup_cpusets('test_cleanup_*', force=True, recursive=True) + except: + pass + + # Also clean up specific problem cpusets from failed runs + for name in ['test_cleanup_nested_parent', 'test_cleanup_bulk_1', + 'test_cleanup_bulk_2', 'other_bulk']: + try: + cpuset.destroy_cpuset_recursive(name, force=True) + except: + pass + + def test_destroy_cpuset_by_name(self): + """Test destroying cpuset by name""" + cs = cpuset.Cpuset('test_cleanup_destroy') + cs.write_memnode('0') + path = cs.cpuset_path + + self.assertTrue(os.path.exists(path)) + + # Destroy by name + cpuset.destroy_cpuset('test_cleanup_destroy', force=False) + + self.assertFalse(os.path.exists(path)) + + def test_destroy_cpuset_by_path(self): + """Test destroying cpuset by full path""" + cs = cpuset.Cpuset('test_cleanup_path') + cs.write_memnode('0') + path = cs.cpuset_path + + self.assertTrue(os.path.exists(path)) + + # Destroy by full path + cpuset.destroy_cpuset(path, force=False) + + self.assertFalse(os.path.exists(path)) + + def test_destroy_cpuset_with_force(self): + """Test destroying cpuset with force (migrates tasks first)""" + cs = cpuset.Cpuset('test_cleanup_force') + cs.write_memnode('0') + cs.assign_cpus('0') + + # Move our PID to the cpuset + cs.write_pid(os.getpid()) + + # Destroy with force (should migrate tasks first) + cpuset.destroy_cpuset('test_cleanup_force', force=True) + + self.assertFalse(os.path.exists(cs.cpuset_path)) + + # Verify we're back in root cgroup + with open('/proc/self/cgroup') as f: + cgroup_info = f.read() + # In cgroup v2, processes in root are at '0::/' + self.assertIn('0::/', cgroup_info) + + def test_destroy_nonexistent_cpuset_raises_error(self): + """Test that destroying non-existent cpuset raises OSError""" + with self.assertRaises(OSError): + cpuset.destroy_cpuset('nonexistent_cpuset') + + def test_cleanup_cpusets_with_pattern(self): + """Test cleanup_cpusets with pattern matching""" + # Create multiple cpusets + cs1 = cpuset.Cpuset('test_cleanup_bulk_1') + cs2 = cpuset.Cpuset('test_cleanup_bulk_2') + cs3 = cpuset.Cpuset('other_bulk') + + cs1.write_memnode('0') + cs2.write_memnode('0') + cs3.write_memnode('0') + + # Cleanup with pattern + cpuset.cleanup_cpusets('test_cleanup_bulk_*', force=True, recursive=False) + + # Pattern-matched cpusets should be gone + self.assertFalse(os.path.exists(cs1.cpuset_path)) + self.assertFalse(os.path.exists(cs2.cpuset_path)) + + # Other cpuset should still exist + self.assertTrue(os.path.exists(cs3.cpuset_path)) + + # Clean up remaining + cs3.destroy() + + def test_cleanup_nested_cpusets(self): + """Test cleanup destroys children before parents""" + # Clean up first in case of previous failed run + cpuset.destroy_cpuset_recursive('test_cleanup_nested_parent', force=True) + + # Create parent + parent = cpuset.Cpuset('test_cleanup_nested_parent') + parent.write_memnode('0') + parent.assign_cpus('0') + + # Enable cpuset controller in parent for children + try: + with open(os.path.join(parent.cpuset_path, 'cgroup.subtree_control'), 'w') as f: + f.write('+cpuset') + except (OSError, PermissionError): + pass + + # Create children as proper cpusets + child1_path = os.path.join(parent.cpuset_path, 'child1') + child2_path = os.path.join(parent.cpuset_path, 'child2') + + for child_path in [child1_path, child2_path]: + if not os.path.exists(child_path): + os.mkdir(child_path) + # Make it a valid cpuset + try: + with open(os.path.join(child_path, 'cpuset.cpus'), 'w') as f: + f.write('0') + with open(os.path.join(child_path, 'cpuset.mems'), 'w') as f: + f.write('0') + except (OSError, PermissionError): + pass + + # Cleanup recursively + cpuset.cleanup_cpusets('test_cleanup_nested_*', force=False, recursive=True) + + # Everything should be gone + self.assertFalse(os.path.exists(child1_path)) + self.assertFalse(os.path.exists(child2_path)) + self.assertFalse(os.path.exists(parent.cpuset_path)) + + +def suite(): + """Create test suite""" + loader = unittest.TestLoader() + suite = unittest.TestSuite() + + # Add all test classes + suite.addTests(loader.loadTestsFromTestCase(TestCpusetsInit)) + suite.addTests(loader.loadTestsFromTestCase(TestCpusetCreation)) + suite.addTests(loader.loadTestsFromTestCase(TestCpusetConfiguration)) + suite.addTests(loader.loadTestsFromTestCase(TestContextManager)) + suite.addTests(loader.loadTestsFromTestCase(TestTaskMigration)) + suite.addTests(loader.loadTestsFromTestCase(TestDiscoveryFunctions)) + suite.addTests(loader.loadTestsFromTestCase(TestCleanupFunctions)) + + return suite + + +if __name__ == '__main__': + # Check if running as root for tests that need it + if os.geteuid() != 0: + print("\n" + "="*60) + print("WARNING: Not running as root") + print("="*60) + print("Most tests require root permissions to create/destroy cgroups.") + print("Only basic detection tests will run.") + print("\nTo run all tests:") + print(" sudo python3 -m unittest tests.test_cpuset") + print("="*60 + "\n") + + # Run tests + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(suite()) + + # Exit with appropriate code + sys.exit(0 if result.wasSuccessful() else 1) -- 2.54.0