sdev 1.0.0__tar.gz → 1.0.2__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 (34) hide show
  1. {sdev-1.0.0 → sdev-1.0.2}/PKG-INFO +1 -1
  2. {sdev-1.0.0 → sdev-1.0.2}/pyproject.toml +1 -1
  3. {sdev-1.0.0 → sdev-1.0.2}/sdev/__init__.py +142 -6
  4. {sdev-1.0.0 → sdev-1.0.2}/sdev/__main__.py +57 -2
  5. {sdev-1.0.0 → sdev-1.0.2}/sdev.egg-info/PKG-INFO +1 -1
  6. {sdev-1.0.0 → sdev-1.0.2}/tests/test_doctor_wait_silence.py +6 -0
  7. {sdev-1.0.0 → sdev-1.0.2}/README.md +0 -0
  8. {sdev-1.0.0 → sdev-1.0.2}/sdev.egg-info/SOURCES.txt +0 -0
  9. {sdev-1.0.0 → sdev-1.0.2}/sdev.egg-info/dependency_links.txt +0 -0
  10. {sdev-1.0.0 → sdev-1.0.2}/sdev.egg-info/entry_points.txt +0 -0
  11. {sdev-1.0.0 → sdev-1.0.2}/sdev.egg-info/requires.txt +0 -0
  12. {sdev-1.0.0 → sdev-1.0.2}/sdev.egg-info/top_level.txt +0 -0
  13. {sdev-1.0.0 → sdev-1.0.2}/setup.cfg +0 -0
  14. {sdev-1.0.0 → sdev-1.0.2}/tests/test_adversarial_ansi.py +0 -0
  15. {sdev-1.0.0 → sdev-1.0.2}/tests/test_adversarial_api_edge.py +0 -0
  16. {sdev-1.0.0 → sdev-1.0.2}/tests/test_adversarial_grep.py +0 -0
  17. {sdev-1.0.0 → sdev-1.0.2}/tests/test_adversarial_grep_linebyline.py +0 -0
  18. {sdev-1.0.0 → sdev-1.0.2}/tests/test_adversarial_interrupt.py +0 -0
  19. {sdev-1.0.0 → sdev-1.0.2}/tests/test_adversarial_module_api.py +0 -0
  20. {sdev-1.0.0 → sdev-1.0.2}/tests/test_adversarial_new_features.py +0 -0
  21. {sdev-1.0.0 → sdev-1.0.2}/tests/test_adversarial_run_connect.py +0 -0
  22. {sdev-1.0.0 → sdev-1.0.2}/tests/test_adversarial_serial_error.py +0 -0
  23. {sdev-1.0.0 → sdev-1.0.2}/tests/test_adversarial_strip.py +0 -0
  24. {sdev-1.0.0 → sdev-1.0.2}/tests/test_adversarial_timeout.py +0 -0
  25. {sdev-1.0.0 → sdev-1.0.2}/tests/test_cli_integration.py +0 -0
  26. {sdev-1.0.0 → sdev-1.0.2}/tests/test_doctor_silence.py +0 -0
  27. {sdev-1.0.0 → sdev-1.0.2}/tests/test_endflag_linemode.py +0 -0
  28. {sdev-1.0.0 → sdev-1.0.2}/tests/test_error_handling.py +0 -0
  29. {sdev-1.0.0 → sdev-1.0.2}/tests/test_probe.py +0 -0
  30. {sdev-1.0.0 → sdev-1.0.2}/tests/test_resource_usage.py +0 -0
  31. {sdev-1.0.0 → sdev-1.0.2}/tests/test_sdev.py +0 -0
  32. {sdev-1.0.0 → sdev-1.0.2}/tests/test_serial_lock.py +0 -0
  33. {sdev-1.0.0 → sdev-1.0.2}/tests/test_write.py +0 -0
  34. {sdev-1.0.0 → sdev-1.0.2}/tests/test_xc01_real.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sdev
3
- Version: 1.0.0
3
+ Version: 1.0.2
4
4
  Summary: Small toolkit for automating a serial-attached Linux shell
5
5
  Author-email: klrc <1440698245@qq.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sdev"
7
- version = "1.0.0"
7
+ version = "1.0.2"
8
8
  description = "Small toolkit for automating a serial-attached Linux shell"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -21,7 +21,7 @@ CLI::
21
21
 
22
22
  from __future__ import annotations
23
23
 
24
- __version__ = "1.0.0"
24
+ __version__ = "1.0.2"
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 doctor(self, timeout: float = 10) -> None:
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
- return
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
- _default_session.doctor(timeout)
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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sdev
3
- Version: 1.0.0
3
+ Version: 1.0.2
4
4
  Summary: Small toolkit for automating a serial-attached Linux shell
5
5
  Author-email: klrc <1440698245@qq.com>
6
6
  License-Expression: MIT
@@ -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