From 34f59db1db5acbea97a12ed5cfa6a2042ceec4fc Mon Sep 17 00:00:00 2001 From: Annie Tang <98965493+annieetang@users.noreply.github.com> Date: Mon, 24 Nov 2025 14:29:06 -0800 Subject: [PATCH 01/17] implement fs.watch as a generator --- lute/std/libs/fs.luau | 27 +++++++++++++++++++++++++-- tests/std/fs.test.luau | 21 ++++++++++++--------- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/lute/std/libs/fs.luau b/lute/std/libs/fs.luau index 573b955dc..117816499 100644 --- a/lute/std/libs/fs.luau +++ b/lute/std/libs/fs.luau @@ -18,6 +18,7 @@ export type watchhandle = fs.WatchHandle export type watchevent = fs.WatchEvent type pathlike = pathlib.pathlike +type path = pathlib.path export type createdirectoryoptions = { makeparents: boolean?, @@ -67,8 +68,30 @@ function fslib.symboliclink(src: pathlike, dest: pathlike): () return fs.symlink(pathlib.format(src), pathlib.format(dest)) end -function fslib.watch(path: pathlike, callback: (filename: pathlike, event: watchevent) -> ()): watchhandle - return fs.watch(pathlib.format(path), callback) +--- Iterator function that yields filename and event pairs when changes occur in the watched path. +--- Also provides a `close` method to stop watching. +function fslib.watch(path: pathlike): () -> { filename: pathlike, event: watchevent }? + local queue: { { filename: pathlike, event: watchevent } } = {} + local handle = fs.watch(pathlib.format(path), function(filename: string, event: watchevent) + table.insert(queue, { filename = filename, event = event }) + end) + + local function iterator(): { filename: pathlike, event: watchevent }? + if #queue > 0 then + local item = table.remove(queue, 1) + return item + else + return nil + end + end + + function iterator:close(): () + if handle then + handle:close() + end + end + + return iterator end function fslib.exists(path: pathlike): boolean diff --git a/tests/std/fs.test.luau b/tests/std/fs.test.luau index 853fc171e..68c08374b 100644 --- a/tests/std/fs.test.luau +++ b/tests/std/fs.test.luau @@ -2,6 +2,7 @@ local fs = require("@std/fs") local path = require("@std/path") local system = require("@std/system") local test = require("@std/test") +local task = require("@lute/task") local tmpdir = system.tmpdir() @@ -96,21 +97,23 @@ test.suite("FsSuite", function(suite) end local triggered = false - local handle = fs.watch(tmpdir, function(filename, event) - if tostring(filename):find("watched.txt") then - triggered = true - end - end) - - fs.writestringtofile(watched, "x") + local it = fs.watch(tmpdir) local start = os.time() while not triggered and os.time() - start < 2 do + local ev = it() + if ev and tostring(ev.filename):find("watched.txt") then + triggered = true + break + end + task.wait(0.01) end - assert.eq(type(triggered) == "boolean", true) + fs.writestringtofile(path.join(tmpdir, "watched.txt"), "x") + + assert.eq(triggered, true) - handle:close() + it:close() if fs.exists(watched) then fs.remove(watched) end From 18a34335bb329536d4aefde1ed8fa20e006a1c05 Mon Sep 17 00:00:00 2001 From: Annie Tang <98965493+annieetang@users.noreply.github.com> Date: Mon, 24 Nov 2025 14:58:03 -0800 Subject: [PATCH 02/17] try using coroutine/yielding --- lute/std/libs/fs.luau | 19 ++++++++++++++----- tests/std/fs.test.luau | 16 ++++++++++++---- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/lute/std/libs/fs.luau b/lute/std/libs/fs.luau index 117816499..6682ebca0 100644 --- a/lute/std/libs/fs.luau +++ b/lute/std/libs/fs.luau @@ -72,16 +72,25 @@ end --- Also provides a `close` method to stop watching. function fslib.watch(path: pathlike): () -> { filename: pathlike, event: watchevent }? local queue: { { filename: pathlike, event: watchevent } } = {} + local iteratorco local handle = fs.watch(pathlib.format(path), function(filename: string, event: watchevent) table.insert(queue, { filename = filename, event = event }) + + if iteratorco and coroutine.status(iteratorco) == "suspended" then + coroutine.resume(iteratorco) + end end) local function iterator(): { filename: pathlike, event: watchevent }? - if #queue > 0 then - local item = table.remove(queue, 1) - return item - else - return nil + while true do + if #queue > 0 then + local item = table.remove(queue, 1) + return item + else + iteratorco = coroutine.running() + coroutine.yield() + iteratorco = nil + end end end diff --git a/tests/std/fs.test.luau b/tests/std/fs.test.luau index 68c08374b..67145119a 100644 --- a/tests/std/fs.test.luau +++ b/tests/std/fs.test.luau @@ -99,8 +99,18 @@ test.suite("FsSuite", function(suite) local triggered = false local it = fs.watch(tmpdir) - local start = os.time() - while not triggered and os.time() - start < 2 do + fs.writestringtofile(path.join(tmpdir, "watched.txt"), "x") + + -- local start = os.time() + -- while not triggered and os.time() - start < 2 do + -- local ev = it() + -- if ev and tostring(ev.filename):find("watched.txt") then + -- triggered = true + -- break + -- end + -- task.wait(0.01) + -- end + while true do local ev = it() if ev and tostring(ev.filename):find("watched.txt") then triggered = true @@ -109,8 +119,6 @@ test.suite("FsSuite", function(suite) task.wait(0.01) end - fs.writestringtofile(path.join(tmpdir, "watched.txt"), "x") - assert.eq(triggered, true) it:close() From c86d994a3fd27620ebfc4d8cc6cfcc3c1b1cc83d Mon Sep 17 00:00:00 2001 From: Annie Tang <98965493+annieetang@users.noreply.github.com> Date: Tue, 25 Nov 2025 10:13:15 -0800 Subject: [PATCH 03/17] use metatable --- lute/std/libs/fs.luau | 50 +++++++++++++++++++++++------------------- tests/std/fs.test.luau | 38 ++++++++++++++++++-------------- 2 files changed, 49 insertions(+), 39 deletions(-) diff --git a/lute/std/libs/fs.luau b/lute/std/libs/fs.luau index 6682ebca0..6de38e112 100644 --- a/lute/std/libs/fs.luau +++ b/lute/std/libs/fs.luau @@ -9,6 +9,9 @@ local sys = require("@std/system") local fslib = {} +local watch_mt = {} +watch_mt.__index = watch_mt + export type handlemode = fs.HandleMode export type file = fs.FileHandle export type type = fs.FileType @@ -32,6 +35,14 @@ export type walkoptions = { recursive: boolean?, } +type watchiteratordata = { + _handle: watchhandle, + _queue: { { filename: path, event: watchevent } }, +} + +type watchinterface = typeof(watch_mt) +export type watchiterator = setmetatable + function fslib.open(path: pathlike, mode: handlemode?): file return fs.open(pathlib.format(path), mode) end @@ -70,37 +81,32 @@ end --- Iterator function that yields filename and event pairs when changes occur in the watched path. --- Also provides a `close` method to stop watching. -function fslib.watch(path: pathlike): () -> { filename: pathlike, event: watchevent }? - local queue: { { filename: pathlike, event: watchevent } } = {} - local iteratorco - local handle = fs.watch(pathlib.format(path), function(filename: string, event: watchevent) - table.insert(queue, { filename = filename, event = event }) +function fslib.watch(path: pathlike): watchiterator + local queue: { { filename: path, event: watchevent } } = {} - if iteratorco and coroutine.status(iteratorco) == "suspended" then - coroutine.resume(iteratorco) - end + local handle = fs.watch(pathlib.format(path), function(filename: string, event: watchevent) + table.insert(queue, { filename = pathlib.parse(filename), event = event }) end) - local function iterator(): { filename: pathlike, event: watchevent }? - while true do - if #queue > 0 then - local item = table.remove(queue, 1) - return item - else - iteratorco = coroutine.running() - coroutine.yield() - iteratorco = nil - end + local self = { + _handle = handle, + _queue = queue, + } + + function watch_mt:__call() + if #self._queue > 0 then + return table.remove(self._queue, 1) end + return nil end - function iterator:close(): () - if handle then - handle:close() + function watch_mt:close() + if self._handle then + self._handle:close() end end - return iterator + return setmetatable(self, watch_mt) end function fslib.exists(path: pathlike): boolean diff --git a/tests/std/fs.test.luau b/tests/std/fs.test.luau index 67145119a..1f31b5125 100644 --- a/tests/std/fs.test.luau +++ b/tests/std/fs.test.luau @@ -96,27 +96,31 @@ test.suite("FsSuite", function(suite) fs.remove(watched) end - local triggered = false local it = fs.watch(tmpdir) - fs.writestringtofile(path.join(tmpdir, "watched.txt"), "x") - - -- local start = os.time() - -- while not triggered and os.time() - start < 2 do - -- local ev = it() - -- if ev and tostring(ev.filename):find("watched.txt") then - -- triggered = true - -- break - -- end - -- task.wait(0.01) - -- end - while true do + -- Give the watcher a moment to initialize + task.wait(0.1) + + fs.writestringtofile(watched, "x") + + -- Poll the iterator with a timeout + local triggered = false + local start = os.clock() + local timeout = 2 -- 2 second timeout + + while not triggered and (os.clock() - start) < timeout do local ev = it() - if ev and tostring(ev.filename):find("watched.txt") then - triggered = true - break + print("polled iterator, got:", ev) + + if ev then + print("event details:", ev.filename, ev.event) + if tostring(ev.filename):find("watched") then + triggered = true + break + end end - task.wait(0.01) + + task.wait(0.05) -- Poll every 50ms end assert.eq(triggered, true) From 4134392add8806883258a581bc5c0a0b0f4f3a4b Mon Sep 17 00:00:00 2001 From: Annie Tang <98965493+annieetang@users.noreply.github.com> Date: Tue, 25 Nov 2025 10:18:08 -0800 Subject: [PATCH 04/17] clean up test case --- tests/std/fs.test.luau | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/tests/std/fs.test.luau b/tests/std/fs.test.luau index 1f31b5125..be90c0592 100644 --- a/tests/std/fs.test.luau +++ b/tests/std/fs.test.luau @@ -91,7 +91,8 @@ test.suite("FsSuite", function(suite) end) suite:case("watch_callback_on_change", function(assert) - local watched = path.join(tmpdir, "watched.txt") + local watchedFileName = "watched.txt" + local watched = path.join(tmpdir, watchedFileName) if fs.exists(watched) then fs.remove(watched) end @@ -105,18 +106,14 @@ test.suite("FsSuite", function(suite) -- Poll the iterator with a timeout local triggered = false - local start = os.clock() - local timeout = 2 -- 2 second timeout + local start = os.time() - while not triggered and (os.clock() - start) < timeout do + while not triggered and (os.time() - start) < 2 do local ev = it() - print("polled iterator, got:", ev) if ev then - print("event details:", ev.filename, ev.event) - if tostring(ev.filename):find("watched") then + if tostring(ev.filename) == watchedFileName then triggered = true - break end end From cd57667f219e260364725ea726d3f0ade51de2d8 Mon Sep 17 00:00:00 2001 From: Annie Tang <98965493+annieetang@users.noreply.github.com> Date: Tue, 25 Nov 2025 10:26:19 -0800 Subject: [PATCH 05/17] add example --- examples/watch_directory.luau | 49 +++++++++++++++++++++++++++++++++++ tests/std/fs.test.luau | 2 +- 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 examples/watch_directory.luau diff --git a/examples/watch_directory.luau b/examples/watch_directory.luau new file mode 100644 index 000000000..15de73438 --- /dev/null +++ b/examples/watch_directory.luau @@ -0,0 +1,49 @@ +local fs = require("@std/fs") +local path = require("@std/path") +local system = require("@std/system") +local task = require("@lute/task") + +-- Setup, get a temporary directory to watch +local tmpdir = system.tmpdir() + +local watchedFileName = "watched.txt" +local watched = path.join(tmpdir, watchedFileName) +if fs.exists(watched) then + fs.remove(watched) +end + +local it = fs.watch(tmpdir) + +-- Give the watcher a moment to initialize +task.wait(0.1) + +-- Trigger a file change event +fs.writestringtofile(watched, "x") + +-- Poll the iterator with a timeout of 2 seconds +local triggered = false +local start = os.time() + +while not triggered and (os.time() - start) < 2 do + local ev = it() + + if ev then + if tostring(ev.filename) == watchedFileName then + triggered = true + end + end + + task.wait(0.05) -- Poll every 50ms +end + +if triggered then + print("File change detected for:", watchedFileName) +else + print("No file change detected within the timeout period.") +end + +-- Cleanup, close the watcher and remove the watched file +it:close() +if fs.exists(watched) then + fs.remove(watched) +end diff --git a/tests/std/fs.test.luau b/tests/std/fs.test.luau index be90c0592..b582662d7 100644 --- a/tests/std/fs.test.luau +++ b/tests/std/fs.test.luau @@ -90,7 +90,7 @@ test.suite("FsSuite", function(suite) fs.remove(src) end) - suite:case("watch_callback_on_change", function(assert) + suite:case("watch_iterator_on_change", function(assert) local watchedFileName = "watched.txt" local watched = path.join(tmpdir, watchedFileName) if fs.exists(watched) then From d1562e4be0ed3675859c55ded52e38182db40e15 Mon Sep 17 00:00:00 2001 From: Annie Tang <98965493+annieetang@users.noreply.github.com> Date: Tue, 25 Nov 2025 10:28:08 -0800 Subject: [PATCH 06/17] add doc comments --- lute/std/libs/fs.luau | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lute/std/libs/fs.luau b/lute/std/libs/fs.luau index 7a843230f..31c551b27 100644 --- a/lute/std/libs/fs.luau +++ b/lute/std/libs/fs.luau @@ -81,6 +81,7 @@ end --- Iterator function that yields filename and event pairs when changes occur in the watched path. --- Also provides a `close` method to stop watching. +--- Note: for loops do not support yielding generalized iterators, so we cannot use fs.watch as `for _ in fs.watch(...) do` directly. A while loop can be used instead. See example/watch_directory.luau for usage. function fslib.watch(path: pathlike): watchiterator local queue: { { filename: path, event: watchevent } } = {} @@ -176,7 +177,7 @@ function fslib.removedirectory(path: pathlike, options: removedirectoryoptions?) end end --- Note: for loops do not support yielding generalized iterators, so we cannot use fs.walk as `for path in fs.walk(...) do` directly. A while loop can be used instead. +--- Note: for loops do not support yielding generalized iterators, so we cannot use fs.walk as `for path in fs.walk(...) do` directly. A while loop can be used instead. See example/walk_directory.luau for usage. function fslib.walk(path: pathlike, options: walkoptions?): () -> path? local queue = { path } From 79c3b4ca9914cf80999eb4d8a4e8bcbf6e0250d1 Mon Sep 17 00:00:00 2001 From: Annie Tang <98965493+annieetang@users.noreply.github.com> Date: Tue, 25 Nov 2025 10:47:10 -0800 Subject: [PATCH 07/17] maybe remove uv_stop --- lute/fs/src/fs.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/lute/fs/src/fs.cpp b/lute/fs/src/fs.cpp index 34cde2b34..930cf6857 100644 --- a/lute/fs/src/fs.cpp +++ b/lute/fs/src/fs.cpp @@ -661,8 +661,6 @@ int fs_watch(lua_State* L) return 2; } ); - - uv_stop(handle->loop); }, path, 0 From da393a5907179a929b6844a643f44ea1ebed06aa Mon Sep 17 00:00:00 2001 From: Annie Tang <98965493+annieetang@users.noreply.github.com> Date: Tue, 25 Nov 2025 15:17:26 -0800 Subject: [PATCH 08/17] improved implementation --- lute/std/libs/fs.luau | 48 ++++++++++++++++-------------------------- tests/std/fs.test.luau | 29 +++++++++++-------------- 2 files changed, 30 insertions(+), 47 deletions(-) diff --git a/lute/std/libs/fs.luau b/lute/std/libs/fs.luau index 31c551b27..def4b9386 100644 --- a/lute/std/libs/fs.luau +++ b/lute/std/libs/fs.luau @@ -9,9 +9,6 @@ local sys = require("@std/system") local fslib = {} -local watch_mt = {} -watch_mt.__index = watch_mt - export type handlemode = fs.HandleMode export type file = fs.FileHandle export type type = fs.FileType @@ -35,14 +32,11 @@ export type walkoptions = { recursive: boolean?, } -type watchiteratordata = { - _handle: watchhandle, - _queue: { { filename: path, event: watchevent } }, +type watcher = { + next: (watcher) -> watchevent?, + close: (self: watcher) -> (), } -type watchinterface = typeof(watch_mt) -export type watchiterator = setmetatable - function fslib.open(path: pathlike, mode: handlemode?): file return fs.open(pathlib.format(path), mode) end @@ -82,32 +76,26 @@ end --- Iterator function that yields filename and event pairs when changes occur in the watched path. --- Also provides a `close` method to stop watching. --- Note: for loops do not support yielding generalized iterators, so we cannot use fs.watch as `for _ in fs.watch(...) do` directly. A while loop can be used instead. See example/watch_directory.luau for usage. -function fslib.watch(path: pathlike): watchiterator - local queue: { { filename: path, event: watchevent } } = {} - +function fslib.watch(path: pathlike): watcher + local queue = {} local handle = fs.watch(pathlib.format(path), function(filename: string, event: watchevent) table.insert(queue, { filename = pathlib.parse(filename), event = event }) end) - local self = { - _handle = handle, - _queue = queue, + return { + next = function(self: watcher): watchevent? + if #queue == 0 then + return nil + end + local item = table.remove(queue, 1) + return item.event + end, + close = function(self: watcher): () + if handle then + handle:close() + end + end, } - - function watch_mt:__call() - if #self._queue > 0 then - return table.remove(self._queue, 1) - end - return nil - end - - function watch_mt:close() - if self._handle then - self._handle:close() - end - end - - return setmetatable(self, watch_mt) end function fslib.exists(path: pathlike): boolean diff --git a/tests/std/fs.test.luau b/tests/std/fs.test.luau index b582662d7..3e9013bcd 100644 --- a/tests/std/fs.test.luau +++ b/tests/std/fs.test.luau @@ -97,32 +97,27 @@ test.suite("FsSuite", function(suite) fs.remove(watched) end - local it = fs.watch(tmpdir) + local watcher = fs.watch(tmpdir) - -- Give the watcher a moment to initialize - task.wait(0.1) + task.wait(0.1) -- Give some time for the watcher to start fs.writestringtofile(watched, "x") - -- Poll the iterator with a timeout - local triggered = false - local start = os.time() + local foundChange = false + local startTime = os.clock() - while not triggered and (os.time() - start) < 2 do - local ev = it() - - if ev then - if tostring(ev.filename) == watchedFileName then - triggered = true - end + local event = watcher:next() + while event and os.clock() - startTime < 2 do + if event.change or event.rename then + foundChange = true end - - task.wait(0.05) -- Poll every 50ms + event = watcher:next() end - assert.eq(triggered, true) + assert.eq(foundChange, true) + assert.eq(event == nil, true) - it:close() + watcher:close() if fs.exists(watched) then fs.remove(watched) end From db38db3b0f855bca4a910b4650a495631302ce91 Mon Sep 17 00:00:00 2001 From: Annie Tang <98965493+annieetang@users.noreply.github.com> Date: Tue, 25 Nov 2025 15:22:51 -0800 Subject: [PATCH 09/17] update example --- examples/watch_directory.luau | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/examples/watch_directory.luau b/examples/watch_directory.luau index 15de73438..b07a211cd 100644 --- a/examples/watch_directory.luau +++ b/examples/watch_directory.luau @@ -12,7 +12,7 @@ if fs.exists(watched) then fs.remove(watched) end -local it = fs.watch(tmpdir) +local watcher = fs.watch(tmpdir) -- Give the watcher a moment to initialize task.wait(0.1) @@ -21,29 +21,25 @@ task.wait(0.1) fs.writestringtofile(watched, "x") -- Poll the iterator with a timeout of 2 seconds -local triggered = false -local start = os.time() +local foundChange = false +local startTime = os.clock() -while not triggered and (os.time() - start) < 2 do - local ev = it() - - if ev then - if tostring(ev.filename) == watchedFileName then - triggered = true - end +local event = watcher:next() +while event and os.clock() - startTime < 2 do + if event.change or event.rename then + foundChange = true end - - task.wait(0.05) -- Poll every 50ms + event = watcher:next() end -if triggered then +if foundChange then print("File change detected for:", watchedFileName) else print("No file change detected within the timeout period.") end -- Cleanup, close the watcher and remove the watched file -it:close() +watcher:close() if fs.exists(watched) then fs.remove(watched) end From 084a0e3983397ae6414c31ae0b5c33597dd8b847 Mon Sep 17 00:00:00 2001 From: Annie Tang <98965493+annieetang@users.noreply.github.com> Date: Mon, 1 Dec 2025 09:04:58 -0800 Subject: [PATCH 10/17] update example and test case --- examples/watch_directory.luau | 24 ++++++++++-------------- tests/std/fs.test.luau | 21 +++++++++------------ 2 files changed, 19 insertions(+), 26 deletions(-) diff --git a/examples/watch_directory.luau b/examples/watch_directory.luau index b07a211cd..7db44daea 100644 --- a/examples/watch_directory.luau +++ b/examples/watch_directory.luau @@ -14,28 +14,24 @@ end local watcher = fs.watch(tmpdir) --- Give the watcher a moment to initialize -task.wait(0.1) - -- Trigger a file change event fs.writestringtofile(watched, "x") -- Poll the iterator with a timeout of 2 seconds -local foundChange = false -local startTime = os.clock() +local event +local start = os.clock() -local event = watcher:next() -while event and os.clock() - startTime < 2 do - if event.change or event.rename then - foundChange = true - end +repeat event = watcher:next() -end + if not event then + task.wait(0.01) + end +until event or (os.clock() - start > 2) -if foundChange then - print("File change detected for:", watchedFileName) +if event then + print("Event detected:", event.change and "change" or "rename") else - print("No file change detected within the timeout period.") + print("No event detected within the timeout period.") end -- Cleanup, close the watcher and remove the watched file diff --git a/tests/std/fs.test.luau b/tests/std/fs.test.luau index 3e9013bcd..96e5857cc 100644 --- a/tests/std/fs.test.luau +++ b/tests/std/fs.test.luau @@ -99,23 +99,20 @@ test.suite("FsSuite", function(suite) local watcher = fs.watch(tmpdir) - task.wait(0.1) -- Give some time for the watcher to start - fs.writestringtofile(watched, "x") - local foundChange = false - local startTime = os.clock() + local start = os.clock() + local event - local event = watcher:next() - while event and os.clock() - startTime < 2 do - if event.change or event.rename then - foundChange = true - end + repeat event = watcher:next() - end + if not event then + task.wait(0.01) + end + until event or (os.clock() - start > 2) - assert.eq(foundChange, true) - assert.eq(event == nil, true) + assert.neq(event, nil) + assert.eq(event.change or event.rename, true) watcher:close() if fs.exists(watched) then From 55a81ecef28b9981c887badf7f6edc099c9bfcae Mon Sep 17 00:00:00 2001 From: Annie Tang <98965493+annieetang@users.noreply.github.com> Date: Mon, 1 Dec 2025 09:28:32 -0800 Subject: [PATCH 11/17] add ifClosed check to destructor --- lute/fs/src/fs.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lute/fs/src/fs.cpp b/lute/fs/src/fs.cpp index 930cf6857..879c5d762 100644 --- a/lute/fs/src/fs.cpp +++ b/lute/fs/src/fs.cpp @@ -573,7 +573,8 @@ struct WatchHandle ~WatchHandle() { - close(); + if (!isClosed) + close(); } }; From bfa1e746bdf1d8e281f76174b2d873382304bdb5 Mon Sep 17 00:00:00 2001 From: Annie Tang <98965493+annieetang@users.noreply.github.com> Date: Mon, 1 Dec 2025 10:14:38 -0800 Subject: [PATCH 12/17] remove WatchHandle destructor and rewrite watchhandle close --- lute/fs/src/fs.cpp | 33 +++++++++++++++------------------ 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/lute/fs/src/fs.cpp b/lute/fs/src/fs.cpp index 879c5d762..176c9cab6 100644 --- a/lute/fs/src/fs.cpp +++ b/lute/fs/src/fs.cpp @@ -553,28 +553,25 @@ struct WatchHandle void close() { - if (!isClosed) - { - int err = uv_fs_event_stop(&handle); - if (err) - { - luaL_errorL(L, "Error stopping fs event: %s", uv_strerror(err)); - } - - uv_close((uv_handle_t*) &handle, nullptr); - - isClosed = true; + if (isClosed) + return; - getRuntime(L)->releasePendingToken(); + isClosed = true; - callbackReference.reset(); + int err = uv_fs_event_stop(&handle); + if (err) + { + luaL_errorL(L, "Error stopping fs event: %s", uv_strerror(err)); } - } - ~WatchHandle() - { - if (!isClosed) - close(); + auto closeCb = [](uv_handle_t* handle) + { + WatchHandle* wh = static_cast(handle->data); + wh->callbackReference.reset(); + getRuntime(wh->L)->releasePendingToken(); + }; + + uv_close((uv_handle_t*) &handle, closeCb); } }; From 67bc0f6ce45d2d26fcc936947a41b8421b1dbcc1 Mon Sep 17 00:00:00 2001 From: Annie Tang <98965493+annieetang@users.noreply.github.com> Date: Mon, 1 Dec 2025 11:56:26 -0800 Subject: [PATCH 13/17] cleanup and fix task.wait heap use after free --- lute/fs/src/fs.cpp | 29 +-- lute/task/src/task.cpp | 7 +- tests/std/fs.test.luau | 446 ++++++++++++++++++++--------------------- 3 files changed, 234 insertions(+), 248 deletions(-) diff --git a/lute/fs/src/fs.cpp b/lute/fs/src/fs.cpp index 176c9cab6..7e768c9a2 100644 --- a/lute/fs/src/fs.cpp +++ b/lute/fs/src/fs.cpp @@ -546,11 +546,11 @@ int fs_symlink(lua_State* L) struct WatchHandle { - lua_State* L; + lua_State* L = nullptr; std::shared_ptr callbackReference; bool isClosed = false; uv_fs_event_t handle; - + void close() { if (isClosed) @@ -634,27 +634,10 @@ int fs_watch(lua_State* L) // events lua_createtable(L, 0, 2); - if ((events & UV_RENAME) == UV_RENAME) - { - lua_pushboolean(L, true); - lua_setfield(L, -2, "rename"); - } - else - { - lua_pushboolean(L, false); - lua_setfield(L, -2, "rename"); - } - - if ((events & UV_CHANGE) == UV_CHANGE) - { - lua_pushboolean(L, true); - lua_setfield(L, -2, "change"); - } - else - { - lua_pushboolean(L, false); - lua_setfield(L, -2, "change"); - } + lua_pushboolean(L, (events & UV_RENAME) != 0); + lua_setfield(L, -2, "rename"); + lua_pushboolean(L, (events & UV_CHANGE) != 0); + lua_setfield(L, -2, "change"); return 2; } diff --git a/lute/task/src/task.cpp b/lute/task/src/task.cpp index 59c9bcab7..30bf1a3e2 100644 --- a/lute/task/src/task.cpp +++ b/lute/task/src/task.cpp @@ -54,12 +54,15 @@ static void yieldLuaStateFor(lua_State* L, uint64_t milliseconds, bool putDeltaT if (yield->putDeltaTimeOnStack) lua_pushnumber(L, static_cast(uv_now(uv_default_loop()) - yield->startedAtMs) / 1000.0); - delete yield; return stackReturnAmount; } ); - uv_timer_stop(&yield->uvTimer); + uv_close((uv_handle_t*)&yield->uvTimer, [](uv_handle_t* handle) + { + WaitData* yield = static_cast(handle->data); + delete yield; + }); }, milliseconds, 0 diff --git a/tests/std/fs.test.luau b/tests/std/fs.test.luau index 96e5857cc..c75d6d519 100644 --- a/tests/std/fs.test.luau +++ b/tests/std/fs.test.luau @@ -7,88 +7,88 @@ local task = require("@lute/task") local tmpdir = system.tmpdir() test.suite("FsSuite", function(suite) - suite:case("open_read_write_close_and_stat", function(assert) - local file = path.join(tmpdir, "file.txt") - - local h = fs.open(file, "w+") - assert.eq(fs.exists(file), true) - fs.write(h, "abc") - fs.close(h) - - local m = fs.metadata(file) - assert.eq(m.size, 3) - - local hr = fs.open(file, "r") - assert.eq(fs.read(hr), "abc") - fs.close(hr) - - fs.remove(file) - end) - - suite:case("writestring_and_readfile", function(assert) - local file = path.join(tmpdir, "hello.txt") - fs.writestringtofile(file, "hello lute") - - local contents = fs.readfiletostring(file) - assert.eq(contents, "hello lute") - - fs.remove(file) - end) - - suite:case("exists_and_type_file_and_dir", function(assert) - local file = path.join(tmpdir, "roblox.txt") - fs.writestringtofile(file, "roblox") - - assert.eq(fs.exists(file), true) - assert.eq(fs.type(file), "file") - assert.eq(fs.exists(tmpdir), true) - assert.eq(fs.type(tmpdir), "dir") - - fs.remove(file) - end) - - suite:case("mkdir_listdir_rmdir", function(assert) - local dir = path.join(tmpdir, "subdir") - if not fs.exists(dir) then - fs.createdirectory(dir, { makeparents = true }) - end - - assert.eq(fs.exists(dir), true) - assert.eq(fs.type(dir), "dir") - - local entries = fs.listdirectory(tmpdir) - local found = false - for _, e in entries do - if e.name == "subdir" then - found = true - end - end - assert.eq(found, true) - fs.removedirectory(dir) - end) - - suite:case("link_and_symlink_and_copy", function(assert) - local srcfile = "src.txt" - local src = path.join(tmpdir, srcfile) - fs.writestringtofile(src, "linkcontent") - - local dstlink = path.join(tmpdir, "dstlink") - fs.link(src, dstlink) - assert.eq(fs.readfiletostring(dstlink), "linkcontent") - fs.remove(dstlink) - - local dstcopy = path.join(tmpdir, "dstcopy.txt") - fs.copy(src, dstcopy) - assert.eq(fs.readfiletostring(dstcopy), "linkcontent") - fs.remove(dstcopy) - - local dstsymlink = path.join(tmpdir, "dstsymlink") - fs.symboliclink(srcfile, dstsymlink) - assert.eq(fs.readfiletostring(dstsymlink), "linkcontent") - fs.remove(dstsymlink) - - fs.remove(src) - end) + -- suite:case("open_read_write_close_and_stat", function(assert) + -- local file = path.join(tmpdir, "file.txt") + + -- local h = fs.open(file, "w+") + -- assert.eq(fs.exists(file), true) + -- fs.write(h, "abc") + -- fs.close(h) + + -- local m = fs.metadata(file) + -- assert.eq(m.size, 3) + + -- local hr = fs.open(file, "r") + -- assert.eq(fs.read(hr), "abc") + -- fs.close(hr) + + -- fs.remove(file) + -- end) + + -- suite:case("writestring_and_readfile", function(assert) + -- local file = path.join(tmpdir, "hello.txt") + -- fs.writestringtofile(file, "hello lute") + + -- local contents = fs.readfiletostring(file) + -- assert.eq(contents, "hello lute") + + -- fs.remove(file) + -- end) + + -- suite:case("exists_and_type_file_and_dir", function(assert) + -- local file = path.join(tmpdir, "roblox.txt") + -- fs.writestringtofile(file, "roblox") + + -- assert.eq(fs.exists(file), true) + -- assert.eq(fs.type(file), "file") + -- assert.eq(fs.exists(tmpdir), true) + -- assert.eq(fs.type(tmpdir), "dir") + + -- fs.remove(file) + -- end) + + -- suite:case("mkdir_listdir_rmdir", function(assert) + -- local dir = path.join(tmpdir, "subdir") + -- if not fs.exists(dir) then + -- fs.createdirectory(dir, { makeparents = true }) + -- end + + -- assert.eq(fs.exists(dir), true) + -- assert.eq(fs.type(dir), "dir") + + -- local entries = fs.listdirectory(tmpdir) + -- local found = false + -- for _, e in entries do + -- if e.name == "subdir" then + -- found = true + -- end + -- end + -- assert.eq(found, true) + -- fs.removedirectory(dir) + -- end) + + -- suite:case("link_and_symlink_and_copy", function(assert) + -- local srcfile = "src.txt" + -- local src = path.join(tmpdir, srcfile) + -- fs.writestringtofile(src, "linkcontent") + + -- local dstlink = path.join(tmpdir, "dstlink") + -- fs.link(src, dstlink) + -- assert.eq(fs.readfiletostring(dstlink), "linkcontent") + -- fs.remove(dstlink) + + -- local dstcopy = path.join(tmpdir, "dstcopy.txt") + -- fs.copy(src, dstcopy) + -- assert.eq(fs.readfiletostring(dstcopy), "linkcontent") + -- fs.remove(dstcopy) + + -- local dstsymlink = path.join(tmpdir, "dstsymlink") + -- fs.symboliclink(srcfile, dstsymlink) + -- assert.eq(fs.readfiletostring(dstsymlink), "linkcontent") + -- fs.remove(dstsymlink) + + -- fs.remove(src) + -- end) suite:case("watch_iterator_on_change", function(assert) local watchedFileName = "watched.txt" @@ -120,147 +120,147 @@ test.suite("FsSuite", function(suite) end end) - suite:case("createdirectory_makeparents_true", function(assert) - local nestedDir = path.join(tmpdir, "nested", "directory") - fs.createdirectory(nestedDir, { makeparents = true }) - assert.eq(fs.exists(nestedDir), true) - - fs.removedirectory(nestedDir) - fs.removedirectory(path.join(tmpdir, "nested")) - end) - - suite:case("createdirectory_makeparents_false", function(assert) - local nestedDir = path.join(tmpdir, "this", "should", "not", "work") - local success, err = pcall(function() - fs.createdirectory(nestedDir, { makeparents = false }) - end) - assert.neq(success, true) - assert.neq(err, nil) - end) - - suite:case("createdirectory_no_options", function(assert) - local nestedDir = path.join(tmpdir, "this", "should", "not", "work") - local success, err = pcall(function() - fs.createdirectory(nestedDir) - end) - assert.neq(success, true) - assert.neq(err, nil) - end) - - suite:case("fs_open_optional_mode", function(assert) - local file = path.join(tmpdir, "optional_mode.txt") - - local h1 = fs.open(file, "w+") - fs.write(h1, "created file") - fs.close(h1) - - local h2 = fs.open(file) -- with no mode specified, should default to "r" - local contents = fs.read(h2) - assert.eq(contents, "created file") - fs.close(h2) - - fs.remove(file) - end) - - suite:case("removedirectory_non_recursive", function(assert) - local dir = path.join(tmpdir, "empty_dir") - fs.createdirectory(dir, { makeparents = true }) - assert.eq(fs.exists(dir), true) - - fs.removedirectory(dir) - assert.eq(fs.exists(dir), false) - end) - - suite:case("removedirectory_recursive", function(assert) - local dir = path.join(tmpdir, "recursive_test") - local subdir1 = path.join(dir, "subdir1") - local subdir2 = path.join(dir, "subdir2") - local nestedDir = path.join(subdir1, "nested") - - fs.createdirectory(nestedDir, { makeparents = true }) - fs.createdirectory(subdir2, { makeparents = true }) - - local file1 = path.join(dir, "file1.txt") - local file2 = path.join(subdir1, "file2.txt") - local file3 = path.join(nestedDir, "file3.txt") - - fs.writestringtofile(file1, "content1") - fs.writestringtofile(file2, "content2") - fs.writestringtofile(file3, "content3") - - assert.eq(fs.exists(dir), true) - assert.eq(fs.exists(file1), true) - assert.eq(fs.exists(file2), true) - assert.eq(fs.exists(file3), true) - - fs.removedirectory(dir, { recursive = true }) - - assert.eq(fs.exists(dir), false) - assert.eq(fs.exists(file1), false) - assert.eq(fs.exists(file2), false) - assert.eq(fs.exists(file3), false) - end) - - suite:case("removedirectory_recursive_false_with_contents", function(assert) - local dir = path.join(tmpdir, "non_empty_dir") - fs.createdirectory(dir, { makeparents = true }) - - local file = path.join(dir, "file.txt") - fs.writestringtofile(file, "content") - - local success, err = pcall(function() - fs.removedirectory(dir, { recursive = false }) - end) - - assert.neq(success, true) - assert.neq(err, nil) - - -- Cleanup - fs.remove(file) - fs.removedirectory(dir) - end) - - suite:case("walk_directory_recursive", function(assert) - local baseDir = path.join(tmpdir, "walktest") - local subDir = path.join(baseDir, "subdir") - fs.createdirectory(subDir, { makeparents = true }) - - local file1 = path.join(baseDir, "file1.txt") - local f1 = fs.open(file1, "w+") - fs.close(f1) - - local file2 = path.join(subDir, "file2.txt") - local f2 = fs.open(file2, "w+") - fs.close(f2) - - local expectedPaths = { - tostring(baseDir), - tostring(file1), - tostring(subDir), - tostring(file2), - } - local foundFiles = {} - - local it = fs.walk(baseDir, { recursive = true }) - local p = it() - while p do - table.insert(foundFiles, tostring(p)) - p = it() - end - - for _, expected in expectedPaths do - local found = false - for _, foundPath in foundFiles do - if foundPath == expected then - found = true - break - end - end - assert.eq(found, true) - end - - fs.removedirectory(baseDir, { recursive = true }) - end) + -- suite:case("createdirectory_makeparents_true", function(assert) + -- local nestedDir = path.join(tmpdir, "nested", "directory") + -- fs.createdirectory(nestedDir, { makeparents = true }) + -- assert.eq(fs.exists(nestedDir), true) + + -- fs.removedirectory(nestedDir) + -- fs.removedirectory(path.join(tmpdir, "nested")) + -- end) + + -- suite:case("createdirectory_makeparents_false", function(assert) + -- local nestedDir = path.join(tmpdir, "this", "should", "not", "work") + -- local success, err = pcall(function() + -- fs.createdirectory(nestedDir, { makeparents = false }) + -- end) + -- assert.neq(success, true) + -- assert.neq(err, nil) + -- end) + + -- suite:case("createdirectory_no_options", function(assert) + -- local nestedDir = path.join(tmpdir, "this", "should", "not", "work") + -- local success, err = pcall(function() + -- fs.createdirectory(nestedDir) + -- end) + -- assert.neq(success, true) + -- assert.neq(err, nil) + -- end) + + -- suite:case("fs_open_optional_mode", function(assert) + -- local file = path.join(tmpdir, "optional_mode.txt") + + -- local h1 = fs.open(file, "w+") + -- fs.write(h1, "created file") + -- fs.close(h1) + + -- local h2 = fs.open(file) -- with no mode specified, should default to "r" + -- local contents = fs.read(h2) + -- assert.eq(contents, "created file") + -- fs.close(h2) + + -- fs.remove(file) + -- end) + + -- suite:case("removedirectory_non_recursive", function(assert) + -- local dir = path.join(tmpdir, "empty_dir") + -- fs.createdirectory(dir, { makeparents = true }) + -- assert.eq(fs.exists(dir), true) + + -- fs.removedirectory(dir) + -- assert.eq(fs.exists(dir), false) + -- end) + + -- suite:case("removedirectory_recursive", function(assert) + -- local dir = path.join(tmpdir, "recursive_test") + -- local subdir1 = path.join(dir, "subdir1") + -- local subdir2 = path.join(dir, "subdir2") + -- local nestedDir = path.join(subdir1, "nested") + + -- fs.createdirectory(nestedDir, { makeparents = true }) + -- fs.createdirectory(subdir2, { makeparents = true }) + + -- local file1 = path.join(dir, "file1.txt") + -- local file2 = path.join(subdir1, "file2.txt") + -- local file3 = path.join(nestedDir, "file3.txt") + + -- fs.writestringtofile(file1, "content1") + -- fs.writestringtofile(file2, "content2") + -- fs.writestringtofile(file3, "content3") + + -- assert.eq(fs.exists(dir), true) + -- assert.eq(fs.exists(file1), true) + -- assert.eq(fs.exists(file2), true) + -- assert.eq(fs.exists(file3), true) + + -- fs.removedirectory(dir, { recursive = true }) + + -- assert.eq(fs.exists(dir), false) + -- assert.eq(fs.exists(file1), false) + -- assert.eq(fs.exists(file2), false) + -- assert.eq(fs.exists(file3), false) + -- end) + + -- suite:case("removedirectory_recursive_false_with_contents", function(assert) + -- local dir = path.join(tmpdir, "non_empty_dir") + -- fs.createdirectory(dir, { makeparents = true }) + + -- local file = path.join(dir, "file.txt") + -- fs.writestringtofile(file, "content") + + -- local success, err = pcall(function() + -- fs.removedirectory(dir, { recursive = false }) + -- end) + + -- assert.neq(success, true) + -- assert.neq(err, nil) + + -- -- Cleanup + -- fs.remove(file) + -- fs.removedirectory(dir) + -- end) + + -- suite:case("walk_directory_recursive", function(assert) + -- local baseDir = path.join(tmpdir, "walktest") + -- local subDir = path.join(baseDir, "subdir") + -- fs.createdirectory(subDir, { makeparents = true }) + + -- local file1 = path.join(baseDir, "file1.txt") + -- local f1 = fs.open(file1, "w+") + -- fs.close(f1) + + -- local file2 = path.join(subDir, "file2.txt") + -- local f2 = fs.open(file2, "w+") + -- fs.close(f2) + + -- local expectedPaths = { + -- tostring(baseDir), + -- tostring(file1), + -- tostring(subDir), + -- tostring(file2), + -- } + -- local foundFiles = {} + + -- local it = fs.walk(baseDir, { recursive = true }) + -- local p = it() + -- while p do + -- table.insert(foundFiles, tostring(p)) + -- p = it() + -- end + + -- for _, expected in expectedPaths do + -- local found = false + -- for _, foundPath in foundFiles do + -- if foundPath == expected then + -- found = true + -- break + -- end + -- end + -- assert.eq(found, true) + -- end + + -- fs.removedirectory(baseDir, { recursive = true }) + -- end) end) test.run() From df1213e959aa76883540ce71dac91eecc028bc9b Mon Sep 17 00:00:00 2001 From: Annie Tang <98965493+annieetang@users.noreply.github.com> Date: Mon, 1 Dec 2025 11:57:23 -0800 Subject: [PATCH 14/17] uncomment fs test cases oops --- tests/std/fs.test.luau | 446 ++++++++++++++++++++--------------------- 1 file changed, 223 insertions(+), 223 deletions(-) diff --git a/tests/std/fs.test.luau b/tests/std/fs.test.luau index c75d6d519..96e5857cc 100644 --- a/tests/std/fs.test.luau +++ b/tests/std/fs.test.luau @@ -7,88 +7,88 @@ local task = require("@lute/task") local tmpdir = system.tmpdir() test.suite("FsSuite", function(suite) - -- suite:case("open_read_write_close_and_stat", function(assert) - -- local file = path.join(tmpdir, "file.txt") - - -- local h = fs.open(file, "w+") - -- assert.eq(fs.exists(file), true) - -- fs.write(h, "abc") - -- fs.close(h) - - -- local m = fs.metadata(file) - -- assert.eq(m.size, 3) - - -- local hr = fs.open(file, "r") - -- assert.eq(fs.read(hr), "abc") - -- fs.close(hr) - - -- fs.remove(file) - -- end) - - -- suite:case("writestring_and_readfile", function(assert) - -- local file = path.join(tmpdir, "hello.txt") - -- fs.writestringtofile(file, "hello lute") - - -- local contents = fs.readfiletostring(file) - -- assert.eq(contents, "hello lute") - - -- fs.remove(file) - -- end) - - -- suite:case("exists_and_type_file_and_dir", function(assert) - -- local file = path.join(tmpdir, "roblox.txt") - -- fs.writestringtofile(file, "roblox") - - -- assert.eq(fs.exists(file), true) - -- assert.eq(fs.type(file), "file") - -- assert.eq(fs.exists(tmpdir), true) - -- assert.eq(fs.type(tmpdir), "dir") - - -- fs.remove(file) - -- end) - - -- suite:case("mkdir_listdir_rmdir", function(assert) - -- local dir = path.join(tmpdir, "subdir") - -- if not fs.exists(dir) then - -- fs.createdirectory(dir, { makeparents = true }) - -- end - - -- assert.eq(fs.exists(dir), true) - -- assert.eq(fs.type(dir), "dir") - - -- local entries = fs.listdirectory(tmpdir) - -- local found = false - -- for _, e in entries do - -- if e.name == "subdir" then - -- found = true - -- end - -- end - -- assert.eq(found, true) - -- fs.removedirectory(dir) - -- end) - - -- suite:case("link_and_symlink_and_copy", function(assert) - -- local srcfile = "src.txt" - -- local src = path.join(tmpdir, srcfile) - -- fs.writestringtofile(src, "linkcontent") - - -- local dstlink = path.join(tmpdir, "dstlink") - -- fs.link(src, dstlink) - -- assert.eq(fs.readfiletostring(dstlink), "linkcontent") - -- fs.remove(dstlink) - - -- local dstcopy = path.join(tmpdir, "dstcopy.txt") - -- fs.copy(src, dstcopy) - -- assert.eq(fs.readfiletostring(dstcopy), "linkcontent") - -- fs.remove(dstcopy) - - -- local dstsymlink = path.join(tmpdir, "dstsymlink") - -- fs.symboliclink(srcfile, dstsymlink) - -- assert.eq(fs.readfiletostring(dstsymlink), "linkcontent") - -- fs.remove(dstsymlink) - - -- fs.remove(src) - -- end) + suite:case("open_read_write_close_and_stat", function(assert) + local file = path.join(tmpdir, "file.txt") + + local h = fs.open(file, "w+") + assert.eq(fs.exists(file), true) + fs.write(h, "abc") + fs.close(h) + + local m = fs.metadata(file) + assert.eq(m.size, 3) + + local hr = fs.open(file, "r") + assert.eq(fs.read(hr), "abc") + fs.close(hr) + + fs.remove(file) + end) + + suite:case("writestring_and_readfile", function(assert) + local file = path.join(tmpdir, "hello.txt") + fs.writestringtofile(file, "hello lute") + + local contents = fs.readfiletostring(file) + assert.eq(contents, "hello lute") + + fs.remove(file) + end) + + suite:case("exists_and_type_file_and_dir", function(assert) + local file = path.join(tmpdir, "roblox.txt") + fs.writestringtofile(file, "roblox") + + assert.eq(fs.exists(file), true) + assert.eq(fs.type(file), "file") + assert.eq(fs.exists(tmpdir), true) + assert.eq(fs.type(tmpdir), "dir") + + fs.remove(file) + end) + + suite:case("mkdir_listdir_rmdir", function(assert) + local dir = path.join(tmpdir, "subdir") + if not fs.exists(dir) then + fs.createdirectory(dir, { makeparents = true }) + end + + assert.eq(fs.exists(dir), true) + assert.eq(fs.type(dir), "dir") + + local entries = fs.listdirectory(tmpdir) + local found = false + for _, e in entries do + if e.name == "subdir" then + found = true + end + end + assert.eq(found, true) + fs.removedirectory(dir) + end) + + suite:case("link_and_symlink_and_copy", function(assert) + local srcfile = "src.txt" + local src = path.join(tmpdir, srcfile) + fs.writestringtofile(src, "linkcontent") + + local dstlink = path.join(tmpdir, "dstlink") + fs.link(src, dstlink) + assert.eq(fs.readfiletostring(dstlink), "linkcontent") + fs.remove(dstlink) + + local dstcopy = path.join(tmpdir, "dstcopy.txt") + fs.copy(src, dstcopy) + assert.eq(fs.readfiletostring(dstcopy), "linkcontent") + fs.remove(dstcopy) + + local dstsymlink = path.join(tmpdir, "dstsymlink") + fs.symboliclink(srcfile, dstsymlink) + assert.eq(fs.readfiletostring(dstsymlink), "linkcontent") + fs.remove(dstsymlink) + + fs.remove(src) + end) suite:case("watch_iterator_on_change", function(assert) local watchedFileName = "watched.txt" @@ -120,147 +120,147 @@ test.suite("FsSuite", function(suite) end end) - -- suite:case("createdirectory_makeparents_true", function(assert) - -- local nestedDir = path.join(tmpdir, "nested", "directory") - -- fs.createdirectory(nestedDir, { makeparents = true }) - -- assert.eq(fs.exists(nestedDir), true) - - -- fs.removedirectory(nestedDir) - -- fs.removedirectory(path.join(tmpdir, "nested")) - -- end) - - -- suite:case("createdirectory_makeparents_false", function(assert) - -- local nestedDir = path.join(tmpdir, "this", "should", "not", "work") - -- local success, err = pcall(function() - -- fs.createdirectory(nestedDir, { makeparents = false }) - -- end) - -- assert.neq(success, true) - -- assert.neq(err, nil) - -- end) - - -- suite:case("createdirectory_no_options", function(assert) - -- local nestedDir = path.join(tmpdir, "this", "should", "not", "work") - -- local success, err = pcall(function() - -- fs.createdirectory(nestedDir) - -- end) - -- assert.neq(success, true) - -- assert.neq(err, nil) - -- end) - - -- suite:case("fs_open_optional_mode", function(assert) - -- local file = path.join(tmpdir, "optional_mode.txt") - - -- local h1 = fs.open(file, "w+") - -- fs.write(h1, "created file") - -- fs.close(h1) - - -- local h2 = fs.open(file) -- with no mode specified, should default to "r" - -- local contents = fs.read(h2) - -- assert.eq(contents, "created file") - -- fs.close(h2) - - -- fs.remove(file) - -- end) - - -- suite:case("removedirectory_non_recursive", function(assert) - -- local dir = path.join(tmpdir, "empty_dir") - -- fs.createdirectory(dir, { makeparents = true }) - -- assert.eq(fs.exists(dir), true) - - -- fs.removedirectory(dir) - -- assert.eq(fs.exists(dir), false) - -- end) - - -- suite:case("removedirectory_recursive", function(assert) - -- local dir = path.join(tmpdir, "recursive_test") - -- local subdir1 = path.join(dir, "subdir1") - -- local subdir2 = path.join(dir, "subdir2") - -- local nestedDir = path.join(subdir1, "nested") - - -- fs.createdirectory(nestedDir, { makeparents = true }) - -- fs.createdirectory(subdir2, { makeparents = true }) - - -- local file1 = path.join(dir, "file1.txt") - -- local file2 = path.join(subdir1, "file2.txt") - -- local file3 = path.join(nestedDir, "file3.txt") - - -- fs.writestringtofile(file1, "content1") - -- fs.writestringtofile(file2, "content2") - -- fs.writestringtofile(file3, "content3") - - -- assert.eq(fs.exists(dir), true) - -- assert.eq(fs.exists(file1), true) - -- assert.eq(fs.exists(file2), true) - -- assert.eq(fs.exists(file3), true) - - -- fs.removedirectory(dir, { recursive = true }) - - -- assert.eq(fs.exists(dir), false) - -- assert.eq(fs.exists(file1), false) - -- assert.eq(fs.exists(file2), false) - -- assert.eq(fs.exists(file3), false) - -- end) - - -- suite:case("removedirectory_recursive_false_with_contents", function(assert) - -- local dir = path.join(tmpdir, "non_empty_dir") - -- fs.createdirectory(dir, { makeparents = true }) - - -- local file = path.join(dir, "file.txt") - -- fs.writestringtofile(file, "content") - - -- local success, err = pcall(function() - -- fs.removedirectory(dir, { recursive = false }) - -- end) - - -- assert.neq(success, true) - -- assert.neq(err, nil) - - -- -- Cleanup - -- fs.remove(file) - -- fs.removedirectory(dir) - -- end) - - -- suite:case("walk_directory_recursive", function(assert) - -- local baseDir = path.join(tmpdir, "walktest") - -- local subDir = path.join(baseDir, "subdir") - -- fs.createdirectory(subDir, { makeparents = true }) - - -- local file1 = path.join(baseDir, "file1.txt") - -- local f1 = fs.open(file1, "w+") - -- fs.close(f1) - - -- local file2 = path.join(subDir, "file2.txt") - -- local f2 = fs.open(file2, "w+") - -- fs.close(f2) - - -- local expectedPaths = { - -- tostring(baseDir), - -- tostring(file1), - -- tostring(subDir), - -- tostring(file2), - -- } - -- local foundFiles = {} - - -- local it = fs.walk(baseDir, { recursive = true }) - -- local p = it() - -- while p do - -- table.insert(foundFiles, tostring(p)) - -- p = it() - -- end - - -- for _, expected in expectedPaths do - -- local found = false - -- for _, foundPath in foundFiles do - -- if foundPath == expected then - -- found = true - -- break - -- end - -- end - -- assert.eq(found, true) - -- end - - -- fs.removedirectory(baseDir, { recursive = true }) - -- end) + suite:case("createdirectory_makeparents_true", function(assert) + local nestedDir = path.join(tmpdir, "nested", "directory") + fs.createdirectory(nestedDir, { makeparents = true }) + assert.eq(fs.exists(nestedDir), true) + + fs.removedirectory(nestedDir) + fs.removedirectory(path.join(tmpdir, "nested")) + end) + + suite:case("createdirectory_makeparents_false", function(assert) + local nestedDir = path.join(tmpdir, "this", "should", "not", "work") + local success, err = pcall(function() + fs.createdirectory(nestedDir, { makeparents = false }) + end) + assert.neq(success, true) + assert.neq(err, nil) + end) + + suite:case("createdirectory_no_options", function(assert) + local nestedDir = path.join(tmpdir, "this", "should", "not", "work") + local success, err = pcall(function() + fs.createdirectory(nestedDir) + end) + assert.neq(success, true) + assert.neq(err, nil) + end) + + suite:case("fs_open_optional_mode", function(assert) + local file = path.join(tmpdir, "optional_mode.txt") + + local h1 = fs.open(file, "w+") + fs.write(h1, "created file") + fs.close(h1) + + local h2 = fs.open(file) -- with no mode specified, should default to "r" + local contents = fs.read(h2) + assert.eq(contents, "created file") + fs.close(h2) + + fs.remove(file) + end) + + suite:case("removedirectory_non_recursive", function(assert) + local dir = path.join(tmpdir, "empty_dir") + fs.createdirectory(dir, { makeparents = true }) + assert.eq(fs.exists(dir), true) + + fs.removedirectory(dir) + assert.eq(fs.exists(dir), false) + end) + + suite:case("removedirectory_recursive", function(assert) + local dir = path.join(tmpdir, "recursive_test") + local subdir1 = path.join(dir, "subdir1") + local subdir2 = path.join(dir, "subdir2") + local nestedDir = path.join(subdir1, "nested") + + fs.createdirectory(nestedDir, { makeparents = true }) + fs.createdirectory(subdir2, { makeparents = true }) + + local file1 = path.join(dir, "file1.txt") + local file2 = path.join(subdir1, "file2.txt") + local file3 = path.join(nestedDir, "file3.txt") + + fs.writestringtofile(file1, "content1") + fs.writestringtofile(file2, "content2") + fs.writestringtofile(file3, "content3") + + assert.eq(fs.exists(dir), true) + assert.eq(fs.exists(file1), true) + assert.eq(fs.exists(file2), true) + assert.eq(fs.exists(file3), true) + + fs.removedirectory(dir, { recursive = true }) + + assert.eq(fs.exists(dir), false) + assert.eq(fs.exists(file1), false) + assert.eq(fs.exists(file2), false) + assert.eq(fs.exists(file3), false) + end) + + suite:case("removedirectory_recursive_false_with_contents", function(assert) + local dir = path.join(tmpdir, "non_empty_dir") + fs.createdirectory(dir, { makeparents = true }) + + local file = path.join(dir, "file.txt") + fs.writestringtofile(file, "content") + + local success, err = pcall(function() + fs.removedirectory(dir, { recursive = false }) + end) + + assert.neq(success, true) + assert.neq(err, nil) + + -- Cleanup + fs.remove(file) + fs.removedirectory(dir) + end) + + suite:case("walk_directory_recursive", function(assert) + local baseDir = path.join(tmpdir, "walktest") + local subDir = path.join(baseDir, "subdir") + fs.createdirectory(subDir, { makeparents = true }) + + local file1 = path.join(baseDir, "file1.txt") + local f1 = fs.open(file1, "w+") + fs.close(f1) + + local file2 = path.join(subDir, "file2.txt") + local f2 = fs.open(file2, "w+") + fs.close(f2) + + local expectedPaths = { + tostring(baseDir), + tostring(file1), + tostring(subDir), + tostring(file2), + } + local foundFiles = {} + + local it = fs.walk(baseDir, { recursive = true }) + local p = it() + while p do + table.insert(foundFiles, tostring(p)) + p = it() + end + + for _, expected in expectedPaths do + local found = false + for _, foundPath in foundFiles do + if foundPath == expected then + found = true + break + end + end + assert.eq(found, true) + end + + fs.removedirectory(baseDir, { recursive = true }) + end) end) test.run() From b2f86d61b492e1acd6fc7d975283b02bea7bcd64 Mon Sep 17 00:00:00 2001 From: Annie Tang <98965493+annieetang@users.noreply.github.com> Date: Tue, 2 Dec 2025 17:01:46 -0800 Subject: [PATCH 15/17] revert watchhandle changes bc that wasn't the problem --- lute/fs/src/fs.cpp | 40 ++++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/lute/fs/src/fs.cpp b/lute/fs/src/fs.cpp index 7e768c9a2..293a1e09a 100644 --- a/lute/fs/src/fs.cpp +++ b/lute/fs/src/fs.cpp @@ -546,32 +546,34 @@ int fs_symlink(lua_State* L) struct WatchHandle { - lua_State* L = nullptr; + lua_State* L; std::shared_ptr callbackReference; bool isClosed = false; uv_fs_event_t handle; - + void close() { - if (isClosed) - return; + if (!isClosed) + { + int err = uv_fs_event_stop(&handle); + if (err) + { + luaL_errorL(L, "Error stopping fs event: %s", uv_strerror(err)); + } - isClosed = true; + uv_close((uv_handle_t*) &handle, nullptr); - int err = uv_fs_event_stop(&handle); - if (err) - { - luaL_errorL(L, "Error stopping fs event: %s", uv_strerror(err)); - } + isClosed = true; - auto closeCb = [](uv_handle_t* handle) - { - WatchHandle* wh = static_cast(handle->data); - wh->callbackReference.reset(); - getRuntime(wh->L)->releasePendingToken(); - }; + getRuntime(L)->releasePendingToken(); - uv_close((uv_handle_t*) &handle, closeCb); + callbackReference.reset(); + } + } + + ~WatchHandle() + { + close(); } }; @@ -642,6 +644,8 @@ int fs_watch(lua_State* L) return 2; } ); + + uv_stop(handle->loop); }, path, 0 @@ -1063,4 +1067,4 @@ int luteopen_fs(lua_State* L) initalizeFS(L); return 1; -} +} \ No newline at end of file From 42f2113c26194e7c0a964a86c8fbd96189c8cde4 Mon Sep 17 00:00:00 2001 From: Annie Tang <98965493+annieetang@users.noreply.github.com> Date: Tue, 2 Dec 2025 17:02:36 -0800 Subject: [PATCH 16/17] format --- lute/fs/src/fs.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lute/fs/src/fs.cpp b/lute/fs/src/fs.cpp index 293a1e09a..a20cabc5c 100644 --- a/lute/fs/src/fs.cpp +++ b/lute/fs/src/fs.cpp @@ -1067,4 +1067,4 @@ int luteopen_fs(lua_State* L) initalizeFS(L); return 1; -} \ No newline at end of file +} From 92451822d1c18f38c590220dfd46c971cbfc8e63 Mon Sep 17 00:00:00 2001 From: Annie Tang <98965493+annieetang@users.noreply.github.com> Date: Tue, 2 Dec 2025 17:14:05 -0800 Subject: [PATCH 17/17] remove a print statement in task.test.luau --- tests/lute/task.test.luau | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/lute/task.test.luau b/tests/lute/task.test.luau index 274e69eb0..12a5da513 100644 --- a/tests/lute/task.test.luau +++ b/tests/lute/task.test.luau @@ -7,8 +7,6 @@ test.suite("LuteTaskSuite", function(suite) task.wait(0.5) local endTime = os.clock() - print("Elapsed time: ", endTime - startTime) - -- task.wait(0.5) actually waits ~0.48 seconds :) assert.eq(math.ceil(endTime - startTime) >= 0.5, true) end)