mirror of
https://github.com/ChrisTitusTech/winutil.git
synced 2026-10-23 21:41:07 +00:00
fix(win11): resolve the first-logon restart race (#5167)
* fix(win11): resolve the first-logon restart race UserOnce could force a restart while FirstLogon was still applying changes. Move the restart to FirstLogon behind a UserOnce.complete wait with a five-minute timeout, wait for OneDrive uninstall, and suppress Recall's automatic restart. * test: cover first-logon restart coordination --------- Co-authored-by: Chris Titus <contact@christitus.com>
This commit is contained in:
1 parent
f9cb440c31
commit
777fb5ccff
3 files changed
+106
-5
No files matched your search
@@ -134,6 +134,10 @@ When you install Windows 11 from your modified ISO:
|
||||
|
||||
---
|
||||
|
||||
### First-login restart
|
||||
|
||||
After installation, WinUtil restarts Windows only after both the system and user customization scripts finish. If user customization has not signaled completion within five minutes after system customization finishes, the automatic restart is skipped. Check `C:\Windows\Setup\Scripts\FirstLogon.log` and `%TEMP%\UserOnce.log` before restarting manually.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
| Problem | Fix |
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
BeforeAll {
|
||||
[xml]$unattend = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../tools/autounattend.xml') -Raw
|
||||
function Get-CompletionTail {
|
||||
param([string]$Name, [string]$Variable)
|
||||
$text = $unattend.SelectSingleNode("//*[local-name()='File' and @path='C:\Windows\Setup\Scripts\$Name.ps1']").InnerText
|
||||
$tokens = $null
|
||||
$errors = $null
|
||||
$ast = [System.Management.Automation.Language.Parser]::ParseInput($text, [ref]$tokens, [ref]$errors)
|
||||
if ($errors.Count) { throw ($errors | Out-String) }
|
||||
$start = @($ast.EndBlock.Statements | Where-Object {
|
||||
$_ -is [System.Management.Automation.Language.AssignmentStatementAst] -and
|
||||
$_.Left -is [System.Management.Automation.Language.VariableExpressionAst] -and
|
||||
$_.Left.VariablePath.UserPath -eq $Variable
|
||||
})
|
||||
if ($start.Count -ne 1) { throw "Expected one $Variable assignment in $Name." }
|
||||
# Execute only completion coordination, never setup commands, downloads, or installers.
|
||||
[scriptblock]::Create($text.Substring($start[0].Extent.StartOffset))
|
||||
}
|
||||
$script:publishCompletion = Get-CompletionTail 'UserOnce' 'completionDir'
|
||||
$script:awaitCompletion = Get-CompletionTail 'FirstLogon' 'completionPath'
|
||||
# Shadow the real restart command even on Windows; a missing mock must never reboot a test host.
|
||||
function Restart-Computer { param([switch]$Force) throw 'Restart must be mocked.' }
|
||||
}
|
||||
|
||||
Describe 'First-logon restart coordination' {
|
||||
BeforeEach {
|
||||
$script:savedLocalAppData = $env:LOCALAPPDATA
|
||||
$env:LOCALAPPDATA = Join-Path $TestDrive ('profile[1]-' + [guid]::NewGuid())
|
||||
$script:completionPath = Join-Path $env:LOCALAPPDATA 'WinUtil/UserOnce.complete'
|
||||
$script:now = [datetime]'2026-01-01T00:00:00'
|
||||
$script:sleeps = 0
|
||||
$script:messages = [System.Collections.Generic.List[string]]::new()
|
||||
Mock Get-Date { $script:now }
|
||||
Mock Start-Sleep { $script:sleeps++; $script:now = $script:now.AddMinutes(1) }
|
||||
Mock Out-File { $script:messages.Add([string]$InputObject) }
|
||||
Mock Restart-Computer { }
|
||||
}
|
||||
|
||||
AfterEach {
|
||||
$env:LOCALAPPDATA = $script:savedLocalAppData
|
||||
}
|
||||
|
||||
It 'restarts immediately when user configuration finished first' {
|
||||
& $script:publishCompletion
|
||||
Test-Path -LiteralPath $script:completionPath | Should -BeTrue
|
||||
& $script:awaitCompletion
|
||||
Should -Invoke Restart-Computer -Times 1 -Exactly -ParameterFilter { $Force }
|
||||
Should -Invoke Start-Sleep -Times 0 -Exactly
|
||||
Test-Path -LiteralPath $script:completionPath | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'waits for delayed user configuration before restarting' {
|
||||
Mock Start-Sleep {
|
||||
$script:sleeps++
|
||||
if ($script:sleeps -eq 2) { & $script:publishCompletion }
|
||||
}
|
||||
Mock Restart-Computer {
|
||||
$script:sleeps | Should -Be 2
|
||||
Test-Path -LiteralPath $script:completionPath | Should -BeTrue
|
||||
}
|
||||
& $script:awaitCompletion
|
||||
Should -Invoke Start-Sleep -Times 2 -Exactly
|
||||
Should -Invoke Restart-Computer -Times 1 -Exactly
|
||||
}
|
||||
|
||||
It 'logs the timeout and leaves the machine running when completion never arrives' {
|
||||
& $script:awaitCompletion
|
||||
Should -Invoke Start-Sleep -Times 5 -Exactly
|
||||
Should -Invoke Restart-Computer -Times 0 -Exactly
|
||||
($script:messages -join '\n') | Should -Match 'Timed out.*Automatic restart skipped'
|
||||
}
|
||||
|
||||
It 'does not restart if the completion check fails' {
|
||||
Mock Test-Path { throw 'Cannot read completion marker' }
|
||||
{ & $script:awaitCompletion } | Should -Throw '*Cannot read completion marker*'
|
||||
Should -Invoke Restart-Computer -Times 0 -Exactly
|
||||
}
|
||||
}
|
||||
+24
-5
@@ -352,9 +352,6 @@ $scripts = @(
|
||||
reg.exe add "HKCU\Software\Microsoft\Windows\CurrentVersion\Start" /v ShowRecentList /t REG_DWORD /d 0 /f;
|
||||
reg.exe add "HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced" /v Start_TrackDocs /t REG_DWORD /d 0 /f;
|
||||
};
|
||||
{
|
||||
Restart-Computer -Force;
|
||||
};
|
||||
);
|
||||
|
||||
& {
|
||||
@@ -378,6 +375,11 @@ $scripts = @(
|
||||
$complete += $increment;
|
||||
}
|
||||
} *>&1 | Out-String -Width 1KB -Stream >> "$env:TEMP\UserOnce.log";
|
||||
|
||||
# RunOnce and FirstLogonCommands can overlap. Publish completion after the log is flushed.
|
||||
$completionDir = Join-Path $env:LOCALAPPDATA 'WinUtil';
|
||||
[System.IO.Directory]::CreateDirectory($completionDir) | Out-Null;
|
||||
[System.IO.File]::WriteAllText((Join-Path $completionDir 'UserOnce.complete'), '');
|
||||
</File>
|
||||
<File path="C:\Windows\Setup\Scripts\DefaultUser.ps1">
|
||||
$scripts = @(
|
||||
@@ -470,7 +472,7 @@ $scripts = @(
|
||||
{
|
||||
$recallFeature = Get-WindowsOptionalFeature -Online -ErrorAction SilentlyContinue | Where-Object { $_.State -eq 'Enabled' -and $_.FeatureName -like 'Recall' };
|
||||
if( $recallFeature ) {
|
||||
Disable-WindowsOptionalFeature -Online -FeatureName 'Recall' -Remove -ErrorAction SilentlyContinue;
|
||||
Disable-WindowsOptionalFeature -Online -FeatureName 'Recall' -Remove -NoRestart -ErrorAction SilentlyContinue;
|
||||
}
|
||||
};
|
||||
{
|
||||
@@ -483,7 +485,7 @@ $scripts = @(
|
||||
Remove-Item -Path $viveDir -Recurse -Force;
|
||||
};
|
||||
{
|
||||
Start-Process C:\Windows\System32\OneDriveSetup.exe -ArgumentList /uninstall
|
||||
Start-Process C:\Windows\System32\OneDriveSetup.exe -ArgumentList /uninstall -Wait
|
||||
};
|
||||
{
|
||||
if( (Get-BitLockerVolume -MountPoint $Env:SystemDrive).ProtectionStatus -eq 'On' ) {
|
||||
@@ -518,6 +520,23 @@ $scripts = @(
|
||||
$complete += $increment;
|
||||
}
|
||||
} *>&1 | Out-String -Width 1KB -Stream >> "C:\Windows\Setup\Scripts\FirstLogon.log";
|
||||
|
||||
# UserOnce may finish first, so keep any completion flag it has already written.
|
||||
$completionPath = Join-Path $env:LOCALAPPDATA 'WinUtil\UserOnce.complete';
|
||||
$deadline = (Get-Date).AddMinutes(5);
|
||||
'WinUtil: Waiting for user configuration to finish before restarting (up to 5 minutes).' | Out-File -LiteralPath 'C:\Windows\Setup\Scripts\FirstLogon.log' -Append -ErrorAction Stop;
|
||||
while( -not (Test-Path -LiteralPath $completionPath -PathType Leaf) ) {
|
||||
if( (Get-Date) -ge $deadline ) {
|
||||
$message = 'WinUtil: Timed out waiting for user configuration. Automatic restart skipped. Check %TEMP%\UserOnce.log.';
|
||||
$message | Out-File -LiteralPath 'C:\Windows\Setup\Scripts\FirstLogon.log' -Append -ErrorAction Stop;
|
||||
return;
|
||||
}
|
||||
Start-Sleep -Seconds 1;
|
||||
}
|
||||
|
||||
$message = 'WinUtil: First-logon customization complete. Restarting the computer.';
|
||||
$message | Out-File -LiteralPath 'C:\Windows\Setup\Scripts\FirstLogon.log' -Append -ErrorAction Stop;
|
||||
Restart-Computer -Force;
|
||||
</File>
|
||||
</Extensions>
|
||||
</unattend>
|
||||
Reference in new issue
Block a user