Skip to content

Commit 0eb5b3b

Browse files
committed
fix: resolve race condition in getStorageBasePath by using unique test file names
- Add unique suffix to .write_test file using process ID, timestamp, and random string - Prevents concurrent calls from conflicting on the same test file - Add comprehensive test coverage for storage.ts including concurrent call testing Fixes #7173
1 parent a8aea14 commit 0eb5b3b

File tree

2 files changed

+180
-1
lines changed

2 files changed

+180
-1
lines changed
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
2+
import * as vscode from "vscode"
3+
import * as fs from "fs/promises"
4+
import * as path from "path"
5+
import { getStorageBasePath } from "../storage"
6+
7+
// Mock vscode module
8+
vi.mock("vscode", () => ({
9+
workspace: {
10+
getConfiguration: vi.fn(),
11+
},
12+
window: {
13+
showErrorMessage: vi.fn(),
14+
},
15+
}))
16+
17+
// Mock fs/promises module
18+
vi.mock("fs/promises", () => ({
19+
mkdir: vi.fn(),
20+
writeFile: vi.fn(),
21+
rm: vi.fn(),
22+
}))
23+
24+
describe("storage", () => {
25+
const mockFs = fs as any
26+
const mockVscode = vscode as any
27+
28+
beforeEach(() => {
29+
vi.clearAllMocks()
30+
})
31+
32+
afterEach(() => {
33+
vi.restoreAllMocks()
34+
})
35+
36+
describe("getStorageBasePath", () => {
37+
const defaultPath = "/default/storage/path"
38+
39+
it("should return default path when no custom path is configured", async () => {
40+
mockVscode.workspace.getConfiguration.mockReturnValue({
41+
get: vi.fn().mockReturnValue(""),
42+
})
43+
44+
const result = await getStorageBasePath(defaultPath)
45+
expect(result).toBe(defaultPath)
46+
})
47+
48+
it("should return default path when vscode configuration is not accessible", async () => {
49+
mockVscode.workspace.getConfiguration.mockImplementation(() => {
50+
throw new Error("VSCode not available")
51+
})
52+
53+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
54+
const result = await getStorageBasePath(defaultPath)
55+
56+
expect(result).toBe(defaultPath)
57+
expect(consoleSpy).toHaveBeenCalledWith("Could not access VSCode configuration - using default path")
58+
59+
consoleSpy.mockRestore()
60+
})
61+
62+
it("should return custom path when it is writable", async () => {
63+
const customPath = "/custom/storage/path"
64+
65+
mockVscode.workspace.getConfiguration.mockReturnValue({
66+
get: vi.fn().mockReturnValue(customPath),
67+
})
68+
69+
mockFs.mkdir.mockResolvedValue(undefined)
70+
mockFs.writeFile.mockResolvedValue(undefined)
71+
mockFs.rm.mockResolvedValue(undefined)
72+
73+
const result = await getStorageBasePath(defaultPath)
74+
75+
expect(result).toBe(customPath)
76+
expect(mockFs.mkdir).toHaveBeenCalledWith(customPath, { recursive: true })
77+
expect(mockFs.writeFile).toHaveBeenCalledWith(
78+
expect.stringMatching(/\.write_test_\d+_\d+_[a-z0-9]+$/),
79+
"test",
80+
)
81+
expect(mockFs.rm).toHaveBeenCalledWith(expect.stringMatching(/\.write_test_\d+_\d+_[a-z0-9]+$/))
82+
})
83+
84+
it("should return default path when custom path is not writable", async () => {
85+
const customPath = "/custom/storage/path"
86+
87+
mockVscode.workspace.getConfiguration.mockReturnValue({
88+
get: vi.fn().mockReturnValue(customPath),
89+
})
90+
91+
mockFs.mkdir.mockResolvedValue(undefined)
92+
mockFs.writeFile.mockRejectedValue(new Error("Permission denied"))
93+
94+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {})
95+
const result = await getStorageBasePath(defaultPath)
96+
97+
expect(result).toBe(defaultPath)
98+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Custom storage path is unusable"))
99+
expect(mockVscode.window.showErrorMessage).toHaveBeenCalled()
100+
101+
consoleSpy.mockRestore()
102+
})
103+
104+
it("should handle concurrent calls without race conditions", async () => {
105+
const customPath = "/custom/storage/path"
106+
107+
mockVscode.workspace.getConfiguration.mockReturnValue({
108+
get: vi.fn().mockReturnValue(customPath),
109+
})
110+
111+
mockFs.mkdir.mockResolvedValue(undefined)
112+
mockFs.writeFile.mockResolvedValue(undefined)
113+
mockFs.rm.mockResolvedValue(undefined)
114+
115+
// Simulate multiple concurrent calls
116+
const promises = Array.from({ length: 10 }, () => getStorageBasePath(defaultPath))
117+
const results = await Promise.all(promises)
118+
119+
// All should return the custom path
120+
results.forEach((result) => {
121+
expect(result).toBe(customPath)
122+
})
123+
124+
// Check that each call used a unique test file
125+
const writeFileCalls = mockFs.writeFile.mock.calls
126+
const testFilePaths = writeFileCalls.map((call: any[]) => call[0])
127+
const uniquePaths = new Set(testFilePaths)
128+
129+
// Each concurrent call should have used a unique test file
130+
expect(uniquePaths.size).toBe(10)
131+
132+
// All test files should have unique suffixes
133+
testFilePaths.forEach((filePath: string) => {
134+
expect(filePath).toMatch(/\.write_test_\d+_\d+_[a-z0-9]+$/)
135+
})
136+
})
137+
138+
it("should use unique test file names with process ID, timestamp, and random suffix", async () => {
139+
const customPath = "/custom/storage/path"
140+
141+
mockVscode.workspace.getConfiguration.mockReturnValue({
142+
get: vi.fn().mockReturnValue(customPath),
143+
})
144+
145+
mockFs.mkdir.mockResolvedValue(undefined)
146+
mockFs.writeFile.mockResolvedValue(undefined)
147+
mockFs.rm.mockResolvedValue(undefined)
148+
149+
const processId = process.pid
150+
const beforeTime = Date.now()
151+
152+
await getStorageBasePath(defaultPath)
153+
154+
const afterTime = Date.now()
155+
156+
const writeFileCall = mockFs.writeFile.mock.calls[0]
157+
const testFilePath = writeFileCall[0]
158+
159+
// Extract the suffix from the test file path
160+
const match = testFilePath.match(/\.write_test_(\d+)_(\d+)_([a-z0-9]+)$/)
161+
expect(match).toBeTruthy()
162+
163+
const [, pid, timestamp, random] = match
164+
165+
// Verify process ID
166+
expect(parseInt(pid)).toBe(processId)
167+
168+
// Verify timestamp is within expected range
169+
const ts = parseInt(timestamp)
170+
expect(ts).toBeGreaterThanOrEqual(beforeTime)
171+
expect(ts).toBeLessThanOrEqual(afterTime)
172+
173+
// Verify random suffix format
174+
expect(random).toMatch(/^[a-z0-9]{7}$/)
175+
})
176+
})
177+
})

src/utils/storage.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ export async function getStorageBasePath(defaultPath: string): Promise<string> {
3333
await fs.mkdir(customStoragePath, { recursive: true })
3434

3535
// Test if path is writable
36-
const testFile = path.join(customStoragePath, ".write_test")
36+
// Use unique suffix to prevent race conditions when multiple instances run simultaneously
37+
const uniqueSuffix = `${process.pid}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`
38+
const testFile = path.join(customStoragePath, `.write_test_${uniqueSuffix}`)
3739
await fs.writeFile(testFile, "test")
3840
await fs.rm(testFile)
3941

0 commit comments

Comments
 (0)