From bdf25bb57773e4b8f54c19a9a0d61fe6b08e57ba Mon Sep 17 00:00:00 2001 From: spielman Date: Fri, 28 Aug 2026 14:44:49 -0400 Subject: [PATCH 1/5] Stringify zlock port for subprocess argv get_config() falls back to zprocess.zlock.DEFAULT_PORT, an int, whenever the labconfig has no [ports] zlock entry. That value was placed directly into the argv list passed to subprocess, which accepts only str, bytes or os.PathLike: TypeError: expected str, bytes or os.PathLike object, not int So labscript-zlock could not start from a labconfig that omits the port. The sibling launchers zlog.py and remote.py already wrap their ports with str(); do the same here. Co-Authored-By: Claude Opus 5 --- labscript_utils/zlock.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/labscript_utils/zlock.py b/labscript_utils/zlock.py index 5878997..baac341 100644 --- a/labscript_utils/zlock.py +++ b/labscript_utils/zlock.py @@ -40,7 +40,7 @@ def main(): '-m', 'zprocess.zlock', '--port', - config['zlock_port'], + str(config['zlock_port']), '-l', LOG_PATH, ] From e760e0c9f1b4a93ac2d2e3a9c86a45282c169224 Mon Sep 17 00:00:00 2001 From: spielman Date: Fri, 28 Aug 2026 14:45:08 -0400 Subject: [PATCH 2/5] Capture the original showwarning once at import set_logger() stashed the currently-installed handler into warnings._showwarning before installing logwarning. On a second set_logger() call the stash therefore captured logwarning itself, so logwarning recursed into itself and the next warning raised: RecursionError: maximum recursion depth exceeded This is reachable whenever a process installs the labscript excepthook more than once, such as a subprocess that re-runs application startup or an app that reconfigures logging. Capture the real handler once at module import into a module-level name instead, which is also idempotent and stops writing to a private-looking attribute of the warnings module. Co-Authored-By: Claude Opus 5 --- labscript_utils/excepthook/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/labscript_utils/excepthook/__init__.py b/labscript_utils/excepthook/__init__.py index 9ac5975..c6551fc 100644 --- a/labscript_utils/excepthook/__init__.py +++ b/labscript_utils/excepthook/__init__.py @@ -28,6 +28,7 @@ class l: logger = None child_processes = [] +_original_showwarning = warnings.showwarning def install_thread_excepthook(): @@ -83,12 +84,11 @@ def tkhandler(exceptclass, exception, exec_info, reraise=True): def logwarning(message, category, filename, lineno, file=None, line=None): logmessage = warnings.formatwarning(message, category, filename, lineno, line) l.logger.warn(logmessage) - warnings._showwarning(message, category, filename, lineno, file, line) + _original_showwarning(message, category, filename, lineno, file, line) def set_logger(logger): l.logger = logger - warnings._showwarning = warnings.showwarning warnings.showwarning = logwarning # Check for tkinter availability. Tkinter is frustratingly not available From 5196548c5dfeb735aa590b055083a52ec910b56e Mon Sep 17 00:00:00 2001 From: spielman Date: Fri, 28 Aug 2026 14:45:17 -0400 Subject: [PATCH 3/5] Serialise numpy floating and boolean scalars _default() is json.dumps' fallback for objects it cannot encode, and only handled np.integer. np.float64 survives by accident because it subclasses Python float, which masked the gap for the other numpy scalar types: int64 ok float64 ok float32 TypeError bool_ TypeError Any device or plugin writing an np.float32 or np.bool_ into shot properties therefore failed in serialise(). Add the two missing branches. Co-Authored-By: Claude Opus 5 --- labscript_utils/properties.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/labscript_utils/properties.py b/labscript_utils/properties.py index 092df52..5f8488e 100644 --- a/labscript_utils/properties.py +++ b/labscript_utils/properties.py @@ -61,6 +61,10 @@ def _default(o): # Workaround for https://bugs.python.org/issue24313 if isinstance(o, np.integer): return int(o) + if isinstance(o, np.floating): + return float(o) + if isinstance(o, np.bool_): + return bool(o) raise TypeError From 960fc65a8e6a4b10e39e55b926fc34cdc0e08296 Mon Sep 17 00:00:00 2001 From: spielman Date: Fri, 28 Aug 2026 14:45:39 -0400 Subject: [PATCH 4/5] Test socket_class against SecureSocket, not SecureContext The comment directly above states the intent: only insert the security-related kwargs when the socket is going to be a SecureSocket. The test used SecureContext, which is a Context class and can never be a socket class, so the branch was unreachable for any explicit socket_class: issubclass(zmq.Socket, SecureContext) = False SecureSocket = False issubclass(SecureSocket, SecureContext) = False SecureSocket = True The common pyzmq 25 path, where ThreadAuthenticator passes zmq.Socket, gives the intended result either way, which is why this went unnoticed. A caller that explicitly passes SecureSocket, however, silently lost its allow_insecure configuration. Co-Authored-By: Claude Opus 5 --- labscript_utils/ls_zprocess.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/labscript_utils/ls_zprocess.py b/labscript_utils/ls_zprocess.py index 4d9e957..9642443 100644 --- a/labscript_utils/ls_zprocess.py +++ b/labscript_utils/ls_zprocess.py @@ -257,7 +257,7 @@ def socket(self, socket_type, socket_class=None, **kwargs): # be a SecureSocket. If caller has explicitly requested a different socket type # (e.g since pyzmq 25, ThreadAuthenticator sets up an internal socket by calling # `Context.socket(..., socket_class=zmq.Socket)), then don't.` - if socket_class is None or issubclass(socket_class, SecureContext): + if socket_class is None or issubclass(socket_class, SecureSocket): config = get_config() kwargs['allow_insecure'] = config['allow_insecure'] return SecureContext.socket(self, socket_type=socket_type, **kwargs) From 635aea9af3258362dde409e4b1f13242e72c63dc Mon Sep 17 00:00:00 2001 From: spielman Date: Fri, 28 Aug 2026 14:46:33 -0400 Subject: [PATCH 5/5] Check for a missing shared secret on both config paths The guard that raises _ERR_NO_SHARED_SECRET was nested inside the except clause, so it only ran when [security] allow_insecure was absent from the labconfig. A labconfig that explicitly sets [security] allow_insecure = False with no shared_secret skipped the check entirely. Inside the except the condition was also dead weight, since allow_insecure had just been set to False on the line above. _ERR_NO_SHARED_SECRET states the intended contract: configure a shared_secret, or opt out with allow_insecure = True. Explicitly writing allow_insecure = False without a secret is precisely the misconfiguration the message exists to catch, so run the guard on both paths. BEHAVIOUR CHANGE: an installation that explicitly sets allow_insecure = False with no shared_secret now fails at startup with the message above instead of continuing. Such a setup previously worked as long as all communication stayed on loopback, because zprocess only enforces this at send time (zprocess/security.py). Those users must now either supply a shared secret or set allow_insecure = True. This commit is deliberately kept separate so it can be dropped if that trade-off is unwanted. Co-Authored-By: Claude Opus 5 --- labscript_utils/ls_zprocess.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/labscript_utils/ls_zprocess.py b/labscript_utils/ls_zprocess.py index 9642443..6fd4745 100644 --- a/labscript_utils/ls_zprocess.py +++ b/labscript_utils/ls_zprocess.py @@ -106,8 +106,8 @@ def get_config(): config['allow_insecure'] = labconfig.getboolean('security', 'allow_insecure') except (labconfig.NoOptionError, labconfig.NoSectionError): config['allow_insecure'] = False - if config['shared_secret'] is None and not config['allow_insecure']: - raise ValueError(_ERR_NO_SHARED_SECRET.replace('/', os.sep)) + if config['shared_secret'] is None and not config['allow_insecure']: + raise ValueError(_ERR_NO_SHARED_SECRET.replace('/', os.sep)) try: config['logging_maxBytes'] = labconfig.getint('logging', 'maxBytes') except (labconfig.NoOptionError, labconfig.NoSectionError):