From 52fac46c3c94797e841c8d003b4e78a090ca6a4f Mon Sep 17 00:00:00 2001 From: Daz DeBoer Date: Fri, 20 Mar 2026 12:31:53 -0600 Subject: [PATCH] Avoid windows shutdown bug --- .../src/actions/dependency-submission/main.ts | 3 +- .../src/actions/dependency-submission/post.ts | 3 +- sources/src/actions/setup-gradle/main.ts | 3 +- sources/src/actions/setup-gradle/post.ts | 3 +- sources/src/force-exit.ts | 14 +++++++ sources/test/jest/force-exit.test.ts | 39 +++++++++++++++++++ 6 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 sources/src/force-exit.ts create mode 100644 sources/test/jest/force-exit.test.ts diff --git a/sources/src/actions/dependency-submission/main.ts b/sources/src/actions/dependency-submission/main.ts index 8ff3fb1b..ca6f8754 100644 --- a/sources/src/actions/dependency-submission/main.ts +++ b/sources/src/actions/dependency-submission/main.ts @@ -15,6 +15,7 @@ import { } from '../../configuration' import {saveDeprecationState} from '../../deprecation-collector' import {handleMainActionError} from '../../errors' +import {forceExit} from '../../force-exit' /** * The main entry point for the action, called by Github Actions for the step. @@ -67,7 +68,7 @@ export async function run(): Promise { } // Explicit process.exit() to prevent waiting for hanging promises. - process.exit() + await forceExit() } run() diff --git a/sources/src/actions/dependency-submission/post.ts b/sources/src/actions/dependency-submission/post.ts index c39e2f8a..f47f2983 100644 --- a/sources/src/actions/dependency-submission/post.ts +++ b/sources/src/actions/dependency-submission/post.ts @@ -2,6 +2,7 @@ import * as setupGradle from '../../setup-gradle' import {CacheConfig, SummaryConfig} from '../../configuration' 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 // @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 { } // Explicit process.exit() to prevent waiting for promises left hanging by `@actions/cache` on save. - process.exit() + await forceExit() } run() diff --git a/sources/src/actions/setup-gradle/main.ts b/sources/src/actions/setup-gradle/main.ts index 65b05b82..a15e89ea 100644 --- a/sources/src/actions/setup-gradle/main.ts +++ b/sources/src/actions/setup-gradle/main.ts @@ -12,6 +12,7 @@ import { } from '../../configuration' import {failOnUseOfRemovedFeature, saveDeprecationState} from '../../deprecation-collector' import {handleMainActionError} from '../../errors' +import {forceExit} from '../../force-exit' /** * The main entry point for the action, called by Github Actions for the step. @@ -42,7 +43,7 @@ export async function run(): Promise { } // Explicit process.exit() to prevent waiting for hanging promises. - process.exit() + await forceExit() } run() diff --git a/sources/src/actions/setup-gradle/post.ts b/sources/src/actions/setup-gradle/post.ts index 29ce31ca..17870d97 100644 --- a/sources/src/actions/setup-gradle/post.ts +++ b/sources/src/actions/setup-gradle/post.ts @@ -4,6 +4,7 @@ import * as dependencyGraph from '../../dependency-graph' import {CacheConfig, DependencyGraphConfig, SummaryConfig} from '../../configuration' import {handlePostActionError} from '../../errors' 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 // @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 { } // Explicit process.exit() to prevent waiting for promises left hanging by `@actions/cache` on save. - process.exit() + await forceExit() } run() diff --git a/sources/src/force-exit.ts b/sources/src/force-exit.ts new file mode 100644 index 00000000..3bd2800a --- /dev/null +++ b/sources/src/force-exit.ts @@ -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 { + const exitDelayMs = getForcedExitDelayMs(platform) + if (exitDelayMs > 0) { + await new Promise(resolve => setTimeout(resolve, exitDelayMs)) + } + + return process.exit() +} diff --git a/sources/test/jest/force-exit.test.ts b/sources/test/jest/force-exit.test.ts new file mode 100644 index 00000000..fa73b47f --- /dev/null +++ b/sources/test/jest/force-exit.test.ts @@ -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) + }) +})