Avoid windows shutdown bug (#900)

* Avoid windows shutdown bug with shutdown delay
* Use a separate concurrency group for integ-test-full
This commit is contained in:
Daz DeBoer 2026-03-23 09:43:17 -06:00 committed by GitHub
parent 2cab5e3c71
commit 25454f526a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 63 additions and 6 deletions

View File

@ -17,7 +17,7 @@ jobs:
caching-integ-tests: caching-integ-tests:
uses: ./.github/workflows/suite-integ-test-caching.yml uses: ./.github/workflows/suite-integ-test-caching.yml
concurrency: concurrency:
group: CI-integ-test group: CI-integ-test-full
cancel-in-progress: false cancel-in-progress: false
with: with:
runner-os: '["ubuntu-latest", "windows-latest", "macos-latest"]' runner-os: '["ubuntu-latest", "windows-latest", "macos-latest"]'
@ -29,7 +29,7 @@ jobs:
contents: write contents: write
uses: ./.github/workflows/suite-integ-test-other.yml uses: ./.github/workflows/suite-integ-test-other.yml
concurrency: concurrency:
group: CI-integ-test group: CI-integ-test-full
cancel-in-progress: false cancel-in-progress: false
with: with:
runner-os: '["ubuntu-latest", "windows-latest", "macos-latest"]' runner-os: '["ubuntu-latest", "windows-latest", "macos-latest"]'

View File

@ -15,6 +15,7 @@ import {
} from '../../configuration' } from '../../configuration'
import {saveDeprecationState} from '../../deprecation-collector' import {saveDeprecationState} from '../../deprecation-collector'
import {handleMainActionError} from '../../errors' import {handleMainActionError} from '../../errors'
import {forceExit} from '../../force-exit'
/** /**
* The main entry point for the action, called by Github Actions for the step. * The main entry point for the action, called by Github Actions for the step.
@ -67,7 +68,7 @@ export async function run(): Promise<void> {
} }
// Explicit process.exit() to prevent waiting for hanging promises. // Explicit process.exit() to prevent waiting for hanging promises.
process.exit() await forceExit()
} }
run() run()

View File

@ -2,6 +2,7 @@ import * as setupGradle from '../../setup-gradle'
import {CacheConfig, SummaryConfig} from '../../configuration' import {CacheConfig, SummaryConfig} from '../../configuration'
import {handlePostActionError} from '../../errors' import {handlePostActionError} from '../../errors'
import {forceExit} from '../../force-exit'
// Catch and log any unhandled exceptions. These exceptions can leak out of the uploadChunk method in // Catch and log any unhandled exceptions. These exceptions can leak out of the uploadChunk method in
// @actions/toolkit when a failed upload closes the file descriptor causing any in-process reads to // @actions/toolkit when a failed upload closes the file descriptor causing any in-process reads to
@ -19,7 +20,7 @@ export async function run(): Promise<void> {
} }
// Explicit process.exit() to prevent waiting for promises left hanging by `@actions/cache` on save. // Explicit process.exit() to prevent waiting for promises left hanging by `@actions/cache` on save.
process.exit() await forceExit()
} }
run() run()

View File

@ -12,6 +12,7 @@ import {
} from '../../configuration' } from '../../configuration'
import {failOnUseOfRemovedFeature, saveDeprecationState} from '../../deprecation-collector' import {failOnUseOfRemovedFeature, saveDeprecationState} from '../../deprecation-collector'
import {handleMainActionError} from '../../errors' import {handleMainActionError} from '../../errors'
import {forceExit} from '../../force-exit'
/** /**
* The main entry point for the action, called by Github Actions for the step. * The main entry point for the action, called by Github Actions for the step.
@ -42,7 +43,7 @@ export async function run(): Promise<void> {
} }
// Explicit process.exit() to prevent waiting for hanging promises. // Explicit process.exit() to prevent waiting for hanging promises.
process.exit() await forceExit()
} }
run() run()

View File

@ -4,6 +4,7 @@ import * as dependencyGraph from '../../dependency-graph'
import {CacheConfig, DependencyGraphConfig, SummaryConfig} from '../../configuration' import {CacheConfig, DependencyGraphConfig, SummaryConfig} from '../../configuration'
import {handlePostActionError} from '../../errors' import {handlePostActionError} from '../../errors'
import {emitDeprecationWarnings, restoreDeprecationState} from '../../deprecation-collector' import {emitDeprecationWarnings, restoreDeprecationState} from '../../deprecation-collector'
import {forceExit} from '../../force-exit'
// Catch and log any unhandled exceptions. These exceptions can leak out of the uploadChunk method in // Catch and log any unhandled exceptions. These exceptions can leak out of the uploadChunk method in
// @actions/toolkit when a failed upload closes the file descriptor causing any in-process reads to // @actions/toolkit when a failed upload closes the file descriptor causing any in-process reads to
@ -27,7 +28,7 @@ export async function run(): Promise<void> {
} }
// Explicit process.exit() to prevent waiting for promises left hanging by `@actions/cache` on save. // Explicit process.exit() to prevent waiting for promises left hanging by `@actions/cache` on save.
process.exit() await forceExit()
} }
run() run()

14
sources/src/force-exit.ts Normal file
View File

@ -0,0 +1,14 @@
const WINDOWS_EXIT_DELAY_MS = 50
export function getForcedExitDelayMs(platform: NodeJS.Platform = process.platform): number {
return platform === 'win32' ? WINDOWS_EXIT_DELAY_MS : 0
}
export async function forceExit(platform: NodeJS.Platform = process.platform): Promise<never> {
const exitDelayMs = getForcedExitDelayMs(platform)
if (exitDelayMs > 0) {
await new Promise(resolve => setTimeout(resolve, exitDelayMs))
}
return process.exit()
}

View File

@ -0,0 +1,39 @@
import {afterEach, describe, expect, it, jest} from '@jest/globals'
import {forceExit, getForcedExitDelayMs} from '../../src/force-exit'
describe('forceExit', () => {
afterEach(() => {
jest.restoreAllMocks()
jest.useRealTimers()
})
it('adds a short delay on Windows before exiting', async () => {
jest.useFakeTimers()
const exitSpy = jest.spyOn(process, 'exit').mockImplementation((() => undefined) as never)
const exitPromise = forceExit('win32')
await jest.advanceTimersByTimeAsync(49)
expect(exitSpy).not.toHaveBeenCalled()
await jest.advanceTimersByTimeAsync(1)
await expect(exitPromise).resolves.toBeUndefined()
expect(exitSpy).toHaveBeenCalledTimes(1)
})
it('exits immediately on non-Windows platforms', async () => {
const exitSpy = jest.spyOn(process, 'exit').mockImplementation((() => undefined) as never)
await expect(forceExit('linux')).resolves.toBeUndefined()
expect(exitSpy).toHaveBeenCalledTimes(1)
})
it('only delays on Windows', () => {
expect(getForcedExitDelayMs('win32')).toBe(50)
expect(getForcedExitDelayMs('linux')).toBe(0)
expect(getForcedExitDelayMs('darwin')).toBe(0)
})
})