fix(desktop): contain runner launcher failure paths
This commit is contained in:
1 parent
d0085c9642
commit
cf0a621494
8 files changed
+115
-30
No files matched your search
@@ -5,7 +5,6 @@ $digest = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([Text.
|
||||
$taskName = 'DSH-Packaging-' + $digest.Substring(0,16)
|
||||
$log = Join-Path $PSScriptRoot ('run-' + [DateTime]::UtcNow.ToString('yyyyMMddTHHmmssfffZ') + '-' + $PID + '.log')
|
||||
try {
|
||||
@{ Pid = $PID; Started = (Get-Process -Id $PID).StartTime.ToUniversalTime().Ticks } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $PSScriptRoot 'process.json')
|
||||
$config = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'config.json') -Raw | ConvertFrom-Json
|
||||
$attemptFile = Join-Path $PSScriptRoot 'attempts.txt'
|
||||
$attempts = [int](Get-Content -LiteralPath $attemptFile -Raw)
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
internal static class WindowsRunnerLauncher
|
||||
@@ -9,9 +11,11 @@ internal static class WindowsRunnerLauncher
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
string root = AppDomain.CurrentDomain.BaseDirectory;
|
||||
string log = Path.Combine(root, "launcher-" + Process.GetCurrentProcess().Id);
|
||||
var owner = Process.GetCurrentProcess();
|
||||
string log = Path.Combine(root, "launcher-" + DateTime.UtcNow.ToString("yyyyMMddTHHmmssfffZ") + "-" + owner.Id + "-" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(root, "process.json"), "{\"Pid\":" + owner.Id + ",\"Started\":" + owner.StartTime.ToUniversalTime().Ticks + "}");
|
||||
if (args.Length != 1 || !Path.IsPathRooted(args[0]))
|
||||
throw new ArgumentException("Expected the absolute PowerShell executable path.");
|
||||
using (var output = File.Create(log + ".out.log"))
|
||||
@@ -22,7 +26,6 @@ internal static class WindowsRunnerLauncher
|
||||
{
|
||||
FileName = args[0],
|
||||
Arguments = "-NoProfile -NonInteractive -File \"" + Path.Combine(root, "run.ps1") + "\"",
|
||||
WorkingDirectory = Directory.GetParent(root.TrimEnd(Path.DirectorySeparatorChar)).FullName,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
@@ -32,19 +35,42 @@ internal static class WindowsRunnerLauncher
|
||||
Task stdout = child.StandardOutput.BaseStream.CopyToAsync(output);
|
||||
Task stderr = child.StandardError.BaseStream.CopyToAsync(errors);
|
||||
child.WaitForExit();
|
||||
Task.WaitAll(stdout, stderr);
|
||||
// Descendants can retain inherited pipe writers after the direct child exits.
|
||||
if (!Task.WaitAll(new[] { stdout, stderr }, TimeSpan.FromSeconds(5)))
|
||||
{
|
||||
File.WriteAllText(log + ".drain.log", "Output drain timed out after child exit " + child.ExitCode + "; launcher logs may be incomplete. Inspect surviving descendants before restarting.");
|
||||
child.StandardOutput.Close();
|
||||
child.StandardError.Close();
|
||||
DisableTask(root);
|
||||
return 5;
|
||||
}
|
||||
return child.ExitCode;
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
// Failures before run.ps1 cannot consume its budget. Disable periodic starts instead.
|
||||
int result = 2;
|
||||
try { DisableTask(root); }
|
||||
catch (Exception disableError) { error = new AggregateException(error, disableError); result = 4; }
|
||||
try { File.AppendAllText(log + ".err.log", error.ToString() + Environment.NewLine); }
|
||||
catch (Exception)
|
||||
{
|
||||
// No console is available. Task Scheduler result 3 reports that diagnostics could not be written.
|
||||
return 3;
|
||||
return result == 4 ? 4 : 3;
|
||||
}
|
||||
return 2;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private static void DisableTask(string root)
|
||||
{
|
||||
string runner = Directory.GetParent(root.TrimEnd(Path.DirectorySeparatorChar)).FullName.TrimEnd('\\', '/');
|
||||
string taskName;
|
||||
using (var hash = SHA256.Create())
|
||||
taskName = "DSH-Packaging-" + BitConverter.ToString(hash.ComputeHash(Encoding.UTF8.GetBytes(runner.ToUpperInvariant()))).Replace("-", "").Substring(0, 16);
|
||||
dynamic scheduler = Activator.CreateInstance(Type.GetTypeFromProgID("Schedule.Service"));
|
||||
scheduler.Connect();
|
||||
scheduler.GetFolder("\\").GetTask(taskName).Enabled = false;
|
||||
}
|
||||
}
|
||||
@@ -38,10 +38,10 @@ if ($Action -eq 'install') {
|
||||
$null = New-Item -ItemType Directory -Path $serviceDir
|
||||
}
|
||||
$compiler = Join-Path $env:SystemRoot 'Microsoft.NET/Framework64/v4.0.30319/csc.exe'
|
||||
if (-not (Test-Path -LiteralPath $compiler)) { throw 'Install requires the Windows .NET Framework 4 compiler (Framework64/v4.0.30319/csc.exe).' }
|
||||
if (-not (Test-Path -LiteralPath $compiler)) { throw 'Install requires the Windows .NET Framework 4.5+ compiler (Framework64/v4.0.30319/csc.exe).' }
|
||||
$launcher = Join-Path $serviceDir 'launcher.exe'
|
||||
& $compiler /nologo /target:winexe "/out:$launcher" (Join-Path $PSScriptRoot 'windows-runner-launcher.cs')
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Runner launcher compilation failed; no task was installed.' }
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Runner launcher compilation failed; verify .NET Framework 4.5+ is installed. No task was installed.' }
|
||||
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'start-windows-packaging-runner.ps1') -Destination (Join-Path $serviceDir 'start.ps1')
|
||||
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'run-windows-runner-task.ps1') -Destination (Join-Path $serviceDir 'run.ps1')
|
||||
@{ RunnerRoot = $RunnerRoot; ConfigRoot = $ConfigRoot; TaskName = $taskName; RestartCount = $RestartCount } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $serviceDir 'config.json') -Encoding utf8
|
||||
|
||||
@@ -23,22 +23,58 @@ function Invoke-Launcher([string]$Executable, [int]$ExpectedExit) {
|
||||
$process.StartInfo.UseShellExecute = $false
|
||||
$process.StartInfo.CreateNoWindow = $true
|
||||
$process.StartInfo.ArgumentList.Add($Executable)
|
||||
$started = $false
|
||||
$primaryFailure = $null
|
||||
try {
|
||||
$null = $process.Start()
|
||||
$started = $process.Start()
|
||||
if (-not $process.WaitForExit(30000)) { throw 'Launcher did not exit within 30 seconds.' }
|
||||
if ($process.ExitCode -ne $ExpectedExit) { throw "Expected exit $ExpectedExit, got $($process.ExitCode)." }
|
||||
return (Join-Path $taskRoot ('launcher-' + $process.Id))
|
||||
} finally {
|
||||
if ($process.Id -and -not $process.HasExited) {
|
||||
$process.Kill($true)
|
||||
if (-not $process.WaitForExit(30000)) { throw 'Launcher teardown did not complete.' }
|
||||
$logs = @(Get-ChildItem -LiteralPath $taskRoot -Filter ('launcher-*-'+$process.Id+'-*.err.log'))
|
||||
if ($logs.Count -ne 1) { throw 'Expected a unique launcher diagnostic.' }
|
||||
return $logs[0].FullName.Substring(0, $logs[0].FullName.Length - '.err.log'.Length)
|
||||
} catch { $primaryFailure = $_; throw } finally {
|
||||
try {
|
||||
if ($started -and -not $process.HasExited) {
|
||||
$process.Kill($true)
|
||||
if (-not $process.WaitForExit(30000)) { throw 'Launcher teardown did not complete.' }
|
||||
}
|
||||
} catch {
|
||||
if (-not $primaryFailure) { throw }
|
||||
Write-Warning "Launcher cleanup also failed: $_"
|
||||
} finally {
|
||||
$process.Dispose()
|
||||
}
|
||||
$process.Dispose()
|
||||
}
|
||||
}
|
||||
$log = Invoke-Launcher (Join-Path $PSHOME 'pwsh.exe') 37
|
||||
if ((Get-Content -LiteralPath ($log + '.out.log') -Raw).Trim() -ne 'console=0') { throw 'Child allocated a console or stdout was lost.' }
|
||||
if ((Get-Content -LiteralPath ($log + '.err.log') -Raw).Trim() -ne 'launcher stderr probe') { throw 'Child stderr was lost.' }
|
||||
$log = Invoke-Launcher (Join-Path $root 'missing.exe') 2
|
||||
$log = Invoke-Launcher (Join-Path $root 'missing.exe') 4
|
||||
if (-not (Get-Content -LiteralPath ($log + '.err.log') -Raw).Contains('Win32Exception')) { throw 'Startup failure diagnostic was lost.' }
|
||||
Write-Output "PASS: GUI executable, no child console, exit propagation, stdout/stderr and startup failure. Evidence: $root"
|
||||
@'
|
||||
$info = [Diagnostics.ProcessStartInfo]::new((Join-Path $PSHOME 'pwsh.exe'))
|
||||
$info.UseShellExecute = $false
|
||||
$info.CreateNoWindow = $true
|
||||
$info.ArgumentList.Add('-NoProfile')
|
||||
$info.ArgumentList.Add('-NonInteractive')
|
||||
$info.ArgumentList.Add('-Command')
|
||||
$info.ArgumentList.Add('Start-Sleep -Seconds 120')
|
||||
$orphan = [Diagnostics.Process]::Start($info)
|
||||
@{ Pid = $orphan.Id; Started = $orphan.StartTime.ToUniversalTime().Ticks } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $PSScriptRoot 'orphan.json')
|
||||
exit 37
|
||||
'@ | Set-Content -LiteralPath (Join-Path $taskRoot 'run.ps1')
|
||||
$orphan = $null
|
||||
try {
|
||||
# This fixture has no registered task; failure to disable it returns 4 after the bounded drain.
|
||||
$log = Invoke-Launcher (Join-Path $PSHOME 'pwsh.exe') 4
|
||||
if (-not (Get-Content -LiteralPath ($log + '.drain.log') -Raw).Contains('child exit 37')) { throw 'Inherited writer did not exercise bounded draining.' }
|
||||
} finally {
|
||||
$record = Get-Content -LiteralPath (Join-Path $taskRoot 'orphan.json') -Raw | ConvertFrom-Json
|
||||
$orphan = Get-Process -Id $record.Pid -ErrorAction SilentlyContinue
|
||||
if ($orphan -and $orphan.StartTime.ToUniversalTime().Ticks -eq $record.Started) {
|
||||
$orphan.Kill($true)
|
||||
if (-not $orphan.WaitForExit(30000)) { throw 'Orphan fixture did not exit.' }
|
||||
$orphan.Dispose()
|
||||
}
|
||||
}
|
||||
Write-Output "PASS: GUI executable, no child console, exit propagation, stdout/stderr, startup failure and bounded inherited-pipe drain. Evidence: $root"
|
||||
@@ -27,7 +27,7 @@ function Invoke-Manager([string]$Operation, [switch]$ExpectFailure) {
|
||||
& (Join-Path $PSHOME 'pwsh.exe') -NoProfile -File $manager -Action $Operation -RunnerRoot $runner @installOptions
|
||||
if (($LASTEXITCODE -eq 0) -eq [bool]$ExpectFailure) { throw "Unexpected exit $LASTEXITCODE for $Operation" }
|
||||
}
|
||||
function Wait-Condition([scriptblock]$Condition, [string]$Description, [int]$Seconds = 30) {
|
||||
function Wait-Condition([scriptblock]$Condition, [string]$Description, [int]$Seconds = 60) {
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds($Seconds)
|
||||
do {
|
||||
if (& $Condition) { return }
|
||||
@@ -53,7 +53,8 @@ try {
|
||||
Invoke-Manager install -ExpectFailure
|
||||
Invoke-Manager start
|
||||
$starts = Join-Path $runner 'starts.txt'
|
||||
Wait-Condition { (Test-Path -LiteralPath $starts) -and @(Get-Content -LiteralPath $starts).Count -ge 2 } 'automatic retry after unexpected clean exit' 110
|
||||
# The one-minute scheduler trigger and cold PowerShell/CIM startup share the busy Windows host.
|
||||
Wait-Condition { (Test-Path -LiteralPath $starts) -and @(Get-Content -LiteralPath $starts).Count -ge 2 } 'automatic retry after unexpected clean exit' 180
|
||||
$consoles = @(Get-Content -LiteralPath (Join-Path $runner 'consoles.txt'))
|
||||
if ($consoles.Count -ne 2 -or @($consoles | Where-Object { $_ -ne '0' }).Count) { throw 'Initial or recovered runner allocated a console.' }
|
||||
$childId = [int]@(Get-Content -LiteralPath $starts)[-1]
|
||||
@@ -88,11 +89,34 @@ try {
|
||||
Start-ScheduledTask -TaskName $config.TaskName -TaskPath '\'
|
||||
Wait-Condition { (Get-ScheduledTask -TaskName $config.TaskName -TaskPath '\').State -eq 'Disabled' } 'invalid restart budget disables task'
|
||||
if (@(Get-Content -LiteralPath $starts).Count -ne 3) { throw 'Invalid budget launched another runner.' }
|
||||
$savedAction = (Get-ScheduledTask -TaskName $taskName -TaskPath '\').Actions
|
||||
$missingAction = New-ScheduledTaskAction -Execute $launcher -Argument ('"' + (Join-Path $root 'missing.exe') + '"') -WorkingDirectory $runner
|
||||
$null = Set-ScheduledTask -TaskName $taskName -TaskPath '\' -Action $missingAction
|
||||
Set-Content -LiteralPath (Join-Path $runner 'dsh-task/attempts.txt') -Value '0'
|
||||
$null = Enable-ScheduledTask -TaskName $taskName -TaskPath '\'
|
||||
Start-ScheduledTask -TaskName $taskName -TaskPath '\'
|
||||
Wait-Condition { (Get-ScheduledTask -TaskName $taskName -TaskPath '\').State -eq 'Disabled' } 'launcher startup failure disables periodic starts'
|
||||
Wait-Condition { (Get-ScheduledTaskInfo -TaskName $taskName -TaskPath '\').LastTaskResult -eq 2 } 'launcher startup failure result'
|
||||
$null = Set-ScheduledTask -TaskName $taskName -TaskPath '\' -Action $savedAction
|
||||
$taskDirectory = Join-Path $runner 'dsh-task'
|
||||
$originalAcl = Get-Acl -LiteralPath $taskDirectory
|
||||
try {
|
||||
$blockedAcl = Get-Acl -LiteralPath $taskDirectory
|
||||
$sid = [Security.Principal.WindowsIdentity]::GetCurrent().User
|
||||
$blockedAcl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($sid, 'WriteData, AppendData', 'ContainerInherit, ObjectInherit', 'None', 'Deny'))
|
||||
Set-Acl -LiteralPath $taskDirectory -AclObject $blockedAcl
|
||||
$null = Enable-ScheduledTask -TaskName $taskName -TaskPath '\'
|
||||
Start-ScheduledTask -TaskName $taskName -TaskPath '\'
|
||||
Wait-Condition { (Get-ScheduledTask -TaskName $taskName -TaskPath '\').State -eq 'Disabled' } 'unwritable task directory disables periodic starts'
|
||||
Wait-Condition { (Get-ScheduledTaskInfo -TaskName $taskName -TaskPath '\').LastTaskResult -eq 3 } 'unwritable diagnostic result'
|
||||
} finally { Set-Acl -LiteralPath $taskDirectory -AclObject $originalAcl }
|
||||
if (@(Get-Content -LiteralPath $starts).Count -ne 3) { throw 'Launcher failure started another runner.' }
|
||||
if ([int](Get-Content -LiteralPath (Join-Path $taskDirectory 'attempts.txt') -Raw) -ne 0) { throw 'Startup failure unexpectedly entered run.ps1.' }
|
||||
Invoke-Manager uninstall
|
||||
$installed = $false
|
||||
Invoke-Manager status -ExpectFailure
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $runner '.runner'))) { throw 'Registration removed.' }
|
||||
Write-Output "PASS: windowless initial launch and recovery, install, duplicate refusal, automatic retry, start, status, stop, child teardown, retry exhaustion, uninstall. Evidence: $root"
|
||||
Write-Output "PASS: windowless initial launch and recovery, install, duplicate refusal, automatic retry, start, status, stop, child teardown, retry exhaustion, startup failure containment, unwritable diagnostics, uninstall. Evidence: $root"
|
||||
} catch { $primaryFailure = $_; throw } finally {
|
||||
$cleanupErrors = @()
|
||||
if ($installed) {
|
||||
@@ -100,8 +124,8 @@ try {
|
||||
try { Stop-ScheduledTask -TaskName $taskName -TaskPath '\' } catch { $cleanupErrors += $_ }
|
||||
try { Unregister-ScheduledTask -TaskName $taskName -TaskPath '\' -Confirm:$false } catch { $cleanupErrors += $_ }
|
||||
}
|
||||
foreach ($process in @(Get-CimInstance Win32_Process -Filter "Name = 'pwsh.exe'")) {
|
||||
if ($process.CommandLine -and $process.CommandLine.Contains($root)) {
|
||||
foreach ($process in @(Get-CimInstance Win32_Process -Filter "Name = 'pwsh.exe' OR Name = 'launcher.exe'")) {
|
||||
if (($process.Name -eq 'pwsh.exe' -and $process.CommandLine -and $process.CommandLine.Contains($root)) -or ($process.Name -eq 'launcher.exe' -and $process.ExecutablePath -and $process.ExecutablePath.StartsWith($root + '\', [StringComparison]::OrdinalIgnoreCase))) {
|
||||
$owned = Get-Process -Id $process.ProcessId -ErrorAction SilentlyContinue
|
||||
if ($owned) {
|
||||
try {
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
en: bb889e9514678d1c
|
||||
zh: 48cbec1e907d6a2b
|
||||
/windows-signing-and-runner-operations/machine-setup-and-runner-startup/supervised-startup:
|
||||
en: 6239b4d4434b0a2d
|
||||
zh: ddb9bfc2819ebfd3
|
||||
en: c8847ec2fbd22657
|
||||
zh: 87ba89af699a5955
|
||||
/windows-signing-and-runner-operations/signing-configuration:
|
||||
en: 4b5548818375eb9a
|
||||
zh: 8c975adbb1dca9c8
|
||||
|
||||
@@ -36,15 +36,15 @@ Run `pwsh -NoProfile -File apps/desktop/scripts/setup-windows-packaging.ps1 -Roo
|
||||
|
||||
### Supervised startup
|
||||
|
||||
For the complete registration, label, routing and approval sequence, start with [CI publication setup](../publishing/ci/README.md#setup-sequence). The task installer requires the Windows .NET Framework 4 compiler at `%SystemRoot%/Microsoft.NET/Framework64/v4.0.30319/csc.exe`. It compiles a GUI executable into `runner/dsh-task/launcher.exe`; this executable starts PowerShell with no console and waits for it to exit. Startup output and errors go to `launcher-*.out.log` and `launcher-*.err.log`. Task result `2` indicates launcher failure; `3` means its error log could not be written. Normal runner output remains in `run-*.log`.
|
||||
For the complete registration, label, routing and approval sequence, start with [CI publication setup](../publishing/ci/README.md#setup-sequence). The task installer requires the Windows .NET Framework 4.5+ compiler at `%SystemRoot%/Microsoft.NET/Framework64/v4.0.30319/csc.exe`. It compiles a GUI executable into `runner/dsh-task/launcher.exe`; this executable starts PowerShell with no console and waits for it to exit. The output and errors of `run.ps1` itself go to uniquely named `launcher-*.out.log` and `launcher-*.err.log`; filenames include UTC time, PID and a random identifier. Launcher failures disable periodic starts, including failures before the retry budget can be read. Task result `2` indicates launcher failure; `3` means its error log could not be written; `4` means disabling the task also failed and an operator must disable it in Task Scheduler. After the direct child exits, output draining is limited to five seconds. A timeout disables the task and returns `5`, with a `launcher-*.drain.log` diagnostic: output may be incomplete, and an operator must inspect surviving descendants before restarting. The recorded stop target is the launcher process, so stop includes the launcher and its descendants. Normal runner output remains in `run-*.log`.
|
||||
|
||||
Run `pwsh -NoProfile -File apps/desktop/scripts/windows-runner-task.ps1 -Action install -RunnerRoot <registered-runner-directory> -ConfigRoot <machine-root>/packaging` as the runner's Windows user, using its installed PowerShell 7. The installer checks the environment and copies startup scripts into `runner/dsh-task`; registration, tools and secrets remain separate. It registers a disabled, limited-permission interactive Task Scheduler task without storing an account password. For `-Action start` or `-Action status`, pass only `-RunnerRoot`; configuration and retry options apply only to installation. The task starts after this user's desktop login, including after reboot, and stops on logout. It does not provide unattended boot or unlock a signing Token.
|
||||
|
||||
The task permits one instance and has no execution time limit. A repeating trigger checks every `-RestartMinutes` minutes (default `1`) and restarts an exited launcher, including an unexpected zero exit. The persisted budget permits the initial launch plus `-RestartCount` restarts (default `10`) between explicit starts. Every desktop login also consumes this budget; `status` shows the remaining launches. After the budget is exhausted, login alone cannot restart the runner. Starting an already running task does not reset the budget. Missing or invalid budget/configuration files disable automatic starts until repaired. GitHub's launcher owns runner updates; task recovery does not rerun a failed workflow or clear signing interlocks. After retries are exhausted, inspect `runner/dsh-task/run-*.log`, the runner's `_diag` logs and `status`, correct the cause, then run `start`. A running task alone does not prove connectivity: verify `Connected to GitHub` and `Listening for Jobs`, then verify assignment in Actions. Logs are retained for operator-managed cleanup.
|
||||
The task permits one instance and has no execution time limit. A repeating trigger checks every `-RestartMinutes` minutes (default `1`) and restarts an exited launcher, including an unexpected zero exit. The persisted budget permits the initial launch plus `-RestartCount` restarts (default `10`) between explicit starts. Every desktop login also consumes this budget; `status` shows the remaining launches. After the budget is exhausted, login alone cannot restart the runner. Starting an already running task does not reset the budget. Missing or invalid budget/configuration files disable automatic starts until repaired. GitHub's launcher owns runner updates; task recovery does not rerun a failed workflow or clear signing interlocks. After retries are exhausted, inspect `runner/dsh-task/run-*.log`, the runner's `_diag` logs and `status`, correct the cause, then run `start`. A running task alone does not prove connectivity: verify `Connected to GitHub` and `Listening for Jobs`, then verify assignment in Actions. Both `run-*.log` and `launcher-*.log` are retained for operator-managed cleanup.
|
||||
|
||||
Before maintenance, pause new workflow dispatch and wait for the runner to become idle. `-Action stop` refuses an active worker for this runner, disables future starts and stops its task process tree; the local check cannot prevent a concurrent GitHub assignment. `-Action uninstall` requires a stopped, disabled task and retains registration, configuration and logs. Pulling repository changes does not update the installed copy. To upgrade it, record the configuration directory and retry settings, then run `stop`, `uninstall`, `install` with those settings, and `start` from the updated checkout. Confirm listening and job assignment again. When migrating a manually launched runner, stop its existing launcher only after it is idle, then start the task; never run both launchers together.
|
||||
|
||||
Operators can run `pwsh -NoProfile -File apps/desktop/tests/windows-runner-task.ps1` on Windows to exercise installation, console-free initial launch and automatic recovery, duplicate-start prevention, stop, child-process teardown and uninstall against an isolated fake runner. This opt-in test creates a temporary task and retains diagnostic files; it does not contact GitHub or access signing hardware. `pwsh -NoProfile -File apps/desktop/tests/windows-runner-launcher.ps1` checks the GUI executable, child console absence, exit status and diagnostic output without Task Scheduler; Windows CI runs it. Real-machine acceptance additionally checks an idle runner coming online without a visible Terminal window, an Actions job being assigned, and login after an operator-approved reboot. Signed packaging and logout/reboot acceptance require separate operator verification.
|
||||
Operators can run `pwsh -NoProfile -File apps/desktop/tests/windows-runner-task.ps1` on Windows to exercise installation, console-free initial launch and automatic recovery, duplicate-start prevention, stop, child-process teardown, containment of startup/logging failures and uninstall against an isolated fake runner. This opt-in test creates a temporary task and retains diagnostic files; it does not contact GitHub or access signing hardware. `pwsh -NoProfile -File apps/desktop/tests/windows-runner-launcher.ps1` checks the GUI executable, child console absence, exit status, diagnostic output and bounded draining with an inherited writer without Task Scheduler; Windows CI runs it. Real-machine acceptance additionally checks an idle runner coming online without a visible Terminal window, an Actions job being assigned, and login after an operator-approved reboot. Signed packaging and logout/reboot acceptance require separate operator verification.
|
||||
|
||||
<a id="signing-configuration"></a>
|
||||
|
||||
|
||||
@@ -36,15 +36,15 @@ description: "配置和维护 Windows 打包 runner、签名凭据、EV Token
|
||||
|
||||
### 受监督启动
|
||||
|
||||
完整的注册、标签、路由和审批配置顺序见 [CI 发布配置](../publishing/ci/README.zh.md#setup-sequence)。任务安装器需要 Windows .NET Framework 4 编译器,路径为 `%SystemRoot%/Microsoft.NET/Framework64/v4.0.30319/csc.exe`。它将 GUI 可执行文件编译到 `runner/dsh-task/launcher.exe`;该程序不分配控制台地启动 PowerShell,并等待其退出。启动输出和错误分别写入 `launcher-*.out.log` 和 `launcher-*.err.log`。任务结果 `2` 表示启动器失败,`3` 表示无法写入错误日志。正常 runner 输出仍保存在 `run-*.log`。
|
||||
完整的注册、标签、路由和审批配置顺序见 [CI 发布配置](../publishing/ci/README.zh.md#setup-sequence)。任务安装器需要 Windows .NET Framework 4.5+ 编译器,路径为 `%SystemRoot%/Microsoft.NET/Framework64/v4.0.30319/csc.exe`。它将 GUI 可执行文件编译到 `runner/dsh-task/launcher.exe`;该程序不分配控制台地启动 PowerShell,并等待其退出。`run.ps1` 自身的输出和错误分别写入名称唯一的 `launcher-*.out.log` 和 `launcher-*.err.log`,文件名包含 UTC 时间、PID 和随机标识。启动器失败时禁用周期启动,包括尚未读取重试预算时发生的失败。任务结果 `2` 表示启动器失败,`3` 表示无法写入错误日志,`4` 表示禁用任务也失败,需要操作者在计划任务中手动禁用。直接子进程退出后,最多等待五秒排空输出;超时则禁用任务并返回 `5`,将诊断写入 `launcher-*.drain.log`:输出可能不完整,操作者需检查残留后代进程后再启动。记录的停止目标是启动器进程,因此 stop 覆盖启动器及其后代。正常 runner 输出仍保存在 `run-*.log`。
|
||||
|
||||
以 runner 的 Windows 用户运行 `pwsh -NoProfile -File apps/desktop/scripts/windows-runner-task.ps1 -Action install -RunnerRoot <registered-runner-directory> -ConfigRoot <machine-root>/packaging`,使用该机器安装的 PowerShell 7。安装器检查环境,并将启动脚本复制到 `runner/dsh-task`;注册、工具和凭据仍单独管理。它注册一个初始禁用、权限受限的交互式计划任务,不保存账户密码。执行 `-Action start` 或 `-Action status` 时只传 `-RunnerRoot`;配置目录及重试选项仅适用于安装。任务在该用户登录桌面后启动,重启后也需要登录,注销时停止。它不提供无人值守开机,也不解锁签名 Token。
|
||||
|
||||
任务只允许一个实例,且没有执行时限。周期触发器每隔 `-RestartMinutes` 分钟(默认 `1`)检查一次,并恢复已退出的启动器,包括意外的零退出码。持久预算允许首次启动及 `-RestartCount` 次重启(默认 `10`),直到再次显式启动。每次桌面登录也会消耗预算,`status` 显示剩余启动次数;预算耗尽后,仅登录不能重启 runner。对正在运行的任务执行 start 不会重置预算。预算或配置文件缺失、无效时,自动启动会禁用,需修复后恢复。GitHub 启动器负责 runner 更新;任务恢复不会重跑失败的 workflow,也不会清除签名互锁。重试耗尽后,检查 `runner/dsh-task/run-*.log`、runner 的 `_diag` 日志和 `status`,修复原因后运行 `start`。任务运行不等于连接成功:确认出现 `Connected to GitHub` 和 `Listening for Jobs`,再到 Actions 确认任务分配。日志保留,由操作者管理清理。
|
||||
任务只允许一个实例,且没有执行时限。周期触发器每隔 `-RestartMinutes` 分钟(默认 `1`)检查一次,并恢复已退出的启动器,包括意外的零退出码。持久预算允许首次启动及 `-RestartCount` 次重启(默认 `10`),直到再次显式启动。每次桌面登录也会消耗预算,`status` 显示剩余启动次数;预算耗尽后,仅登录不能重启 runner。对正在运行的任务执行 start 不会重置预算。预算或配置文件缺失、无效时,自动启动会禁用,需修复后恢复。GitHub 启动器负责 runner 更新;任务恢复不会重跑失败的 workflow,也不会清除签名互锁。重试耗尽后,检查 `runner/dsh-task/run-*.log`、runner 的 `_diag` 日志和 `status`,修复原因后运行 `start`。任务运行不等于连接成功:确认出现 `Connected to GitHub` 和 `Listening for Jobs`,再到 Actions 确认任务分配。`run-*.log` 和 `launcher-*.log` 均保留,由操作者管理清理。
|
||||
|
||||
维护前暂停新 workflow 的触发,等待 runner 空闲。`-Action stop` 在该 runner 存在活跃 worker 时拒绝停止,否则禁用后续启动并停止任务进程树;本地检查无法阻止 GitHub 同时分配任务。`-Action uninstall` 要求任务已停止并禁用,保留注册、配置和日志。拉取仓库更新不会更新已安装的副本。升级时先记录配置目录和重试设置,再从更新后的 checkout 依次执行 `stop`、`uninstall`、携带原设置的 `install` 和 `start`,最后重新确认监听与 job 分配。迁移手动启动的 runner 时,只在空闲后停止原启动器,再启动计划任务;不要同时运行两个启动器。
|
||||
|
||||
操作者可在 Windows 上运行 `pwsh -NoProfile -File apps/desktop/tests/windows-runner-task.ps1`,使用隔离的假 runner 验证安装、无控制台的首次启动与自动恢复、防止重复启动、停止、子进程退出和卸载。此显式运行的测试创建临时任务并保留诊断文件,不连接 GitHub 或访问签名硬件。`pwsh -NoProfile -File apps/desktop/tests/windows-runner-launcher.ps1` 无需计划任务,检查 GUI 可执行文件、子进程无控制台、退出状态及诊断输出;Windows CI 会执行该测试。真实机器验收还需检查空闲 runner 上线且不出现 Terminal 窗口、Actions job 分配,以及经操作者批准的重启后登录。签名打包和注销/重启验收需要操作者单独验证。
|
||||
操作者可在 Windows 上运行 `pwsh -NoProfile -File apps/desktop/tests/windows-runner-task.ps1`,使用隔离的假 runner 验证安装、无控制台的首次启动与自动恢复、防止重复启动、停止、子进程退出、启动/日志失败时的禁用处理和卸载。此显式运行的测试创建临时任务并保留诊断文件,不连接 GitHub 或访问签名硬件。`pwsh -NoProfile -File apps/desktop/tests/windows-runner-launcher.ps1` 无需计划任务,检查 GUI 可执行文件、子进程无控制台、退出状态、诊断输出以及存在继承管道写端时的有界排空;Windows CI 会执行该测试。真实机器验收还需检查空闲 runner 上线且不出现 Terminal 窗口、Actions job 分配,以及经操作者批准的重启后登录。签名打包和注销/重启验收需要操作者单独验证。
|
||||
|
||||
<a id="signing-configuration"></a>
|
||||
|
||||
|
||||
Reference in new issue
Block a user