sdev 1.0.0__tar.gz → 1.0.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.
- {sdev-1.0.0 → sdev-1.0.1}/PKG-INFO +1 -1
- {sdev-1.0.0 → sdev-1.0.1}/pyproject.toml +1 -1
- {sdev-1.0.0 → sdev-1.0.1}/sdev/__init__.py +142 -6
- {sdev-1.0.0 → sdev-1.0.1}/sdev/__main__.py +57 -2
- {sdev-1.0.0 → sdev-1.0.1}/sdev.egg-info/PKG-INFO +1 -1
- {sdev-1.0.0 → sdev-1.0.1}/tests/test_doctor_wait_silence.py +6 -0
- {sdev-1.0.0 → sdev-1.0.1}/README.md +0 -0
- {sdev-1.0.0 → sdev-1.0.1}/sdev.egg-info/SOURCES.txt +0 -0
- {sdev-1.0.0 → sdev-1.0.1}/sdev.egg-info/dependency_links.txt +0 -0
- {sdev-1.0.0 → sdev-1.0.1}/sdev.egg-info/entry_points.txt +0 -0
- {sdev-1.0.0 → sdev-1.0.1}/sdev.egg-info/requires.txt +0 -0
- {sdev-1.0.0 → sdev-1.0.1}/sdev.egg-info/top_level.txt +0 -0
- {sdev-1.0.0 → sdev-1.0.1}/setup.cfg +0 -0
- {sdev-1.0.0 → sdev-1.0.1}/tests/test_adversarial_ansi.py +0 -0
- {sdev-1.0.0 → sdev-1.0.1}/tests/test_adversarial_api_edge.py +0 -0
- {sdev-1.0.0 → sdev-1.0.1}/tests/test_adversarial_grep.py +0 -0
- {sdev-1.0.0 → sdev-1.0.1}/tests/test_adversarial_grep_linebyline.py +0 -0
- {sdev-1.0.0 → sdev-1.0.1}/tests/test_adversarial_interrupt.py +0 -0
- {sdev-1.0.0 → sdev-1.0.1}/tests/test_adversarial_module_api.py +0 -0
- {sdev-1.0.0 → sdev-1.0.1}/tests/test_adversarial_new_features.py +0 -0
- {sdev-1.0.0 → sdev-1.0.1}/tests/test_adversarial_run_connect.py +0 -0
- {sdev-1.0.0 → sdev-1.0.1}/tests/test_adversarial_serial_error.py +0 -0
- {sdev-1.0.0 → sdev-1.0.1}/tests/test_adversarial_strip.py +0 -0
- {sdev-1.0.0 → sdev-1.0.1}/tests/test_adversarial_timeout.py +0 -0
- {sdev-1.0.0 → sdev-1.0.1}/tests/test_cli_integration.py +0 -0
- {sdev-1.0.0 → sdev-1.0.1}/tests/test_doctor_silence.py +0 -0
- {sdev-1.0.0 → sdev-1.0.1}/tests/test_endflag_linemode.py +0 -0
- {sdev-1.0.0 → sdev-1.0.1}/tests/test_error_handling.py +0 -0
- {sdev-1.0.0 → sdev-1.0.1}/tests/test_probe.py +0 -0
- {sdev-1.0.0 → sdev-1.0.1}/tests/test_resource_usage.py +0 -0
- {sdev-1.0.0 → sdev-1.0.1}/tests/test_sdev.py +0 -0
- {sdev-1.0.0 → sdev-1.0.1}/tests/test_serial_lock.py +0 -0
- {sdev-1.0.0 → sdev-1.0.1}/tests/test_write.py +0 -0
- {sdev-1.0.0 → sdev-1.0.1}/tests/test_xc01_real.py +0 -0
|
@@ -21,7 +21,7 @@ CLI::
|
|
|
21
21
|
|
|
22
22
|
from __future__ import annotations
|
|
23
23
|
|
|
24
|
-
__version__ = "1.0.
|
|
24
|
+
__version__ = "1.0.1"
|
|
25
25
|
|
|
26
26
|
import time
|
|
27
27
|
import re
|
|
@@ -65,6 +65,7 @@ __all__ = [
|
|
|
65
65
|
"write",
|
|
66
66
|
"doctor",
|
|
67
67
|
"wait_for_silence",
|
|
68
|
+
"interpret_sysrq_blocked",
|
|
68
69
|
]
|
|
69
70
|
|
|
70
71
|
|
|
@@ -171,6 +172,7 @@ class SerialSession:
|
|
|
171
172
|
self.baud = baud
|
|
172
173
|
self._prompts = prompts if prompts is not None else list(PROMPTS)
|
|
173
174
|
self._lock = threading.Lock()
|
|
175
|
+
self.last_doctor_report: Optional[str] = None
|
|
174
176
|
|
|
175
177
|
@property
|
|
176
178
|
def prompts(self) -> list[bytes]:
|
|
@@ -247,13 +249,78 @@ class SerialSession:
|
|
|
247
249
|
pass
|
|
248
250
|
self._connection = None
|
|
249
251
|
|
|
250
|
-
def
|
|
252
|
+
def _capture_serial_idle(self, ser: serial.Serial, seconds: float) -> bytes:
|
|
253
|
+
"""Accumulate inbound bytes for roughly *seconds* (polling)."""
|
|
254
|
+
out = bytearray()
|
|
255
|
+
end = time.monotonic() + max(0.05, seconds)
|
|
256
|
+
while time.monotonic() < end:
|
|
257
|
+
try:
|
|
258
|
+
chunk = ser.read(4096)
|
|
259
|
+
except serial.SerialException:
|
|
260
|
+
break
|
|
261
|
+
if chunk:
|
|
262
|
+
out.extend(chunk)
|
|
263
|
+
if len(out) > MAX_BUFFER_SIZE:
|
|
264
|
+
del out[: len(out) - TRIM_BUFFER_SIZE]
|
|
265
|
+
else:
|
|
266
|
+
time.sleep(0.05)
|
|
267
|
+
return bytes(out)
|
|
268
|
+
|
|
269
|
+
def sysrq(self, key: str, *, capture_secs: float = 2.0) -> str:
|
|
270
|
+
"""Send Linux Magic SysRq over serial (UART BREAK + command letter).
|
|
271
|
+
|
|
272
|
+
*key*: one ascii letter (e.g. ``"h"`` help, ``"w"`` blocked tasks,
|
|
273
|
+
``"s"`` sync, ``"b"`` immediate reboot).
|
|
274
|
+
|
|
275
|
+
Reads from the serial line for up to *capture_secs* and returns
|
|
276
|
+
decoded output (kernel messages as seen on the serial console).
|
|
277
|
+
|
|
278
|
+
Raises:
|
|
279
|
+
RuntimeError: if the connection is unavailable or BREAK is not supported.
|
|
280
|
+
ValueError: if *key* is not a single alphabetic character.
|
|
281
|
+
"""
|
|
282
|
+
if not isinstance(key, str) or len(key) != 1 or not key.isalpha():
|
|
283
|
+
raise ValueError("sysrq key must be a single alphabetic character")
|
|
284
|
+
lk = key.lower().encode()
|
|
285
|
+
|
|
286
|
+
ser = self._ensure_open()
|
|
287
|
+
send_break = getattr(ser, "send_break", None)
|
|
288
|
+
if not callable(send_break):
|
|
289
|
+
raise RuntimeError(
|
|
290
|
+
"Serial connection does not support send_break(); "
|
|
291
|
+
"Magic SysRq over UART is unavailable."
|
|
292
|
+
)
|
|
293
|
+
send_break(0.25)
|
|
294
|
+
time.sleep(0.1)
|
|
295
|
+
ser.write(lk)
|
|
296
|
+
ser.flush()
|
|
297
|
+
captured = self._capture_serial_idle(ser, capture_secs)
|
|
298
|
+
self._capture_serial_idle(ser, min(0.3, capture_secs))
|
|
299
|
+
text = captured.decode(errors="replace")
|
|
300
|
+
return text
|
|
301
|
+
|
|
302
|
+
def doctor(
|
|
303
|
+
self,
|
|
304
|
+
timeout: float = 10,
|
|
305
|
+
*,
|
|
306
|
+
sysrq_diagnose: bool = False,
|
|
307
|
+
sysrq_blocked: bool = False,
|
|
308
|
+
sysrq_sync: bool = False,
|
|
309
|
+
sysrq_reboot: bool = False,
|
|
310
|
+
sysrq_capture_secs: float = 2.5,
|
|
311
|
+
) -> None:
|
|
251
312
|
"""Clear stray foreground processes and drain garbage from serial buffer.
|
|
252
313
|
|
|
253
314
|
Sends multiple Ctrl+C sequences, then waits for a clean prompt.
|
|
254
315
|
Useful after a device reboot or when previous commands left
|
|
255
316
|
interactive programs (top, vi, etc.) running.
|
|
317
|
+
|
|
318
|
+
When Ctrl+C fails to restore a shell prompt — for example because the shell
|
|
319
|
+
is blocked in kernel I/O — optional Magic SysRq steps can clarify the situation
|
|
320
|
+
(see issue #64). Dangerous SysRq operations are never performed unless explicitly
|
|
321
|
+
requested via *sysrq_sync* / *sysrq_reboot*.
|
|
256
322
|
"""
|
|
323
|
+
self.last_doctor_report = None
|
|
257
324
|
if not self.is_open:
|
|
258
325
|
self.connect()
|
|
259
326
|
ser = self._ensure_open()
|
|
@@ -272,7 +339,7 @@ class SerialSession:
|
|
|
272
339
|
while True:
|
|
273
340
|
remaining = deadline - (time.monotonic() - start)
|
|
274
341
|
if remaining <= 0:
|
|
275
|
-
|
|
342
|
+
break
|
|
276
343
|
try:
|
|
277
344
|
chunk = ser.read(4096)
|
|
278
345
|
except serial.SerialException:
|
|
@@ -285,6 +352,48 @@ class SerialSession:
|
|
|
285
352
|
return
|
|
286
353
|
time.sleep(min(0.1, remaining))
|
|
287
354
|
|
|
355
|
+
want_sysrq = (
|
|
356
|
+
sysrq_diagnose or sysrq_blocked or sysrq_sync or sysrq_reboot
|
|
357
|
+
)
|
|
358
|
+
if not want_sysrq:
|
|
359
|
+
return
|
|
360
|
+
|
|
361
|
+
parts: list[str] = []
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
if sysrq_diagnose:
|
|
365
|
+
txt = self.sysrq("h", capture_secs=sysrq_capture_secs)
|
|
366
|
+
parts.append("[sdev SysRq] help (echo after 'h')\n" + txt.strip())
|
|
367
|
+
|
|
368
|
+
if sysrq_blocked:
|
|
369
|
+
txt = self.sysrq("w", capture_secs=sysrq_capture_secs)
|
|
370
|
+
parts.append("[sdev SysRq] blocked/WCHAN (echo after 'w')\n" + txt.strip())
|
|
371
|
+
hint = interpret_sysrq_blocked(txt)
|
|
372
|
+
if hint:
|
|
373
|
+
parts.append("[sdev] " + hint)
|
|
374
|
+
|
|
375
|
+
if sysrq_sync:
|
|
376
|
+
self.sysrq("s", capture_secs=min(1.0, sysrq_capture_secs))
|
|
377
|
+
parts.append(
|
|
378
|
+
"[sdev SysRq] sent 's' (sync). Check remote console "
|
|
379
|
+
"for kernel confirmation."
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
if sysrq_reboot:
|
|
383
|
+
self.sysrq("s", capture_secs=min(1.5, sysrq_capture_secs))
|
|
384
|
+
time.sleep(0.35)
|
|
385
|
+
self.sysrq(
|
|
386
|
+
"b",
|
|
387
|
+
capture_secs=min(2.0, sysrq_capture_secs),
|
|
388
|
+
)
|
|
389
|
+
parts.append("[sdev SysRq] sent 's' then 'b' (sync + reboot).")
|
|
390
|
+
except (RuntimeError, ValueError, serial.SerialException) as exc:
|
|
391
|
+
parts.append(f"[sdev SysRq] error: {exc}")
|
|
392
|
+
|
|
393
|
+
report = "\n\n".join(p for p in parts if p)
|
|
394
|
+
if report:
|
|
395
|
+
self.last_doctor_report = report
|
|
396
|
+
|
|
288
397
|
def wait_for_silence(self, timeout: float = 1.5) -> None:
|
|
289
398
|
"""Block until no data arrives on the serial line for *timeout* seconds.
|
|
290
399
|
|
|
@@ -594,6 +703,30 @@ class SerialSession:
|
|
|
594
703
|
self.close()
|
|
595
704
|
|
|
596
705
|
|
|
706
|
+
def interpret_sysrq_blocked(text: str) -> Optional[str]:
|
|
707
|
+
"""Infer whether SysRq *w* output suggests an unrecoverable blocked shell."""
|
|
708
|
+
|
|
709
|
+
lowered = text.lower()
|
|
710
|
+
clues: list[str] = []
|
|
711
|
+
|
|
712
|
+
if re.search(r"(?m)^\s*\S[^\n]{0,80}?\sD\s+[0-9]", text):
|
|
713
|
+
clues.append(
|
|
714
|
+
"uninterruptible (D-state) tasks appear in SysRq output; "
|
|
715
|
+
"the shell cannot be recovered with Ctrl+C."
|
|
716
|
+
)
|
|
717
|
+
for needle in ("nfs_", "nfs3_", "nfs4_", "rpc_", "rpc_wait"):
|
|
718
|
+
if needle in lowered:
|
|
719
|
+
clues.append(
|
|
720
|
+
"NFS/RPC wait symbols detected — the shell may be stuck on "
|
|
721
|
+
"network filesystem operations while the NFS path is unavailable."
|
|
722
|
+
)
|
|
723
|
+
break
|
|
724
|
+
|
|
725
|
+
if not clues:
|
|
726
|
+
return None
|
|
727
|
+
return " ".join(clues)
|
|
728
|
+
|
|
729
|
+
|
|
597
730
|
# ---------------------------------------------------------------------------
|
|
598
731
|
# Module-level convenience APIs (backward compatibility)
|
|
599
732
|
# ---------------------------------------------------------------------------
|
|
@@ -685,9 +818,12 @@ def write(data: bytes) -> int:
|
|
|
685
818
|
return _default_session.write(data)
|
|
686
819
|
|
|
687
820
|
|
|
688
|
-
def doctor(timeout: float = 10) -> None:
|
|
689
|
-
"""Clear stray foreground processes on the default connection.
|
|
690
|
-
|
|
821
|
+
def doctor(timeout: float = 10, **kwargs) -> None:
|
|
822
|
+
"""Clear stray foreground processes on the default connection.
|
|
823
|
+
|
|
824
|
+
Optional keyword arguments match :meth:`SerialSession.doctor` (``sysrq_*``).
|
|
825
|
+
"""
|
|
826
|
+
_default_session.doctor(timeout, **kwargs)
|
|
691
827
|
|
|
692
828
|
|
|
693
829
|
def wait_for_silence(timeout: float = 1.5) -> None:
|
|
@@ -108,6 +108,37 @@ def main() -> None:
|
|
|
108
108
|
action="store_true",
|
|
109
109
|
help="Clear stray foreground processes and exit without running a command.",
|
|
110
110
|
)
|
|
111
|
+
parser.add_argument(
|
|
112
|
+
"--sysrq-diagnose",
|
|
113
|
+
action="store_true",
|
|
114
|
+
help="With --doctor/--doctor-only: if no shell prompt returns, send Magic "
|
|
115
|
+
"SysRq 'h' (help) over UART and capture output.",
|
|
116
|
+
)
|
|
117
|
+
parser.add_argument(
|
|
118
|
+
"--sysrq-blocked",
|
|
119
|
+
action="store_true",
|
|
120
|
+
help="With --doctor/--doctor-only: if no shell prompt returns, send SysRq "
|
|
121
|
+
"'w' (blocked tasks) and capture output.",
|
|
122
|
+
)
|
|
123
|
+
parser.add_argument(
|
|
124
|
+
"--sysrq-sync",
|
|
125
|
+
action="store_true",
|
|
126
|
+
help="With --doctor/--doctor-only: after failed prompt recovery, send SysRq "
|
|
127
|
+
"'s' (sync disks).",
|
|
128
|
+
)
|
|
129
|
+
parser.add_argument(
|
|
130
|
+
"--sysrq-reboot",
|
|
131
|
+
action="store_true",
|
|
132
|
+
help="With --doctor/--doctor-only: after failed prompt recovery, send SysRq "
|
|
133
|
+
"'s' then 'b' (sync + immediate reboot). Dangerous; opt-in only.",
|
|
134
|
+
)
|
|
135
|
+
parser.add_argument(
|
|
136
|
+
"--sysrq-capture",
|
|
137
|
+
type=float,
|
|
138
|
+
metavar="SECS",
|
|
139
|
+
default=2.5,
|
|
140
|
+
help="Seconds to read serial after each SysRq (default: 2.5).",
|
|
141
|
+
)
|
|
111
142
|
|
|
112
143
|
sub = parser.add_subparsers(dest="subcommand")
|
|
113
144
|
set_parser = sub.add_parser(
|
|
@@ -119,6 +150,17 @@ def main() -> None:
|
|
|
119
150
|
|
|
120
151
|
args = parser.parse_args()
|
|
121
152
|
|
|
153
|
+
sysrq_any = (
|
|
154
|
+
args.sysrq_diagnose
|
|
155
|
+
or args.sysrq_blocked
|
|
156
|
+
or args.sysrq_sync
|
|
157
|
+
or args.sysrq_reboot
|
|
158
|
+
)
|
|
159
|
+
if sysrq_any and not (args.doctor or args.doctor_only):
|
|
160
|
+
parser.error(
|
|
161
|
+
"--sysrq-* options require --doctor or --doctor-only",
|
|
162
|
+
)
|
|
163
|
+
|
|
122
164
|
# Load saved defaults, then override with CLI flags
|
|
123
165
|
defaults = sdev.load_defaults()
|
|
124
166
|
|
|
@@ -139,12 +181,23 @@ def main() -> None:
|
|
|
139
181
|
sys.exit(1)
|
|
140
182
|
return
|
|
141
183
|
|
|
184
|
+
def _doctor_kw() -> dict:
|
|
185
|
+
return {
|
|
186
|
+
"sysrq_diagnose": args.sysrq_diagnose,
|
|
187
|
+
"sysrq_blocked": args.sysrq_blocked,
|
|
188
|
+
"sysrq_sync": args.sysrq_sync,
|
|
189
|
+
"sysrq_reboot": args.sysrq_reboot,
|
|
190
|
+
"sysrq_capture_secs": args.sysrq_capture,
|
|
191
|
+
}
|
|
192
|
+
|
|
142
193
|
# --- --doctor-only: clear foreground processes without running a command ---
|
|
143
194
|
if args.doctor_only:
|
|
144
195
|
device = args.device or defaults.get("device", sdev.DEFAULT_DEVICE)
|
|
145
196
|
baud = args.baud or defaults.get("baud", sdev.DEFAULT_BAUD)
|
|
146
197
|
with sdev.SerialSession(device, baud) as sess:
|
|
147
|
-
sess.doctor()
|
|
198
|
+
sess.doctor(**_doctor_kw())
|
|
199
|
+
if sess.last_doctor_report:
|
|
200
|
+
print(sess.last_doctor_report, file=sys.stderr)
|
|
148
201
|
print("[sdev] doctor: done")
|
|
149
202
|
return
|
|
150
203
|
|
|
@@ -175,7 +228,9 @@ def main() -> None:
|
|
|
175
228
|
|
|
176
229
|
with sdev.SerialSession(device, baud, prompts=prompt_bytes) as sess:
|
|
177
230
|
if args.doctor:
|
|
178
|
-
sess.doctor()
|
|
231
|
+
sess.doctor(**_doctor_kw())
|
|
232
|
+
if sess.last_doctor_report:
|
|
233
|
+
print(sess.last_doctor_report, file=sys.stderr)
|
|
179
234
|
|
|
180
235
|
if args.stream:
|
|
181
236
|
if args.grep:
|
|
@@ -31,6 +31,12 @@ class TestModuleLevelWaitForSilence(unittest.TestCase):
|
|
|
31
31
|
sdev.wait_for_silence(timeout=3.0)
|
|
32
32
|
mock_sess.wait_for_silence.assert_called_once_with(3.0)
|
|
33
33
|
|
|
34
|
+
def test_wait_for_silence_default_timeout(self):
|
|
35
|
+
"""sdev.wait_for_silence() should use default timeout of 1.5 if not specified."""
|
|
36
|
+
with patch.object(sdev, "_default_session") as mock_sess:
|
|
37
|
+
sdev.wait_for_silence()
|
|
38
|
+
mock_sess.wait_for_silence.assert_called_once_with(1.5)
|
|
39
|
+
|
|
34
40
|
|
|
35
41
|
class TestDoctorOnlyCLI(unittest.TestCase):
|
|
36
42
|
"""CLI --doctor-only flag should run doctor and exit."""
|
|
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
|
|
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
|