@twinsearth/w2m-dsh-plugin 0.5.9 → 0.6.1

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.
@@ -40,6 +40,10 @@
40
40
  # Get-ScheduledTask -TaskName 'W2M Localside agent' | Get-ScheduledTaskInfo
41
41
  # powershell -ExecutionPolicy Bypass -File deploy\windows\install-service.ps1 -Uninstall
42
42
  #
43
+ # # repair a task registered by an older version of this script (one that used
44
+ # # `MultipleInstances IgnoreNew` and therefore could not recover from a supervisor death):
45
+ # powershell -ExecutionPolicy Bypass -File deploy\windows\install-service.ps1 -UpdateTask
46
+ #
43
47
  # Everything after the parameters goes to the agent, so `-AgentArgs
44
48
  # '--p2p-port','41235'` pins the punch socket (and then one inbound firewall rule
45
49
  # is enough, instead of a rule per restart).
@@ -58,6 +62,8 @@ param(
58
62
  [int]$RestartBackoffMs = 60000,
59
63
  [int]$WatchdogMinutes = 5,
60
64
  [switch]$Interactive,
65
+ [switch]$UpdateTask,
66
+ [switch]$UpdateConfig,
61
67
  [switch]$Uninstall,
62
68
  [switch]$WhatIfOnly
63
69
  )
@@ -65,6 +71,54 @@ param(
65
71
  $ErrorActionPreference = 'Stop'
66
72
  $repo = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) # ...\deploy\windows -> repo root
67
73
  $supervisor = Join-Path $PSScriptRoot 'w2m-agent-supervisor.mjs'
