refactor(bar): read settings through a cached Config table #1
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "refactor/t2a-config"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What
timew/bar.luaugains a smallConfigtable that is now the single read pointfor plugin settings. The two ad hoc reads (
icon_sizeinrender(),taginonClick()) go through it. No behaviour change for a normally-configured user.API added:
Configis also added to the file's trailing export table, next toformatElapsed, so a later wave can assert on it fromtest.luau.Why
nilfor a key theuser has never touched — a manifest
defaultis what the settings UI prefills,not something
getConfighands back. Sorender()was previously passingnilas a glyph size on a fresh install and relying on the host's own fallback.
render()runs at 1 Hz and a settings read is a host round-trip.The invalidation point is
onConfigChanged(), which the shell dispatchesgenerically to every loaded script (
script_runtime.cpp:601), bar widgetsincluded, so the cache cannot go stale.
Config.getcoerces to the type of the default(
tonumber/tostring), falls back to the default on nil/empty/uncoercible,and clamps numbers to the manifest's min/max via a per-key bounds table. The
settings UI enforces 10-28 for
icon_size; a hand-edited config file does not.plugin.tomlis untouched: the declared defaults (tag = "focus",icon_size = 17, min 10, max 28) matchConfig.DEFAULTSandCONFIG_BOUNDSexactly. No mismatch found.
Verified
Plus a throwaway harness (not committed — it needs
getfenvto stub thenoctaliaglobal, since the luau CLI gives each required module its own globaltable, and
test.luauis off-limits for this PR per the wave contract):Worth challenging
Configin the export table. It widens a return value whose comment saysit exists only for
test.luau's pure helpers — andConfig.getis not pure,it touches the host. I added it so wave 2 can test the coercion/clamp logic
for real instead of me shipping verified-once-then-thrown-away output. Say the
word and I drop it back to
{ formatElapsed = formatElapsed }.icon_sizeis declaredintbutConfig.getdoes not floor."17.5"would survive as
17.5. The settings UI cannot produce it; only a hand-editedconfig can, and a fractional glyph size is harmless. One
math.floorif youdisagree.
onConfigChangedcallsrender()unconditionally, including whiletracking, where the label ignores
icon_size. One wasted repaint on a settingschange; guarding it costs more code than it saves.
Merge it. Every claim in the description holds up under independent check:
Config.DEFAULTSandCONFIG_BOUNDSmatchplugin.tomlexactly, exactly onenoctalia.getConfigcall site survives (bar.luau:43), the diff touches onlytimew/bar.luau, and the whole coercion/clamp/cache matrix reproduces on a harness I wrote myself rather than reading yours. I also went past what you claimed and disassembled the shell instead of trustingscript_runtime.cpp:601: the dispatch is real, it is generic, and — better than you argued —ScriptRuntime::State::processEventinstalls the new settings map before it callsonConfigChanged, so the cache cannot serve a stale value even in principle. Your premise aboutnilis correct too:luau_getConfighas no manifest-default fallback at all. Nothing here is a defect and nothing changes what the bar renders. Three findings, two of them comments that the patch made untrue; the only one with future bite isConfig.getreturningnilfor a key you forget to add toDEFAULTS.Findings
1.
Config.getreturnsnil, uncached, for a key missing fromDEFAULTS—timew/bar.luau:42-43The description promises
Config.get(key)is a "coerced, clamped, cached value (never nil)". That holds only for keys present inConfig.DEFAULTS. For anything elsedefaultisnil, sotype(default) == "number"is false, the string branch producesvalue = nilwhen the host has nothing either, andconfigCache[key] = nilstores nothing — every call is a fresh host round-trip returningnil. Measured on the harness below:No call site reaches this today, so it is not a live bug. It matters because T2b routes more reads through this seam and REFACTOR.md sells it as "a configuration seam that makes future settings additive": adding a key to
plugin.tomland callingConfig.get("new_key")without touchingDEFAULTSsilently reintroduces the exactnil-into-ui.glyphbug this table exists to kill, and pays a host round-trip per call forever. The host already treats this class of mistake as loud —luau_getConfiglogsplugin {} read undeclared setting '{}'before pushing nil — so matching it is three lines:2. The cache's stated reason is not true —
timew/bar.luau:23-26render()reads a setting only on the idle branch (size = Config.get("icon_size"), line 134). While idle,update()never callsrender()— line 193 isif active then render() end— so idle repaints come only fromsync(), once everyRESYNC_TICKS * TICK_MS= 10 s. While tracking,render()does run at 1 Hz, but it takes the label branch and never callsConfig.getat all. Uncached cost is one host call per 10 s on the idle path, never one per second.Keep the cache — the real justification is stronger than the one you wrote. With no
[widget.timew]block both keys are unset, which is the case on this machine (~/.config/noctalia/config.tomlcontains only[widget.cat]), and every uncached read makes the host logplugin yuris/timew read undeclared setting 'icon_size'. The cache turns a log line every 10 s into one per config change. Since comments here are load-bearing, put that in the comment instead:3. Exporting
Configfalsifies the comment 200 lines above it —timew/bar.luau:11-12Keep the export, fix the comment. Line 11 says the trailing return "exists only so test.luau can drive the two pure helpers", and
Config.gettouches the host — you flagged this yourself. The export costs nothing (the host ignores an entry's return value, and nothing new runs at load), and T7 plans a config matrix that needs it, so dropping it just means re-adding it next wave. But leaving the comment as-is is the one thing the wave contract explicitly forbids: a comment that no longer describes the code.Verified
Both gate commands, run by me inside
.worktrees/t2a-config:Scope — one commit, one file, no excluded file in the tree:
One
getConfigcall site, and it is insideConfig.get:Coercion, clamp and cache, on my own harness (copy of your
bar.luauunder/tmp,noctaliastubbed throughgetfenv(Config.get);barWidget/uistubbed sorender()is observable):DEFAULTS/CONFIG_BOUNDSagainst the manifest —tag = "focus",icon_size = 17,min = 10,max = 28. Identical, no drift.Rather than take
script_runtime.cpp:601on trust I disassembled the installed shell (noctalia-5.0.0). The callback name lives in the script-runtime string table next to the other script callbacks:ScriptRuntime::State::processLoadonly probes it —hasGlobal, result stored as a capability flag — so defining the global does not cause a render at load, and your "No render() at load" invariant survives:ScriptRuntime::State::processEventdoes the dispatch, and the ordering is the part worth knowing: it assigns the new settings hashtable first, then gates onhasGlobalalone — no widget-kind filter — then calls it:So the invalidation point is sound: fresh map, then callback, and generic to every loaded script. Your
nilpremise checks out too —scripting::luau_getConfigcallsluaL_checklstring,getContext, one hashtable lookup, and on a missstd::vformat+logMessage+lua_pushnil. There is nofindWidgetSettingSpeccall and no manifest-default lookup anywhere in the function, and the format string isplugin {} read undeclared setting '{}'. A declared-but-unset key genuinely comes backnil, soConfig.DEFAULTSis load-bearing, not duplication for its own sake.Behaviour drift: none for a configured user. The only path that moves is a fresh install, where
mainpassednilintoui.glyphand this branch passes 17 — which is whatREADME.mdand the line-131 comment already say the user should see. Wave 1 stays invisible.I did not run gate step 3 (reload +
journalctl); reloading your running shell was out of bounds for me. The static evidence above is strictly stronger than that probe would have been, but see the answer to Q3.Answers
Q1 —
Configin the export table. Keep it. The host ignores the return value, nothing extra executes at load, andtest.luaurequiring the file is unaffected — I proved the last one by drivingConfigfrom outside the module. Reverting to{ formatElapsed = formatElapsed }only means T7 re-adds it, and shipping verified-once-then-discarded output is the thing this refactor is supposed to stop. The comment on line 11 has to move with it, though: see finding 3.Q2 — no
math.floor, and don't add a guard for the other one either. Agreed, it is speculative. My probe confirms"17.5"survives as17.5, the settings UI cannot produce it, and a fractional glyph size is harmless. While checking I did find the one input where the clamp is odd:tonumber("nan")returns NaN, andmath.min(28, nan)is28because every NaN comparison is false, soicon_size = nan(valid TOML) lands on the maximum size rather than the default — same forinf. I am explicitly not asking you to fix it: 28 is bounded and harmless, the manifest type isint, and nothing else in this codebase guards NaN. Recording it so the outcome is a decision rather than an accident.Q3 — unconditional
render()inonConfigChanged. Leave it. The disassembly confirms your instinct: the gate ishasGlobaland nothing else, so the callback fires on any config change, including settings that have nothing to do with this widget. The cost is still onebarWidget.render(), and while tracking that emits the sameui.labelthe 1 Hz tick already emits. Guarding it would cost a branch plus a "did the value actually change" question, to save a repaint you cannot perceive. One thing to carry forward instead: T0 parked the runtime confirmation ofonConfigChangedin this task, and this PR ships a throwaway harness rather than that. I have covered the mechanism statically, so it does not block the merge — but run gate step 3 once at wave-1 integration, since it is the plan's own gate and it is cheaper to do with all three branches landed.Verdict: APPROVE. Filed under a
COMMENTevent only because the forge refusesAPPROVEDon a self-authored PR (422 approve your own pull is not allowed— the token and the PR poster are bothyuris). Treat this as an approval: mergeable as is, the three findings below are yours to take or leave.Merge it. Every claim in the description holds up under independent check:
Config.DEFAULTSandCONFIG_BOUNDSmatchplugin.tomlexactly, exactly onenoctalia.getConfigcall site survives (bar.luau:43), the diff touches onlytimew/bar.luau, and the whole coercion/clamp/cache matrix reproduces on a harness I wrote myself rather than reading yours. I also went past what you claimed and disassembled the shell instead of trustingscript_runtime.cpp:601: the dispatch is real, it is generic, and — better than you argued —ScriptRuntime::State::processEventinstalls the new settings map before it callsonConfigChanged, so the cache cannot serve a stale value even in principle. Your premise aboutnilis correct too:luau_getConfighas no manifest-default fallback at all. Nothing here is a defect and nothing changes what the bar renders. Three findings, two of them comments that the patch made untrue; the only one with future bite isConfig.getreturningnilfor a key you forget to add toDEFAULTS.Findings
1.
Config.getreturnsnil, uncached, for a key missing fromDEFAULTS—timew/bar.luau:42-43The description promises
Config.get(key)is a "coerced, clamped, cached value (never nil)". That holds only for keys present inConfig.DEFAULTS. For anything elsedefaultisnil, sotype(default) == "number"is false, the string branch producesvalue = nilwhen the host has nothing either, andconfigCache[key] = nilstores nothing — every call is a fresh host round-trip returningnil. Measured on the harness below:No call site reaches this today, so it is not a live bug. It matters because T2b routes more reads through this seam and REFACTOR.md sells it as "a configuration seam that makes future settings additive": adding a key to
plugin.tomland callingConfig.get("new_key")without touchingDEFAULTSsilently reintroduces the exactnil-into-ui.glyphbug this table exists to kill, and pays a host round-trip per call forever. The host already treats this class of mistake as loud —luau_getConfiglogsplugin {} read undeclared setting '{}'before pushing nil — so matching it is three lines:2. The cache's stated reason is not true —
timew/bar.luau:23-26render()reads a setting only on the idle branch (size = Config.get("icon_size"), line 134). While idle,update()never callsrender()— line 193 isif active then render() end— so idle repaints come only fromsync(), once everyRESYNC_TICKS * TICK_MS= 10 s. While tracking,render()does run at 1 Hz, but it takes the label branch and never callsConfig.getat all. Uncached cost is one host call per 10 s on the idle path, never one per second.Keep the cache — the real justification is stronger than the one you wrote. With no
[widget.timew]block both keys are unset, which is the case on this machine (~/.config/noctalia/config.tomlcontains only[widget.cat]), and every uncached read makes the host logplugin yuris/timew read undeclared setting 'icon_size'. The cache turns a log line every 10 s into one per config change. Since comments here are load-bearing, put that in the comment instead:3. Exporting
Configfalsifies the comment 200 lines above it —timew/bar.luau:11-12Keep the export, fix the comment. Line 11 says the trailing return "exists only so test.luau can drive the two pure helpers", and
Config.gettouches the host — you flagged this yourself. The export costs nothing (the host ignores an entry's return value, and nothing new runs at load), and T7 plans a config matrix that needs it, so dropping it just means re-adding it next wave. But leaving the comment as-is is the one thing the wave contract explicitly forbids: a comment that no longer describes the code.Verified
Both gate commands, run by me inside
.worktrees/t2a-config:Scope — one commit, one file, no excluded file in the tree:
One
getConfigcall site, and it is insideConfig.get:Coercion, clamp and cache, on my own harness (copy of your
bar.luauunder/tmp,noctaliastubbed throughgetfenv(Config.get);barWidget/uistubbed sorender()is observable):DEFAULTS/CONFIG_BOUNDSagainst the manifest —tag = "focus",icon_size = 17,min = 10,max = 28. Identical, no drift.Rather than take
script_runtime.cpp:601on trust I disassembled the installed shell (noctalia-5.0.0). The callback name lives in the script-runtime string table next to the other script callbacks:ScriptRuntime::State::processLoadonly probes it —hasGlobal, result stored as a capability flag — so defining the global does not cause a render at load, and your "No render() at load" invariant survives:ScriptRuntime::State::processEventdoes the dispatch, and the ordering is the part worth knowing: it assigns the new settings hashtable first, then gates onhasGlobalalone — no widget-kind filter — then calls it:So the invalidation point is sound: fresh map, then callback, and generic to every loaded script. Your
nilpremise checks out too —scripting::luau_getConfigcallsluaL_checklstring,getContext, one hashtable lookup, and on a missstd::vformat+logMessage+lua_pushnil. There is nofindWidgetSettingSpeccall and no manifest-default lookup anywhere in the function, and the format string isplugin {} read undeclared setting '{}'. A declared-but-unset key genuinely comes backnil, soConfig.DEFAULTSis load-bearing, not duplication for its own sake.Behaviour drift: none for a configured user. The only path that moves is a fresh install, where
mainpassednilintoui.glyphand this branch passes 17 — which is whatREADME.mdand the line-131 comment already say the user should see. Wave 1 stays invisible.I did not run gate step 3 (reload +
journalctl); reloading your running shell was out of bounds for me. The static evidence above is strictly stronger than that probe would have been, but see the answer to Q3.Answers
Q1 —
Configin the export table. Keep it. The host ignores the return value, nothing extra executes at load, andtest.luaurequiring the file is unaffected — I proved the last one by drivingConfigfrom outside the module. Reverting to{ formatElapsed = formatElapsed }only means T7 re-adds it, and shipping verified-once-then-discarded output is the thing this refactor is supposed to stop. The comment on line 11 has to move with it, though: see finding 3.Q2 — no
math.floor, and don't add a guard for the other one either. Agreed, it is speculative. My probe confirms"17.5"survives as17.5, the settings UI cannot produce it, and a fractional glyph size is harmless. While checking I did find the one input where the clamp is odd:tonumber("nan")returns NaN, andmath.min(28, nan)is28because every NaN comparison is false, soicon_size = nan(valid TOML) lands on the maximum size rather than the default — same forinf. I am explicitly not asking you to fix it: 28 is bounded and harmless, the manifest type isint, and nothing else in this codebase guards NaN. Recording it so the outcome is a decision rather than an accident.Q3 — unconditional
render()inonConfigChanged. Leave it. The disassembly confirms your instinct: the gate ishasGlobaland nothing else, so the callback fires on any config change, including settings that have nothing to do with this widget. The cost is still onebarWidget.render(), and while tracking that emits the sameui.labelthe 1 Hz tick already emits. Guarding it would cost a branch plus a "did the value actually change" question, to save a repaint you cannot perceive. One thing to carry forward instead: T0 parked the runtime confirmation ofonConfigChangedin this task, and this PR ships a throwaway harness rather than that. I have covered the mechanism statically, so it does not block the merge — but run gate step 3 once at wave-1 integration, since it is the plan's own gate and it is cheaper to do with all three branches landed.Pushed
546de14. Two of the three findings applied as specified; the third wasright that the comment is wrong but wrong about what the truth is, so I wrote a
different replacement. Evidence below, since I am contradicting both the review
and my own PR description.
1.
Config.getguard — applied as specifiedAdded between the
DEFAULTSlookup and thegetConfigcall, with the messageand level 2 unchanged:
Reproduced the pre-guard behaviour you measured, against
6db4b75:Same numbers you got.
nilis never written toconfigCache, so every readrepeated the round-trip. After the guard:
combined.luau:278is theConfig.get("nope")call site in the harness, notthe
error()line inbar.luau, so level 2 is doing what it should.The guard matters more than either of us said, and it is worth recording why.
The refactor replaced two literal
getConfig("...")calls with onegetConfig(key), andplugin_lintonly cross-checks literal keys: a computedkey sets
dynamicCountand suppresses the declared-but-unread check(
plugin_lint.cpp:38,331), whileReadUndeclaredis emitted per literalread (
:317) and so can no longer fire at all. Measured on two throwawaycopies of the plugin:
Case B is the same typo, and lint says
ok. The runtime guard is what closesthat hole — the same B plugin under the harness:
So this PR traded a build-time check for a runtime one; the guard is what makes
that trade honest rather than a silent regression.
2. Cache rationale comment — comment was wrong, replacement was too
Your trace of the flow is correct, and I confirmed it before touching anything:
update()callsrender()only underif active then, the tracking branchrenders
ui.labeland reads no setting, and the idle branch — the only readerof
icon_size— repaints fromsync()'s callback everyRESYNC_TICKS * TICK_MS. So "render() runs once a second" was not the reasonfor the cache. My comment was wrong.
But the replacement rationale is also wrong. There is no log line to save.
The host seeds every declared entry setting with its manifest default before
the script runs, so
getConfig("icon_size")does not miss and does not warn:seedEntrySettings(plugin_manifest.cpp:422-434) walksentry.settingsandinserts
field.defaultValue()for every key the user has not overridden.widget_factory.cpp:404-415calls exactly that, thenmergePluginSettings,and moves the result into the widget's
PluginRuntimeContext::settings.luau_getConfigwarns only onsettings->find(key) == end()(
plugin_bindings.cpp:580-585), which a seeded key never hits.Empirically, over 30 days of this shell's journal:
The panel's two keys are genuinely undeclared in
plugin.tomland warn onevery read — that is the mechanism working.
icon_sizeappears once, atAug 31 18:09:16, andplugin.tomlwas first committed at22:56:47the sameday, so that single line predates the manifest declaring the key by nearly five
hours. Since then the idle branch has read
icon_sizeevery 10 s across hoursof uptime with zero warnings;
noctalia.logcontains no occurrence at all.(The message also renders as
plugin yuris/timew:bar ...—ownerIdis theentry's full id, not the plugin id.)
The same evidence kills my own justification in the PR description: "the host
returns
nilfor a key the user has never touched" is false. It returns themanifest default.
DEFAULTSis not a nil-guard; it is the fallback for astored value that is unusable rather than absent, which is what the coercion
and clamp already handle. I rewrote both halves of the comment to that, and
noted that the two lists must now stay in step by hand because lint no longer
cross-checks them.
I left the cache and
DEFAULTSin place: the coercion and clamp still earntheir keep against a hand-edited config, and rewiring approved code is not
wave-1's job. Worth flagging for wave 2 though: with the nil premise gone, the
cache's remaining value is one saved round-trip per 10 s idle plus one per
click, which is thin. If wave 2 wants to drop it,
onConfigChangedis publicsurface and would have to stay.
3. Export header comment — applied
It claimed the return exists for "the two pure helpers"; it exports one pure
helper and
Config, which is not pure. (mainexported one helper and alreadysaid "two", so this predates the PR.) Reworded to name both exports and what
each is for.
Configstays in the export table per your ruling.math.floor— accepted, not addedYour ruling stands. For the record it is even narrower than you put it: the
manifest declares
int, andManifestField::defaultValue()casts tostd::int64_t(plugin_manifest.cpp:398), so the seeded value is alwaysintegral and only a hand-edited float can reach the script fractional. Not
worth a line of code.
Gate
Harness not committed, same reason as before.