Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
251 changes: 208 additions & 43 deletions ui-tests/ctrl-c.ahk
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,15 @@ WaitForRegExInWindowsTerminal('PS [A-Z]:.*>[ `n`r]*$', 'Timed out waiting for Po
; sleep test
Sleep 1500
; The `:;` is needed to force Git to call this via the shell, otherwise `/usr/bin/` would not resolve.
Send('git -c alias.sleep="{!}:;/usr/bin/sleep" sleep 15{Enter}')
Sleep 500
Send('git -c alias.sleep="{!}:;echo __SLEEP_STARTED__;' .
'/usr/bin/sleep" sleep 15{Enter}')
WaitForRegExInWindowsTerminal(
'(^|`n)__SLEEP_STARTED__`r?`n',
'Timed out waiting for sleep to start', 'Sleep started',
10000, 'ahk_id ' . hwnd)
; interrupt sleep; Ideally we'd call `Send('^C')` but that would too quick on GitHub Actions' runners.
; The idea for this work-around comes from https://www.reddit.com/r/AutoHotkey/comments/aok10s/comment/eg57e81/.
WinActivate('ahk_id ' . hwnd)
Send '{Ctrl down}{c down}'
Sleep 50
Send '{c up}{Ctrl up}'
Expand Down Expand Up @@ -91,14 +96,14 @@ if (openSSHPath != '' and FileExist(openSSHPath . '\sshd.exe')) {
ExitWithError 'Could not add admin read permission from ' . path . ': ' A_LastError
}

WaitForSshd() {
WaitForSshd(expectedPID) {
deadline := A_TickCount + 60000
while true {
if FileExist('sshd.pid') {
content := ''
try
content := Trim(FileRead('sshd.pid'), ' `t`r`n')
if content != '' {
if content == expectedPID && ProcessExist(expectedPID) {
Info('sshd is accepting connections (PID ' . content . ')')
return
}
Expand All @@ -109,6 +114,94 @@ if (openSSHPath != '' and FileExist(openSSHPath . '\sshd.exe')) {
}
}

StartSshd(openSSHPath, sshdOptions, sshdPIDs) {
try FileDelete('sshd.pid')
Run(openSSHPath . '\sshd.exe ' . sshdOptions, '', 'Hide', &pid)
if A_LastError
ExitWithError 'Error starting SSH server: ' A_LastError
sshdPIDs.Push(pid)
Info('Started SSH server: ' . pid)
WaitForSshd(pid)
return pid
}

StopSshd(pid, openSSHPath, sshdPIDs) {
if !pid
return true
proc := FindProcess(pid)
executablePath := ''
if proc
try executablePath := proc.ExecutablePath
if executablePath == openSSHPath . '\sshd.exe' {
Info('Stopping sshd.exe (PID ' . pid . ')')
try ProcessClose(pid)
try ProcessWaitClose(pid, 5)
}
if !ProcessExist(pid) {
loop sshdPIDs.Length {
if sshdPIDs[A_Index] == pid {
sshdPIDs.RemoveAt(A_Index)
break
}
}
}
return !ProcessExist(pid)
}

CleanUpSshdProcesses(sshdPIDs, openSSHPath, *) {
for pid in sshdPIDs.Clone()
StopSshd(pid, openSSHPath, sshdPIDs)
}

FindProcess(pid) {
query := 'SELECT ProcessId, ParentProcessId, Name, CommandLine, ' .
'ExecutablePath FROM Win32_Process WHERE ProcessId = ' . pid
for proc in ComObjGet('winmgmts:').ExecQuery(query)
return proc
return 0
}

ProcessMatches(pid, name, marker) {
proc := FindProcess(pid)
if !proc || proc.Name != name
return false
commandLine := ''
try commandLine := proc.CommandLine
return InStr(commandLine, marker)
}

; Count the regular files (not directories) below `dir`, recursing into
; subdirectories and hidden entries such as a `.git` folder.
CountFilesRecursively(dir) {
count := 0
Loop Files, dir . '\*', 'FR'
count++
return count
}

WatchSshStarts() {
query := 'SELECT * FROM Win32_ProcessStartTrace ' .
'WHERE ProcessName = "ssh.exe"'
return ComObjGet('winmgmts:').ExecNotificationQuery(query)
}

WaitForCloneSsh(events, keyPath) {
deadline := A_TickCount + 15000
while A_TickCount < deadline {
try event := events.NextEvent(deadline - A_TickCount)
catch
break
ssh := FindProcess(event.ProcessID)
if !ssh
continue
sshCommandLine := ''
try sshCommandLine := ssh.CommandLine
if InStr(sshCommandLine, keyPath)
return ssh.ProcessId
}
return 0
}

; Set up SSH server
Info('Generating host key')
RunWait('git -c alias.c="!ssh-keygen -b 4096 -f ssh_host_rsa_key -N \"\"" c', '', 'Hide')
Expand All @@ -129,13 +222,14 @@ if (openSSHPath != '' and FileExist(openSSHPath . '\sshd.exe')) {
'PidFile "' . workTree . '\sshd.pid"`n',
'sshd_config')
sshdOptions := '-f "' . workTree . '\sshd_config" -D -E "' . workTree . '\sshd.log"'
sshdPIDs := []
sshdCleanup := CleanUpSshdProcesses.Bind(
sshdPIDs, openSSHPath)
OnExit(sshdCleanup)

; Start SSH server
Info('Starting SSH server')
Run(openSSHPath . '\sshd.exe ' . sshdOptions, '', 'Hide', &sshdPID)
if A_LastError
ExitWithError 'Error starting SSH server: ' A_LastError
Info('Started SSH server: ' sshdPID)
sshdPID := StartSshd(openSSHPath, sshdOptions, sshdPIDs)

Info('Starting clone')
workTreeMSYS := RunWaitOne('git -c alias.cygpath="!cygpath" cygpath -u "' . workTree . '"')
Expand All @@ -152,64 +246,135 @@ if (openSSHPath != '' and FileExist(openSSHPath . '\sshd.exe')) {
; `ssh.exe` prefixes the username with the domain name.
cloneOptions := '--upload-pack="powershell git upload-pack" "' .
EnvGet('USERNAME') . '@localhost:' . largeGitRepoPath . '" "' . largeGitClonePath . '"'
WaitForSshd()
Send('git -c core.sshCommand="ssh ' . sshOptions . '" clone ' . cloneOptions . '{Enter}')
Sleep 50
Info('Waiting for clone to start')
sshStartEvents := WatchSshStarts()
WinActivate('ahk_id ' . hwnd)
WaitForRegExInWindowsTerminal('remote: ', 'Timed out waiting for clone to start', 'Clone started', 15000, 'ahk_id ' . hwnd)
Send('git -c core.sshCommand="ssh ' . sshOptions . '" clone ' .
cloneOptions . '{Enter}')
cloneSshPID := WaitForCloneSsh(
sshStartEvents, workTreeMSYS . '/id_rsa')
if !cloneSshPID
ExitWithError 'Timed out waiting for clone ssh.exe'
Info('Clone ssh.exe started: ' . cloneSshPID)
Info('Trying to interrupt clone')
Send('^C') ; interrupt clone
Sleep 150
WaitForRegExInWindowsTerminal('`nfatal: (.*`r?`n){1,3}PS .*>[ `n`r]*$', 'Timed out waiting for clone to be interrupted', 'clone was interrupted as desired')
if !ProcessMatches(cloneSshPID, 'ssh.exe', workTreeMSYS . '/id_rsa')
ExitWithError 'Clone completed before Ctrl+C could be sent'
; Interrupt the clone. A bare `Send('^C')` is too quick to be delivered
; reliably on GitHub Actions' runners (see the sleep interrupt above), and
; even the deliberate key-down/up sequence is occasionally lost to a
; focus/scheduling race. A missed interrupt lets the clone run to completion
; (its ssh.exe only exits once the ~26M transfer finishes), so keep
; re-issuing the Ctrl+C, re-focusing the window each time, until ssh.exe
; actually exits.
deadline := A_TickCount + 15000
while ProcessMatches(
cloneSshPID, 'ssh.exe', workTreeMSYS . '/id_rsa') &&
A_TickCount < deadline {
WinActivate('ahk_id ' . hwnd)
Send '{Ctrl down}{c down}'
Sleep 50
Send '{c up}{Ctrl up}'
checkDeadline := A_TickCount + 600
while ProcessExist(cloneSshPID) && A_TickCount < checkDeadline
Sleep 20
}
if ProcessMatches(cloneSshPID, 'ssh.exe', workTreeMSYS . '/id_rsa')
ExitWithError 'Clone ssh.exe did not exit after Ctrl+C'
Info('clone was interrupted as desired')

if DirExist(largeGitClonePath)
ExitWithError('`large-clone` was unexpectedly not deleted on interrupt')
; Interrupting `git clone` makes it run its `remove_junk` cleanup, which
; unlinks every file of the partial clone. On Windows that cleanup races
; with the still-terminating child processes: their delete-pending file
; handles (and CWDs) keep the now file-less directories busy, so git's
; `rmdir` of the empty scaffolding fails and it gives up, permanently
; leaving behind an empty `large-clone\.git\{objects,refs}` skeleton. That
; benign leftover is not a completed clone, so it must not fail the test:
; the interrupt is already proven by the clone's `ssh.exe` having exited
; (above) and by the clone content being gone. Wait for every file to
; disappear (tolerating empty directories), fail only if actual clone
; content survives (i.e. the clone was not aborted), then remove any empty
; scaffolding ourselves so the verification clone below starts clean.
deadline := A_TickCount + 5000
while DirExist(largeGitClonePath) &&
CountFilesRecursively(largeGitClonePath) > 0 &&
A_TickCount < deadline
Sleep 10
if DirExist(largeGitClonePath) {
remainingFiles := CountFilesRecursively(largeGitClonePath)
if remainingFiles > 0
ExitWithError('`large-clone` still contained ' . remainingFiles .
' file(s) after interrupt (clone was not aborted)')
; Only empty scaffolding remains; drop it so the verification clone
; below can create the target afresh. The directories may stay briefly
; busy while the interrupted clone's children finish exiting, so retry.
deadline := A_TickCount + 5000
while DirExist(largeGitClonePath) && A_TickCount < deadline {
try DirDelete(largeGitClonePath, true)
if !DirExist(largeGitClonePath)
break
Sleep 50
}
}

; Now verify that the SSH-based clone actually works and does not hang
Info('Re-starting SSH server')
Run(openSSHPath . '\sshd.exe ' . sshdOptions, '', 'Hide', &sshdPID)
if A_LastError
ExitWithError 'Error starting SSH server: ' A_LastError
Info('Started SSH server: ' sshdPID)
if !StopSshd(sshdPID, openSSHPath, sshdPIDs)
ExitWithError 'Could not stop SSH server before restart'
sshdPID := StartSshd(openSSHPath, sshdOptions, sshdPIDs)

Info('Starting clone')
retries := 5
cloneResultMarker := 'GIT_CLONE_EXIT_CODE='
Loop retries {
Send('git -c core.sshCommand="ssh ' . sshOptions . '" clone ' . cloneOptions . '{Enter}')
WinActivate('ahk_id ' . hwnd)
Send('git -c core.sshCommand="ssh ' . sshOptions . '" clone ' .
cloneOptions . '; Write-Output "' . cloneResultMarker .
'$LASTEXITCODE"{Enter}')
Sleep 500
Info('Waiting for clone to finish (attempt ' . A_Index . '/' . retries . ')')
WinActivate('ahk_id ' . hwnd)
matchObj := WaitForRegExInWindowsTerminal('(Receiving objects: .*, done\.|fatal: early EOF)`r?`nPS .*>[ `n`r]*$', 'Timed out waiting for clone to finish', 'Clone command completed', 15000, 'ahk_id ' . hwnd)
matchObj := WaitForRegExInWindowsTerminal(
cloneResultMarker . '([0-9]+)',
'Timed out waiting for clone to finish',
'Clone command completed', 15000, 'ahk_id ' . hwnd)

if InStr(matchObj[1], 'done.')
if matchObj[1] == '0'
break
if A_Index == retries
ExitWithError('Clone failed after ' . retries . ' attempts (early EOF)')
Info('Clone failed (early EOF), restarting SSH server and retrying...')
ExitWithError('Clone failed after ' . retries .
' attempts (exit code ' . matchObj[1] . ')')
Info('Clone failed with exit code ' . matchObj[1] .
', restarting SSH server and retrying...')
if DirExist(largeGitClonePath)
DirDelete(largeGitClonePath, true)
; Restart sshd for the next attempt (it may have exited after the failed connection)
Run(openSSHPath . '\sshd.exe ' . sshdOptions, '', 'Hide', &sshdPID)
if A_LastError
ExitWithError 'Error restarting SSH server: ' A_LastError
Info('Restarted SSH server: ' sshdPID)
if !StopSshd(sshdPID, openSSHPath, sshdPIDs)
ExitWithError 'Could not stop SSH server before retry'
sshdPID := StartSshd(openSSHPath, sshdOptions, sshdPIDs)
Info('Restarted SSH server: ' . sshdPID)
}

if not DirExist(largeGitClonePath)
ExitWithError('`large-clone` did not work?!?')

for proc in ComObjGet('winmgmts:').ExecQuery('SELECT ProcessId, Name, ExecutablePath FROM Win32_Process WHERE Name LIKE "sshd%.exe"') {
if (proc.ExecutablePath != '' and InStr(proc.ExecutablePath, openSSHPath) > 0) {
Info('Stopping ' . proc.Name . ' (PID ' . proc.ProcessId . ')')
try {
ProcessClose proc.ProcessId
ProcessWaitClose proc.ProcessId, 5
}
}
}
CleanUpSshdProcesses(sshdPIDs, openSSHPath)
if sshdPIDs.Length
ExitWithError 'Could not stop all SSH servers'
OnExit(sshdCleanup, 0)
}

Send('exit{Enter}')
Sleep 50
; Close the PowerShell window. As with the Ctrl+C interrupts above, a single
; `Send('exit{Enter}')` is occasionally lost to a focus/scheduling race on
; GitHub Actions' runners, which would leave the Windows Terminal window (and
; its OpenConsole/PowerShell processes) behind. Re-issue the exit, re-focusing
; the window each time, until it actually closes; the leading `{Enter}` flushes
; any partial command a half-delivered attempt might have left on the prompt.
deadline := A_TickCount + 20000
while WinExist('ahk_id ' . hwnd) && A_TickCount < deadline {
try WinActivate('ahk_id ' . hwnd)
if WinExist('ahk_id ' . hwnd)
Send('{Enter}exit{Enter}')
WinWaitClose('ahk_id ' . hwnd, , 3)
}
if WinExist('ahk_id ' . hwnd)
ExitWithError 'PowerShell window did not close'
Info 'PowerShell window closed'
CleanUpWorkTree()
36 changes: 36 additions & 0 deletions ui-tests/pcon-grandchild-input.ahk
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,42 @@ if !InStr(capture, '$ ')
ExitWithError 'Timed out waiting for bash prompt'
Info 'Bash prompt appeared'

; A native Git process starts sh.exe for the alias, which in turn starts
; native PowerShell. Verify that PowerShell still sees console input and
; can query Console.KeyAvailable. Do not use shell redirection here: it
; changes Git's handles before pcon setup and masks the regression.
probeOutput := 'git-alias-console.out'
probeStatus := 'git-alias-console.status'
if FileExist(probeOutput)
FileDelete probeOutput
if FileExist(probeStatus)
FileDelete probeStatus
probeCommand := "git -c 'alias.console-probe=!powershell.exe " .
"-NoLogo -NoProfile -Command `"\$redirected = " .
"[Console]::IsInputRedirected; try { " .
"[void][Console]::KeyAvailable; \$status = 0 } catch { \$status = 1 }; " .
"Set-Content -NoNewline -Path " probeOutput " -Value \$redirected; " .
"Set-Content -NoNewline -Path " probeStatus " -Value \$status; " .
"exit \$status`"' console-probe"
WinActivate(winId)
SendEvent('{Text}' probeCommand)
SendEvent('{Enter}')

deadline := A_TickCount + 10000
while !FileExist(probeStatus) && A_TickCount < deadline
Sleep 100
if !FileExist(probeStatus)
ExitWithError 'Timed out waiting for Git alias console probe'

probeRedirected := Trim(FileRead(probeOutput), ' `t`r`n')
probeExitCode := Trim(FileRead(probeStatus), ' `t`r`n')
Info 'Git alias console probe output: ' probeRedirected
Info 'Git alias console probe exit code: ' probeExitCode
if probeRedirected != 'False'
ExitWithError 'PowerShell stdin is redirected through the Git alias'
if probeExitCode != '0'
ExitWithError 'PowerShell could not query Console.KeyAvailable'

; Launch cmd.exe to activate pseudo console
WinActivate(winId)
SetKeyDelay 20, 20
Expand Down
Loading
Loading