src/Engine/Tasks.ps1
|
# Tasks.ps1 — in-session background task registry (ThreadJob-based). # Powers the UI's unobtrusive progress overlay (#25), the unmanaged-module # scan (#26), background Update-Help (#35) and any user-initiated long work. # No rendering here — the UI asks for summaries and draws them itself. # # Output is BOUNDED (gh#24). It used to be captured with `Receive-Job -Keep`, # which never drains the job's buffer: every 500 ms poll re-materialised the # entire output and replaced the stored array, so a chatty job grew both the # job buffer and psmm's copy without limit. Now each poll harvests only what # is new, into a ring buffer with a stated cap, and keeps a running count so # nothing has to re-read a buffer just to measure it. # Ring-buffer caps. Deliberately generous — they exist to bound a runaway job, # not to truncate ordinary output — and both are reported when they bite. $script:PSMM_TaskLineCap = 1000 # lines kept per task $script:PSMM_TaskCharCap = 2000 # characters kept per line function Start-PSMMTask { [CmdletBinding()] param( [Parameter(Mandatory)][string]$Label, [Parameter(Mandatory)][scriptblock]$ScriptBlock, [object[]]$ArgumentList, [string]$Kind = 'generic', # lets the UI react to completion (install/updatecheck/scan/...) $Data # optional payload for the completion handler ) if (-not $script:PSMM_Tasks) { $script:PSMM_Tasks = [System.Collections.Generic.List[object]]::new() } $script:PSMM_TaskSeq = [int]$script:PSMM_TaskSeq + 1 # the name is load-bearing: the session-exit handler finds psmm's jobs by # matching it, because it cannot reach module state (see Clear-PSMMJob) $job = Start-ThreadJob -Name "psmm-task-$($script:PSMM_TaskSeq)" -ScriptBlock $ScriptBlock -ArgumentList $ArgumentList $task = [pscustomobject]@{ Id = $script:PSMM_TaskSeq Label = $Label Kind = $Kind Data = $Data Job = $job StartedAt = [datetime]::Now # ring buffer, capped. [object], not [string]: the scan and # update-check tasks emit rich objects that Receive-PSMMUITask reads # by property - stringifying them here would silently break both. Output = [System.Collections.Generic.List[object]]::new() LineCount = 0 # total lines ever produced (Output may hold fewer) Dropped = 0 # lines evicted by the cap Done = $false Failed = $false Cancelled = $false SawFailure = $false # a 'FAILED ...' line was harvested at some point Seen = $false # UI sets this after showing the completion notice } $script:PSMM_Tasks.Add($task) Register-PSMMJobDisposal $task } # Append harvested lines to one task's ring buffer, capping line length and # total lines. The FAILED flag is latched HERE rather than derived from the # buffer later, because the line that set it may since have been evicted. function Add-PSMMTaskOutput { [CmdletBinding()] param( [Parameter(Mandatory)] $Task, [AllowNull()][AllowEmptyCollection()][object[]]$Lines ) foreach ($l in @($Lines)) { # only STRINGS are length-capped; an emitted object is stored whole, # because the completion handlers read it by property $item = $l if ($l -is [string] -and $l.Length -gt $script:PSMM_TaskCharCap) { $item = $l.Substring(0, $script:PSMM_TaskCharCap) + " $([char]0x2026)[line truncated]" } if ("$l" -like 'FAILED*') { $Task.SawFailure = $true } $Task.LineCount++ $Task.Output.Add($item) } if ($Task.Output.Count -gt $script:PSMM_TaskLineCap) { $drop = $Task.Output.Count - $script:PSMM_TaskLineCap $Task.Output.RemoveRange(0, $drop) $Task.Dropped += $drop } } # Harvest state + new output from every task. Cheap; safe to call per poll. function Update-PSMMTask { [CmdletBinding()] param() foreach ($t in (Get-PSMMTask)) { if ($t.Done) { continue } $state = "$($t.Job.State)" Add-PSMMTaskOutput -Task $t -Lines @(Receive-Job -Job $t.Job -ErrorAction SilentlyContinue) if ($state -notin 'NotStarted', 'Running') { # final drain: anything the job emitted between reading its state # and the harvest above would otherwise be lost for good, now that # the buffer is actually being consumed Add-PSMMTaskOutput -Task $t -Lines @(Receive-Job -Job $t.Job -ErrorAction SilentlyContinue) $t.Done = $true # a job can reach Completed and still have failed at what it was # asked to do - Update-Help is the case that proved it (gh#26) $t.Failed = (-not $t.Cancelled) -and (($state -ne 'Completed') -or $t.SawFailure) } } } function Get-PSMMTask { [CmdletBinding()] param() if ($script:PSMM_Tasks) { @($script:PSMM_Tasks) } else { @() } } # Cancel one running task (gh#24 - there was no way to stop anything). # Returns $true when it actually stopped something. function Stop-PSMMTask { [CmdletBinding()] param([Parameter(Mandatory)] $Task) if ($Task.Done) { return $false } $Task.Cancelled = $true # set BEFORE stopping: Update-PSMMTask reads it try { Stop-Job -Job $Task.Job -ErrorAction Stop } catch { $Task.Cancelled = $false; return $false } Update-PSMMTask $true } # Remove finished tasks from the registry (and their jobs). function Clear-PSMMTask { [CmdletBinding()] param() if (-not $script:PSMM_Tasks) { return } foreach ($t in @($script:PSMM_Tasks | Where-Object Done)) { Remove-Job -Job $t.Job -Force -ErrorAction SilentlyContinue $null = $script:PSMM_Tasks.Remove($t) } } # Stop and remove every job psmm started, wherever it started it. The single # implementation behind session exit, module removal and the tests. # # Jobs are found by NAME through the session job repository rather than from # $script:PSMM_Tasks, because the startup job (Start-PSMMDeferredJob) was never # in the task registry - and because this has to work when called from an # engine-event handler, which cannot see module state at all. function Clear-PSMMJob { [CmdletBinding()] param() $n = 0 foreach ($j in @(Get-Job -ErrorAction SilentlyContinue)) { if ("$($j.Name)" -notmatch '^(PSMM-Startup|psmm-task-\d+)$') { continue } try { if ("$($j.State)" -in 'NotStarted', 'Running') { Stop-Job -Job $j -ErrorAction SilentlyContinue } } catch { } try { Remove-Job -Job $j -Force -ErrorAction SilentlyContinue } catch { } $n++ } if ($script:PSMM_Tasks) { $script:PSMM_Tasks.Clear() } $script:PSMM_StartupJob = $null $n } # Classify what Update-Help complained about, and turn it into task output # lines (gh#26). # # Update-Help emits NON-terminating errors, so the job always reaches # 'Completed' and job state alone can never say whether it worked. Classify by # FullyQualifiedErrorId, never by message text: the ids are stable, the # messages are localised. Lines beginning 'FAILED' are what Update-PSMMTask # latches into the task's Failed flag. # # The four ids below were captured from live Update-Help runs, not recalled: # HelpInfoUriNotFound - the module ships no updatable help (the common case) # ModuleNotFound - the name matched nothing # UpdatableHelpSystemRequiresElevation - AllUsers scope without elevation # HelpCultureNotSupported - no help published for this UI culture # Anything else - network, proxy, a corrupt HelpInfo.xml - falls to the # default branch and is reported WITH its id, so an unclassified failure is # still visible and still fails the task rather than being swallowed. # # Lives here, as a real function, so the tests exercise THIS code. It is # shipped into the job by Get-PSMMJobPrelude like every other actuator. function Get-PSMMUpdateHelpReport { [CmdletBinding()] param([AllowNull()][AllowEmptyCollection()] $ErrorRecords) $benign = 0 foreach ($e in @($ErrorRecords)) { if (-not $e) { continue } $id = ("$($e.FullyQualifiedErrorId)" -split ',')[0] $msg = "$($e.Exception.Message)" -replace '\s+', ' ' switch ($id) { 'HelpInfoUriNotFound' { $benign++ } 'ModuleNotFound' { $benign++ } 'UpdatableHelpSystemRequiresElevation' { "FAILED elevation required: $msg" } 'HelpCultureNotSupported' { "FAILED UI culture not supported: $msg" } default { "FAILED [$id]: $msg" } } } if ($benign) { "note: $benign module(s) ship no updatable help - nothing to download for them" } 'help update finished' } # Dispose psmm's background jobs when the session ends (gh#28). # # Registered LAZILY - from Start-PSMMTask and Start-PSMMDeferredJob, never at # import - so a zero-config startup pays nothing for machinery it never uses. # # The handler reaches module scope through `& (Get-Module psmm) { ... }`, which # is verified to work from inside an exit handler. It cannot simply read # $script: variables: a Register-EngineEvent -Action scriptblock does NOT see # the defining module's script scope (they read as $null, and -MessageData did # not survive either), so anything written that way would have silently # disposed nothing while looking correct. # # PowerShell.Exiting does NOT fire on window close, on Stop-Process -Force, or # on a crash. It can never be the only layer - which is why psmm also disposes # on module removal (OnRemove in psmm.psm1), and why Start-ThreadJob stays the # transport: those jobs are in-process and die with pwsh regardless. function Register-PSMMJobDisposal { [CmdletBinding()] param() if ($script:PSMM_JobDisposalRegistered) { return } $script:PSMM_JobDisposalRegistered = $true try { $null = Register-EngineEvent -SourceIdentifier ([System.Management.Automation.PsEngineEvent]::Exiting) -Action { $m = Get-Module -Name psmm -ErrorAction SilentlyContinue | Select-Object -First 1 if ($m) { $null = & $m { Clear-PSMMJob } } } } catch { $script:PSMM_JobDisposalRegistered = $false } } # A cheap change-fingerprint: the UI's key loop polls this and re-renders # when it changes (task started/finished/produced output). LineCount, never # Output.Count - the ring buffer stops growing at the cap, and a job that is # still talking must still count as "changed" (gh#24). function Get-PSMMTaskFingerprint { [CmdletBinding()] param() $parts = foreach ($t in (Get-PSMMTask)) { "$($t.Id):$($t.Job.State):$($t.LineCount)" } $job = Get-PSMMStartupJob if ($job) { $parts = @($parts) + "startup:$($job.State):$(Get-PSMMStartupJobLineCount)" } $parts -join '|' } # One-line summary for the grid's side overlay; $null when nothing to show. function Get-PSMMTaskSummary { [CmdletBinding()] param() Update-PSMMTask $running = @(Get-PSMMTask | Where-Object { -not $_.Done }) $fresh = @(Get-PSMMTask | Where-Object { $_.Done -and -not $_.Seen }) if (-not $running.Count -and -not $fresh.Count) { return $null } $bits = @() foreach ($t in $running) { $n = $t.LineCount $bits += if ($n) { "$($t.Label) ($n)" } else { $t.Label } } foreach ($t in $fresh) { $bits += if ($t.Cancelled) { "$($t.Label) cancelled" } elseif ($t.Failed) { "$($t.Label) FAILED" } else { "$($t.Label) done" } } [pscustomobject]@{ RunningCount = $running.Count Text = ($bits -join ' | ') } } |