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:
OmarandChris Titus authored and GitHub committed 2026-10-21 13:45:41 -05:00
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 |
+78
View File
@@ -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
View File
@@ -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;
};
);
&amp; {
@@ -378,6 +375,11 @@ $scripts = @(
$complete += $increment;
}
} *&gt;&amp;1 | Out-String -Width 1KB -Stream &gt;&gt; "$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;
}
} *&gt;&amp;1 | Out-String -Width 1KB -Stream &gt;&gt; "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>