Skip to content

Commit d6ddd45

Browse files
committed
Check attributes under /sys can be read without stucking the system
1 parent 38b29e2 commit d6ddd45

File tree

3 files changed

+181
-0
lines changed

3 files changed

+181
-0
lines changed
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
#!/bin/env python3
2+
# This file is part of Checkbox.
3+
#
4+
# Copyright 2026 Canonical Ltd.
5+
#
6+
# Checkbox is free software: you can redistribute it and/or modify
7+
# it under the terms of the GNU General Public License version 3,
8+
# as published by the Free Software Foundation.
9+
#
10+
# Checkbox is distributed in the hope that it will be useful,
11+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
# GNU General Public License for more details.
14+
#
15+
# You should have received a copy of the GNU General Public License
16+
# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
17+
import os
18+
import multiprocessing
19+
import sys
20+
21+
22+
def try_read_node(path):
23+
"""
24+
Attempts to read a single sysfs attribute.
25+
Isolated in a subprocess to protect against D-state hangs.
26+
"""
27+
try:
28+
with open(path, "r") as f:
29+
# We only need the first byte to trigger the kernel 'show' function
30+
f.read(1)
31+
except Exception:
32+
pass
33+
34+
35+
def walk_devices(base_path="/sys/devices", timeout=10.0):
36+
37+
# We use os.walk but skip non-device directories if necessary
38+
failed = 0
39+
for root, dirs, files in os.walk(base_path):
40+
for name in files:
41+
full_path = os.path.join(root, name)
42+
43+
# Skip known 'noisy' or non-hardware files to be efficient
44+
if name in ["uevent", "modalias", "resource"]:
45+
continue
46+
47+
if os.access(full_path, os.R_OK):
48+
p = multiprocessing.Process(
49+
target=try_read_node, args=(full_path,)
50+
)
51+
p.start()
52+
53+
p.join(timeout)
54+
55+
if p.is_alive():
56+
failed = 1
57+
print(full_path)
58+
p.terminate()
59+
p.join()
60+
# We stay silent on success to highlight the problem areas
61+
return failed
62+
63+
64+
if __name__ == "__main__":
65+
print(
66+
"Scanning /sys/devices for unresponsive attributes (Timeout: 10s)..."
67+
)
68+
sys.exit(walk_devices())
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
#!/usr/bin/env python3
2+
# Copyright 2026 Canonical Ltd.
3+
#
4+
# This program is free software: you can redistribute it and/or modify
5+
# it under the terms of the GNU General Public License version 3,
6+
# as published by the Free Software Foundation.
7+
#
8+
# This program is distributed in the hope that it will be useful,
9+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
# GNU General Public License for more details.
12+
#
13+
# You should have received a copy of the GNU General Public License
14+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
15+
16+
import unittest
17+
import os
18+
import time
19+
from unittest.mock import patch, MagicMock, mock_open
20+
from check_hardware_attributes import walk_devices, try_read_node
21+
22+
23+
class TestSysfsScanner(unittest.TestCase):
24+
25+
@patch("builtins.open", new_callable=mock_open, read_data="test_data")
26+
def test_try_read_node_success(self, mock_file):
27+
"""Test that try_read_node successfully opens and reads a byte."""
28+
try_read_node("/fake/path")
29+
mock_file.assert_called_once_with("/fake/path", "r")
30+
mock_file().read.assert_called_once_with(1)
31+
32+
@patch("builtins.open", side_effect=Exception("Read Error"))
33+
def test_try_read_node_handles_exception(self, mock_file):
34+
"""Test that try_read_node catches and suppresses exceptions."""
35+
try:
36+
try_read_node("/fake/path")
37+
except Exception as e:
38+
self.fail("try_read_node raised {} unexpectedly!".format(e))
39+
40+
@patch("os.walk")
41+
@patch("os.access")
42+
@patch("multiprocessing.Process")
43+
def test_walk_devices_skips_noisy_files(
44+
self, mock_process, mock_access, mock_walk
45+
):
46+
"""Verify that uevent, modalias, and resource are ignored."""
47+
# Mocking os.walk to return a few files, including an excluded one
48+
mock_walk.return_value = [
49+
("/sys/devices", ("dir1",), ("uevent", "valid_node"))
50+
]
51+
mock_access.return_value = True
52+
53+
walk_devices("/sys/devices", timeout=0.1)
54+
55+
# Ensure Process was only called for 'valid_node', not 'uevent'
56+
self.assertEqual(mock_process.call_count, 1)
57+
_, args = mock_process.call_args
58+
self.assertIn("valid_node", args["args"][0])
59+
60+
@patch("os.walk")
61+
@patch("os.access")
62+
@patch("multiprocessing.Process")
63+
def test_walk_devices_detects_hang(
64+
self, mock_process, mock_access, mock_walk
65+
):
66+
"""Simulate a subprocess hang and ensure failed status is returned."""
67+
mock_walk.return_value = [("/sys/devices", (), ("stuck_node",))]
68+
mock_access.return_value = True
69+
70+
# Create a mock process that appears alive after joining
71+
instance = mock_process.return_value
72+
instance.is_alive.return_value = True
73+
74+
# Test walk_devices
75+
with patch("builtins.print") as mock_print:
76+
result = walk_devices("/sys/devices", timeout=0.1)
77+
78+
# Verify status is failed (1) and path was printed
79+
self.assertEqual(result, 1)
80+
mock_print.assert_called_with("/sys/devices/stuck_node")
81+
82+
# Verify clean up was attempted
83+
instance.terminate.assert_called_once()
84+
self.assertEqual(
85+
instance.join.call_count, 2
86+
) # Start join + cleanup join
87+
88+
@patch("os.walk")
89+
@patch("os.access")
90+
@patch("multiprocessing.Process")
91+
def test_walk_devices_success_path(
92+
self, mock_process, mock_access, mock_walk
93+
):
94+
"""Ensure result is 0 when all processes finish within timeout."""
95+
mock_walk.return_value = [("/sys/devices", (), ("healthy_node",))]
96+
mock_access.return_value = True
97+
98+
instance = mock_process.return_value
99+
instance.is_alive.return_value = False
100+
101+
result = walk_devices("/sys/devices", timeout=1.0)
102+
self.assertEqual(result, 0)
103+
104+
105+
if __name__ == "__main__":
106+
unittest.main()

providers/base/units/miscellanea/jobs.pxu

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -632,3 +632,10 @@ _steps:
632632
3. Boot into the recovered system without errors
633633
_verification:
634634
1. The system boots into the factory recovery system successfully.
635+
636+
plugin: shell
637+
category_id: com.canonical.plainbox::miscellanea
638+
estimated_duration: 120.0
639+
id: miscellanea/check-hardware-attributes
640+
command: check_hardware_attributes.py
641+
_summary: Check that all the attributes are able to read

0 commit comments

Comments
 (0)