PyroLab 0.5.0rc2__tar.gz → 0.5.0rc3__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 (71) hide show
  1. {pyrolab-0.5.0rc2/pyrolab.egg-info → pyrolab-0.5.0rc3}/PKG-INFO +1 -1
  2. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyproject.toml +1 -1
  3. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/cli.py +2 -4
  4. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/logs.py +34 -2
  5. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/manager.py +18 -0
  6. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/pyrolabd.py +22 -2
  7. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/utils.py +35 -0
  8. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3/pyrolab.egg-info}/PKG-INFO +1 -1
  9. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/tests/test_cli.py +21 -0
  10. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/tests/test_logs.py +55 -0
  11. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/tests/test_pyrolabd.py +35 -0
  12. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/tests/test_registration.py +37 -0
  13. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/tests/test_utils.py +24 -0
  14. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/LICENSE +0 -0
  15. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/MANIFEST.in +0 -0
  16. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/NOTICE.txt +0 -0
  17. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/README.md +0 -0
  18. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/__init__.py +0 -0
  19. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/api.py +0 -0
  20. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/configure.py +0 -0
  21. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/data/wordlist.txt +0 -0
  22. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/drivers/VISAResourceExtensions.py +0 -0
  23. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/drivers/__init__.py +0 -0
  24. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/drivers/arduino/__init__.py +0 -0
  25. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/drivers/arduino/arduino.py +0 -0
  26. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/drivers/cameras/__init__.py +0 -0
  27. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/drivers/cameras/amcam.py +0 -0
  28. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/drivers/cameras/sciTSI.py +0 -0
  29. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/drivers/cameras/thorcam.py +0 -0
  30. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/drivers/cameras/uc480.py +0 -0
  31. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/drivers/fpgas/__init__.py +0 -0
  32. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/drivers/fpgas/analogdiscovery.py +0 -0
  33. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/drivers/function_generators/__init__.py +0 -0
  34. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/drivers/function_generators/smcv100b.py +0 -0
  35. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/drivers/lasers/__init__.py +0 -0
  36. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/drivers/lasers/ppcl55x.py +0 -0
  37. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/drivers/lasers/sachertec.py +0 -0
  38. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/drivers/lasers/tsl550.py +0 -0
  39. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/drivers/motion/KMTS25E.py +0 -0
  40. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/drivers/motion/__init__.py +0 -0
  41. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/drivers/motion/kinesis/__init__.py +0 -0
  42. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/drivers/motion/kinesis/bpc303.py +0 -0
  43. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/drivers/motion/kinesis/kdc101.py +0 -0
  44. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/drivers/motion/max31x.py +0 -0
  45. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/drivers/motion/prm1z8.py +0 -0
  46. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/drivers/motion/z8xx.py +0 -0
  47. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/drivers/sample.py +0 -0
  48. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/drivers/scopes/__init__.py +0 -0
  49. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/drivers/scopes/rohdeschwarz.py +0 -0
  50. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/drivers/smu/__init__.py +0 -0
  51. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/drivers/smu/keysight.py +0 -0
  52. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/locations.py +0 -0
  53. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/nameserver.py +0 -0
  54. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/server.py +0 -0
  55. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/service.py +0 -0
  56. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab/updates.py +0 -0
  57. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab.egg-info/SOURCES.txt +0 -0
  58. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab.egg-info/dependency_links.txt +0 -0
  59. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab.egg-info/entry_points.txt +0 -0
  60. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab.egg-info/requires.txt +0 -0
  61. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/pyrolab.egg-info/top_level.txt +0 -0
  62. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/setup.cfg +0 -0
  63. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/tests/test_configure.py +0 -0
  64. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/tests/test_imports.py +0 -0
  65. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/tests/test_lifecycle.py +0 -0
  66. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/tests/test_locations.py +0 -0
  67. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/tests/test_manager.py +0 -0
  68. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/tests/test_nameserver.py +0 -0
  69. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/tests/test_server.py +0 -0
  70. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/tests/test_service.py +0 -0
  71. {pyrolab-0.5.0rc2 → pyrolab-0.5.0rc3}/tests/test_updates.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyrolab
3
- Version: 0.5.0rc2
3
+ Version: 0.5.0rc3
4
4
  Summary: A framework for using remote lab instruments as local resources built on Pyro5.
