Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ name: CI

on:
push:
branches: [main]
pull_request:
schedule:
- cron: '0 8 * * 1'
Expand Down
21 changes: 19 additions & 2 deletions src/site-memory/file-lock.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import { spawnSync } from 'node:child_process';
import { mkdtemp, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises';
import { mkdtemp, open, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises';
import { hostname, tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { lockPathFor, withFileLock } from './file-lock.js';

const tempDirs: string[] = [];

vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>();
return { ...actual, open: vi.fn(actual.open) };
});

afterEach(async () => {
vi.restoreAllMocks();
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});

Expand All @@ -28,6 +34,17 @@ describe('site memory file lock', () => {
await expect(exists(lockPathFor(target))).resolves.toBe(false);
});

it('retries a transient Windows EPERM when creating the lock', async () => {
const target = await tempTarget();
vi.mocked(open).mockRejectedValueOnce(
Object.assign(new Error('transient Windows lock'), { code: 'EPERM' }),
);
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32');

await expect(withFileLock(target, async () => 'written')).resolves.toBe('written');
await expect(exists(lockPathFor(target))).resolves.toBe(false);
});

it('releases the lock when the critical section throws', async () => {
const target = await tempTarget();

Expand Down
14 changes: 9 additions & 5 deletions src/site-memory/file-lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,15 @@ async function acquire(lockPath: string, options: FileLockOptions): Promise<stri
/** Resolves true when this call created the lock, false when someone else holds it. */
async function create(lockPath: string, body: string): Promise<boolean> {
let handle;
try {
handle = await open(lockPath, 'wx');
} catch (err) {
if (isNodeError(err) && err.code === 'EEXIST') return false;
throw err;
for (let attempt = 0; ; attempt += 1) {
try {
handle = await open(lockPath, 'wx');
break;
} catch (err) {
if (isNodeError(err) && err.code === 'EEXIST') return false;
if (process.platform !== 'win32' || !isNodeError(err) || err.code !== 'EPERM' || attempt >= 2) throw err;
await delay(backoffMs(attempt));
}
}
try {
await handle.writeFile(body, 'utf8');
Expand Down
Loading