From 9c60991b0dc39770ff4be229102fb4aa3f38fc6c Mon Sep 17 00:00:00 2001 From: blacku Date: Wed, 29 Jul 2026 03:11:34 +0200 Subject: [PATCH] fix(keymap): prevent Niri and Hyprland CPU-budget failures (1.3.4) (#135) * fix(keymap): reduce Niri parser CPU usage * test(keymap): cover Niri parser scan budget * fix(keymap): keep Hyprland refreshes within CPU budget * chore(keymap): bump release to 1.3.4 --- keymap/CHANGELOG.md | 24 ++++ keymap/niri_service.luau | 45 ++++--- keymap/plugin.toml | 2 +- keymap/service.luau | 183 ++++++++++++++++---------- keymap/tests/command_library_test.py | 11 +- keymap/tests/hypr_cpu_budget_test.lua | 135 +++++++++++++++++++ keymap/tests/niri_cpu_budget_test.lua | 100 ++++++++++++++ keymap/tests/writer_update_test.lua | 3 + keymap/writer_service.luau | 48 +++++-- 9 files changed, 449 insertions(+), 102 deletions(-) create mode 100644 keymap/tests/hypr_cpu_budget_test.lua create mode 100644 keymap/tests/niri_cpu_budget_test.lua diff --git a/keymap/CHANGELOG.md b/keymap/CHANGELOG.md index 13fa7e4..3d62d32 100644 --- a/keymap/CHANGELOG.md +++ b/keymap/CHANGELOG.md @@ -2,6 +2,30 @@ All notable changes to Keymap are documented in this file. +## [1.3.4] - 2026-07-29 + +### Fixed + +- Prevented Niri startup CPU-budget failures on large, split configurations. +- Prevented Hyprland refreshes from exhausting the callback CPU budget and + leaving the shortcut snapshot stuck in its loading state. +- Restored editing for literal native-Lua binds after the optimized refresh. + +### Changed + +- Skipped the hidden-bind scan for files without Keymap hidden sentinels. +- Skipped character-level parsing for included files that cannot contain binds + or nested includes. +- Added fast paths for common Hyprland Lua literals and dispatcher expressions. +- Switched active Hyprland source validation to exact snippet comparison while + retaining legacy fingerprints for hidden blocks and older snapshots. + +### Tests + +- Added a 127-bind split-config regression with a large unrelated include. +- Added a split native-Lua regression covering invalid Hyprland JSON fallback, + callback instruction budgets, and editable source provenance. + ## [1.3.1] - 2026-07-21 ### Fixed diff --git a/keymap/niri_service.luau b/keymap/niri_service.luau index f59ff07..0cb1471 100644 --- a/keymap/niri_service.luau +++ b/keymap/niri_service.luau @@ -744,26 +744,41 @@ local function parseConfig(root, rootSource) local currentBind = nil local slashdashDepth = nil local lineNumber = 0 - local hiddenLines = sourceLines(source) - local hiddenCategory = nil - local hiddenCursor = 1 - while hiddenCursor <= #hiddenLines do - local block, blockEnd, candidate = hiddenBlockAt(hiddenLines, hiddenCursor) - if candidate then - if block == nil then - warn("hidden_block_invalid:" .. path .. ":" .. tostring(hiddenCursor)) + -- Hidden sentinels are uncommon. Avoid a second character-by-character + -- pass over every ordinary config: on large stock Niri configs that pass + -- alone can consume a substantial part of Luau's callback CPU budget. + if source:find("// Keymap hidden ", 1, true) ~= nil then + local hiddenLines = sourceLines(source) + local hiddenCategory = nil + local hiddenCursor = 1 + while hiddenCursor <= #hiddenLines do + local block, blockEnd, candidate = hiddenBlockAt(hiddenLines, hiddenCursor) + if candidate then + if block == nil then + warn("hidden_block_invalid:" .. path .. ":" .. tostring(hiddenCursor)) + else + hidden[#hidden + 1] = hiddenTarget(block, path, hiddenCursor, blockEnd, hiddenCategory) + end + hiddenCursor = blockEnd + 1 else - hidden[#hidden + 1] = hiddenTarget(block, path, hiddenCursor, blockEnd, hiddenCategory) + local _, comment = stripComments(hiddenLines[hiddenCursor], false) + local label = type(comment) == "string" and comment:match('^%s*#?%s*"([^\"]+)"%s*$') or nil + if label ~= nil and label ~= "" then hiddenCategory = label end + hiddenCursor = hiddenCursor + 1 end - hiddenCursor = blockEnd + 1 - else - local _, comment = stripComments(hiddenLines[hiddenCursor], false) - local label = type(comment) == "string" and comment:match('^%s*#?%s*"([^\"]+)"%s*$') or nil - if label ~= nil and label ~= "" then hiddenCategory = label end - hiddenCursor = hiddenCursor + 1 end end + -- Most split Niri setups also include theme, output, input, and window + -- rule files. Once hidden sentinels have been handled, a file without + -- either keyword cannot contribute binds or extend the include graph. + -- This cheap rejection keeps unrelated config files out of the parser's + -- character-level brace/comment scan. + if source:find("binds", 1, true) == nil and source:find("include", 1, true) == nil then + visiting[path] = nil + return + end + local function finishBind() local action = contentBeforeOuterClose(table.concat(currentBind.parts, "\n")) parseBind( diff --git a/keymap/plugin.toml b/keymap/plugin.toml index b56010a..9801db7 100644 --- a/keymap/plugin.toml +++ b/keymap/plugin.toml @@ -1,6 +1,6 @@ id = "blackbartblues/keymap" name = "Keymap" -version = "1.3.2" +version = "1.3.4" plugin_api = 9 author = "blackbartblues" license = "MIT" diff --git a/keymap/service.luau b/keymap/service.luau index 72299fc..90100b3 100644 --- a/keymap/service.luau +++ b/keymap/service.luau @@ -10,6 +10,7 @@ local MAX_LUA_FILES = 64 local MAX_SOURCE_BYTES = 512 * 1024 local MAX_HIDDEN_BYTES = 2 * 1024 * 1024 local HYPRCTL_TIMEOUT_MS = 5000 +local EXACT_SOURCE_FINGERPRINT = "exact-v1" local refreshing = false local refreshQueued = false @@ -194,6 +195,15 @@ local function assignmentLiteral(line, field) if quote ~= '"' and quote ~= "'" then return nil, false end + local simpleValue, simpleTail + if quote == '"' then + simpleValue, simpleTail = literal:match('^"([^"\\]*)"(.*)$') + else + simpleValue, simpleTail = literal:match("^'([^'\\]*)'(.*)$") + end + if simpleValue ~= nil then + return simpleValue, simpleTail:match("^%s*%.%.") ~= nil + end local out = {} local escaped = false @@ -255,6 +265,13 @@ local function generatedCommand(line) local suffix = line:sub(endIndex + 1) local quote = suffix:sub(1, 1) if quote == '"' or quote == "'" then + local simpleValue + if quote == '"' then + simpleValue = suffix:match('^"([^"\\]*)"') + else + simpleValue = suffix:match("^'([^'\\]*)'") + end + if simpleValue ~= nil then return simpleValue end local out = {} local escaped = false for index = 2, #suffix do @@ -281,6 +298,8 @@ local function generatedCommand(line) end local function generatedAction(line) + local balanced = line:match("(hl%.dsp%.[%w_%.]+%b())") + if balanced ~= nil then return balanced end local startIndex = line:find("hl%.dsp%.") if startIndex == nil then return nil end local quote, escaped, depth, opened = nil, false, 0, false @@ -303,9 +322,9 @@ local function generatedAction(line) return nil end -local function xorByte(left, right) +local function xorNibbleSlow(left, right) local result, place = 0, 1 - for _ = 1, 8 do + for _ = 1, 4 do if left % 2 ~= right % 2 then result = result + place end left = math.floor(left / 2) right = math.floor(right / 2) @@ -314,6 +333,19 @@ local function xorByte(left, right) return result end +local xorNibbles = {} +for left = 0, 15 do + xorNibbles[left] = {} + for right = 0, 15 do + xorNibbles[left][right] = xorNibbleSlow(left, right) + end +end + +local function xorByte(left, right) + return xorNibbles[left % 16][right % 16] + + xorNibbles[math.floor(left / 16)][math.floor(right / 16)] * 16 +end + local xorByteFast = type(bit32) == "table" and type(bit32.bxor) == "function" and bit32.bxor or xorByte local function fingerprint(value) @@ -419,6 +451,7 @@ local function scanLuaSources(rootPath) warnings[#warnings + 1] = "lua_source_truncated" end + local containsHidden = source:find("-- Keymap hidden ", 1, true) ~= nil local currentCategory = nil local pendingBindCategory = nil local pendingMarkerLine = nil @@ -427,7 +460,10 @@ local function scanLuaSources(rootPath) local lineNumber = 1 while lineNumber <= #lines do local line = lines[lineNumber] - local block, blockEnd, candidate = hiddenBlockAt(lines, lineNumber) + local block, blockEnd, candidate = nil, lineNumber, false + if containsHidden then + block, blockEnd, candidate = hiddenBlockAt(lines, lineNumber) + end if candidate then if block == nil then warnings[#warnings + 1] = "hidden_block_invalid:" .. path .. ":" .. tostring(lineNumber) @@ -467,81 +503,88 @@ local function scanLuaSources(rootPath) lineNumber = blockEnd + 1 else local bindCategory = line:match( - "^%s*%-%-%s*Keymap bind%-category:%s*(.-)%s*$" - ) - local header = line:match("^%s*%-%-%s*%d+%.%s*(.-)%s*$") - if bindCategory ~= nil and bindCategory ~= "" then - pendingBindCategory = bindCategory - pendingMarkerLine = lineNumber - pendingMarkerRaw = line - elseif header ~= nil and header ~= "" then - currentCategory = header - appendUnique(headers, headerSeen, header) - elseif not line:match("^%s*%-%-") then - local modules = requiredModules(line) - for _, moduleName in ipairs(modules) do - local requiredPath = modulePath(path, moduleName) - if requiredPath ~= nil and not queued[requiredPath] and noctalia.fileExists(requiredPath) then - queued[requiredPath] = true - queue[#queue + 1] = requiredPath - end - end - - local effectiveCategory = pendingBindCategory or currentCategory - if effectiveCategory ~= nil then - local description, dynamic = assignmentLiteral(line, "description") - if description == nil then - description, dynamic = assignmentLiteral(line, "desc") - end - if description ~= nil and trim(description) ~= "" then - if dynamic then - local prefixKey = description .. "\0" .. effectiveCategory - if not prefixSeen[prefixKey] then - prefixSeen[prefixKey] = true - prefixes[#prefixes + 1] = { - prefix = description, - category = effectiveCategory, - } + "^%s*%-%-%s*Keymap bind%-category:%s*(.-)%s*$" + ) + local header = line:match("^%s*%-%-%s*%d+%.%s*(.-)%s*$") + if bindCategory ~= nil and bindCategory ~= "" then + pendingBindCategory = bindCategory + pendingMarkerLine = lineNumber + pendingMarkerRaw = line + elseif header ~= nil and header ~= "" then + currentCategory = header + appendUnique(headers, headerSeen, header) + elseif not line:match("^%s*%-%-") then + if line:find("require", 1, true) ~= nil then + local modules = requiredModules(line) + for _, moduleName in ipairs(modules) do + local requiredPath = modulePath(path, moduleName) + if requiredPath ~= nil and not queued[requiredPath] + and noctalia.fileExists(requiredPath) then + queued[requiredPath] = true + queue[#queue + 1] = requiredPath end - elseif exactCategories[description] == nil then - exactCategories[description] = effectiveCategory end - if keySequences[description] == nil then - keySequences[description] = multiKeySequence(line) + end + + local effectiveCategory = pendingBindCategory or currentCategory + local hasBind = line:find("hl.bind", 1, true) ~= nil + if effectiveCategory ~= nil and hasBind then + local description, dynamic = assignmentLiteral(line, "description") + if description == nil then + description, dynamic = assignmentLiteral(line, "desc") end - if origins[description] == nil then + if description ~= nil and trim(description) ~= "" then local command = generatedCommand(line) local action = generatedAction(line) - local rawSnippet = pendingMarkerRaw ~= nil - and (pendingMarkerRaw .. "\n" .. line) or line - origins[description] = { - source = path, - line = pendingMarkerLine or lineNumber, - start_line = pendingMarkerLine or lineNumber, - end_line = lineNumber, - managed = path:match("([^/]+)$") == "keymap.lua", - raw_snippet = rawSnippet, - fingerprint = fingerprint(rawSnippet), - action = action, - capabilities = { - combo = not dynamic, - category = not dynamic, - description = not dynamic, - command = not dynamic and command ~= nil, - activation = not dynamic, - }, - } + if dynamic then + local prefixKey = description .. "\0" .. effectiveCategory + if not prefixSeen[prefixKey] then + prefixSeen[prefixKey] = true + prefixes[#prefixes + 1] = { + prefix = description, + category = effectiveCategory, + } + end + elseif exactCategories[description] == nil then + exactCategories[description] = effectiveCategory + end + if keySequences[description] == nil then + keySequences[description] = multiKeySequence(line) + end + if origins[description] == nil then + local rawSnippet = pendingMarkerRaw ~= nil + and (pendingMarkerRaw .. "\n" .. line) or line + origins[description] = { + source = path, + line = pendingMarkerLine or lineNumber, + start_line = pendingMarkerLine or lineNumber, + end_line = lineNumber, + managed = path:match("([^/]+)$") == "keymap.lua", + raw_snippet = rawSnippet, + -- The writer verifies this complete snippet byte-for-byte at + -- the recorded line range. Avoid hashing every bind inside the + -- service's tightly budgeted refresh callback. + fingerprint = EXACT_SOURCE_FINGERPRINT, + action = action, + capabilities = { + combo = not dynamic, + category = not dynamic, + description = not dynamic, + command = not dynamic and command ~= nil, + activation = not dynamic, + }, + } + end + if commands[description] == nil then commands[description] = command end + if actions[description] == nil then actions[description] = action end end - if commands[description] == nil then commands[description] = generatedCommand(line) end - if actions[description] == nil then actions[description] = generatedAction(line) end + end + if hasBind then + pendingBindCategory = nil + pendingMarkerLine = nil + pendingMarkerRaw = nil end end - if line:find("hl.bind", 1, true) ~= nil then - pendingBindCategory = nil - pendingMarkerLine = nil - pendingMarkerRaw = nil - end - end lineNumber = lineNumber + 1 end end diff --git a/keymap/tests/command_library_test.py b/keymap/tests/command_library_test.py index 1f3cb1a..cbd6d28 100644 --- a/keymap/tests/command_library_test.py +++ b/keymap/tests/command_library_test.py @@ -59,10 +59,13 @@ assert all(";" not in entry["template"] and "{" not in re.sub(r"\{\{[^}]+\}\}", assert all("#" not in entry["template"] for entry in entries if entry["source"] == "mangowc") -# The retained UI deliberately renders only a small result window and removes -# callbacks that are no longer part of the current render. +# The retained UI deliberately renders only a small result window and passes +# closures directly instead of registering callback names in the script global +# environment. Keep this assertion aligned with the plugin API 9 callback form. assert "local visibleCount = math.min(#matches, 6)" in panel -assert "finishDynamicCallbackRender()" in panel -assert 'registerDynamicCallback(callbackName' in panel +assert "local selectedEntry = entry" in panel +assert "onClick = useCallback" in panel +assert "finishDynamicCallbackRender()" not in panel +assert "registerDynamicCallback(" not in panel print(f"command library tests: ok ({len(entries)} entries: {dict(counts)})") diff --git a/keymap/tests/hypr_cpu_budget_test.lua b/keymap/tests/hypr_cpu_budget_test.lua new file mode 100644 index 0000000..f9a4e5f --- /dev/null +++ b/keymap/tests/hypr_cpu_budget_test.lua @@ -0,0 +1,135 @@ +local rootPath = "/fixture/hypr/hyprland.lua" +local files = {} + +local rootLines = { + "-- Hyprland configuration", + 'require("keybind")', + 'require("colors")', + 'require("noctalia").apply_theme()', + 'require("keymap")', +} +for index = 1, 120 do + rootLines[#rootLines + 1] = string.format("hl.config({ value_%d = %d })", index, index) +end +files[rootPath] = table.concat(rootLines, "\n") + +local bindLines, bindDescriptions = {}, {} +for index = 1, 60 do + if index % 15 == 1 then + bindLines[#bindLines + 1] = "-- " .. tostring(math.floor(index / 15) + 1) .. ". Group" + end + local description = "Action " .. tostring(index) + bindDescriptions[#bindDescriptions + 1] = description + bindLines[#bindLines + 1] = string.format( + 'hl.bind("SUPER + Key%d", hl.dsp.exec_cmd("command-%d"), { description = "%s" })', + index, index, description + ) +end +files["/fixture/hypr/keybind.lua"] = table.concat(bindLines, "\n") + +local unrelated = {} +for index = 1, 100 do + unrelated[#unrelated + 1] = string.format("local value_%d = %d", index, index) +end +files["/fixture/hypr/colors.lua"] = table.concat(unrelated, "\n") +files["/fixture/hypr/noctalia.lua"] = table.concat(unrelated, "\n") +files["/fixture/hypr/keymap.lua"] = table.concat({ + "-- Managed by Noctalia Keymap.", + "-- 5. Managed", + 'hl.bind("CTRL + grave", hl.dsp.exec_cmd("managed-command"), { description = "Managed action" })', +}, "\n") +bindDescriptions[#bindDescriptions + 1] = "Managed action" + +local textRecords = {} +for index, description in ipairs(bindDescriptions) do + textRecords[#textRecords + 1] = table.concat({ + "bindd", + "\tmodmask: 64", + "\tsubmap: ", + "\tkey: Key" .. tostring(index), + "\tkeycode: 0", + "\tcatchall: false", + "\tdescription: " .. description, + "\tdispatcher: __lua", + "\targ: " .. tostring(index), + "", + }, "\n") +end +local textOutput = table.concat(textRecords, "\n") + +local values, watchers = {}, {} +local instructionBlocks, firstAsyncInstructionBlocks = 0, nil +noctalia = { + getConfig = function(key) + return ({ + compositor = "hyprland", + hyprland_config = rootPath, + merge_sequential = false, + show_undescribed = true, + })[key] + end, + getenv = function(key) return key == "HYPRLAND_INSTANCE_SIGNATURE" and "fixture" or "" end, + expandPath = function(path) return path end, + fileExists = function(path) return files[path] ~= nil end, + listDir = function() return nil end, + readFile = function(path) return files[path] end, + commandExists = function(command) return command == "hyprctl" end, + json = { decode = function() error("Hyprland emitted invalid JSON") end }, + runAsync = function(command, callback) + if firstAsyncInstructionBlocks == nil then firstAsyncInstructionBlocks = instructionBlocks end + callback({ + exitCode = 0, + timedOut = false, + stdout = command == "hyprctl binds -j" and "{invalid-json" or textOutput, + }) + return true + end, + tr = function(key) + if key == "category.other" then return "Other" end + if key == "category.undescribed" then return "Without description" end + return key + end, + state = { + get = function(key) return values[key] end, + set = function(key, value) + values[key] = value + if watchers[key] ~= nil then watchers[key](value) end + end, + watch = function(key, callback) watchers[key] = callback end, + }, +} + +debug.sethook(function() instructionBlocks = instructionBlocks + 1 end, "", 1000) +assert(loadfile("service.luau"))() +debug.sethook() +local initialInstructionBlocks = instructionBlocks +instructionBlocks, firstAsyncInstructionBlocks = 0, nil +debug.sethook(function() instructionBlocks = instructionBlocks + 1 end, "", 1000) +watchers["keymap.refresh_request"](1) +debug.sethook() +local refreshInstructionBlocks = instructionBlocks +local refreshScanInstructionBlocks = firstAsyncInstructionBlocks + +local snapshot = values["keymap.snapshot"] +assert(snapshot.status == "ready", "split Hyprland fallback did not publish a ready snapshot") +assert(snapshot.total == #bindDescriptions, "split Hyprland fallback lost binds") + +local editable = 0 +for _, category in ipairs(snapshot.categories or {}) do + for _, bind in ipairs(category.binds or {}) do + if bind.capabilities ~= nil and bind.capabilities.combo == true + and bind.capabilities.description == true and bind.capabilities.command == true + and bind.source ~= nil and bind.raw_snippet ~= nil and bind.fingerprint == "exact-v1" then + editable = editable + 1 + end + end +end +assert(editable == #bindDescriptions, "literal Hyprland binds did not retain editable source provenance") +assert(refreshScanInstructionBlocks < 50, "Hyprland source scan exceeded its callback instruction budget") +assert(refreshInstructionBlocks < 150, "Hyprland refresh exceeded its regression instruction budget") +assert(refreshScanInstructionBlocks ~= nil, "Hyprland parser did not start the live bind request") + +print(string.format( + "hypr CPU-budget regression tests: ok (%d initial / %d refresh scan / %d refresh total blocks, %d editable binds)", + initialInstructionBlocks, refreshScanInstructionBlocks, refreshInstructionBlocks, editable +)) diff --git a/keymap/tests/niri_cpu_budget_test.lua b/keymap/tests/niri_cpu_budget_test.lua new file mode 100644 index 0000000..dc65ad7 --- /dev/null +++ b/keymap/tests/niri_cpu_budget_test.lua @@ -0,0 +1,100 @@ +local rootLines = {} +for index = 1, 420 do + rootLines[#rootLines + 1] = "// Stock Niri configuration documentation line " .. tostring(index) +end +rootLines[#rootLines + 1] = "binds {" +for index = 1, 114 do + rootLines[#rootLines + 1] = string.format( + " Mod+Key%d repeat=false { focus-workspace %d; }", index, index + ) +end +rootLines[#rootLines + 1] = "}" +rootLines[#rootLines + 1] = 'include "mine/binds.kdl"' +rootLines[#rootLines + 1] = 'include optional=true "mine/debug.kdl"' +rootLines[#rootLines + 1] = 'include "mine/theme.kdl"' + +local includeLines = { "binds {" } +for index = 115, 126 do + includeLines[#includeLines + 1] = string.format( + ' Mod+Key%d hotkey-overlay-title="Included %d" { focus-workspace %d; }', + index, index, index + ) +end +includeLines[#includeLines + 1] = "}" + +local sources = { + ["/fixture/config.kdl"] = table.concat(rootLines, "\n"), + ["/fixture/mine/binds.kdl"] = table.concat(includeLines, "\n"), + ["/fixture/mine/debug.kdl"] = "binds {\n Mod+Key127 { toggle-debug-tint; }\n}", + ["/fixture/mine/theme.kdl"] = string.rep("// unrelated theme setting\n", 500), +} + +local values, watchers = {}, {} +local reads = {} +local xorCalls = 0 +local stringSubCalls = 0 +local originalBit32 = bit32 +local originalStringSub = string.sub +string.sub = function(...) + stringSubCalls = stringSubCalls + 1 + return originalStringSub(...) +end +bit32 = { + bxor = function(left, right) + xorCalls = xorCalls + 1 + local result, place = 0, 1 + for _ = 1, 8 do + if left % 2 ~= right % 2 then result = result + place end + left = math.floor(left / 2) + right = math.floor(right / 2) + place = place * 2 + end + return result + end, +} + +noctalia = { + state = { + get = function(key) return values[key] end, + set = function(key, value) + values[key] = value + if watchers[key] ~= nil then watchers[key](value) end + end, + watch = function(key, callback) watchers[key] = callback end, + }, + getConfig = function(key) + return ({ + compositor = "niri", niri_config = "/fixture/config.kdl", merge_sequential = false, + })[key] + end, + getenv = function(key) return key == "NIRI_SOCKET" and "test" or "" end, + fileExists = function(path) return path == "/fixture/config.kdl" end, + listDir = function() return nil end, + readFile = function(path) + reads[path] = (reads[path] or 0) + 1 + return sources[path] + end, + tr = function(key, args) + if args ~= nil and args.action ~= nil then return args.action end + return key == "category.other" and "Other" or key + end, +} + +assert(loadfile("niri_service.luau"))() +bit32 = originalBit32 +string.sub = originalStringSub + +local snapshot = values["keymap.snapshot"] +assert(snapshot.status == "ready", "large, split Niri fixture did not parse") +assert(snapshot.total == 127, "large, split Niri fixture lost binds") +assert(reads["/fixture/config.kdl"] == 1, "Niri root config should only be read once") +assert(reads["/fixture/mine/binds.kdl"] == 1, "Niri bind include should only be read once") +assert(reads["/fixture/mine/debug.kdl"] == 1, "Niri optional include should only be read once") +assert(reads["/fixture/mine/theme.kdl"] == 1, "Niri non-bind include should only be read once") +assert(xorCalls > 0, "Niri parser did not use the native-xor fingerprint path") +assert( + stringSubCalls < 45000, + "Niri parser scanned an unrelated include character by character: " .. tostring(stringSubCalls) +) + +print("niri CPU-budget regression tests: ok") diff --git a/keymap/tests/writer_update_test.lua b/keymap/tests/writer_update_test.lua index d7074e3..8a51414 100644 --- a/keymap/tests/writer_update_test.lua +++ b/keymap/tests/writer_update_test.lua @@ -111,6 +111,9 @@ local function runCase(case) case.id, source, case.snippet, case.capabilities, case.startLine, case.endLine ) + -- Active Hyprland source entries use exact byte comparison to keep hashing + -- out of the service's tightly budgeted refresh callback. + if case.compositor == "Hyprland" then bind.fingerprint = "exact-v1" end stateValues["keymap.snapshot"] = { status = "ready", compositor = case.compositor, source = root, categories = { { name = case.category, binds = { bind } } }, diff --git a/keymap/writer_service.luau b/keymap/writer_service.luau index 35c25ea..004f97c 100644 --- a/keymap/writer_service.luau +++ b/keymap/writer_service.luau @@ -11,6 +11,7 @@ local REFRESH_REQUEST_KEY = "keymap.refresh_request" local LAST_HANDLED_REQUEST_KEY = "keymap.last_handled_create_request" local LAST_HANDLED_UPDATE_KEY = "keymap.last_handled_update_request" local SNAPSHOT_KEY = "keymap.snapshot" +local EXACT_SOURCE_FINGERPRINT = "exact-v1" local MAX_ROOT_BYTES = 2 * 1024 * 1024 local MAX_MANAGED_BYTES = 1024 * 1024 @@ -756,9 +757,9 @@ processValidatedRequest = function(request) verifyAndReload(request, transaction) end -local function xorByte(left, right) +local function xorNibbleSlow(left, right) local result, place = 0, 1 - for _ = 1, 8 do + for _ = 1, 4 do if left % 2 ~= right % 2 then result = result + place end left = math.floor(left / 2) right = math.floor(right / 2) @@ -767,6 +768,19 @@ local function xorByte(left, right) return result end +local xorNibbles = {} +for left = 0, 15 do + xorNibbles[left] = {} + for right = 0, 15 do + xorNibbles[left][right] = xorNibbleSlow(left, right) + end +end + +local function xorByte(left, right) + return xorNibbles[left % 16][right % 16] + + xorNibbles[math.floor(left / 16)][math.floor(right / 16)] * 16 +end + local xorByteFast = type(bit32) == "table" and type(bit32.bxor) == "function" and bit32.bxor or xorByte local function contentFingerprint(value) @@ -779,6 +793,10 @@ local function contentFingerprint(value) return string.format("%08x", hash) end +local function sourceFingerprintMatches(value, expected) + return expected == EXACT_SOURCE_FINGERPRINT or contentFingerprint(value) == expected +end + local function splitLines(content) local lines, cursor = {}, 1 while cursor <= #content do @@ -797,7 +815,7 @@ local function replaceLineRange(content, firstLine, lastLine, expected, replacem local lines, trailingNewline = splitLines(content) if firstLine < 1 or lastLine < firstLine or lastLine > #lines then return nil, "target_range_invalid" end local current = table.concat(lines, "\n", firstLine, lastLine) - if current ~= expected or contentFingerprint(current) ~= contentFingerprint(expected) then + if current ~= expected then return nil, "target_changed" end local replacementLines = {} @@ -831,11 +849,11 @@ local function reorderLineRanges(content, target, anchor, placement, replacement local currentTarget = table.concat(lines, "\n", targetFirst, targetLast) local currentAnchor = table.concat(lines, "\n", anchorFirst, anchorLast) if currentTarget ~= targetRaw - or contentFingerprint(currentTarget) ~= tostring(target.fingerprint or "") then + or not sourceFingerprintMatches(currentTarget, tostring(target.fingerprint or "")) then return nil, "target_changed" end if currentAnchor ~= anchorRaw - or contentFingerprint(currentAnchor) ~= tostring(anchor.fingerprint or "") then + or not sourceFingerprintMatches(currentAnchor, tostring(anchor.fingerprint or "")) then return nil, "anchor_changed" end @@ -1088,7 +1106,9 @@ local function processValidatedUpdate(request, target, currentCategory) busy = false return end - if contentFingerprint(tostring(target.raw_snippet or "")) ~= tostring(target.fingerprint or "") then + if not sourceFingerprintMatches( + tostring(target.raw_snippet or ""), tostring(target.fingerprint or "") + ) then publishUpdateResult(request.request_id, false, "target_changed", target.source) busy = false return @@ -1176,7 +1196,7 @@ local function processValidatedMove(request, target, currentCategory) return end local rawSnippet = tostring(target.raw_snippet or "") - if contentFingerprint(rawSnippet) ~= tostring(target.fingerprint or "") then + if not sourceFingerprintMatches(rawSnippet, tostring(target.fingerprint or "")) then publishUpdateResult(request.request_id, false, "target_changed", target.source) busy = false return @@ -1249,12 +1269,16 @@ local function processValidatedReorder(request, target, anchor) busy = false return end - if contentFingerprint(tostring(target.raw_snippet or "")) ~= tostring(target.fingerprint or "") then + if not sourceFingerprintMatches( + tostring(target.raw_snippet or ""), tostring(target.fingerprint or "") + ) then publishUpdateResult(request.request_id, false, "target_changed", target.source) busy = false return end - if contentFingerprint(tostring(anchor.raw_snippet or "")) ~= tostring(anchor.fingerprint or "") then + if not sourceFingerprintMatches( + tostring(anchor.raw_snippet or ""), tostring(anchor.fingerprint or "") + ) then publishUpdateResult(request.request_id, false, "anchor_changed", anchor.source) busy = false return @@ -1359,9 +1383,9 @@ local function processValidatedCategoryRename(request, category) return end local current = table.concat(lines, "\n", bind.start_line, bind.end_line) - if contentFingerprint(rawSnippet) ~= tostring(bind.fingerprint or "") + if not sourceFingerprintMatches(rawSnippet, tostring(bind.fingerprint or "")) or current ~= rawSnippet - or contentFingerprint(current) ~= tostring(bind.fingerprint or "") then + or not sourceFingerprintMatches(current, tostring(bind.fingerprint or "")) then publishUpdateResult(request.request_id, false, "target_changed", path) busy = false return @@ -1445,7 +1469,7 @@ local function processValidatedMutation(request, target) return end local rawSnippet = tostring(target.raw_snippet or "") - if contentFingerprint(rawSnippet) ~= tostring(target.fingerprint or "") then + if not sourceFingerprintMatches(rawSnippet, tostring(target.fingerprint or "")) then publishUpdateResult(request.request_id, false, "target_changed", target.source) busy = false return