5
5
  Author-email: Sequoia Ploeg <sequoiap4@gmail.com>, BYU CamachoLab <camacho@byu.edu>
6
6
  Maintainer-email: Sequoia Ploeg <sequoiap4@gmail.com>, BYU CamachoLab <camacho@byu.edu>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pyrolab"
3
- version = "0.5.0rc2"
3
+ version = "0.5.0rc3"
4
4
  description = "A framework for using remote lab instruments as local resources built on Pyro5."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -796,10 +796,8 @@ def ns_list(
796
796
  services = ns.list(return_metadata=True)
797
797
 
798
798
  listing = []
799
- for k, v in services.items():
800
- name = k
801
- uri, description = v
802
- listing.append([name, uri, ": ".join(description)])
799
+ for name, (uri, description) in sorted(services.items()):
800
+ listing.append([name, uri, ": ".join(sorted(description))])
803
801
 
804
802
  typer.echo(tabulate(listing, headers=["NAME", "URI", "DESCRIPTION"]))
805
803
 
@@ -29,6 +29,8 @@ import json
29
29
  import logging
30
30
  import logging.handlers
31
31
  import os
32
+ import signal
33
+ import sys
32
34
  import threading
33
35
  import warnings
34
36
  from datetime import datetime
@@ -117,8 +119,6 @@ def configure_process() -> None:
117
119
  (unless ``PYROLAB_HUSH_DEPRECATION`` is set). Never called on import: a
118
120
  library must not change these for the application that imports it.
119
121
  """
120
- import sys
121
-
122
122
  import Pyro5.errors
123
123
 
124
124
  sys.excepthook = Pyro5.errors.excepthook
@@ -219,6 +219,38 @@ class PipeHandler(logging.Handler):
219
219
  return data
220
220
 
221
221
 
222
+ def line_buffer_output() -> None:
223
+ """
224
+ Write this process's stdout and stderr out line by line.
225
+
226
+ The daemon and its child processes have their output redirected to
227
+ ``pyrolabd_output.log``, and Python block-buffers output to a file: a
228
+ driver's ``print()`` would only appear once 8 KB had built up, and would be
229
+ lost if the process were killed. Drivers often report problems that way.
230
+ """
231
+ for stream in (sys.stdout, sys.stderr):
232
+ reconfigure = getattr(stream, "reconfigure", None) # None under pythonw
233
+ if reconfigure is not None:
234
+ try:
235
+ reconfigure(line_buffering=True)
236
+ except (ValueError, OSError): # e.g. a stream that's already closed
237
+ pass
238
+
239
+
240
+ def on_sigterm(handler: Callable[[int, object], None]) -> None:
241
+ """
242
+ Call ``handler`` when this process is asked to stop with SIGTERM.
243
+
244
+ Without a handler, Python exits on SIGTERM at once, skipping all cleanup.
245
+ That's what ``systemctl stop`` sends (to every process of the service), and
246
+ what ``multiprocessing.Process.terminate()`` sends on POSIX. Signal
247
+ handlers can only be installed from the main thread; elsewhere (as when a
248
+ runner's ``run()`` is called in a thread by the tests) this does nothing.
249
+ """
250
+ if threading.current_thread() is threading.main_thread():
251
+ signal.signal(signal.SIGTERM, handler)
252
+
253
+
222
254
  def log_to_pipe(conn) -> None:
223
255
  """
224
256
  In a child process: send all logging to the parent over ``conn``.
@@ -276,6 +276,8 @@ class NameServerRunner(multiprocessing.Process):
276
276
  goes there (see :py:mod:`pyrolab.logs`).
277
277
  """
278
278
 
279
+ KIND = "nameserver"
280
+
279
281
  def __init__(
280
282
  self,
281
283
  *args,
@@ -336,6 +338,11 @@ class NameServerRunner(multiprocessing.Process):
336
338
  """
337
339
  return not self.KILL_SIGNAL
338
340
 
341
+ def _stop_on_sigterm(self, signum, frame) -> None:
342
+ """Treat SIGTERM like the stop message: shut down cleanly."""
343
+ log.info("SIGTERM received; stopping %s '%s'", self.KIND, self.name)
344
+ self.KILL_SIGNAL = True
345
+
339
346
  def run(self) -> None:
340
347
  """
341
348
  Creates and runs the child process.
@@ -345,6 +352,8 @@ class NameServerRunner(multiprocessing.Process):
345
352
  """
346
353
  if self.log_conn is not None:
347
354
  logs.log_to_pipe(self.log_conn)
355
+ logs.line_buffer_output()
356
+ logs.on_sigterm(self._stop_on_sigterm)
348
357
  log.info("Starting nameserver '%s'", self.name)
349
358
  try:
350
359
  self.nsconfig.update_pyro_config()
@@ -398,6 +407,8 @@ class DaemonRunner(multiprocessing.Process):
398
407
  goes there (see :py:mod:`pyrolab.logs`).
399
408
  """
400
409
 
410
+ KIND = "daemon"
411
+
401
412
  def __init__(
402
413
  self,
403
414
  *args,
@@ -497,6 +508,11 @@ class DaemonRunner(multiprocessing.Process):
497
508
  """
498
509
  return not self.KILL_SIGNAL
499
510
 
511
+ def _stop_on_sigterm(self, signum, frame) -> None:
512
+ """Treat SIGTERM like the stop message: shut down cleanly."""
513
+ log.info("SIGTERM received; stopping %s '%s'", self.KIND, self.name)
514
+ self.KILL_SIGNAL = True
515
+
500
516
  def plan_registrations(self, uris: Dict[str, URI]) -> Registrations:
501
517
  """Queue every nameserver registration this daemon should make."""
502
518
  config = PyroLabConfiguration.from_file(RUNTIME_CONFIG)
@@ -526,6 +542,8 @@ class DaemonRunner(multiprocessing.Process):
526
542
  """
527
543
  if self.log_conn is not None:
528
544
  logs.log_to_pipe(self.log_conn)
545
+ logs.line_buffer_output()
546
+ logs.on_sigterm(self._stop_on_sigterm)
529
547
  log.info("Starting daemon '%s'", self.name)
530
548
  stop_registering = threading.Event()
531
549
  registrar = None
@@ -12,6 +12,7 @@ Submodule defining the background PyroLab daemon.
12
12
  import logging
13
13
  import os
14
14
  import shutil
15
+ import signal
15
16
  import sys
16
17
  import threading
17
18
  from typing import NamedTuple, Optional
@@ -34,7 +35,7 @@ from pyrolab import (
34
35
  from pyrolab.configure import GlobalConfiguration, describe_config_error
35
36
  from pyrolab.locations import migrate_legacy_files
36
37
  from pyrolab.manager import ProcessManager
37
- from pyrolab.utils import atomic_write_text, pid_is_running
38
+ from pyrolab.utils import atomic_write_text, names_this_machine, pid_is_running
38
39
 
39
40
  log = logging.getLogger("pyrolab.pyrolabd")
40
41
 
@@ -220,8 +221,12 @@ class PyroLabDaemon:
220
221
  log.debug("Daemon process listing requested.")
221
222
 
222
223
  listing = []
223
- for ns in self.gconfig.get_config().nameservers.keys():
224
+ for ns, nsconfig in self.gconfig.get_config().nameservers.items():
224
225
  info = self.manager.get_nameserver_process_info(ns)
226
+ if info["status"] == "Stopped" and not names_this_machine(nsconfig.host):
227
+ # Configured so services can register with it, but it runs on
228
+ # another machine; "Stopped" would suggest something's wrong.
229
+ info["status"] = "Remote"
225
230
  listing.append(NameServerInfo(name=ns, **_short_error(info)))
226
231
  nsstring = tabulate(
227
232
  listing, headers=["NAMESERVER", "CREATED", "STATUS", "URI", "LAST ERROR"]
@@ -401,6 +406,8 @@ def main(port: int = 0) -> int:
401
406
  return 1
402
407
 
403
408
  handler = logs.start_file_logging(PYROLAB_LOGFILE)
409
+ logs.line_buffer_output()
410
+ logs.on_sigterm(_exit_on_sigterm)
404
411
  try:
405
412
  for copied in migrate_legacy_files(
406
413
  LEGACY_DATA_DIR,
@@ -414,6 +421,19 @@ def main(port: int = 0) -> int:
414
421
  logs.stop_file_logging(handler)
415
422
 
416
423
 
424
+ def _exit_on_sigterm(signum, frame) -> None:
425
+ """
426
+ Shut down cleanly on SIGTERM (``systemctl stop``) instead of dying at once.
427
+
428
+ Raising SystemExit unwinds out of the request loop, so ``_serve``'s
429
+ ``finally`` stops every nameserver and daemon, removes the lockfile and
430
+ the runtime config, and the process exits with status 0.
431
+ """
432
+ log.info("SIGTERM received; shutting down")
433
+ signal.signal(signal.SIGTERM, signal.SIG_IGN) # don't interrupt the cleanup
434
+ raise SystemExit(0)
435
+
436
+
417
437
  def _serve(port: int) -> int:
418
438
  daemon = None
419
439
  published = False
@@ -9,11 +9,13 @@ Utils
9
9
  Convenience functions for working with the pyrolab package.
10
10
  """
11
11
 
12
+ import ipaddress
12
13
  import logging
13
14
  import os
14
15
  import secrets
15
16
  import socket
16
17
  import sys
18
+ from typing import Optional
17
19
 
18
20
  try:
19
21
  import importlib.resources as pkg_resources
@@ -74,6 +76,39 @@ def get_ip() -> str:
74
76
  return "127.0.0.1"
75
77
 
76
78
 
79
+ def names_this_machine(host: Optional[str]) -> bool:
80
+ """
81
+ Whether a configured ``host`` refers to this machine.
82
+
83
+ Decided without DNS, so it never waits on the network: wildcard and
84
+ loopback addresses, ``public``, this machine's hostname (or its first
85
+ label, so ``lab.example.edu`` matches a machine called ``lab``), and, for a
86
+ literal IP address, this machine's address. Anything else is taken to be
87
+ another machine.
88
+
89
+ Parameters
90
+ ----------
91
+ host : str, optional
92
+ A host as written in the configuration.
93
+
94
+ Returns
95
+ -------
96
+ bool
97
+ False if ``host`` names another machine.
98
+ """
99
+ if not host:
100
+ return True
101
+ host = host.strip().lower().strip("[]")
102
+ if host in ("localhost", "public", "0.0.0.0", "::", "::1"):
103
+ return True
104
+ try:
105
+ address = ipaddress.ip_address(host)
106
+ except ValueError:
107
+ hostname = socket.gethostname().lower()
108
+ return host == hostname or host.split(".")[0] == hostname.split(".")[0]
109
+ return address.is_loopback or address.is_unspecified or host == get_ip()
110
+
111
+
77
112
  def generate_random_name(count: int = 3) -> str:
78
113
  """
79
114
  Concatenates ``count`` random words as a hyphenated string.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyrolab
3
- Version: 0.5.0rc2
3
+ Version: 0.5.0rc3
4
4
  Summary: A framework for using remote lab instruments as local resources built on Pyro5.
5
5
  Author-email: Sequoia Ploeg <sequoiap4@gmail.com>, BYU CamachoLab <camacho@byu.edu>
6
6
  Maintainer-email: Sequoia Ploeg <sequoiap4@gmail.com>, BYU CamachoLab <camacho@byu.edu>
@@ -838,3 +838,24 @@ def test_spawned_daemon_output_goes_to_log_file(data_dir, monkeypatch):
838
838
  assert seen["stderr"] is cli.subprocess.STDOUT
839
839
  assert seen["stdout"].name == str(data_dir.PYROLAB_LOGDIR / cli.DAEMON_OUTPUT_LOG)
840
840
  assert seen["start_new_session"] is True
841
+
842
+
843
+ def test_nslist_is_sorted(monkeypatch):
844
+ # Regression: listed in whatever order the nameserver returned (reversed,
845
+ # in the lab).
846
+ class FakeNS:
847
+ def list(self, return_metadata=False):
848
+ return {
849
+ "zeta.service": ("PYRO:z@host:1", {"b-tag", "a-tag"}),
850
+ "alpha.service": ("PYRO:a@host:1", set()),
851
+ "Pyro.NameServer": ("PYRO:Pyro.NameServer@host:9090", set()),
852
+ }
853
+
854
+ import pyrolab.api
855
+
856
+ monkeypatch.setattr(pyrolab.api, "locate_ns", lambda host, port: FakeNS())
857
+ result = runner.invoke(cli.app, ["nslist", "--host", "lab", "--port", "9090"])
858
+ assert result.exit_code == 0, result.output
859
+ names = [line.split()[0] for line in result.output.splitlines()[2:] if line.strip()]
860
+ assert names == ["Pyro.NameServer", "alpha.service", "zeta.service"]
861
+ assert "a-tag: b-tag" in result.output
@@ -9,6 +9,10 @@ import json
9
9
  import logging
10
10
  import multiprocessing as mp
11
11
  import pickle
12
+ import signal
13
+ import subprocess
14
+ import sys
15
+ import threading
12
16
  import time
13
17
 
14
18
  import pytest
@@ -331,3 +335,54 @@ def test_cli_logging_only_when_requested(monkeypatch):
331
335
  def test_log_level_from_environment(monkeypatch, value, level):
332
336
  monkeypatch.setenv(logs.LOGLEVEL_ENV_VAR, value)
333
337
  assert logs.log_level() == level
338
+
339
+
340
+ ###############################################################################
341
+ # Output buffering and SIGTERM
342
+ ###############################################################################
343
+
344
+
345
+ @pytest.mark.parametrize("line_buffered", [False, True])
346
+ def test_output_survives_a_hard_exit_only_when_line_buffered(tmp_path, line_buffered):
347
+ # Regression: driver print()s went to pyrolabd_output.log block-buffered,
348
+ # so they showed up late and were lost when the process was killed.
349
+ # os._exit skips flushing, like a kill does.
350
+ script = (
351
+ "import os\n"
352
+ "from pyrolab import logs\n"
353
+ + ("logs.line_buffer_output()\n" if line_buffered else "")
354
+ + "print('Arduino Failed: no such port')\n"
355
+ "os._exit(1)\n"
356
+ )
357
+ out = tmp_path / "pyrolabd_output.log"
358
+ with open(out, "w") as f:
359
+ subprocess.run([sys.executable, "-c", script], stdout=f, check=False)
360
+ printed = "Arduino Failed" in out.read_text()
361
+ assert printed == line_buffered
362
+
363
+
364
+ def test_line_buffer_output_tolerates_missing_streams(monkeypatch):
365
+ monkeypatch.setattr(sys, "stdout", None) # as under pythonw
366
+ monkeypatch.setattr(sys, "stderr", object())
367
+ logs.line_buffer_output()
368
+
369
+
370
+ def test_on_sigterm_installs_the_handler_in_the_main_thread(monkeypatch):
371
+ installed = {}
372
+ monkeypatch.setattr(signal, "signal", lambda sig, h: installed.update({sig: h}))
373
+
374
+ def handler(signum, frame):
375
+ pass
376
+
377
+ logs.on_sigterm(handler)
378
+ assert installed == {signal.SIGTERM: handler}
379
+
380
+
381
+ def test_on_sigterm_does_nothing_outside_the_main_thread(monkeypatch):
382
+ # The tests run runners' run() in threads, where signal() would raise.
383
+ installed = {}
384
+ monkeypatch.setattr(signal, "signal", lambda sig, h: installed.update({sig: h}))
385
+ worker = threading.Thread(target=logs.on_sigterm, args=(print,))
386
+ worker.start()
387
+ worker.join()
388
+ assert installed == {}
@@ -345,3 +345,38 @@ def test_main_copies_legacy_files(run_main, data_dir, wait_for):
345
345
  proxy.shutdown()
346
346
  thread.join(timeout=10)
347
347
  assert data_dir.USER_CONFIG_FILE.read_text() == "daemons: {old: {}}\n"
348
+
349
+
350
+ def test_ps_shows_a_nameserver_on_another_machine_as_remote(
351
+ data_dir, global_config, fake_manager, monkeypatch
352
+ ):
353
+ # Regression: a nameserver configured only so services can register with
354
+ # it (it runs elsewhere) was listed as "Stopped".
355
+ data_dir.USER_CONFIG_FILE.write_text(
356
+ "version: '1.0'\n"
357
+ "nameservers:\n"
358
+ " here: {host: localhost, ns_port: 9090}\n"
359
+ " elsewhere: {host: lab-nameserver.example.edu, ns_port: 9090}\n"
360
+ )
361
+ monkeypatch.setattr(
362
+ fake_manager,
363
+ "get_nameserver_process_info",
364
+ lambda name: {"created": "", "status": "Stopped", "uri": "", "error": ""},
365
+ )
366
+ rows = {line.split()[0]: line for line in PyroLabDaemon().ps().splitlines() if line}
367
+ assert "Remote" in rows["elsewhere"]
368
+ assert "Stopped" in rows["here"]
369
+
370
+
371
+ def test_sigterm_exits_cleanly(monkeypatch):
372
+ # Regression: under systemd, SIGTERM killed the daemon at once, skipping
373
+ # the cleanup in _serve's finally (stopping children, the lockfile).
374
+ ignored = []
375
+ monkeypatch.setattr(
376
+ pyrolabd.signal, "signal", lambda sig, handler: ignored.append((sig, handler))
377
+ )
378
+ with pytest.raises(SystemExit) as exit_:
379
+ pyrolabd._exit_on_sigterm(15, None)
380
+ assert exit_.value.code == 0
381
+ # Further SIGTERMs are ignored so they can't interrupt the cleanup.
382
+ assert ignored == [(pyrolabd.signal.SIGTERM, pyrolabd.signal.SIG_IGN)]
@@ -12,10 +12,13 @@ be observed directly.
12
12
 
13
13
  import gc
14
14
  import logging
15
+ import multiprocessing
15
16
  import queue
16
17
  import socket
18
+ import sys
17
19
  import threading
18
20
  import time
21
+ from types import SimpleNamespace
19
22
 
20
23
  import Pyro5
21
24
  import pytest
@@ -355,3 +358,37 @@ def test_daemon_runner_closes_its_instruments_when_stopping(
355
358
  assert len(closed) == 1
356
359
  finally:
357
360
  gc.enable()
361
+
362
+
363
+ @pytest.mark.skipif(sys.platform == "win32", reason="terminate() is not SIGTERM")
364
+ def test_runner_in_a_real_process_stops_cleanly_on_sigterm(wait_for):
365
+ # Regression: SIGTERM (what systemctl stop and Process.terminate() send)
366
+ # killed a runner at once, skipping its cleanup; it now stops the same
367
+ # way the stop message does.
368
+ with multiprocessing.Manager() as shared:
369
+ state = shared.dict()
370
+ runner = NameServerRunner(
371
+ name="ns",
372
+ nsconfig=NameServerConfiguration(host="127.0.0.1", ns_port=free_port()),
373
+ msg_queue=multiprocessing.Queue(),
374
+ msg_polling=0.05,
375
+ shared_state=state,
376
+ )
377
+ runner.start()
378
+ try:
379
+ assert wait_for(lambda: state.get("ready"), timeout=20)
380
+ runner.terminate()
381
+ runner.join(timeout=10)
382
+ assert runner.exitcode == 0 # -15 if the signal had killed it
383
+ finally:
384
+ if runner.is_alive():
385
+ runner.kill()
386
+ runner.join()
387
+
388
+
389
+ def test_runners_treat_sigterm_like_the_stop_message():
390
+ for cls in (NameServerRunner, DaemonRunner):
391
+ runner = SimpleNamespace(KIND=cls.KIND, name="x", KILL_SIGNAL=False)
392
+ cls._stop_on_sigterm(runner, 15, None)
393
+ assert runner.KILL_SIGNAL is True
394
+ assert cls.stay_alive(runner) is False
@@ -137,3 +137,27 @@ def test_get_ip_falls_back_to_loopback(no_route, monkeypatch, caplog, resolved):
137
137
  monkeypatch.setattr(utils.socket, "gethostbyname", resolve)
138
138
  assert utils.get_ip() == "127.0.0.1"
139
139
  assert "only clients on this machine can reach" in caplog.text
140
+
141
+
142
+ @pytest.mark.parametrize(
143
+ "host, here",
144
+ [
145
+ (None, True),
146
+ ("", True),
147
+ ("localhost", True),
148
+ ("public", True),
149
+ ("0.0.0.0", True),
150
+ ("::", True),
151
+ ("[::1]", True),
152
+ ("127.0.0.5", True),
153
+ ("lab", True),
154
+ ("LAB.example.edu", True), # this machine's name, fully qualified
155
+ ("10.0.0.7", True), # this machine's address
156
+ ("camacholab.ee.byu.edu", False),
157
+ ("10.0.0.8", False),
158
+ ],
159
+ )
160
+ def test_names_this_machine(monkeypatch, host, here):
161
+ monkeypatch.setattr(utils.socket, "gethostname", lambda: "lab")
162
+ monkeypatch.setattr(utils, "get_ip", lambda: "10.0.0.7")
163
+ assert utils.names_this_machine(host) is here
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes