Skip to content

Commit ac5c2e0

Browse files
committed
fix(core): serialize task history updates across parallel tabs
1 parent 367013f commit ac5c2e0

5 files changed

Lines changed: 447 additions & 52 deletions

File tree

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import * as fs from "fs/promises"
2+
import * as path from "path"
3+
import * as lockfile from "proper-lockfile"
4+
5+
import { GlobalFileNames } from "../../shared/globalFileNames"
6+
import { getStorageBasePath } from "../../utils/storage"
7+
8+
/**
9+
* Cross-process lock for task history mutations.
10+
*
11+
* Multiple `ClineProvider` instances may live in separate extension-host
12+
* processes (for example VS Code windows) while sharing the same task history
13+
* storage. Each process has its own `TaskHistoryStore`, so an in-memory mutex is
14+
* not sufficient. This lock serializes mutations by taking an exclusive advisory
15+
* lock on the shared `tasks/_history.lock` file.
16+
*/
17+
export class TaskHistoryLock {
18+
private queue: Promise<unknown> = Promise.resolve()
19+
20+
/**
21+
* Acquires the shared task-history lock and executes `fn` while holding it.
22+
*
23+
* The lock file is scoped to the effective storage root (including custom
24+
* storage path resolution) so all windows/processes targeting the same history
25+
* store contend on the same file.
26+
*/
27+
async withLock<T>(globalStoragePath: string, fn: () => Promise<T>): Promise<T> {
28+
const result = this.queue.then(
29+
async () => {
30+
const lockFilePath = await this.getLockFilePath(globalStoragePath)
31+
return this.runWithFileLock(lockFilePath, fn)
32+
},
33+
async () => {
34+
const lockFilePath = await this.getLockFilePath(globalStoragePath)
35+
return this.runWithFileLock(lockFilePath, fn)
36+
},
37+
)
38+
39+
this.queue = result.then(
40+
() => undefined,
41+
() => undefined,
42+
)
43+
44+
return result
45+
}
46+
47+
/**
48+
* Clears in-process queues. File locks held by other processes are not affected.
49+
*/
50+
reset(): void {
51+
this.queue = Promise.resolve()
52+
}
53+
54+
async getLockFilePath(globalStoragePath: string): Promise<string> {
55+
const basePath = await getStorageBasePath(globalStoragePath)
56+
const tasksDir = path.join(basePath, "tasks")
57+
await fs.mkdir(tasksDir, { recursive: true })
58+
const lockFilePath = path.join(tasksDir, GlobalFileNames.historyLock)
59+
60+
try {
61+
await fs.open(lockFilePath, "a").then((handle) => handle.close())
62+
} catch (error) {
63+
console.error(`[TaskHistoryLock] Failed to create lock file at ${lockFilePath}:`, error)
64+
throw error
65+
}
66+
67+
return lockFilePath
68+
}
69+
70+
private async runWithFileLock<T>(lockFilePath: string, fn: () => Promise<T>): Promise<T> {
71+
let releaseLock: (() => Promise<void>) | undefined
72+
73+
try {
74+
releaseLock = await lockfile.lock(lockFilePath, {
75+
stale: 31000,
76+
update: 10000,
77+
realpath: false,
78+
retries: {
79+
retries: 10,
80+
factor: 2,
81+
minTimeout: 50,
82+
maxTimeout: 1000,
83+
},
84+
onCompromised: (err) => {
85+
console.error(`[TaskHistoryLock] Lock at ${lockFilePath} was compromised:`, err)
86+
throw err
87+
},
88+
})
89+
90+
return await fn()
91+
} finally {
92+
if (releaseLock) {
93+
await releaseLock()
94+
}
95+
}
96+
}
97+
}
98+
99+
// Singleton instance shared across all ClineProvider instances in this process.
100+
export const taskHistoryLock = new TaskHistoryLock()
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import * as fs from "fs/promises"
2+
import * as os from "os"
3+
import * as path from "path"
4+
import { fork, type ChildProcess } from "child_process"
5+
6+
import { TaskHistoryLock } from "../TaskHistoryLock"
7+
8+
vi.mock("../../../utils/storage", () => ({
9+
getStorageBasePath: vi.fn().mockImplementation((defaultPath: string) => defaultPath),
10+
}))
11+
12+
const waitForMessage = (child: ChildProcess, expected: string): Promise<void> =>
13+
new Promise((resolve, reject) => {
14+
const timeout = setTimeout(() => {
15+
child.off("message", onMessage)
16+
reject(new Error(`Timed out waiting for child process message: ${expected}`))
17+
}, 5000)
18+
19+
const onMessage = (message: unknown) => {
20+
if (message === expected) {
21+
clearTimeout(timeout)
22+
child.off("message", onMessage)
23+
resolve()
24+
}
25+
}
26+
27+
child.on("message", onMessage)
28+
})
29+
30+
describe("TaskHistoryLock", () => {
31+
let tmpDir: string
32+
33+
beforeEach(async () => {
34+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-lock-"))
35+
})
36+
37+
afterEach(async () => {
38+
await fs.rm(tmpDir, { recursive: true, force: true })
39+
})
40+
41+
it("serializes concurrent operations", async () => {
42+
const lock = new TaskHistoryLock()
43+
let activeCount = 0
44+
let maxActiveCount = 0
45+
const order: string[] = []
46+
47+
const run = (id: string) =>
48+
lock.withLock(tmpDir, async () => {
49+
activeCount++
50+
maxActiveCount = Math.max(maxActiveCount, activeCount)
51+
order.push(`start:${id}`)
52+
await new Promise((resolve) => setTimeout(resolve, 5))
53+
order.push(`end:${id}`)
54+
activeCount--
55+
return id
56+
})
57+
58+
const results = await Promise.all([run("a"), run("b"), run("c")])
59+
60+
expect(results).toEqual(["a", "b", "c"])
61+
expect(maxActiveCount).toBe(1)
62+
expect(order).toHaveLength(6)
63+
for (const id of ["a", "b", "c"]) {
64+
expect(order).toContain(`start:${id}`)
65+
expect(order).toContain(`end:${id}`)
66+
expect(order.indexOf(`start:${id}`)).toBeLessThan(order.indexOf(`end:${id}`))
67+
}
68+
})
69+
70+
it("continues processing after a previous operation rejects", async () => {
71+
const lock = new TaskHistoryLock()
72+
const order: string[] = []
73+
74+
const failed = lock.withLock(tmpDir, async () => {
75+
order.push("start:fail")
76+
throw new Error("simulated failure")
77+
})
78+
79+
const succeeded = lock.withLock(tmpDir, async () => {
80+
order.push("start:success")
81+
return "ok"
82+
})
83+
84+
await expect(failed).rejects.toThrow("simulated failure")
85+
await expect(succeeded).resolves.toBe("ok")
86+
expect(order).toEqual(["start:fail", "start:success"])
87+
})
88+
89+
it("waits for an independent process holding the same lock file", async () => {
90+
const lock = new TaskHistoryLock()
91+
const lockFilePath = await lock.getLockFilePath(tmpDir)
92+
const childScriptPath = path.join(tmpDir, "hold-history-lock.cjs")
93+
await fs.writeFile(
94+
childScriptPath,
95+
`
96+
const lockfile = require("proper-lockfile")
97+
98+
let release
99+
100+
async function main() {
101+
release = await lockfile.lock(process.argv[2], {
102+
stale: 31000,
103+
update: 10000,
104+
realpath: false,
105+
})
106+
process.send?.("locked")
107+
}
108+
109+
process.on("message", async (message) => {
110+
if (message === "release") {
111+
await release?.()
112+
process.send?.("released")
113+
process.exit(0)
114+
}
115+
})
116+
117+
main().catch((error) => {
118+
process.send?.({ error: error instanceof Error ? error.message : String(error) })
119+
process.exit(1)
120+
})
121+
`,
122+
"utf8",
123+
)
124+
125+
const child = fork(childScriptPath, [lockFilePath], { stdio: ["ignore", "ignore", "ignore", "ipc"] })
126+
try {
127+
await waitForMessage(child, "locked")
128+
129+
let enteredCriticalSection = false
130+
const blocked = lock.withLock(tmpDir, async () => {
131+
enteredCriticalSection = true
132+
return "acquired"
133+
})
134+
135+
await new Promise((resolve) => setTimeout(resolve, 100))
136+
expect(enteredCriticalSection).toBe(false)
137+
138+
child.send("release")
139+
await waitForMessage(child, "released")
140+
await expect(blocked).resolves.toBe("acquired")
141+
expect(enteredCriticalSection).toBe(true)
142+
} finally {
143+
if (!child.killed) {
144+
child.kill()
145+
}
146+
}
147+
})
148+
149+
it("reset is a no-op for file-based locking", () => {
150+
const lock = new TaskHistoryLock()
151+
expect(() => lock.reset()).not.toThrow()
152+
})
153+
})

0 commit comments

Comments
 (0)