74
+
75
+ # ---------------------------------------------------------------------------------------------
76
+ # -UpdateTask: repair an EXISTING registration, and touch nothing else.
77
+ #
78
+ # This exists because the task and the supervisor have to agree, and an installation registered by an
79
+ # older version of this script does not: `MultipleInstances IgnoreNew` together with the watchdog
80
+ # trigger means a task whose supervisor died while the task still reports Running can never start
81
+ # again. Measured on this machine: one supervisor, one agent, the port bound, and no way back.
82
+ #
83
+ # It runs FIRST and RETURNS, before the agent config is written and before node is even looked for,
84
+ # so it needs no elevation: `Set-ScheduledTask` on a task this user already owns is permitted
85
+ # (measured), while re-registering the S4U principal is not. Triggers and settings only -- the action,
86
+ # the principal and `%USERPROFILE%\.dsh\w2m\agent.json` are left exactly as they are.
87
+ # ---------------------------------------------------------------------------------------------
88
+ if ($UpdateTask) {
89
+ if (-not (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue)) {
90
+ Write-Error "nothing to update: $TaskName is not registered. Run without -UpdateTask to install it."
91
+ return
92
+ }
93
+ $watchdog = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) `
94
+ -RepetitionInterval (New-TimeSpan -Minutes $WatchdogMinutes) `
95
+ -RepetitionDuration ([TimeSpan]::FromDays(3650))
96
+ $existingTriggers = (Get-ScheduledTask -TaskName $TaskName).Triggers
97
+ $isInteractive = -not ($existingTriggers | Where-Object { $_.CimClass.CimClassName -eq 'MSFT_TaskBootTrigger' })
98
+ $updateTriggers = if ($isInteractive) {
99
+ @((New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\$env:USERNAME"), $watchdog)
100
+ } else {
101
+ @(
102
+ (New-ScheduledTaskTrigger -AtStartup),
103
+ (New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\$env:USERNAME"),
104
+ $watchdog
105
+ )
106
+ }
107
+ $updateSettings = New-ScheduledTaskSettingsSet `
108
+ -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
109
+ -MultipleInstances Parallel `
110
+ -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) `
111
+ -ExecutionTimeLimit (New-TimeSpan -Seconds 0) `
112
+ -StartWhenAvailable -Hidden
113
+ Set-ScheduledTask -TaskName $TaskName -Trigger $updateTriggers -Settings $updateSettings | Out-Null
114
+ $updated = Get-ScheduledTask -TaskName $TaskName
115
+ Write-Host "updated $TaskName (triggers and settings only; the action and the config are untouched)"
116
+ Write-Host " multiple instances : $($updated.Settings.MultipleInstances) (Parallel: the supervisor's lock is what prevents a second agent)"
117
+ Write-Host " watchdog : every $WatchdogMinutes min, indefinitely"
118
+ Write-Host " state : $($updated.State)"
119
+ return
120
+ }
121
+
68
122
  $node = (Get-Command node -ErrorAction SilentlyContinue).Source
69
123
  if (-not $node) { throw 'node is not on PATH; install Node 20.19+ first' }
70
124
  if (-not (Test-Path $supervisor)) { throw "supervisor missing: $supervisor" }
@@ -124,6 +178,27 @@ if ($WhatIfOnly) {
124
178
  $config | ConvertTo-Json -Depth 4
125
179
  return
126
180
  }
181
+ # THE CONFIG IS THE PART THAT CANNOT BE REBUILT FROM DEFAULTS.
182
+ #
183
+ # Measured the hard way while testing this deployment: re-running the installer with no parameters
184
+ # overwrote a live `agent.json` with the defaults -- the project became the current directory, the
185
+ # machine name became the hostname, and `--p2p-mode auto --p2p-port 41235` disappeared, so the agent
186
+ # immediately took an ephemeral port and every dispatch would have fallen back to the relay. Nothing
187
+ # errored and nothing warned; the file was simply replaced. Refusing by default, and naming exactly
188
+ # what would be lost, is one line of protection against the failure this whole directory is about.
189
+ if ((Test-Path $configPath) -and -not $UpdateConfig -and -not $WhatIfOnly) {
190
+ $current = Get-Content $configPath -Raw | ConvertFrom-Json
191
+ if ($current.project -ne $Project -or $current.name -ne $MachineName -or [string]$current.agentArgs -ne ($agentArgs -join ' ')) {
192
+ Write-Error @"
193
+ $configPath already exists and differs from what this run would write:
194
+ now : project=$($current.project) name=$($current.name) agentArgs="$($current.agentArgs)"
195
+ next: project=$Project name=$MachineName agentArgs="$($agentArgs -join ' ')"
196
+ Re-run with -UpdateConfig to replace it, or with -WhatIfOnly to see the whole file first.
197
+ A silent replacement is how a machine loses its pinned punch port and its identity.
198
+ "@
199
+ return
200
+ }
201
+ }
127
202
  New-Item -ItemType Directory -Force -Path (Split-Path -Parent $configPath) | Out-Null
128
203
  # Write it WITHOUT a BOM. `Set-Content -Encoding utf8` on PowerShell 5.1 prepends one, `JSON.parse`
129
204
  # refuses it, and the supervisor then exits 78 before it has opened its log -- a failure with no
@@ -135,28 +210,30 @@ Write-Host "wrote $configPath"
135
210
 
136
211
  $action = New-ScheduledTaskAction -Execute $node -Argument ($arguments -join ' ') -WorkingDirectory $PSScriptRoot
137
212
  # ---------------------------------------------------------------------------------------------
138
- # SELF-HEALING WITHOUT ELEVATION
213
+ # THE WATCHDOG TRIGGER, AND WHY THE TASK MAY NOW RUN MORE THAN ONE INSTANCE
139
214
  #
140
215
  # Measured twice on this machine: the whole process tree (supervisor + agent) is killed with
141
216
  # 0xC000013A (STATUS_CONTROL_C_EXIT -- its console went away), the log ends mid-sentence, and Task
142
217
  # Scheduler does NOT bring it back even with RestartCount 999: the task simply ends. The fleet then
143
- # silently loses the machine, which is exactly the failure a supervisor was supposed to prevent --
144
- # a supervisor that shares the console dies with it.
145
- #
146
- # A repeating trigger fixes that without any administrator right: combined with
147
- # `MultipleInstances IgnoreNew` the task means "be running, or start", so a death from any cause is
148
- # repaired at the next tick. The window is the repetition interval, and that is the honest trade:
149
- # up to N minutes offline after a console-close death, versus needing elevation for a session-0
150
- # service (the real fix, and the default mode above).
218
+ # silently loses the machine, which is exactly the failure a supervisor was supposed to prevent.
219
+ #
220
+ # A repeating trigger fixes that without any administrator right: the task means "be running, or
221
+ # start", so a death from any cause is repaired at the next tick. Until recently that trigger had to
222
+ # be paired with `MultipleInstances IgnoreNew` to keep it from producing a second agent, and that
223
+ # pairing is what cost this machine a day: measured, a supervisor killed while the task still
224
+ # reported Running left the task in `Running` forever (the rejected start is 0x800710E0, "the
225
+ # operator or administrator has refused the request"), so the watchdog could never fire again while
226
+ # the detached agent kept the punch port -- one supervisor, one agent, port bound, and no way back.
227
+ #
228
+ # `MultipleInstances Parallel` is now the correct setting, because the supervisor's own lock is what
229
+ # makes a second start harmless: a generation that finds the lock held logs a no-op line and exits
230
+ # without spawning, so a tick during a healthy run costs one short-lived process and nothing else.
231
+ # The trigger therefore does what it was written to do -- repair a dead supervisor -- and cannot do
232
+ # the thing it was feared for.
151
233
  # ---------------------------------------------------------------------------------------------
152
- $repetition = if ($Interactive) {
153
- # `[TimeSpan]::MaxValue` is NOT a valid duration here -- Task Scheduler rejects it and the whole
154
- # registration fails (measured: the script had already stopped the old task, so the machine was
155
- # left with no agent at all). Ten years is the idiomatic stand-in for "repeat indefinitely".
156
- New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) `
157
- -RepetitionInterval (New-TimeSpan -Minutes $WatchdogMinutes) `
158
- -RepetitionDuration ([TimeSpan]::FromDays(3650))
159
- } else { $null }
234
+ $repetition = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) `
235
+ -RepetitionInterval (New-TimeSpan -Minutes $WatchdogMinutes) `
236
+ -RepetitionDuration ([TimeSpan]::FromDays(3650))
160
237
  # AtStartup only makes sense for the session-0 (S4U) shape, which also runs whether or not anyone is
161
238
  # signed in. Registering an AtStartup trigger on an interactive task is refused without elevation --
162
239
  # measured, and the reason this list is conditional rather than always two entries.
@@ -165,15 +242,17 @@ $triggers = if ($Interactive) {
165
242
  } else {
166
243
  @(
167
244
  (New-ScheduledTaskTrigger -AtStartup),
168
- (New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\$env:USERNAME")
245
+ (New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\$env:USERNAME"),
246
+ $repetition
169
247
  )
170
248
  }
171
249
  $settings = New-ScheduledTaskSettingsSet `
172
250
  -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
173
- -MultipleInstances IgnoreNew `
251
+ -MultipleInstances Parallel `
174
252
  -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) `
175
253
  -ExecutionTimeLimit (New-TimeSpan -Seconds 0) `
176
254
  -StartWhenAvailable -Hidden
255
+
177
256
  $principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" `
178
257
  -LogonType $(if ($Interactive) { 'Interactive' } else { 'S4U' }) -RunLevel Limited
179
258