From c38cc7b73f4dee4af40335cc83b7e49bf5043490 Mon Sep 17 00:00:00 2001 From: Alexander Courtis Date: Sun, 10 Jul 2022 16:27:29 +1000 Subject: [PATCH 1/7] fix: make debouncer thread safe, reschedule when a job is executing --- lua/nvim-tree/utils.lua | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/lua/nvim-tree/utils.lua b/lua/nvim-tree/utils.lua index af8dad808ec..8401f5dd407 100644 --- a/lua/nvim-tree/utils.lua +++ b/lua/nvim-tree/utils.lua @@ -306,23 +306,39 @@ function M.key_by(tbl, key) return keyed end ----Execute callback timeout ms after the lastest invocation with context. Waiting invocations for that context will be discarded. Caller should this ensure that callback performs the same or functionally equivalent actions. +---Execute callback timeout ms after the lastest invocation with context. +---Waiting invocations for that context will be discarded. +---Invocation will be rescheduled while a callback is being executed. +---Caller must ensure that callback performs the same or functionally equivalent actions. +--- ---@param context string identifies the callback to debounce ---@param timeout number ms to wait ---@param callback function to execute on completion function M.debounce(context, timeout, callback) - if M.debouncers[context] then - pcall(uv.close, M.debouncers[context]) + M.debouncers[context] = M.debouncers[context] or {} + local debouncer = M.debouncers[context] + + if debouncer.timer then + if debouncer.timer:is_active() then + debouncer.timer:stop() + end + if not debouncer.timer:is_closing() then + debouncer.timer:close() + end end - M.debouncers[context] = uv.new_timer() - M.debouncers[context]:start( + debouncer.timer = uv.new_timer() + debouncer.timer:start( timeout, 0, vim.schedule_wrap(function() - M.debouncers[context]:close() - M.debouncers[context] = nil - callback() + if debouncer.executing then + M.debounce(context, timeout, callback) + else + debouncer.executing = true + callback() + debouncer.executing = false + end end) ) end From fd66fcf5e3dfc9ec071c4ac6172e151b67b42318 Mon Sep 17 00:00:00 2001 From: Alexander Courtis Date: Sun, 10 Jul 2022 17:02:02 +1000 Subject: [PATCH 2/7] fix: make debouncer thread safe, reschedule when a job is executing --- lua/nvim-tree/utils.lua | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/lua/nvim-tree/utils.lua b/lua/nvim-tree/utils.lua index 8401f5dd407..e3aa512b6b5 100644 --- a/lua/nvim-tree/utils.lua +++ b/lua/nvim-tree/utils.lua @@ -2,6 +2,7 @@ local has_notify, notify = pcall(require, "notify") local a = vim.api local uv = vim.loop +local log = require "nvim-tree.log" local Iterator = require "nvim-tree.iterators.node-iterator" @@ -306,6 +307,17 @@ function M.key_by(tbl, key) return keyed end +local function timer_stop_close(timer) + if timer then + if timer:is_active() then + timer:stop() + end + if not timer:is_closing() then + timer:close() + end + end +end + ---Execute callback timeout ms after the lastest invocation with context. ---Waiting invocations for that context will be discarded. ---Invocation will be rescheduled while a callback is being executed. @@ -318,20 +330,16 @@ function M.debounce(context, timeout, callback) M.debouncers[context] = M.debouncers[context] or {} local debouncer = M.debouncers[context] - if debouncer.timer then - if debouncer.timer:is_active() then - debouncer.timer:stop() - end - if not debouncer.timer:is_closing() then - debouncer.timer:close() - end - end + timer_stop_close(debouncer.timer) - debouncer.timer = uv.new_timer() - debouncer.timer:start( + local timer = uv.new_timer() + timer:start( timeout, 0, vim.schedule_wrap(function() + -- timers must be closed to release their memory + timer_stop_close(timer) + if debouncer.executing then M.debounce(context, timeout, callback) else @@ -341,6 +349,8 @@ function M.debounce(context, timeout, callback) end end) ) + + debouncer.timer = timer end return M From 835a66dbf5790fc65e90fc9106eefe334ec5e1a8 Mon Sep 17 00:00:00 2001 From: Alexander Courtis Date: Sat, 16 Jul 2022 18:33:01 +1000 Subject: [PATCH 3/7] fix: make debouncer thread safe, reschedule when a job is executing --- doc/nvim-tree-lua.txt | 4 +++ lua/nvim-tree.lua | 2 ++ lua/nvim-tree/diagnostics.lua | 66 +++++++++++++++++++---------------- lua/nvim-tree/utils.lua | 56 ++++++++++++++++------------- 4 files changed, 73 insertions(+), 55 deletions(-) diff --git a/doc/nvim-tree-lua.txt b/doc/nvim-tree-lua.txt index c79770c87c6..ff589d9d16e 100644 --- a/doc/nvim-tree-lua.txt +++ b/doc/nvim-tree-lua.txt @@ -888,6 +888,10 @@ Configuration for diagnostic logging. File copy and paste actions. Type: `boolean`, Default: `false` + *nvim-tree.log.types.dev* + Used for local development only. Not useful for users. + Type: `boolean`, Default: `false` + *nvim-tree.log.types.diagnostics* LSP and COC processing, verbose. Type: `boolean`, Default: `false` diff --git a/lua/nvim-tree.lua b/lua/nvim-tree.lua index e162f1b4d8d..f6149972309 100644 --- a/lua/nvim-tree.lua +++ b/lua/nvim-tree.lua @@ -511,6 +511,7 @@ local DEFAULT_OPTS = { -- BEGIN_DEFAULT_OPTS diagnostics = { enable = false, show_on_dirs = false, + debounce_delay = 50, icons = { hint = "", info = "", @@ -576,6 +577,7 @@ local DEFAULT_OPTS = { -- BEGIN_DEFAULT_OPTS all = false, config = false, copy_paste = false, + dev = false, diagnostics = false, git = false, profile = false, diff --git a/lua/nvim-tree/diagnostics.lua b/lua/nvim-tree/diagnostics.lua index f2360570640..0acbf81e4c7 100644 --- a/lua/nvim-tree/diagnostics.lua +++ b/lua/nvim-tree/diagnostics.lua @@ -90,43 +90,46 @@ function M.update() if not M.enable or not core.get_explorer() or not view.is_buf_valid(view.get_bufnr()) then return end - local ps = log.profile_start "diagnostics update" - log.line("diagnostics", "update") - - local buffer_severity - if is_using_coc() then - buffer_severity = from_coc() - else - buffer_severity = from_nvim_lsp() - end + utils.debounce("diagnostics", M.debounce_delay, function() - M.clear() + local ps = log.profile_start "diagnostics update" + log.line("diagnostics", "update") - local nodes_by_line = utils.get_nodes_by_line(core.get_explorer().nodes, core.get_nodes_starting_line()) - for _, node in pairs(nodes_by_line) do - node.diag_status = nil - end + local buffer_severity + if is_using_coc() then + buffer_severity = from_coc() + else + buffer_severity = from_nvim_lsp() + end + + M.clear() - for bufname, severity in pairs(buffer_severity) do - local bufpath = utils.canonical_path(bufname) - log.line("diagnostics", " bufpath '%s' severity %d", bufpath, severity) - if 0 < severity and severity < 5 then - for line, node in pairs(nodes_by_line) do - local nodepath = utils.canonical_path(node.absolute_path) - log.line("diagnostics", " %d checking nodepath '%s'", line, nodepath) - if M.show_on_dirs and vim.startswith(bufpath, nodepath) then - log.line("diagnostics", " matched fold node '%s'", node.absolute_path) - node.diag_status = severity - add_sign(line, severity) - elseif nodepath == bufpath then - log.line("diagnostics", " matched file node '%s'", node.absolute_path) - node.diag_status = severity - add_sign(line, severity) + local nodes_by_line = utils.get_nodes_by_line(core.get_explorer().nodes, core.get_nodes_starting_line()) + for _, node in pairs(nodes_by_line) do + node.diag_status = nil + end + + for bufname, severity in pairs(buffer_severity) do + local bufpath = utils.canonical_path(bufname) + log.line("diagnostics", " bufpath '%s' severity %d", bufpath, severity) + if 0 < severity and severity < 5 then + for line, node in pairs(nodes_by_line) do + local nodepath = utils.canonical_path(node.absolute_path) + log.line("diagnostics", " %d checking nodepath '%s'", line, nodepath) + if M.show_on_dirs and vim.startswith(bufpath, nodepath) then + log.line("diagnostics", " matched fold node '%s'", node.absolute_path) + node.diag_status = severity + add_sign(line, severity) + elseif nodepath == bufpath then + log.line("diagnostics", " matched file node '%s'", node.absolute_path) + node.diag_status = severity + add_sign(line, severity) + end end end end - end - log.profile_end(ps, "diagnostics update") + log.profile_end(ps, "diagnostics update") + end) end local links = { @@ -138,6 +141,7 @@ local links = { function M.setup(opts) M.enable = opts.diagnostics.enable + M.debounce_delay = opts.diagnostics.debounce_delay if M.enable then log.line("diagnostics", "setup") diff --git a/lua/nvim-tree/utils.lua b/lua/nvim-tree/utils.lua index 493d7704512..3800a77929c 100644 --- a/lua/nvim-tree/utils.lua +++ b/lua/nvim-tree/utils.lua @@ -309,13 +309,11 @@ function M.key_by(tbl, key) end local function timer_stop_close(timer) - if timer then - if timer:is_active() then - timer:stop() - end - if not timer:is_closing() then - timer:close() - end + if timer:is_active() then + timer:stop() + end + if not timer:is_closing() then + timer:close() end end @@ -331,27 +329,37 @@ function M.debounce(context, timeout, callback) M.debouncers[context] = M.debouncers[context] or {} local debouncer = M.debouncers[context] - timer_stop_close(debouncer.timer) + -- cancel active timer + if debouncer.timer then + timer_stop_close(debouncer.timer) + end + -- start the one and only timer local timer = uv.new_timer() - timer:start( - timeout, - 0, - vim.schedule_wrap(function() - -- timers must be closed to release their memory - timer_stop_close(timer) - - if debouncer.executing then - M.debounce(context, timeout, callback) - else - debouncer.executing = true - callback() - debouncer.executing = false + debouncer.timer = timer + timer:start(timeout, 0, function() + + -- timers must be closed to release their memory + timer_stop_close(timer) + + -- reschedule whilst callback is running + if debouncer.executing then + M.debounce(context, timeout, callback) + return + end + + -- call back at a safe time + debouncer.executing = true + vim.schedule(function() + callback() + debouncer.executing = false + + -- no other timer in progress, clear + if debouncer.timer == timer then + M.debouncers[context] = nil end end) - ) - - debouncer.timer = timer + end) end function M.focus_file(path) From 9d048022e3dc64b427517c5b892d591b40cc4dec Mon Sep 17 00:00:00 2001 From: Alexander Courtis Date: Sun, 17 Jul 2022 12:56:15 +1000 Subject: [PATCH 4/7] fix: make debouncer thread safe, reschedule when a job is executing --- lua/nvim-tree.lua | 10 ++++++++-- lua/nvim-tree/git/init.lua | 4 ---- lua/nvim-tree/git/runner.lua | 6 +++--- lua/nvim-tree/utils.lua | 12 +++++------- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/lua/nvim-tree.lua b/lua/nvim-tree.lua index f6149972309..c41f1a8be55 100644 --- a/lua/nvim-tree.lua +++ b/lua/nvim-tree.lua @@ -395,11 +395,17 @@ local function setup_autocommands(opts) if opts.diagnostics.enable then create_nvim_tree_autocmd("DiagnosticChanged", { - callback = require("nvim-tree.diagnostics").update, + callback = function() + log.line("diagnostics", "DiagnosticChanged") + require("nvim-tree.diagnostics").update() + end, }) create_nvim_tree_autocmd("User", { pattern = "CocDiagnosticChange", - callback = require("nvim-tree.diagnostics").update, + callback = function() + log.line("diagnostics", "CocDiagnosticChange") + require("nvim-tree.diagnostics").update() + end, }) end end diff --git a/lua/nvim-tree/git/init.lua b/lua/nvim-tree/git/init.lua index 77f9858889e..c1eb094835e 100644 --- a/lua/nvim-tree/git/init.lua +++ b/lua/nvim-tree/git/init.lua @@ -138,10 +138,6 @@ function M.load_project_status(cwd) reload_tree_at(opts.project_root) end) end, - on_event0 = function() - log.line("watcher", "git event") - M.reload_tree_at(project_root) - end, } end diff --git a/lua/nvim-tree/git/runner.lua b/lua/nvim-tree/git/runner.lua index cd488a15349..a62043c5220 100644 --- a/lua/nvim-tree/git/runner.lua +++ b/lua/nvim-tree/git/runner.lua @@ -147,11 +147,11 @@ function Runner.run(opts) log.profile_end(ps, "git job %s %s", opts.project_root, opts.path) if self.rc == -1 then - log.line("git", "job timed out") + log.line("git", "job timed out %s %s", opts.project_root, opts.path) elseif self.rc ~= 0 then - log.line("git", "job failed with return code %d", self.rc) + log.line("git", "job fail rc %d %s %s", self.rc, opts.project_root, opts.path) else - log.line("git", "job success") + log.line("git", "job success %s %s", opts.project_root, opts.path) end return self.output diff --git a/lua/nvim-tree/utils.lua b/lua/nvim-tree/utils.lua index 3800a77929c..e65e43cb2a7 100644 --- a/lua/nvim-tree/utils.lua +++ b/lua/nvim-tree/utils.lua @@ -2,7 +2,6 @@ local has_notify, notify = pcall(require, "notify") local a = vim.api local uv = vim.loop -local log = require "nvim-tree.log" local Iterator = require "nvim-tree.iterators.node-iterator" @@ -326,23 +325,22 @@ end ---@param timeout number ms to wait ---@param callback function to execute on completion function M.debounce(context, timeout, callback) + -- all execution here is done in a synchronous context; no thread safety required + M.debouncers[context] = M.debouncers[context] or {} local debouncer = M.debouncers[context] - -- cancel active timer + -- cancel waiting or executing timer if debouncer.timer then timer_stop_close(debouncer.timer) end - -- start the one and only timer local timer = uv.new_timer() debouncer.timer = timer timer:start(timeout, 0, function() - - -- timers must be closed to release their memory timer_stop_close(timer) - -- reschedule whilst callback is running + -- reschedule when callback is running if debouncer.executing then M.debounce(context, timeout, callback) return @@ -354,7 +352,7 @@ function M.debounce(context, timeout, callback) callback() debouncer.executing = false - -- no other timer in progress, clear + -- no other timer waiting if debouncer.timer == timer then M.debouncers[context] = nil end From 57f2f0abe98d5241a54fd35f498c503a9679a821 Mon Sep 17 00:00:00 2001 From: Alexander Courtis Date: Sun, 17 Jul 2022 13:01:47 +1000 Subject: [PATCH 5/7] fix: make debouncer thread safe, reschedule when a job is executing --- lua/nvim-tree/diagnostics.lua | 1 - 1 file changed, 1 deletion(-) diff --git a/lua/nvim-tree/diagnostics.lua b/lua/nvim-tree/diagnostics.lua index 0acbf81e4c7..fb16d3ca4bd 100644 --- a/lua/nvim-tree/diagnostics.lua +++ b/lua/nvim-tree/diagnostics.lua @@ -91,7 +91,6 @@ function M.update() return end utils.debounce("diagnostics", M.debounce_delay, function() - local ps = log.profile_start "diagnostics update" log.line("diagnostics", "update") From be18bf87843072065858babb927f1aa3f8e07c71 Mon Sep 17 00:00:00 2001 From: Alexander Courtis Date: Sun, 17 Jul 2022 13:20:00 +1000 Subject: [PATCH 6/7] fix: make debouncer thread safe, reschedule when a job is executing --- doc/nvim-tree-lua.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/doc/nvim-tree-lua.txt b/doc/nvim-tree-lua.txt index 74a8b772d8f..40828d2d9cb 100644 --- a/doc/nvim-tree-lua.txt +++ b/doc/nvim-tree-lua.txt @@ -263,6 +263,7 @@ Subsequent calls to setup will replace the previous configuration. diagnostics = { enable = false, show_on_dirs = false, + debounce_delay = 50, icons = { hint = "", info = "", @@ -328,6 +329,7 @@ Subsequent calls to setup will replace the previous configuration. all = false, config = false, copy_paste = false, + dev = false, diagnostics = false, git = false, profile = false, @@ -471,6 +473,10 @@ Show LSP and COC diagnostics in the signcolumn Enable/disable the feature. Type: `boolean`, Default: `false` + *nvim-tree.diagnostics.debounce_delay* + Idle milliseconds between diagnostic event and update. + Type: `number`, Default: `50` (ms) + *nvim-tree.diagnostics.show_on_dirs* Show diagnostic icons on parent directories. Type: `boolean`, Default: `false` From 386c6f753fc7bab9eaaf90fb71ceb1657ee4bcf8 Mon Sep 17 00:00:00 2001 From: Alexander Courtis Date: Sun, 17 Jul 2022 16:48:04 +1000 Subject: [PATCH 7/7] feat(watcher): fs_poll -> fs_event, destroy nodes/watchers (#1431) --- lua/nvim-tree/core.lua | 2 +- lua/nvim-tree/explorer/common.lua | 10 +++++++ lua/nvim-tree/explorer/init.lua | 17 +++++------- lua/nvim-tree/explorer/reload.lua | 7 ++++- lua/nvim-tree/explorer/watch.lua | 2 +- lua/nvim-tree/git/init.lua | 6 ++--- lua/nvim-tree/watcher.lua | 44 ++++++++++++++++++++----------- 7 files changed, 56 insertions(+), 32 deletions(-) diff --git a/lua/nvim-tree/core.lua b/lua/nvim-tree/core.lua index 83f3db92877..a61fea72743 100644 --- a/lua/nvim-tree/core.lua +++ b/lua/nvim-tree/core.lua @@ -10,7 +10,7 @@ local first_init_done = false function M.init(foldername) if TreeExplorer then - TreeExplorer:_clear_watchers() + TreeExplorer:destroy() end TreeExplorer = explorer.Explorer.new(foldername) if not first_init_done then diff --git a/lua/nvim-tree/explorer/common.lua b/lua/nvim-tree/explorer/common.lua index 14cef92b92a..e3c3e76d32a 100644 --- a/lua/nvim-tree/explorer/common.lua +++ b/lua/nvim-tree/explorer/common.lua @@ -43,6 +43,16 @@ function M.update_git_status(node, parent_ignored, status) end end +function M.node_destroy(node) + if not node then + return + end + + if node.watcher then + node.watcher:destroy() + end +end + function M.setup(opts) M.config = { git = opts.git, diff --git a/lua/nvim-tree/explorer/init.lua b/lua/nvim-tree/explorer/init.lua index 5578331f67c..6b9a8c9e5cc 100644 --- a/lua/nvim-tree/explorer/init.lua +++ b/lua/nvim-tree/explorer/init.lua @@ -2,6 +2,7 @@ local uv = vim.loop local git = require "nvim-tree.git" local watch = require "nvim-tree.explorer.watch" +local common = require "nvim-tree.explorer.common" local M = {} @@ -33,22 +34,16 @@ function Explorer:expand(node) self:_load(node) end -function Explorer.clear_watchers_for(root_node) +function Explorer:destroy() local function iterate(node) - if node.watcher then - node.watcher:stop() + common.node_destroy(node) + if node.nodes then for _, child in pairs(node.nodes) do - if child.watcher then - iterate(child) - end + iterate(child) end end end - iterate(root_node) -end - -function Explorer:_clear_watchers() - Explorer.clear_watchers_for(self) + iterate(self) end function M.setup(opts) diff --git a/lua/nvim-tree/explorer/reload.lua b/lua/nvim-tree/explorer/reload.lua index e26a68b0a92..5d9299b7830 100644 --- a/lua/nvim-tree/explorer/reload.lua +++ b/lua/nvim-tree/explorer/reload.lua @@ -69,7 +69,12 @@ function M.reload(node, status) node.nodes = vim.tbl_map( update_status(nodes_by_path, node_ignored, status), vim.tbl_filter(function(n) - return child_names[n.absolute_path] + if child_names[n.absolute_path] then + return child_names[n.absolute_path] + else + common.node_destroy(n) + return nil + end end, node.nodes) ) diff --git a/lua/nvim-tree/explorer/watch.lua b/lua/nvim-tree/explorer/watch.lua index 2243df2ada8..79b50fc57c0 100644 --- a/lua/nvim-tree/explorer/watch.lua +++ b/lua/nvim-tree/explorer/watch.lua @@ -46,7 +46,7 @@ function M.create_watcher(absolute_path) end log.line("watcher", "node start '%s'", absolute_path) - Watcher.new { + return Watcher.new { absolute_path = absolute_path, interval = M.interval, on_event = function(opts) diff --git a/lua/nvim-tree/git/init.lua b/lua/nvim-tree/git/init.lua index c1eb094835e..761677d042e 100644 --- a/lua/nvim-tree/git/init.lua +++ b/lua/nvim-tree/git/init.lua @@ -29,8 +29,8 @@ function M.reload_project(project_root, path) return end - if path and not path:match("^" .. project_root) then - path = nil + if path and path:find(project_root, 1, true) ~= 1 then + return end local git_status = Runner.run { @@ -43,7 +43,7 @@ function M.reload_project(project_root, path) if path then for p in pairs(project.files) do - if p:match("^" .. path) then + if p:find(path, 1, true) == 1 then project.files[p] = nil end end diff --git a/lua/nvim-tree/watcher.lua b/lua/nvim-tree/watcher.lua index 15ebeb90296..3ab7a42d8b2 100644 --- a/lua/nvim-tree/watcher.lua +++ b/lua/nvim-tree/watcher.lua @@ -9,6 +9,13 @@ local M = { local Watcher = {} Watcher.__index = Watcher +local FS_EVENT_FLAGS = { + -- inotify or equivalent will be used; fallback to stat has not yet been implemented + stat = false, + -- recursive is not functional in neovim's libuv implementation + recursive = false, +} + function Watcher.new(opts) for _, existing in ipairs(M._watchers) do if existing._opts.absolute_path == opts.absolute_path then @@ -35,40 +42,47 @@ function Watcher:start() local rc, _, name - self._p, _, name = uv.new_fs_poll() - if not self._p then - self._p = nil + self._e, _, name = uv.new_fs_event() + if not self._e then + self._e = nil utils.warn( - string.format("Could not initialize an fs_poll watcher for path %s : %s", self._opts.absolute_path, name) + string.format("Could not initialize an fs_event watcher for path %s : %s", self._opts.absolute_path, name) ) return nil end - local poll_cb = vim.schedule_wrap(function(err) + local event_cb = vim.schedule_wrap(function(err, filename, events) if err then - log.line("watcher", "poll_cb for %s fail : %s", self._opts.absolute_path, err) + log.line("watcher", "event_cb for %s fail : %s", self._opts.absolute_path, err) else + log.line("watcher", "event_cb '%s' '%s' %s", self._opts.absolute_path, filename, vim.inspect(events)) self._opts.on_event(self._opts) end end) - rc, _, name = uv.fs_poll_start(self._p, self._opts.absolute_path, self._opts.interval, poll_cb) + rc, _, name = self._e:start(self._opts.absolute_path, FS_EVENT_FLAGS, event_cb) if rc ~= 0 then - utils.warn(string.format("Could not start the fs_poll watcher for path %s : %s", self._opts.absolute_path, name)) + utils.warn(string.format("Could not start the fs_event watcher for path %s : %s", self._opts.absolute_path, name)) return nil end return self end -function Watcher:stop() - log.line("watcher", "Watcher:stop '%s'", self._opts.absolute_path) - if self._p then - local rc, _, name = uv.fs_poll_stop(self._p) +function Watcher:destroy() + log.line("watcher", "Watcher:destroy '%s'", self._opts.absolute_path) + if self._e then + local rc, _, name = self._e:stop() if rc ~= 0 then - utils.warn(string.format("Could not stop the fs_poll watcher for path %s : %s", self._opts.absolute_path, name)) + utils.warn(string.format("Could not stop the fs_event watcher for path %s : %s", self._opts.absolute_path, name)) + end + self._e = nil + end + for i, w in ipairs(M._watchers) do + if w == self then + table.remove(M._watchers, i) + break end - self._p = nil end end @@ -76,7 +90,7 @@ M.Watcher = Watcher function M.purge_watchers() for _, watcher in pairs(M._watchers) do - watcher:stop() + watcher:destroy() end M._watchers = {} end