Skip to content

Commit 5420c4b

Browse files
fix: clean up nested temporary file directories (#804)
* feat: support nested directory for tmpfile_format * fix: handle race conditions when removing temporary directories * fix: only delete temporary directories created * refactor: extract logic into file so it can be tested --------- Co-authored-by: Steven Arcangeli <[email protected]>
1 parent 5f5152f commit 5420c4b

File tree

3 files changed

+124
-1
lines changed

3 files changed

+124
-1
lines changed

lua/conform/dir_manager.lua

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
local log = require("conform.log")
2+
local uv = vim.uv or vim.loop
3+
4+
local M = {}
5+
6+
---Set of directories that have been created
7+
---@type string[]
8+
M._dirs = {}
9+
10+
---Ensure that all parent directories of a path exist
11+
---@param path string
12+
M.ensure_parent = function(path)
13+
local current_parent_dir = vim.fs.dirname(path)
14+
-- Keep track of the current parent directories created, so we can delete them later
15+
while current_parent_dir and not uv.fs_stat(current_parent_dir) do
16+
table.insert(M._dirs, current_parent_dir)
17+
current_parent_dir = vim.fs.dirname(current_parent_dir)
18+
end
19+
vim.fn.mkdir(vim.fs.dirname(path), "p")
20+
end
21+
22+
---Clean up temporary directories
23+
M.cleanup = function()
24+
-- Before cleanup we make sure to order the deepest paths first
25+
table.sort(M._dirs, function(a, b)
26+
return a:len() > b:len()
27+
end)
28+
local temp_dir_idx = 1
29+
while temp_dir_idx <= #M._dirs do
30+
local temp_dir_to_remove = M._dirs[temp_dir_idx]
31+
log.trace("Cleaning up temp dir %s", temp_dir_to_remove)
32+
local success, err_name, err_msg = uv.fs_rmdir(temp_dir_to_remove)
33+
if not success then
34+
log.warn("Failed to remove temp directory %s: %s: %s", temp_dir_to_remove, err_name, err_msg)
35+
temp_dir_idx = temp_dir_idx + 1
36+
else
37+
table.remove(M._dirs, temp_dir_idx)
38+
end
39+
end
40+
end
41+
42+
return M

lua/conform/runner.lua

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
local dir_manager = require("conform.dir_manager")
12
local errors = require("conform.errors")
23
local fs = require("conform.fs")
34
local ft_to_ext = require("conform.ft_to_ext")
@@ -370,13 +371,14 @@ local function run_formatter(bufnr, formatter, config, ctx, input_lines, opts, c
370371

371372
if not config.stdin then
372373
log.debug("Creating temp file %s", ctx.filename)
373-
vim.fn.mkdir(vim.fs.dirname(ctx.filename), "p")
374+
dir_manager.ensure_parent(ctx.filename)
374375
local fd = assert(uv.fs_open(ctx.filename, "w", 448)) -- 0700
375376
uv.fs_write(fd, buffer_text)
376377
uv.fs_close(fd)
377378
callback = util.wrap_callback(callback, function()
378379
log.debug("Cleaning up temp file %s", ctx.filename)
379380
uv.fs_unlink(ctx.filename)
381+
dir_manager.cleanup()
380382
end)
381383
end
382384

tests/dir_manager_spec.lua

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
local dir_manager = require("conform.dir_manager")
2+
local test_util = require("tests.test_util")
3+
4+
local function touch(path)
5+
local fd = assert(vim.uv.fs_open(path, "w", 448))
6+
vim.uv.fs_write(fd, "")
7+
vim.uv.fs_close(fd)
8+
end
9+
10+
describe("dir_manager", function()
11+
after_each(function()
12+
test_util.reset_editor()
13+
end)
14+
15+
it("creates and cleans up nested created directories", function()
16+
local root, err = vim.uv.fs_mkdtemp("conform_XXXXXX")
17+
assert(root, err)
18+
dir_manager.ensure_parent(root .. "/foo/bar/baz.txt")
19+
assert(vim.uv.fs_stat(root .. "/foo"))
20+
assert(vim.uv.fs_stat(root .. "/foo/bar"))
21+
assert(not vim.uv.fs_stat(root .. "/foo/bar/baz.txt"))
22+
dir_manager.cleanup()
23+
assert(not vim.uv.fs_stat(root .. "/foo/bar"))
24+
assert(not vim.uv.fs_stat(root .. "/foo"))
25+
assert(vim.uv.fs_stat(root))
26+
assert(vim.uv.fs_rmdir(root))
27+
end)
28+
29+
it("handles race condition for two concurrent processes", function()
30+
local root, err = vim.uv.fs_mkdtemp("conform_XXXXXX")
31+
assert(root, err)
32+
dir_manager.ensure_parent(root .. "/foo/bar/baz.txt")
33+
touch(root .. "/foo/bar/baz.txt")
34+
assert(vim.uv.fs_stat(root .. "/foo"))
35+
assert(vim.uv.fs_stat(root .. "/foo/bar"))
36+
assert(vim.uv.fs_stat(root .. "/foo/bar/baz.txt"))
37+
38+
-- This cleanup will fail because baz.txt exists
39+
dir_manager.cleanup()
40+
assert(vim.uv.fs_stat(root .. "/foo/bar/baz.txt"))
41+
42+
assert(vim.uv.fs_unlink(root .. "/foo/bar/baz.txt"))
43+
-- This cleanup should succeed
44+
dir_manager.cleanup()
45+
assert(not vim.uv.fs_stat(root .. "/foo/bar"))
46+
assert(not vim.uv.fs_stat(root .. "/foo"))
47+
48+
assert(vim.uv.fs_stat(root))
49+
assert(vim.uv.fs_rmdir(root))
50+
end)
51+
52+
it("handles race condition for semi-matched nested paths", function()
53+
local root, err = vim.uv.fs_mkdtemp("conform_XXXXXX")
54+
assert(root, err)
55+
dir_manager.ensure_parent(root .. "/foo/bar/baz.txt")
56+
dir_manager.ensure_parent(root .. "/foo/qux/foo.txt")
57+
touch(root .. "/foo/qux/foo.txt")
58+
assert(vim.uv.fs_stat(root .. "/foo"))
59+
assert(vim.uv.fs_stat(root .. "/foo/bar"))
60+
assert(vim.uv.fs_stat(root .. "/foo/qux"))
61+
assert(vim.uv.fs_stat(root .. "/foo/qux/foo.txt"))
62+
63+
-- This cleanup will partially succeed because foo.txt exists
64+
dir_manager.cleanup()
65+
assert(vim.uv.fs_stat(root .. "/foo"))
66+
assert(not vim.uv.fs_stat(root .. "/bar"))
67+
assert(vim.uv.fs_stat(root .. "/foo/qux"))
68+
assert(vim.uv.fs_stat(root .. "/foo/qux/foo.txt"))
69+
70+
assert(vim.uv.fs_unlink(root .. "/foo/qux/foo.txt"))
71+
-- This cleanup should succeed
72+
dir_manager.cleanup()
73+
assert(not vim.uv.fs_stat(root .. "/foo/qux"))
74+
assert(not vim.uv.fs_stat(root .. "/foo"))
75+
76+
assert(vim.uv.fs_stat(root))
77+
assert(vim.uv.fs_rmdir(root))
78+
end)
79+
end)

0 commit comments

Comments
 (0)