From 777fb5ccff5589317b32516ce3f234bd857e95bd Mon Sep 17 00:00:00 2001 From: Omar <90123670+mewclouds@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:45:41 -0400 Subject: [PATCH] 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 --- docs/src/content/docs/guides/win11creator.mdx | 4 + pester/first-logon.Tests.ps1 | 78 +++++++++++++++++++ tools/autounattend.xml | 29 +++++-- 3 files changed, 106 insertions(+), 5 deletions(-) create mode 100644 pester/first-logon.Tests.ps1 diff --git a/docs/src/content/docs/guides/win11creator.mdx b/docs/src/content/docs/guides/win11creator.mdx index 9139f240..e31db79a 100644 --- a/docs/src/content/docs/guides/win11creator.mdx +++ b/docs/src/content/docs/guides/win11creator.mdx @@ -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 | diff --git a/pester/first-logon.Tests.ps1 b/pester/first-logon.Tests.ps1 new file mode 100644 index 00000000..74cb83c6 --- /dev/null +++ b/pester/first-logon.Tests.ps1 @@ -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 + } +} diff --git a/tools/autounattend.xml b/tools/autounattend.xml index dde53ae7..748aa9fc 100644 --- a/tools/autounattend.xml +++ b/tools/autounattend.xml @@ -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'), ''); $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;