Skip to content

Commit 3ed2940

Browse files
authored
[TECH] Improve Patching process to avoid file and directory removal errors (#1246)
* feat: implement safeRemoveDirectory function with retry logic for directory removal * feat: refactor cleanUpDownload to use safeRemoveDirectory and update related async calls * chore: re-add second abort check * feat: add patching aborted and cleanup failed event tracking * feat: add Sentry error tracking for failed directory removal attempts in safeRemoveDirectory * chore: bump version to 0.24.0 --------- Co-authored-by: Flavio F Lima <flavioislima@users.noreply.github.com>
1 parent 58d1360 commit 3ed2940

File tree

5 files changed

+225
-20
lines changed

5 files changed

+225
-20
lines changed

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "hyperplay",
3-
"version": "0.23.3",
3+
"version": "0.24.0",
44
"private": true,
55
"main": "build/main/main.js",
66
"homepage": "./",

src/backend/metrics/types.ts

+25
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,29 @@ export interface PatchingFailed {
228228
sensitiveProperties?: never
229229
}
230230

231+
export interface PatchingAborted {
232+
event: 'Patching Aborted'
233+
properties: {
234+
game_name: string
235+
game_title: string
236+
platform: ReturnType<typeof getPlatformName>
237+
platform_arch: InstallPlatform
238+
}
239+
sensitiveProperties?: never
240+
}
241+
242+
export interface PatchingCleanupFailed {
243+
event: 'Patching Cleanup Failed'
244+
properties: {
245+
game_name: string
246+
error: string
247+
game_title: string
248+
platform?: ReturnType<typeof getPlatformName>
249+
platform_arch?: InstallPlatform
250+
}
251+
sensitiveProperties?: never
252+
}
253+
231254
export interface PatchingTooSlow {
232255
event: 'Patching Too Slow'
233256
properties: {
@@ -475,6 +498,8 @@ export type PossibleMetricPayloads =
475498
| PatchingStarted
476499
| PatchingSuccess
477500
| PatchingFailed
501+
| PatchingAborted
502+
| PatchingCleanupFailed
478503
| PatchingTooSlow
479504
| AccountDropdownPortfolioClicked
480505

src/backend/storeManagers/hyperplay/games.ts

+86-16
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ import {
6565
handleArchAndPlatform,
6666
handlePlatformReversed,
6767
runModPatcher,
68-
sanitizeVersion
68+
sanitizeVersion,
69+
safeRemoveDirectory
6970
} from './utils'
7071
import { getSettings as getSettingsSideload } from 'backend/storeManagers/sideload/games'
7172
import {
@@ -423,11 +424,11 @@ const findExecutables = async (folderPath: string): Promise<string[]> => {
423424
return executables
424425
}
425426

426-
export function cleanUpDownload(appName: string, directory: string) {
427+
export async function cleanUpDownload(appName: string, directory: string) {
427428
inProgressDownloadsMap.delete(appName)
428429
inProgressExtractionsMap.delete(appName)
429430
deleteAbortController(appName)
430-
rmSync(directory, { recursive: true, force: true })
431+
await safeRemoveDirectory(directory)
431432
}
432433

433434
function getDownloadUrl(platformInfo: PlatformConfig, appName: string) {
@@ -523,9 +524,9 @@ async function downloadGame(
523524
res()
524525
}
525526

526-
function onCancel() {
527+
async function onCancel() {
527528
try {
528-
cleanUpDownload(appName, directory)
529+
await cleanUpDownload(appName, directory)
529530
} catch (err) {
530531
rej(err)
531532
}
@@ -1181,7 +1182,7 @@ export async function extract(
11811182
body: `Installed`
11821183
})
11831184

1184-
cleanUpDownload(appName, directory)
1185+
await cleanUpDownload(appName, directory)
11851186

11861187
sendFrontendMessage('refreshLibrary', 'hyperplay')
11871188

@@ -1190,21 +1191,21 @@ export async function extract(
11901191
})
11911192
}
11921193
)
1193-
extractService.once('error', (error: Error) => {
1194+
extractService.once('error', async (error: Error) => {
11941195
logError(`Extracting Error ${error.message}`, LogPrefix.HyperPlay)
11951196

11961197
cancelQueueExtraction()
11971198
callAbortController(appName)
11981199

1199-
cleanUpDownload(appName, directory)
1200+
await cleanUpDownload(appName, directory)
12001201

12011202
sendFrontendMessage('refreshLibrary', 'hyperplay')
12021203

12031204
resolve({
12041205
status: 'error'
12051206
})
12061207
})
1207-
extractService.once('canceled', () => {
1208+
extractService.once('canceled', async () => {
12081209
logInfo(
12091210
`Canceled Extracting: Cancellation completed on ${appName} - Destination ${destinationPath}`,
12101211
LogPrefix.HyperPlay
@@ -1242,7 +1243,7 @@ export async function extract(
12421243
body: 'Installation Stopped'
12431244
})
12441245

1245-
cleanUpDownload(appName, directory)
1246+
await cleanUpDownload(appName, directory)
12461247

12471248
sendFrontendMessage('refreshLibrary', 'hyperplay')
12481249

@@ -1914,13 +1915,18 @@ async function applyPatching(
19141915

19151916
if (signal.aborted) {
19161917
logInfo(`Patching ${appName} aborted`, LogPrefix.HyperPlay)
1917-
rmSync(datastoreDir, { recursive: true })
1918+
await safeRemoveDirectory(datastoreDir, {
1919+
sizeThresholdMB: blockSize * totalBlocks
1920+
})
1921+
aborted = true
19181922
return { status: 'abort' }
19191923
}
19201924

1921-
signal.onabort = () => {
1925+
signal.onabort = async () => {
19221926
aborted = true
1923-
rmSync(datastoreDir, { recursive: true })
1927+
await safeRemoveDirectory(datastoreDir, {
1928+
sizeThresholdMB: blockSize * totalBlocks
1929+
})
19241930
return { status: 'abort' }
19251931
}
19261932

@@ -2005,7 +2011,36 @@ async function applyPatching(
20052011
}
20062012
// need this to cover 100% of abort cases
20072013
if (aborted) {
2008-
rmSync(datastoreDir, { recursive: true })
2014+
try {
2015+
await safeRemoveDirectory(datastoreDir, {
2016+
sizeThresholdMB: blockSize * totalBlocks
2017+
})
2018+
} catch (cleanupError) {
2019+
trackEvent({
2020+
event: 'Patching Cleanup Failed',
2021+
properties: {
2022+
error: `${cleanupError}`,
2023+
game_name: gameInfo.app_name,
2024+
game_title: gameInfo.title,
2025+
platform: getPlatformName(platform),
2026+
platform_arch: platform
2027+
}
2028+
})
2029+
2030+
logWarning(
2031+
`Patching aborted and cleanup failed: ${cleanupError}`,
2032+
LogPrefix.HyperPlay
2033+
)
2034+
}
2035+
trackEvent({
2036+
event: 'Patching Aborted',
2037+
properties: {
2038+
game_name: gameInfo.app_name,
2039+
game_title: gameInfo.title,
2040+
platform: getPlatformName(platform),
2041+
platform_arch: platform
2042+
}
2043+
})
20092044
return { status: 'abort' }
20102045
}
20112046

@@ -2020,8 +2055,27 @@ async function applyPatching(
20202055
})
20212056

20222057
logInfo(`Patching ${appName} completed`, LogPrefix.HyperPlay)
2023-
rmSync(datastoreDir, { recursive: true })
2058+
try {
2059+
await safeRemoveDirectory(datastoreDir, {
2060+
sizeThresholdMB: blockSize * totalBlocks
2061+
})
2062+
} catch (cleanupError) {
2063+
trackEvent({
2064+
event: 'Patching Cleanup Failed',
2065+
properties: {
2066+
error: `${cleanupError}`,
2067+
game_name: gameInfo.app_name,
2068+
game_title: gameInfo.title,
2069+
platform: getPlatformName(platform),
2070+
platform_arch: platform
2071+
}
2072+
})
20242073

2074+
logWarning(
2075+
`Patching succeeded but cleanup failed: ${cleanupError}`,
2076+
LogPrefix.HyperPlay
2077+
)
2078+
}
20252079
return { status: 'done' }
20262080
} catch (error) {
20272081
if (error instanceof PatchingError) {
@@ -2061,7 +2115,23 @@ async function applyPatching(
20612115

20622116
// errors can be thrown before datastore dir created. rmSync on nonexistent dir blocks indefinitely
20632117
if (existsSync(datastoreDir)) {
2064-
rmSync(datastoreDir, { recursive: true })
2118+
try {
2119+
await safeRemoveDirectory(datastoreDir)
2120+
} catch (cleanupError) {
2121+
trackEvent({
2122+
event: 'Patching Cleanup Failed',
2123+
properties: {
2124+
error: `${cleanupError}`,
2125+
game_name: gameInfo.app_name,
2126+
game_title: gameInfo.title
2127+
}
2128+
})
2129+
2130+
logWarning(
2131+
`Patching failed and cleanup failed: ${cleanupError}`,
2132+
LogPrefix.HyperPlay
2133+
)
2134+
}
20652135
}
20662136

20672137
return { status: 'error', error: `Error while patching ${error}` }

src/backend/storeManagers/hyperplay/utils.ts

+112-2
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,13 @@ import {
1616
valistListingsApiUrl
1717
} from 'backend/constants'
1818
import { getGameInfo } from './games'
19-
import { LogPrefix, logError, logInfo } from 'backend/logger/logger'
19+
import { LogPrefix, logError, logInfo, logWarning } from 'backend/logger/logger'
2020
import { join } from 'path'
21-
import { existsSync } from 'graceful-fs'
21+
import { existsSync, rmSync } from 'graceful-fs'
2222
import { ProjectMetaInterface } from '@valist/sdk/dist/typesShared'
2323
import getPartitionCookies from 'backend/utils/get_partition_cookies'
2424
import { DEV_PORTAL_URL } from 'common/constants'
25+
import { captureException } from '@sentry/electron'
2526

2627
export async function getHyperPlayStoreRelease(
2728
appName: string
@@ -419,3 +420,112 @@ export const runModPatcher = async (appName: string) => {
419420
throw new Error(`Error running patcher: ${error}`)
420421
}
421422
}
423+
424+
type SafeRemoveDirectoryOptions = {
425+
maxRetries?: number
426+
retryDelay?: number
427+
sizeThresholdMB?: number
428+
}
429+
430+
/**
431+
* Safely removes a directory with retry logic to handle Windows EPERM issues
432+
* @param directory Path to the directory to remove
433+
* @param options Optional configuration for removal
434+
* @param options.maxRetries Maximum number of removal attempts before giving up (default: 5)
435+
* @param options.retryDelay Delay in milliseconds between removal attempts (default: 10000)
436+
* @param options.sizeThresholdMB Threshold in MB above which async removal is used (default: 500)
437+
* @returns True if directory was successfully removed or doesn't exist, false otherwise
438+
* @warning This function MUST always be awaited to prevent race conditions
439+
*/
440+
export async function safeRemoveDirectory(
441+
directory: string,
442+
{
443+
maxRetries = 10,
444+
retryDelay = 6000,
445+
sizeThresholdMB = 500
446+
}: SafeRemoveDirectoryOptions = {}
447+
): Promise<boolean> {
448+
if (!existsSync(directory)) {
449+
return true // Directory doesn't exist, nothing to remove
450+
}
451+
452+
// Log start of removal process
453+
logInfo(`Starting removal of directory ${directory}`, LogPrefix.HyperPlay)
454+
455+
// Import fs promises for async operations only when needed
456+
const fsPromises = await import('fs/promises')
457+
458+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
459+
try {
460+
// Use different removal strategies based on expected size
461+
// For directories larger than threshold, use async removal to not block the main thread
462+
if (sizeThresholdMB > 250) {
463+
try {
464+
await fsPromises.rm(directory, {
465+
recursive: true,
466+
force: true,
467+
maxRetries: 3
468+
})
469+
} catch (asyncError) {
470+
// Fallback to sync if async fails
471+
logWarning(
472+
`Async removal failed, falling back to sync removal: ${asyncError}`,
473+
LogPrefix.HyperPlay
474+
)
475+
rmSync(directory, { recursive: true, force: true, maxRetries: 3 })
476+
}
477+
} else {
478+
// For smaller directories, use sync removal
479+
rmSync(directory, { recursive: true, force: true, maxRetries: 3 })
480+
}
481+
482+
// Verify directory was actually removed
483+
try {
484+
await fsPromises.access(directory)
485+
// If we get here, directory still exists
486+
logWarning(
487+
`Failed to remove directory ${directory} on attempt ${attempt}/${maxRetries}, directory still exists`,
488+
LogPrefix.HyperPlay
489+
)
490+
captureException(
491+
new Error(`Failed to remove directory, directory still exists`),
492+
{
493+
extra: {
494+
directory,
495+
attempts: attempt,
496+
method: 'safeRemoveDirectory'
497+
}
498+
}
499+
)
500+
} catch {
501+
// Directory doesn't exist (access threw an error), removal succeeded
502+
logInfo(
503+
`Successfully removed directory ${directory}`,
504+
LogPrefix.HyperPlay
505+
)
506+
return true
507+
}
508+
} catch (error) {
509+
logWarning(
510+
`Error removing directory ${directory} on attempt ${attempt}/${maxRetries}: ${error}`,
511+
LogPrefix.HyperPlay
512+
)
513+
}
514+
515+
// Use exponential backoff for retries (increases wait time with each attempt)
516+
if (attempt < maxRetries) {
517+
const backoffDelay = retryDelay * Math.pow(1.5, attempt - 1)
518+
logInfo(
519+
`Waiting ${backoffDelay}ms before retry ${attempt + 1}/${maxRetries}`,
520+
LogPrefix.HyperPlay
521+
)
522+
await new Promise((resolve) => setTimeout(resolve, backoffDelay))
523+
}
524+
}
525+
526+
logError(
527+
`Failed to remove directory ${directory} after ${maxRetries} attempts`,
528+
LogPrefix.HyperPlay
529+
)
530+
return false
531+
}

src/backend/utils.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -983,7 +983,7 @@ export async function downloadFile(
983983
abortController: AbortController,
984984
progressCallback?: ProgressCallback,
985985
onCompleted?: (file: File) => void,
986-
onCancel?: (item: DownloadItem) => void
986+
onCancel?: (item: DownloadItem) => Promise<void>
987987
): Promise<DownloadItem> {
988988
let lastProgressUpdateTime = Date.now()
989989
let lastBytesWritten = 0

0 commit comments

Comments
 (0)