Skip to content

Commit ebc0a99

Browse files
committed
feat: add retry logic for transient filesystem errors (EAGAIN/EBUSY)
Signed-off-by: leocavalcante <leo@cavalcante.dev>
1 parent 5955d51 commit ebc0a99

5 files changed

Lines changed: 303 additions & 8 deletions

File tree

postinstall.mjs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
getAgentsSourceDir,
1616
getErrorMessage,
1717
getPackageRoot,
18+
retryOnTransientError,
1819
validateAgentContent,
1920
} from "./src/paths.mjs"
2021

@@ -58,7 +59,7 @@ function validateAgentFile(filePath) {
5859
* The function handles partial failures gracefully, installing as many
5960
* agents as possible and reporting individual failures.
6061
*
61-
* @returns {void}
62+
* @returns {Promise<void>}
6263
*
6364
* @throws {never} Does not throw - uses process.exit() for error conditions
6465
*
@@ -67,7 +68,7 @@ function validateAgentFile(filePath) {
6768
* - 1: Complete failure - source directory missing, no agent files found,
6869
* or all file copies failed
6970
*/
70-
function main() {
71+
async function main() {
7172
const prefix = DRY_RUN ? "[DRY-RUN] " : ""
7273
console.log(`${prefix}opencode-plugin-opencoder: Installing agents...`)
7374

@@ -131,7 +132,7 @@ function main() {
131132
console.log(`${prefix}Would install: ${file} -> ${targetPath}`)
132133
} else {
133134
verbose(` Copying file...`)
134-
copyFileSync(sourcePath, targetPath)
135+
await retryOnTransientError(() => copyFileSync(sourcePath, targetPath))
135136

136137
// Verify the copy succeeded by comparing file sizes
137138
const sourceSize = statSync(sourcePath).size
@@ -195,4 +196,7 @@ function main() {
195196
}
196197
}
197198

198-
main()
199+
main().catch((err) => {
200+
console.error("opencode-plugin-opencoder: Unexpected error:", err.message)
201+
process.exit(1)
202+
})

preuninstall.mjs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
getAgentsSourceDir,
1616
getErrorMessage,
1717
getPackageRoot,
18+
retryOnTransientError,
1819
} from "./src/paths.mjs"
1920

2021
const packageRoot = getPackageRoot(import.meta.url)
@@ -46,15 +47,15 @@ function verbose(message) {
4647
* The function handles missing directories and files gracefully,
4748
* continuing to remove remaining agents even if some fail.
4849
*
49-
* @returns {void}
50+
* @returns {Promise<void>}
5051
*
5152
* @throws {never} Does not throw - handles all errors internally
5253
*
5354
* Exit codes:
5455
* - 0: Always exits successfully, even if no agents were removed or
5556
* some removals failed. This ensures npm uninstall completes.
5657
*/
57-
function main() {
58+
async function main() {
5859
const prefix = DRY_RUN ? "[DRY-RUN] " : ""
5960
console.log(`${prefix}opencode-plugin-opencoder: Removing agents...`)
6061

@@ -105,7 +106,7 @@ function main() {
105106
console.log(`${prefix}Would remove: ${targetPath}`)
106107
removedCount++
107108
} else {
108-
unlinkSync(targetPath)
109+
await retryOnTransientError(() => unlinkSync(targetPath))
109110
console.log(` Removed: ${file}`)
110111
removedCount++
111112
}
@@ -129,4 +130,7 @@ function main() {
129130
}
130131
}
131132

132-
main()
133+
main().catch((err) => {
134+
console.error("opencode-plugin-opencoder: Unexpected error:", err.message)
135+
// Don't exit with error code - we want uninstall to succeed
136+
})

src/paths.d.mts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,35 @@ export function getErrorMessage(
3535
targetPath: string,
3636
): string
3737

38+
/** Error codes that indicate transient errors that may succeed on retry */
39+
export declare const TRANSIENT_ERROR_CODES: string[]
40+
41+
/**
42+
* Checks if an error is a transient error that may succeed on retry.
43+
*/
44+
export function isTransientError(error: Error & { code?: string }): boolean
45+
46+
/**
47+
* Options for retryOnTransientError function.
48+
*/
49+
export interface RetryOptions {
50+
/** Number of retry attempts (default: 3) */
51+
retries?: number
52+
/** Delay between retries in milliseconds (default: 100) */
53+
delayMs?: number
54+
}
55+
56+
/**
57+
* Retries a function on transient filesystem errors.
58+
*
59+
* If the function throws a transient error (EAGAIN, EBUSY), it will be retried
60+
* up to the specified number of times with a delay between attempts.
61+
*/
62+
export function retryOnTransientError<T>(
63+
fn: () => T | Promise<T>,
64+
options?: RetryOptions,
65+
): Promise<T>
66+
3867
/**
3968
* Result of parsing YAML frontmatter from markdown content.
4069
*/

src/paths.mjs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,86 @@ export function getErrorMessage(error, file, targetPath) {
8888
return `Target already exists: ${targetPath}`
8989
case "EISDIR":
9090
return `Expected a file but found a directory: ${targetPath}`
91+
case "EAGAIN":
92+
return "Resource temporarily unavailable. Try again"
93+
case "EBUSY":
94+
return "File is busy or locked. Try again later"
9195
default:
9296
return error.message || "Unknown error"
9397
}
9498
}
9599

100+
/** Error codes that indicate transient errors that may succeed on retry */
101+
export const TRANSIENT_ERROR_CODES = ["EAGAIN", "EBUSY"]
102+
103+
/**
104+
* Checks if an error is a transient error that may succeed on retry.
105+
*
106+
* @param {Error & {code?: string}} error - The error to check
107+
* @returns {boolean} True if the error is transient
108+
*/
109+
export function isTransientError(error) {
110+
return TRANSIENT_ERROR_CODES.includes(error.code)
111+
}
112+
113+
/**
114+
* Delays execution for the specified number of milliseconds.
115+
*
116+
* @param {number} ms - Milliseconds to wait
117+
* @returns {Promise<void>}
118+
*/
119+
function delay(ms) {
120+
return new Promise((resolve) => setTimeout(resolve, ms))
121+
}
122+
123+
/**
124+
* Retries a function on transient filesystem errors.
125+
*
126+
* If the function throws a transient error (EAGAIN, EBUSY), it will be retried
127+
* up to the specified number of times with a delay between attempts.
128+
*
129+
* @template T
130+
* @param {() => T | Promise<T>} fn - The function to execute
131+
* @param {{ retries?: number, delayMs?: number }} [options] - Retry options
132+
* @returns {Promise<T>} The result of the function
133+
* @throws {Error} The last error if all retries fail
134+
*
135+
* @example
136+
* // Retry a file copy operation
137+
* await retryOnTransientError(() => copyFileSync(src, dest))
138+
*
139+
* @example
140+
* // Custom retry options
141+
* await retryOnTransientError(
142+
* () => unlinkSync(path),
143+
* { retries: 5, delayMs: 200 }
144+
* )
145+
*/
146+
export async function retryOnTransientError(fn, options = {}) {
147+
const { retries = 3, delayMs = 100 } = options
148+
let lastError
149+
150+
for (let attempt = 0; attempt <= retries; attempt++) {
151+
try {
152+
return await fn()
153+
} catch (err) {
154+
lastError = err
155+
const isTransient = isTransientError(err)
156+
157+
// If not a transient error or last attempt, throw immediately
158+
if (!isTransient || attempt === retries) {
159+
throw err
160+
}
161+
162+
// Wait before retrying
163+
await delay(delayMs)
164+
}
165+
}
166+
167+
// This should never be reached, but TypeScript needs it
168+
throw lastError
169+
}
170+
96171
/**
97172
* Parses YAML frontmatter from markdown content.
98173
*

0 commit comments

Comments
 (0)