fix: replace non-standard flock() with portable file-based mutex

The original implementation used fs.flock() which is not available in the
Node.js fs/promises API. Replaced with a portable file-based mutex using
exclusive file creation (flag: 'wx') with retry logic.

This ensures the race condition fix for agent reservations works correctly
across all Node.js versions and platforms.
This commit is contained in:
openhands 2026-02-14 17:17:00 +00:00
parent e46062b4f5
commit 710556aa45
2 changed files with 41 additions and 44 deletions

16
package-lock.json generated
View file

@ -77,7 +77,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@ -1735,7 +1734,6 @@
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"playwright": "1.58.2"
},
@ -1866,7 +1864,6 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@ -1926,7 +1923,6 @@
"integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.55.0",
"@typescript-eslint/types": "8.55.0",
@ -2445,7 +2441,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -2865,7 +2860,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -3150,7 +3144,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@ -3645,7 +3638,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -3831,7 +3823,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@ -5094,7 +5085,6 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@ -5885,7 +5875,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@ -6044,7 +6033,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -6054,7 +6042,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@ -6906,7 +6893,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -7096,7 +7082,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -7404,7 +7389,6 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View file

@ -169,28 +169,47 @@ async function readActiveReservations(): Promise<AgentReservation[]> {
}
}
async function lockActiveReservations(): Promise<number> {
// Ensure the directory and file exist before trying to lock
await fs.mkdir(path.dirname(activeReservationsPath()), { recursive: true });
try {
const fd = await fs.open(activeReservationsPath(), 'r+');
await fs.flock(fd, 'ex');
return fd;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
// File doesn't exist, create it first
await fs.writeFile(activeReservationsPath(), JSON.stringify({ reservations: [] }), 'utf8');
const fd = await fs.open(activeReservationsPath(), 'r+');
await fs.flock(fd, 'ex');
return fd;
// Simple mutex-based locking using a shared lock file to prevent race conditions
const LOCK_FILE_PATH = path.join(reservationsRoot(), '.lock');
async function lockActiveReservations(): Promise<void> {
// Ensure the directory exists
await fs.mkdir(path.dirname(LOCK_FILE_PATH), { recursive: true });
// Use a simple file-based mutex - create file exclusively, fail if exists
let attempts = 0;
const maxAttempts = 100;
while (attempts < maxAttempts) {
try {
await fs.writeFile(LOCK_FILE_PATH, String(process.pid), { flag: 'wx' });
return;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
// Lock file exists, wait and retry
await new Promise(resolve => setTimeout(resolve, 50));
attempts++;
continue;
}
throw error;
}
throw error;
}
throw new Error('Failed to acquire lock after maximum attempts');
}
async function unlockActiveReservations(fd: number): Promise<void> {
await fs.flock(fd, 'un');
await fs.close(fd);
async function unlockActiveReservations(): Promise<void> {
try {
const content = await fs.readFile(LOCK_FILE_PATH, 'utf8');
// Only release if we own the lock
if (content.trim() === String(process.pid)) {
await fs.unlink(LOCK_FILE_PATH);
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
// Lock file doesn't exist, ignore
}
}
async function atomicWriteJson(filePath: string, payload: string): Promise<void> {
@ -294,10 +313,9 @@ export async function reserveAgentScope(
return invalid(command, 'INVALID_ARGS', `TTL must be an integer between ${MIN_TTL_MINUTES} and ${MAX_TTL_MINUTES} minutes.`);
}
let lockFd: number | null = null;
try {
// Acquire exclusive lock to prevent race conditions
lockFd = await lockActiveReservations();
await lockActiveReservations();
const now = deps.now ? deps.now() : new Date().toISOString();
const reservations = await readActiveReservations();
@ -349,9 +367,7 @@ export async function reserveAgentScope(
} catch (error) {
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to reserve scope.');
} finally {
if (lockFd !== null) {
await unlockActiveReservations(lockFd);
}
await unlockActiveReservations();
}
}
@ -372,10 +388,9 @@ export async function releaseAgentReservation(
return invalid(command, 'INVALID_ARGS', 'Scope is required.');
}
let lockFd: number | null = null;
try {
// Acquire exclusive lock to prevent race conditions
lockFd = await lockActiveReservations();
await lockActiveReservations();
const now = deps.now ? deps.now() : new Date().toISOString();
const reservations = await readActiveReservations();
@ -408,9 +423,7 @@ export async function releaseAgentReservation(
} catch (error) {
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to release reservation.');
} finally {
if (lockFd !== null) {
await unlockActiveReservations(lockFd);
}
await unlockActiveReservations();
}
}