From ca991883896b299786c694b854f3ec135a359b5a Mon Sep 17 00:00:00 2001 From: cosminr Date: Fri, 1 May 2026 20:46:12 -0700 Subject: [PATCH] fix(continuum): singleton-guard auto-save loop, replace Start-Job with Start-Process The client-attached hook in plugin.conf and the Start-Job spawn in psmux-continuum.ps1 each launched a fresh persistent pwsh loop on every re-attach / plugin load, with no de-duplication. Over days this accumulated long-running ~100 MB pwsh processes whose memory continued to grow. plugin.conf even acknowledged this ("multiple loops may start - this is harmless since saves are idempotent"), which was wrong: each loop is a persistent background process consuming real memory. The auto-save loop now enforces a per-user singleton via a PID file at %LOCALAPPDATA%\psmux-continuum\auto_save.pid: * Duplicate launches detect the live owner and exit immediately, so the client-attached hook is safe to re-fire on every attach. * The loop releases the slot via try/finally and self-exits gracefully if superseded (PID file no longer points at it). * Periodic [GC]::Collect() bounds memory growth in the long-running loop. The launcher in psmux-continuum.ps1 now uses Start-Process -WindowStyle Hidden instead of Start-Job. Start-Job spawned an outer scriptblock-host pwsh that itself spawned the inner pwsh -File auto_save.ps1, so every plugin load created two pwsh processes that never reaped. Start-Process gives a single detached pwsh, and the singleton guard inside the loop makes repeat launches safe. The three regenerated helper scripts (auto_save.ps1, auto_restore.ps1, boot.ps1) now carry a "DO NOT EDIT - regenerated from psmux-continuum.ps1 on plugin load" header so future contributors know to edit the heredoc, not the artifact. The plugin.conf comment is updated to describe the new singleton behavior. --- psmux-continuum/plugin.conf | 5 +- psmux-continuum/psmux-continuum.ps1 | 120 +++++++++++++++++++---- psmux-continuum/scripts/auto_restore.ps1 | 1 + psmux-continuum/scripts/auto_save.ps1 | 98 +++++++++++++++--- psmux-continuum/scripts/boot.ps1 | 1 + 5 files changed, 192 insertions(+), 33 deletions(-) diff --git a/psmux-continuum/plugin.conf b/psmux-continuum/plugin.conf index 141fbfb..4a5dc49 100644 --- a/psmux-continuum/plugin.conf +++ b/psmux-continuum/plugin.conf @@ -7,8 +7,9 @@ # # NOTE: The auto_save.ps1 script runs a persistent loop (sleeping between # saves). run-shell executes it asynchronously so it won't block psmux. -# If you attach multiple clients, multiple loops may start — this is -# harmless since saves are idempotent. +# It enforces a per-user singleton via PID file at +# %LOCALAPPDATA%\psmux-continuum\auto_save.pid, so re-firing this hook on +# every client attach will NOT spawn additional loops. # Start auto-save background loop on client attach (every 15 minutes) set-hook -g client-attached 'run-shell "pwsh -NoProfile -File \"~/.psmux/plugins/psmux-continuum/scripts/auto_save.ps1\" -IntervalMinutes 15"' diff --git a/psmux-continuum/psmux-continuum.ps1 b/psmux-continuum/psmux-continuum.ps1 index ffe86a9..3843909 100644 --- a/psmux-continuum/psmux-continuum.ps1 +++ b/psmux-continuum/psmux-continuum.ps1 @@ -33,8 +33,14 @@ if (-not (Test-Path $SCRIPTS_DIR)) { } # --- Create the auto-save background script --- +# NOTE: The auto_save loop is a per-user singleton enforced by a PID file +# at $env:LOCALAPPDATA\psmux-continuum\auto_save.pid. This means the +# client-attached hook in plugin.conf can re-fire on every re-attach +# without accumulating long-running pwsh processes — duplicate launches +# detect the live owner and exit immediately. $autoSaveScript = @' #!/usr/bin/env pwsh +# DO NOT EDIT — regenerated from psmux-continuum.ps1 on plugin load. # psmux-continuum: Background auto-save loop param( [int]$IntervalMinutes = 15 @@ -50,6 +56,45 @@ function Get-PsmuxBin { return 'psmux' } +# --- Singleton guard: one auto-save loop per user --- +$pidDir = Join-Path $env:LOCALAPPDATA 'psmux-continuum' +$pidFile = Join-Path $pidDir 'auto_save.pid' +$logFile = Join-Path $pidDir 'auto_save.log' +New-Item -ItemType Directory -Path $pidDir -Force -ErrorAction SilentlyContinue | Out-Null + +# Rotate the log if it grew past 256 KB. Add-Content reopens per write, so +# concurrent invocations don't corrupt each other's writes. +if ((Test-Path $logFile) -and ((Get-Item $logFile).Length -gt 262144)) { + Move-Item $logFile "$logFile.old" -Force -ErrorAction SilentlyContinue +} + +function Log { + param([string]$Message, [string]$Level = 'INF') + try { + "[{0}] [{1}] [PID {2}] {3}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Level, $PID, $Message | + Add-Content -Path $logFile -Encoding UTF8 -ErrorAction Stop + } + catch {} +} + +# If a live owner already holds the PID file, defer. +$existingPid = $null +try { + $existingPid = (Get-Content $pidFile -Raw -ErrorAction Stop).Trim() +} +catch {} +if ($existingPid -match '^\d+$') { + $existing = Get-Process -Id ([int]$existingPid) -ErrorAction SilentlyContinue + if ($existing -and ($existing.ProcessName -in @('pwsh','powershell'))) { + Log "auto-save already running (PID $existingPid), exiting." + exit 0 + } +} + +# Claim the slot +"$PID" | Set-Content -Path $pidFile -Encoding UTF8 -Force +Log "claimed singleton slot; interval=${IntervalMinutes}m" + $PSMUX = Get-PsmuxBin # Find the resurrect save script @@ -59,25 +104,59 @@ if (-not (Test-Path $saveScript)) { } if (-not (Test-Path $saveScript)) { - Write-Host "psmux-continuum: psmux-resurrect not found. Install it first." -ForegroundColor Red + Log "psmux-resurrect not found. Install it first." 'ERR' + Remove-Item $pidFile -Force -ErrorAction SilentlyContinue exit 1 } $IntervalSeconds = $IntervalMinutes * 60 - -while ($true) { - Start-Sleep -Seconds $IntervalSeconds - - # Check if psmux server is still running - $sessions = & $PSMUX ls 2>&1 | Out-String - if ($LASTEXITCODE -ne 0) { - Write-Host "psmux-continuum: Server not running, stopping auto-save." -ForegroundColor Yellow - break +$iter = 0 + +try { + while ($true) { + Start-Sleep -Seconds $IntervalSeconds + + # Graceful supersede: if the PID file no longer points to us, exit. + $owner = $null + try { + $owner = (Get-Content $pidFile -Raw -ErrorAction Stop).Trim() + } + catch {} + if ($owner -ne "$PID") { + Log "superseded, exiting." + break + } + + # Check if psmux server is still running + $sessions = & $PSMUX ls 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { + Log "psmux server not running, stopping auto-save." 'WRN' + break + } + + # Run the save; capture all streams (output, errors, warnings) into the log. + & pwsh -NoProfile -File $saveScript *>> $logFile + Log "auto-saved." + + # Bound memory growth in this long-running loop + $iter++ + if (($iter % 4) -eq 0) { + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + [GC]::Collect() + } } - - # Run the save - & pwsh -NoProfile -File $saveScript - Write-Host "psmux-continuum: Auto-saved at $(Get-Date -Format 'HH:mm:ss')" -ForegroundColor DarkGray +} +finally { + Log "exiting; releasing singleton slot." + # Release singleton slot only if we still own it + try { + $owner = (Get-Content $pidFile -Raw -ErrorAction Stop).Trim() + if ($owner -eq "$PID") { + Remove-Item $pidFile -Force -ErrorAction SilentlyContinue + } + } + catch {} } '@ @@ -86,6 +165,7 @@ Set-Content -Path (Join-Path $SCRIPTS_DIR 'auto_save.ps1') -Value $autoSaveScrip # --- Create the auto-restore script --- $autoRestoreScript = @' #!/usr/bin/env pwsh +# DO NOT EDIT — regenerated from psmux-continuum.ps1 on plugin load. # psmux-continuum: Auto-restore on server start $ErrorActionPreference = 'Continue' @@ -107,6 +187,7 @@ Set-Content -Path (Join-Path $SCRIPTS_DIR 'auto_restore.ps1') -Value $autoRestor # --- Create boot script --- $bootScript = @' #!/usr/bin/env pwsh +# DO NOT EDIT — regenerated from psmux-continuum.ps1 on plugin load. # psmux-continuum: Register/unregister psmux auto-start on Windows login param( [switch]$Enable, @@ -143,7 +224,7 @@ if ($Enable) { Set-Content -Path (Join-Path $SCRIPTS_DIR 'boot.ps1') -Value $bootScript -Force -# --- Start auto-save background job --- +# --- Start auto-save background loop --- $interval = 15 # Default 15 minutes # Try to read interval from psmux options @@ -154,10 +235,11 @@ if ($intervalOpt -match '^\d+$') { if ($interval -gt 0) { $autoSavePath = Join-Path $SCRIPTS_DIR 'auto_save.ps1' - Start-Job -ScriptBlock { - param($script, $interval) - & pwsh -NoProfile -File $script -IntervalMinutes $interval - } -ArgumentList $autoSavePath, $interval | Out-Null + # Detached, hidden pwsh — single process, not a Start-Job wrapper. + # The singleton guard inside auto_save.ps1 makes repeated launches safe. + Start-Process -FilePath 'pwsh' ` + -ArgumentList @('-NoProfile','-File',$autoSavePath,'-IntervalMinutes',"$interval") ` + -WindowStyle Hidden | Out-Null } # --- Auto-restore on first load --- diff --git a/psmux-continuum/scripts/auto_restore.ps1 b/psmux-continuum/scripts/auto_restore.ps1 index 279de56..4736de8 100644 --- a/psmux-continuum/scripts/auto_restore.ps1 +++ b/psmux-continuum/scripts/auto_restore.ps1 @@ -1,4 +1,5 @@ #!/usr/bin/env pwsh +# DO NOT EDIT — regenerated from psmux-continuum.ps1 on plugin load. # psmux-continuum: Auto-restore on server start $ErrorActionPreference = 'Continue' diff --git a/psmux-continuum/scripts/auto_save.ps1 b/psmux-continuum/scripts/auto_save.ps1 index e06a48c..19cdc79 100644 --- a/psmux-continuum/scripts/auto_save.ps1 +++ b/psmux-continuum/scripts/auto_save.ps1 @@ -1,4 +1,5 @@ #!/usr/bin/env pwsh +# DO NOT EDIT — regenerated from psmux-continuum.ps1 on plugin load. # psmux-continuum: Background auto-save loop param( [int]$IntervalMinutes = 15 @@ -14,6 +15,45 @@ function Get-PsmuxBin { return 'psmux' } +# --- Singleton guard: one auto-save loop per user --- +$pidDir = Join-Path $env:LOCALAPPDATA 'psmux-continuum' +$pidFile = Join-Path $pidDir 'auto_save.pid' +$logFile = Join-Path $pidDir 'auto_save.log' +New-Item -ItemType Directory -Path $pidDir -Force -ErrorAction SilentlyContinue | Out-Null + +# Rotate the log if it grew past 256 KB. Add-Content reopens per write, so +# concurrent invocations don't corrupt each other's writes. +if ((Test-Path $logFile) -and ((Get-Item $logFile).Length -gt 262144)) { + Move-Item $logFile "$logFile.old" -Force -ErrorAction SilentlyContinue +} + +function Log { + param([string]$Message, [string]$Level = 'INF') + try { + "[{0}] [{1}] [PID {2}] {3}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Level, $PID, $Message | + Add-Content -Path $logFile -Encoding UTF8 -ErrorAction Stop + } + catch {} +} + +# If a live owner already holds the PID file, defer. +$existingPid = $null +try { + $existingPid = (Get-Content $pidFile -Raw -ErrorAction Stop).Trim() +} +catch {} +if ($existingPid -match '^\d+$') { + $existing = Get-Process -Id ([int]$existingPid) -ErrorAction SilentlyContinue + if ($existing -and ($existing.ProcessName -in @('pwsh','powershell'))) { + Log "auto-save already running (PID $existingPid), exiting." + exit 0 + } +} + +# Claim the slot +"$PID" | Set-Content -Path $pidFile -Encoding UTF8 -Force +Log "claimed singleton slot; interval=${IntervalMinutes}m" + $PSMUX = Get-PsmuxBin # Find the resurrect save script @@ -23,23 +63,57 @@ if (-not (Test-Path $saveScript)) { } if (-not (Test-Path $saveScript)) { - Write-Host "psmux-continuum: psmux-resurrect not found. Install it first." -ForegroundColor Red + Log "psmux-resurrect not found. Install it first." 'ERR' + Remove-Item $pidFile -Force -ErrorAction SilentlyContinue exit 1 } $IntervalSeconds = $IntervalMinutes * 60 +$iter = 0 -while ($true) { - Start-Sleep -Seconds $IntervalSeconds +try { + while ($true) { + Start-Sleep -Seconds $IntervalSeconds - # Check if psmux server is still running - $sessions = & $PSMUX ls 2>&1 | Out-String - if ($LASTEXITCODE -ne 0) { - Write-Host "psmux-continuum: Server not running, stopping auto-save." -ForegroundColor Yellow - break - } + # Graceful supersede: if the PID file no longer points to us, exit. + $owner = $null + try { + $owner = (Get-Content $pidFile -Raw -ErrorAction Stop).Trim() + } + catch {} + if ($owner -ne "$PID") { + Log "superseded, exiting." + break + } + + # Check if psmux server is still running + $sessions = & $PSMUX ls 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { + Log "psmux server not running, stopping auto-save." 'WRN' + break + } - # Run the save - & pwsh -NoProfile -File $saveScript - Write-Host "psmux-continuum: Auto-saved at $(Get-Date -Format 'HH:mm:ss')" -ForegroundColor DarkGray + # Run the save; capture all streams (output, errors, warnings) into the log. + & pwsh -NoProfile -File $saveScript *>> $logFile + Log "auto-saved." + + # Bound memory growth in this long-running loop + $iter++ + if (($iter % 4) -eq 0) { + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + [GC]::Collect() + } + } +} +finally { + Log "exiting; releasing singleton slot." + # Release singleton slot only if we still own it + try { + $owner = (Get-Content $pidFile -Raw -ErrorAction Stop).Trim() + if ($owner -eq "$PID") { + Remove-Item $pidFile -Force -ErrorAction SilentlyContinue + } + } + catch {} } diff --git a/psmux-continuum/scripts/boot.ps1 b/psmux-continuum/scripts/boot.ps1 index 6390a87..e7212a6 100644 --- a/psmux-continuum/scripts/boot.ps1 +++ b/psmux-continuum/scripts/boot.ps1 @@ -1,4 +1,5 @@ #!/usr/bin/env pwsh +# DO NOT EDIT — regenerated from psmux-continuum.ps1 on plugin load. # psmux-continuum: Register/unregister psmux auto-start on Windows login param( [switch]$Enable,