From cce6df85fdf28f32ffbfa49cf10fcd15b58aa494 Mon Sep 17 00:00:00 2001 From: Phil Date: Sun, 25 Oct 2026 07:57:49 +1300 Subject: [PATCH 1/4] FileMonitor: fix macOS shared-memory name exceeding shm_open limit start_library_monitor_service() has never actually started the background service on macOS. SHAREDMEMORY_NAME ("DavidMaisonaveAxter_FileMonitor", 31 chars) exceeds macOS's POSIX shm_open name length limit (PSHMNAMLEN, 31 chars including the leading "/"), so SharedMemory(create=True) raises OSError "File name too long" every time. The bare except clause in start_library_monitor() swallowed that exception and logged the generic "FileMonitor must already be running" message instead, making the real cause invisible in the log for over a year of start attempts. - Shorten SHAREDMEMORY_NAME to 26 chars (verified empirically: 30 chars succeeds, 31 fails, on current macOS). - Log the real exception alongside the existing message. - Harden executeProcess()'s detach path (shared byte-for-byte across FileMonitor, DupFileManager, and RenameFile's StashPluginHelper.py) to pass start_new_session=True, so a detached child survives the launching task process exiting; the code already carried a comment noting this was needed on macOS but never applied it. Tested against a live Stash instance on macOS: before this change, "Start Library Monitor Service" logged a spawned pid and then immediately reported "FileMonitor is NOT running" on every status check. After, the service starts and reports "running" as expected. LLM-assisted contribution disclosure: this change was prepared with LLM assistance (Claude). I reviewed the diff, the testing described above was performed by me against my own Stash installation, and I take full responsibility for the change and its license compliance. --- plugins/DupFileManager/DupFileManager.yml | 2 +- plugins/DupFileManager/StashPluginHelper.py | 5 ++++- plugins/FileMonitor/StashPluginHelper.py | 5 ++++- plugins/FileMonitor/filemonitor.py | 11 ++++++++--- plugins/FileMonitor/filemonitor.yml | 2 +- plugins/FileMonitor/version_history/README.md | 5 +++++ plugins/RenameFile/StashPluginHelper.py | 5 ++++- plugins/RenameFile/renamefile.yml | 2 +- 8 files changed, 28 insertions(+), 9 deletions(-) diff --git a/plugins/DupFileManager/DupFileManager.yml b/plugins/DupFileManager/DupFileManager.yml index 3d8650b9..bfd10c22 100644 --- a/plugins/DupFileManager/DupFileManager.yml +++ b/plugins/DupFileManager/DupFileManager.yml @@ -1,6 +1,6 @@ name: DupFileManager description: Manages duplicate files. -version: 1.0.2 +version: 1.0.3 url: https://discourse.stashapp.cc/t/dupfilemanager/1331 ui: javascript: diff --git a/plugins/DupFileManager/StashPluginHelper.py b/plugins/DupFileManager/StashPluginHelper.py index 07beba5e..1544e5a9 100644 --- a/plugins/DupFileManager/StashPluginHelper.py +++ b/plugins/DupFileManager/StashPluginHelper.py @@ -481,9 +481,12 @@ def executeProcess(self, args, ExecDetach=False): if self.IS_LINUX: args = ["nohup"] + args self.Trace(f"Executing detached process using Popen({args})") + # start_new_session puts the child in its own session, so it survives the + # parent task process exiting (needed on macOS; harmless on Linux too). + pid = subprocess.Popen(args, start_new_session=True).pid else: self.Trace(f"Executing process using normal Popen({args})") - pid = subprocess.Popen(args).pid # On detach, may need the following for MAC OS subprocess.Popen(args, shell=True, start_new_session=True) + pid = subprocess.Popen(args).pid self.Trace(f"pid={pid}") return pid diff --git a/plugins/FileMonitor/StashPluginHelper.py b/plugins/FileMonitor/StashPluginHelper.py index 90c8c1b9..3516b4fb 100644 --- a/plugins/FileMonitor/StashPluginHelper.py +++ b/plugins/FileMonitor/StashPluginHelper.py @@ -481,9 +481,12 @@ def executeProcess(self, args, ExecDetach=False): if self.IS_LINUX: args = ["nohup"] + args self.Trace(f"Executing detached process using Popen({args})") + # start_new_session puts the child in its own session, so it survives the + # parent task process exiting (needed on macOS; harmless on Linux too). + pid = subprocess.Popen(args, start_new_session=True).pid else: self.Trace(f"Executing process using normal Popen({args})") - pid = subprocess.Popen(args).pid # On detach, may need the following for MAC OS subprocess.Popen(args, shell=True, start_new_session=True) + pid = subprocess.Popen(args).pid self.Trace(f"pid={pid}") return pid diff --git a/plugins/FileMonitor/filemonitor.py b/plugins/FileMonitor/filemonitor.py index bbf8c790..93a66728 100644 --- a/plugins/FileMonitor/filemonitor.py +++ b/plugins/FileMonitor/filemonitor.py @@ -84,7 +84,12 @@ signal = Condition(mutex) shouldUpdate = False -SHAREDMEMORY_NAME = "DavidMaisonaveAxter_FileMonitor" # Unique name for shared memory +SHAREDMEMORY_NAME = "DavidMaisonave_FileMonitor" # Unique name for shared memory +# Note: must stay <=30 chars - macOS's POSIX shm_open() rejects names (incl. leading "/") +# longer than PSHMNAMLEN (31), raising OSError "File name too long" on SharedMemory(create=True). +# "DavidMaisonaveAxter_FileMonitor" (31 chars) silently hit this on every macOS start attempt, +# and was reported as the generic "must already be running" message below instead of the +# real OSError, since the except clause doesn't log the caught exception. SHAREDMEMORY_SIZE = 4 RECURSIVE = stash.pluginSettings["recursiveDisabled"] == False SCAN_MODIFIED = stash.pluginConfig["scanModified"] @@ -602,8 +607,8 @@ def start_library_monitor(): try: # Create shared memory buffer which can be used as singleton logic or to get a signal to quit task from external script shm_a = shared_memory.SharedMemory(name=SHAREDMEMORY_NAME, create=True, size=SHAREDMEMORY_SIZE) - except: - stash.Error(f"Could not open shared memory map ({SHAREDMEMORY_NAME}). Change File Monitor must be running. Can not run multiple instance of Change File Monitor. Stop FileMonitor before trying to start it again.") + except Exception as e: + stash.Error(f"Could not open shared memory map ({SHAREDMEMORY_NAME}). Change File Monitor must be running. Can not run multiple instance of Change File Monitor. Stop FileMonitor before trying to start it again. Error: {e}") return type(shm_a.buf) shm_buffer = shm_a.buf diff --git a/plugins/FileMonitor/filemonitor.yml b/plugins/FileMonitor/filemonitor.yml index ccdd1b51..43bffd21 100644 --- a/plugins/FileMonitor/filemonitor.yml +++ b/plugins/FileMonitor/filemonitor.yml @@ -1,6 +1,6 @@ name: FileMonitor description: Monitors the Stash library folders, and updates Stash if any changes occurs in the Stash library paths. -version: 1.0.7 +version: 1.0.8 url: https://discourse.stashapp.cc/t/filemonitor/1333 ui: javascript: diff --git a/plugins/FileMonitor/version_history/README.md b/plugins/FileMonitor/version_history/README.md index 2b619718..4c3c5237 100644 --- a/plugins/FileMonitor/version_history/README.md +++ b/plugins/FileMonitor/version_history/README.md @@ -26,3 +26,8 @@ ### 1.0.5 - Fixed bug associated with library cleaning + +### 1.0.8 + +- Fixed bug on macOS where "Start Library Monitor Service" always failed: the shared-memory name used as the service's running/singleton lock exceeded macOS's POSIX shm_open name length limit (OSError "File name too long"), which was being swallowed and misreported as "FileMonitor must already be running". Shortened the name and surfaced the real exception in the error message. +- Hardened detached process launches (used by the service start, and shared with DupFileManager/RenameFile) to put the child in its own session via `start_new_session=True`, so it isn't torn down when the launching task process exits. diff --git a/plugins/RenameFile/StashPluginHelper.py b/plugins/RenameFile/StashPluginHelper.py index 90c8c1b9..3516b4fb 100644 --- a/plugins/RenameFile/StashPluginHelper.py +++ b/plugins/RenameFile/StashPluginHelper.py @@ -481,9 +481,12 @@ def executeProcess(self, args, ExecDetach=False): if self.IS_LINUX: args = ["nohup"] + args self.Trace(f"Executing detached process using Popen({args})") + # start_new_session puts the child in its own session, so it survives the + # parent task process exiting (needed on macOS; harmless on Linux too). + pid = subprocess.Popen(args, start_new_session=True).pid else: self.Trace(f"Executing process using normal Popen({args})") - pid = subprocess.Popen(args).pid # On detach, may need the following for MAC OS subprocess.Popen(args, shell=True, start_new_session=True) + pid = subprocess.Popen(args).pid self.Trace(f"pid={pid}") return pid diff --git a/plugins/RenameFile/renamefile.yml b/plugins/RenameFile/renamefile.yml index ca3040e0..0dc62c49 100644 --- a/plugins/RenameFile/renamefile.yml +++ b/plugins/RenameFile/renamefile.yml @@ -1,6 +1,6 @@ name: RenameFile description: Renames video (scene) file names when the user edits the [Title] field located in the scene [Edit] tab. -version: 1.0.4 +version: 1.0.5 url: https://discourse.stashapp.cc/t/renamefile/1334 ui: css: From 7ac299b5fe5cbad3c5b21243fdadeeb95425e6c0 Mon Sep 17 00:00:00 2001 From: Phil Date: Sun, 25 Oct 2026 08:10:15 +1300 Subject: [PATCH 2/4] FileMonitor: stop status/already-running checks from unlinking the lock getFileMonitorRunningStatus() and start_library_monitor_service()'s own pre-flight "already running" guard both open the shared-memory lock with create=False just to confirm it exists, then call shm_a.unlink() on success. unlink() deletes the named POSIX shared memory object from the OS immediately - it doesn't wait for the running service to close its own mapping. The running loop is unaffected by this (it keeps its already-open handle and keeps working), but the name is now gone, so: - Every later getFileMonitorRunningStatus() call fails to reopen it by name and reports "NOT running", even while the service is genuinely still running. - stop_library_monitor() (same open-by-name) can no longer signal that instance to stop via the UI/CLI. - start_library_monitor_service()'s "already running" guard destroys the real instance's lock as a side effect of merely detecting it, before refusing to start a duplicate. Found this while verifying the previous commit's fix end-to-end: the service started fine and kept running (confirmed via `ps`), but the very first status check flipped every later check to "NOT running" by unlinking the lock out from under it. Both functions now just attach-and-close without unlinking; only stop_library_monitor() (which is actually supposed to tear the lock down) still unlinks. Tested against the same live macOS Stash instance: restarted the service after killing the orphaned process whose lock had already been destroyed by the old code, and confirmed repeated status checks now consistently report "running". --- plugins/FileMonitor/filemonitor.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/plugins/FileMonitor/filemonitor.py b/plugins/FileMonitor/filemonitor.py index 93a66728..863201f0 100644 --- a/plugins/FileMonitor/filemonitor.py +++ b/plugins/FileMonitor/filemonitor.py @@ -902,9 +902,12 @@ def stop_library_monitor(): def start_library_monitor_service(): # First check if FileMonitor is already running try: + # Just attach-and-close to confirm the segment exists; do NOT unlink it here. + # unlink() would delete the already-running instance's lock as a side effect of + # merely checking for it, breaking that instance's own status checks and stop + # command even though we're refusing to start a duplicate, not actually stopping it. shm_a = shared_memory.SharedMemory(name=SHAREDMEMORY_NAME, create=False, size=SHAREDMEMORY_SIZE) shm_a.close() - shm_a.unlink() stash.Error("FileMonitor is already running. Need to stop FileMonitor before trying to start it again.") return except: @@ -996,9 +999,13 @@ def manageTagggedScenes(clearTag=True): def getFileMonitorRunningStatus(): FileMonitorStatus = "NOT running" try: + # Just attach-and-close to confirm the segment exists; do NOT unlink it here. + # unlink() deletes the named shared memory object immediately, so a mere status + # check would permanently break the name for the actually-running service (and + # for subsequent status checks and stop_library_monitor), even though the running + # process keeps its own already-open mapping and is otherwise unaffected. shm_a = shared_memory.SharedMemory(name=SHAREDMEMORY_NAME, create=False, size=SHAREDMEMORY_SIZE) shm_a.close() - shm_a.unlink() FileMonitorStatus = "RUNNING" stash.Log("FileMonitor is running...") except: From f6d0008874d7329e5c2aecc7ae3cafa14997831a Mon Sep 17 00:00:00 2001 From: Phil Date: Sun, 25 Oct 2026 08:15:10 +1300 Subject: [PATCH 3/4] FileMonitor: unregister read-only shm attaches from resource_tracker Removing the explicit unlink() calls (previous commit) wasn't enough. Reproduced directly: any process that attaches to a multiprocessing.shared_memory segment - even read-only, create=False - gets it auto-registered with *that process's own* resource_tracker. When that short-lived process exits normally, its tracker treats the unregistered-but-tracked segment as "leaked" and unlinks it anyway, regardless of whether another process (the real owner) is still using it: creator process: SharedMemory(create=True) -> keeps running attacher process: SharedMemory(create=False); close(); exits -> attacher's own resource_tracker now deletes the segment -> next status check: "No such file or directory" This is a known multiprocessing.shared_memory gotcha; the fix is to call resource_tracker.unregister(shm._name, "shared_memory") right after attaching read-only, telling *this* process's tracker it isn't responsible for cleanup. Verified the failure and the fix in isolation with a 3-process repro (creator/attacher/checker) before applying it to getFileMonitorRunningStatus() and start_library_monitor_service()'s "already running" guard - the same two read-only attach sites touched in the previous commit. stop_library_monitor() needs no change: SharedMemory.unlink() already calls resource_tracker.unregister() internally as part of what it does. Tested against the same live macOS Stash instance: killed the second orphaned instance (whose lock the resource_tracker had destroyed before this fix landed), restarted the service, and confirmed status checks several seconds apart both report "running". --- plugins/FileMonitor/filemonitor.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/plugins/FileMonitor/filemonitor.py b/plugins/FileMonitor/filemonitor.py index 863201f0..030413e7 100644 --- a/plugins/FileMonitor/filemonitor.py +++ b/plugins/FileMonitor/filemonitor.py @@ -14,7 +14,7 @@ from StashPluginHelper import StashPluginHelper from StashPluginHelper import taskQueue from threading import Lock, Condition -from multiprocessing import shared_memory +from multiprocessing import shared_memory, resource_tracker from filemonitor_config import config from filemonitor_task_examples import task_examples from filemonitor_self_unit_test import self_unit_test @@ -907,6 +907,11 @@ def start_library_monitor_service(): # merely checking for it, breaking that instance's own status checks and stop # command even though we're refusing to start a duplicate, not actually stopping it. shm_a = shared_memory.SharedMemory(name=SHAREDMEMORY_NAME, create=False, size=SHAREDMEMORY_SIZE) + # Also unregister from *this* process's resource_tracker: just attaching + # (even read-only) registers the segment there, and when this short-lived + # process exits, its tracker treats an unregistered-but-tracked segment as + # "leaked" and unlinks it - destroying the real running instance's lock. + resource_tracker.unregister(shm_a._name, "shared_memory") shm_a.close() stash.Error("FileMonitor is already running. Need to stop FileMonitor before trying to start it again.") return @@ -1005,6 +1010,11 @@ def getFileMonitorRunningStatus(): # for subsequent status checks and stop_library_monitor), even though the running # process keeps its own already-open mapping and is otherwise unaffected. shm_a = shared_memory.SharedMemory(name=SHAREDMEMORY_NAME, create=False, size=SHAREDMEMORY_SIZE) + # Also unregister from *this* process's resource_tracker: just attaching + # (even read-only) registers the segment there, and when this short-lived + # process exits, its tracker treats an unregistered-but-tracked segment as + # "leaked" and unlinks it - destroying the real running instance's lock. + resource_tracker.unregister(shm_a._name, "shared_memory") shm_a.close() FileMonitorStatus = "RUNNING" stash.Log("FileMonitor is running...") From 822964026d92a4a04b75daa3644e10af38f03295 Mon Sep 17 00:00:00 2001 From: Phil Date: Mon, 26 Oct 2026 20:27:19 +1300 Subject: [PATCH 4/4] Limit PR to FileMonitor plugin only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per maintainer request, drop the DupFileManager and RenameFile changes from this PR. Those two plugins share a vendored copy of StashPluginHelper.py with FileMonitor, which is why the start_new_session=True detach fix had touched all three — but only FileMonitor is tied to an actual filed issue (#801). The same fix for DupFileManager and RenameFile will follow as separate single-plugin PRs. Co-Authored-By: Claude Sonnet 5 --- plugins/DupFileManager/DupFileManager.yml | 2 +- plugins/DupFileManager/StashPluginHelper.py | 5 +---- plugins/RenameFile/StashPluginHelper.py | 5 +---- plugins/RenameFile/renamefile.yml | 2 +- 4 files changed, 4 insertions(+), 10 deletions(-) diff --git a/plugins/DupFileManager/DupFileManager.yml b/plugins/DupFileManager/DupFileManager.yml index bfd10c22..3d8650b9 100644 --- a/plugins/DupFileManager/DupFileManager.yml +++ b/plugins/DupFileManager/DupFileManager.yml @@ -1,6 +1,6 @@ name: DupFileManager description: Manages duplicate files. -version: 1.0.3 +version: 1.0.2 url: https://discourse.stashapp.cc/t/dupfilemanager/1331 ui: javascript: diff --git a/plugins/DupFileManager/StashPluginHelper.py b/plugins/DupFileManager/StashPluginHelper.py index 1544e5a9..07beba5e 100644 --- a/plugins/DupFileManager/StashPluginHelper.py +++ b/plugins/DupFileManager/StashPluginHelper.py @@ -481,12 +481,9 @@ def executeProcess(self, args, ExecDetach=False): if self.IS_LINUX: args = ["nohup"] + args self.Trace(f"Executing detached process using Popen({args})") - # start_new_session puts the child in its own session, so it survives the - # parent task process exiting (needed on macOS; harmless on Linux too). - pid = subprocess.Popen(args, start_new_session=True).pid else: self.Trace(f"Executing process using normal Popen({args})") - pid = subprocess.Popen(args).pid + pid = subprocess.Popen(args).pid # On detach, may need the following for MAC OS subprocess.Popen(args, shell=True, start_new_session=True) self.Trace(f"pid={pid}") return pid diff --git a/plugins/RenameFile/StashPluginHelper.py b/plugins/RenameFile/StashPluginHelper.py index 3516b4fb..90c8c1b9 100644 --- a/plugins/RenameFile/StashPluginHelper.py +++ b/plugins/RenameFile/StashPluginHelper.py @@ -481,12 +481,9 @@ def executeProcess(self, args, ExecDetach=False): if self.IS_LINUX: args = ["nohup"] + args self.Trace(f"Executing detached process using Popen({args})") - # start_new_session puts the child in its own session, so it survives the - # parent task process exiting (needed on macOS; harmless on Linux too). - pid = subprocess.Popen(args, start_new_session=True).pid else: self.Trace(f"Executing process using normal Popen({args})") - pid = subprocess.Popen(args).pid + pid = subprocess.Popen(args).pid # On detach, may need the following for MAC OS subprocess.Popen(args, shell=True, start_new_session=True) self.Trace(f"pid={pid}") return pid diff --git a/plugins/RenameFile/renamefile.yml b/plugins/RenameFile/renamefile.yml index 0dc62c49..ca3040e0 100644 --- a/plugins/RenameFile/renamefile.yml +++ b/plugins/RenameFile/renamefile.yml @@ -1,6 +1,6 @@ name: RenameFile description: Renames video (scene) file names when the user edits the [Title] field located in the scene [Edit] tab. -version: 1.0.5 +version: 1.0.4 url: https://discourse.stashapp.cc/t/renamefile/1334 ui: css: