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
5 changes: 3 additions & 2 deletions src/core/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,8 +334,9 @@ export class StateStore {
try {
fd = openSync(this.lockPath, "wx")
break
} catch {
// Either EEXIST (someone else holds it) or a race lost. Inspect age.
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error
// Someone else holds it, or another creator won the race. Inspect age.
try {
const st = statSync(this.lockPath)
if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
Expand Down
10 changes: 6 additions & 4 deletions src/orchestrator/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,13 +390,13 @@ export class OrchestratorStore {
private async ownLock<T>(fn: () => T): Promise<T> {
mkdirSync(this.dir, { recursive: true })
const deadline = Date.now() + ORCH_LOCK_TIMEOUT_MS
let fd: number
for (;;) {
try {
const fd = openSync(this.lockPath, "wx")
writeFileSync(fd, `${process.pid}@${Date.now()}`, "utf8")
closeSync(fd)
fd = openSync(this.lockPath, "wx")
break
} catch {
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error
try {
if (Date.now() - statSync(this.lockPath).mtimeMs > ORCH_LOCK_STALE_MS) {
try {
Expand All @@ -415,8 +415,10 @@ export class OrchestratorStore {
}
}
try {
writeFileSync(fd, `${process.pid}@${Date.now()}`, "utf8")
return fn()
} finally {
closeSync(fd)
try {
unlinkSync(this.lockPath)
} catch {
Expand Down
62 changes: 62 additions & 0 deletions test/unit/lock-errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { test } from "node:test"
import assert from "node:assert/strict"
import fs from "node:fs"
import { syncBuiltinESMExports } from "node:module"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { StateStore } from "../../src/core/store.js"
import { OrchestratorStore } from "../../src/orchestrator/state.js"

for (const Store of [StateStore, OrchestratorStore]) {
for (const code of ["EACCES", "EROFS", "EMFILE"]) {
test(`${Store.name} immediately preserves ${code} from lock creation`, async (t) => {
const dir = fs.mkdtempSync(join(tmpdir(), "opencomms-lock-error-"))
const error = Object.assign(new Error(`${code}: cannot create lock`), { code })
let attempts = 0
let called = false
t.mock.method(fs, "openSync", () => {
attempts++
throw error
})
syncBuiltinESMExports()
try {
await assert.rejects(
new Store(dir).withLock(() => {
called = true
}),
(err) => err === error,
)
assert.equal(attempts, 1)
assert.equal(called, false)
} finally {
t.mock.restoreAll()
syncBuiltinESMExports()
fs.rmSync(dir, { recursive: true, force: true })
}
})
}

test(`${Store.name} retries EEXIST and releases the acquired lock`, async (t) => {
const dir = fs.mkdtempSync(join(tmpdir(), "opencomms-lock-retry-"))
const originalOpen = fs.openSync
let attempts = 0
t.mock.method(fs, "openSync", (...args: Parameters<typeof fs.openSync>) => {
if (++attempts === 1) throw Object.assign(new Error("already exists"), { code: "EEXIST" })
return originalOpen(...args)
})
syncBuiltinESMExports()
try {
const store = new Store(dir)
assert.equal(await store.withLock(() => "acquired"), "acquired")
assert.equal(attempts, 2)
assert.deepEqual(
fs.readdirSync(store.dir).filter((name) => name.endsWith(".lock")),
[],
)
} finally {
t.mock.restoreAll()
syncBuiltinESMExports()
fs.rmSync(dir, { recursive: true, force: true })
}
})
}
Loading