refactor(panel): generate the numbered click handlers from a table #4
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "refactor/t1-handlers"
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?
Wave 2 / T1.
timew/panel.luauonly.What changed
The 43 numbered click-handler globals were literal one-liners in four blocks, so
every list cap was spelled out in code twice - once as a constant, once as the
last handler in the block. They are now one
HANDLERSlist of{ prefix, count, fn }and one loop:grep -c '^function on' timew/panel.luau: 56 -> 13, a drop of exactly 43. Thegenerator is the only definition site for the four numbered families. Nothing
else moved: no rewire onto
Timew/Fmt/Parse/Query, no change torender,state, or
test.luau. 25 insertions, 47 deletions._Gdoes not work here,getfenv(0)doesThe task asked for this to be verified rather than assumed, and
_Gloses.luau_host.cpp:1867-1870callsluaL_sandbox(m_L)on the main state and thenluaL_sandboxthread(m_T)on the plugin thread.luaL_sandboxthreadgives thethread a fresh globals table whose metatable
__indexis the frozen parent, andit does not set a
_Gfield on that new table - so the name_Gfallsthrough to the parent's
_G, which points at the frozen parent itself.Probed under the
luauCLI, which sandboxes identically (luaL_sandboxon theglobal state,
luaL_sandboxthreadon the run thread):getfenv(0)is the thread's own globals table - which is both where a plainfunction onDay1()statement lands and, decisively, the exact table the shell'shandler lookup reads:
LuauHost::pushCallbackdoeslua_getglobal(m_T, name)(
luau_host.cpp:3079). So the generated names are visible on precisely the paththe shell uses. Neither
getfenvnorsetfenvis stripped by the host (the onlylua_setglobalcalls near the sandbox are additions; there is no global-removallist), and adding fresh keys cannot disturb the
lua_setsafeenvimport cachebecause no handler name is ever an import.
Generation runs at chunk load, before the shell can dispatch anything.
onRow: 8 generated, not 20Deliberate:
ROWS_MAX, giving 8 names where the literals reached 20.The proof that no
onClickstring can name a missing handler is inactivityRows. It fillsshownunderlocal cap = ROWS_MAXwithif matching > listOffset and #shown < cap, so#shown <= ROWS_MAX. It thenbuilds the click targets with
for i, row in ipairs(shown)andonClick = "onRow" .. i(both the dot box and the ghost tag button), soiisbounded by
#shown <= ROWS_MAX.onRow9is a string the panel cannot produce.The count now tracks the constant, so raising
ROWS_MAXraises the handler countwith it - which the literal block did not do (it is why the block was stuck at
20 while the live cap moved to 8).
The other three are bounded the same way:
recentButtonsiteratesipairs(recent)andfetchTagscaps#recent < RECENT_MAX; the day bars runfor i = math.min(DAYS, #days), 1, -1; the pager emits"onPage" .. (p - firstShown + 1)withlastShown = math.min(pages, firstShown + PAGE_BUTTONS - 1).pageBaseis still read at click timepageBaseis a module-levellocal, so the generated closure bodygotoPage(pageBase + i - 1)reads it through an upvalue when the click arrives,exactly as
function onPage2() gotoPage(pageBase + 1) enddid. Onlyiiscaptured.
renderassignspageBase = firstShownon every pass, and the stripslides, so a base captured at generation time would page to a stale window - the
comment records that.
Comments
The API-3 comment above
startRecentis kept and extended: it still records whyhandlers are looked up by name (a click cannot carry its value at this API) and
now points at
HANDLERSas the generation site. Two new comments carry theROWS_MAXreasoning and the_G-vs-getfenv(0)finding.Static proof
Throwaway script,
requirespanel.luauand printstype(...)for every livename plus one past the end of each family. Because a required module gets its own
global table, it reaches panel's table through the env of a function panel
exports - the same table the generator wrote to.
A function for all 31 live names,
nilone past each end. The script is notcommitted.
Gate
plugin_lint.cpponly cross-checks declared-vs-read settings keys, require pathsand entry files - it has no handler-string check - so there was no new warning
class to trip. Runtime verification (reload, open panel, IPC-drive the click
paths) is the integrator's.
Generated handler names, for spot-checking against the
onClickstringsonTag1..onTag4->startRecent(i)(RECENT_MAX= 4); built atonClick = "onTag" .. iinrecentButtonsonDay1..onDay14->loadDay(i)(DAYS= 14); built atonClick = "onDay" .. i, twice per bar (hit box and bar)onRow1..onRow8->filterFromRow(i)(ROWS_MAX= 8); built atonClick = "onRow" .. i, twice per row (dot and tag button)onPage1..onPage5->gotoPage(pageBase + i - 1)(PAGE_BUTTONS= 5);built at
onClick = "onPage" .. (p - firstShown + 1)Untouched hand-written handlers (13):
onClearFilter,onScopeDay,onScopeAll,onPagePrev,onPageNext,onSearchChange,onSearchSubmit,onDraftChange,onDraftSubmit,onStop,onStartDraft,onOpen,onIpc.The 43 onTag/onDay/onRow/onPage globals were literal one-liners, so every cap was spelled out in code. They are now one HANDLERS list of { prefix, count, fn } and one loop, and a cap is data. onRow generates ROWS_MAX (8) names rather than the 20 the literals reached: activityRows caps `shown` at ROWS_MAX and numbers the buttons over ipairs(shown), so render cannot build a string past it. Assignment goes through getfenv(0), not _G: under luaL_sandbox the plugin thread inherits reads from a frozen parent table and `_G` resolves to that parent, so `_G[name] = fn` raises "attempt to modify a readonly table". getfenv(0) is the thread's own globals, which is both where a plain `function onDay1()` lands and what the shell's lua_getglobal(m_T, name) handler lookup reads.Verdict: REQUEST_CHANGES
The refactor is functionally correct. I could not construct a dead click on any path, the
onRownarrowing is safe,pageBaseis still read at click time, and the diff is clean. All of that I re-derived rather than took on trust, and it all holds.What blocks the merge is the mechanism.
getfenv(0)permanently disablessafeenvfor this plugin's globals table, which is the one thing the PR body says it does not do. That is a measured ~2.4x on import-bound work in a 1448-line UI module, spent against a hard 25 ms per-handler CPU budget, to save 22 source lines.Findings
1.
getfenv(0)disablessafeenvfor the plugin's whole lifetime — P1timew/panel.luau:946The PR body argues: "adding fresh keys cannot disturb the
lua_setsafeenvimport cache because no handler name is ever an import." The premise is true and the conclusion does not follow, because the deoptimisation does not come from the writes. It comes from thegetfenvcall itself:With
getfenv(0),getfuncresolves level 0 to the running chunk andlua_getfenvpushes that chunk's env —m_T->gt, the plugin's own globals.lua_setsafeenv(..., false)then lands on exactly that table. Nothing in Lua can set the flag back, so it is off for the rest of the plugin's life. That gates bothGETIMPORT(everyui.*,noctalia.*,math.*,table.*,string.*read) and builtin fastcall, throughoutrender,activityRows,dayColumnsand the pager.Isolated to the single line — same table writes, same workload, only
getfenv(0)vs{}differing:Hoisting the hot globals into locals is not a way out; builtin fastcall is gated on the same flag:
Why it is worth a block rather than a note: every click handler runs under a hard CPU budget, and
render()runs inside it.beginBudget/budgetDeadlineCrossed(luau_host.cpp:2833, 2656) abort the call when it is crossed.maincalls nogetfenvat all, so this headroom is spent by the patch. I did not measurerender()itself — that needs the live host, which is out of scope for a review — so I am reporting the multiplier, not an absolute. The multiplier is enough: it is permanent, module-wide, and was costed at zero.setfenvis not an escape hatch; it clears the same flag (lbaselib.cpp:140).There is no third way in pure Lua. Under
luaL_sandboxthreada runtime-computed global name requires a reference to the globals table, andgetfenv/setfenvare the only routes to it. So the choice is:(a) Merge-now fix. Revert to the literal
function onTagN()/onDayN()/onRowN()/onPageN()statements.SETGLOBALwrites tom_T->gtwithout ever materialising the table, so it costs nothing. Keep the win the PR was actually after — killing the cap duplication — with an assertion intest.luauinstead of a runtime generator:That is the cap-as-data property with none of the runtime cost, and it fails loudly on exactly the drift the generator was built to prevent. Note
getfenvin a throwaway test process is free; the objection is only to it running inside the shipped plugin.(b) The durable fix. Move the panel to
plugin_api = 4. A function-valued handler prop is registered directly:and dispatched through
@ui_handlers(luau_host.cpp:3084-3090,ui_handler_table.h:18-19). That deletes the numbered handlers, the caps-to-handler-count coupling, the generator andgetfenvin one move — the entire problem T1 was assigned. The installed shell is v5.0.0 and supports API 4. Out of scope for this PR, but it is where this should go, and it is worth knowing before this pattern gets copied intobar.luau, whose timer runs far hotter than the panel's.2.
HANDLERSis a one-consumer data structure — P3timew/panel.luau:931-951Ruling on the weight question, since the task asked for one explicitly: the four-entry table is heavier than what it does. It is read in exactly one place, 16 lines below its own definition, and it buys nothing a helper does not. Adding a family costs one line either way. It also costs a
family.prefix/family.count/family.fnhash lookup where a helper has upvalues, includingfamily.fnon every click.12 code lines become 10, one nesting level disappears, and the three field names per entry stop being written out:
Moot if finding 1 is resolved by (a).
3.
HANDLERSsnapshotsfnby value, adding a silent ordering constraint — P3timew/panel.luau:933loadDayis a forward-declared local (:337) assigned at:756. Line 933 reads its value, so the table holds a snapshot. The literal it replaced —function onDay1() loadDay(1) end— heldloadDayas an upvalue and resolved it at call time, so the order of the two blocks did not matter. Now it does.Correct today, because 756 precedes 931. But the constraint is new, unwritten, and unenforced by lint or tests. Move the
HANDLERSblock up beside the other handlers around:880— exactly where a tidy-up would put it — or moveloadDaydown, andfnisnil: everyonDayNclick raisesattempt to call a nil value, with nothing catching it. Wave 2 has concurrent tasks editing this file.Same file, only the order of two blocks differing:
One comment line is a proportionate fix — the
generate()form in finding 2 has the same property, so the note is worth having either way:Verified
Both gates re-run in the author's worktree at
cc0178f:Diff scope, and the excluded files:
.git/info/excludecarries*.md,secretspec.toml,forge.sh,.worktrees/; none are tracked and none appear in the diff. One file changed, one commit.Sandbox behaviour, probed rather than assumed. Both halves of the
getfenv(0)claim hold:_Gis not the thread's globals table and rejects writes;getfenv(0)is, accepts them, and is where a plainfunctionstatement lands. Confirmed against the host:plugin.tomldeclaresentry = "panel.luau"under[[panel]], and an entry is loaded and run onm_T—luau_load(m_T, ...)(luau_host.cpp:3063),lua_pcall(m_T, ...)(:2873), with nolua_setfenvon that path. Sogetfenv(0)inside the chunk ism_T->gt, the tablelua_getglobal(m_T, name)reads at:3079. The names are also plain, so they take that branch rather than the@ui:one (ui_handler_table.h:19).Handler coverage, derived independently from the four producers. Caps read out of the source, not the PR body:
onClicksiteonTagrecentButtons:998"onTag" .. ioveripairs(recent)fetchTagsguards#recent < RECENT_MAX(:719); sole writer ofrecentonDaydayColumns:1054,:1061"onDay" .. ifor i = math.min(DAYS, #days), 1, -1(:1031)onRowactivityRows:1112,:1126"onRow" .. ioveripairs(shown)local cap = ROWS_MAX(:1086) +#shown < cap(:1091)onPage:1330"onPage" .. (p - firstShown + 1)forp = firstShown, lastShownlastShown = math.min(pages, firstShown + PAGE_BUTTONS - 1)(:1309)Non-numbered click strings —
onPagePrev(:1318),onPageNext(:1343),onClearFilter(:1225),onScopeDay/onScopeAll(viascopeButton,:1244),onStop/onStartDraft(:1203) — plus the non-click callback propsonDraftChange/onDraftSubmit(:1178-1179) andonSearchChange/onSearchSubmit(:1290-1291). All 13 hand-written handlers survive.Checked in both directions, against the loaded module's own globals table:
31 = 4 + 14 + 8 + 5. No producible name is missing, nothing survives past its cap, and no numbered global exists outside the four families.
The pager, swept rather than argued.
firstShown/lastShown/onClickreplicated verbatim from:1301-1330and:1355over every reachable(pages, current, p):The last-page clamp is the case worth naming: at
current == pages(forpages >= 5),firstShown = max(1, min(pages-2, pages-4)) = pages-4,lastShown = min(pages, pages) = pages, so the final button emitspages - (pages-4) + 1 = 5. ExactlyPAGE_BUTTONS, never past it.pageBaseread at click time. The generated body isfunction(i) gotoPage(pageBase + i - 1) end;pageBaseis the module-level local from:228, so it is an upvalue, and onlyiis captured. Luau gives each numeric-foriteration a freshi, so the indices do not collide. Demonstrated on the same closure shape, reassigning the base the wayrenderdoes at:1355:Answers
2. The
onRownarrowing from 20 to 8 — safe. No objection.Verified three ways, independently of the PR body. Statically,
activityRows(:1082-1094) fillsshownunderlocal cap = ROWS_MAXwith#shown < cap, then numbers buttons overipairs(shown), so the largest emitted index is#shown <= 8. Dynamically, the enumeration above showsonRow9absent and no producer able to name it. And onmainthe extra twelve were already unreachable:maindefinesonRow1..onRow20butROWS_MAXis already 8 there, soonRow9..20were dead weight, not live surface. The narrowing removes handlers that nothing could ever call.On the IPC path specifically —
noctalia msg plugin yuris/timew:panel all row 12is unaffected, becauseonIpcdoes not route through the globals at all:It calls the local directly.
filterFromRow(12)readsshown[12], getsnil, and itsif row thenguard makes it a no-op. Identical before and after:Query.window'scapargument is not a leak either. It honours whatever cap it is given, andtest.luau:94-95already pins that (assert(#win == 3 and count == 10, ...)), so a future rewire ofactivityRowsonto it stays bounded as long as the caller passesROWS_MAX— the same invariant, already covered.One consequence worth stating plainly: the patch removes twelve handlers of slack, so
#shown <= ROWS_MAXis now load-bearing for handler existence with zero margin. That is the right design — the count should track the constant — but it is a good reason to want thetest.luauassertion from finding 1(a), which pins it mechanically instead of by reading two functions.3.
getfenv(0)— correct, and the wrong mechanism to ship.Both halves of the correctness claim hold, and I verified them rather than taking them: the write succeeds at chunk load, and it lands in the same table
lua_getglobal(m_T, name)reads, because an entry chunk is loaded and executed onm_Twith nolua_setfenvin the way. The author'sluau_host.cppcitations are accurate._Ggenuinely loses.The deprecation risk is the smaller half of the problem and I would not block on it:
getfenvis deprecated in Luau but present, the host strips nothing (the onlylua_setfenvuses are in therequirepath, which entries do not take), and if a future shell did remove it the plugin would fail loudly at load rather than silently — 31 dead handlers is not a subtle symptom.What I object to is the cost, which is finding 1: the call permanently deoptimises the module, and the PR body's analysis of
safeenvaddresses the writes rather than the call, so the trade was never actually priced. What I would do instead: revert to literal statements and move the cap invariant intotest.luau(1a), which keeps every stated goal of the task — the caps stop being spelled twice, drift fails a gate — at zero runtime cost. Longer term, API 4 (1b) deletes the numbered-handler scheme outright, and that is the change actually worth making.5.
HANDLERS— heavier than it needs to be.Ruling: four small
generate(...)calls beat the four-entry table. The table has exactly one consumer sixteen lines below it, adding a family costs one line either way, and{ prefix = , count = , fn = }spells out three field names per entry that positional arguments make free — plus afamily.fnlookup on every click. See finding 2. It is a P3: the direction of the PR is right and 25-replacing-47 is a real improvement, the specific shape is just two lines and one indirection heavier than the simplest thing that works.6. Diff scope and the API-3 comment — both good.
One file, one commit, no excluded file tracked or committed. The API-3 comment survives at
:873-875and the extension is accurate: handlers still cannot carry a value at this API, andHANDLERSreally is the generation site. The retained clause "which also caps every list" now reads slightly against the grain — the handler block used to set the cap and now follows it — but the appended sentence "so a cap is data, not 43 lines" says exactly that, so the pair is not misleading. No change needed.Finally, credit where it is due: the static proof in the description is sound. I rebuilt it from scratch, in both directions and with the caps read out of the source rather than the prose, and it came back clean. The functional half of this PR is well argued; it is the mechanism's price tag that needs revisiting.
Fix pushed as
f5b248d. The block stands, and the review is right about where the cost was hiding: the PR body priced the writes againstsafeenvand never priced the call.Accepted: finding 1, option (a)
The generator and
getfenv(0)are gone. The four numbered families are literalfunction onTagN() ... endstatements again, which compile toSETGLOBALand never materialise the globals table, so thelua_setsafeenv(L, -1, false)atlbaselib.cpp:131is never reached andsafeenvstays on for the module's life.GETIMPORTand builtin fastcall keep working throughrender,activityRows,dayColumnsand the pager, which is what the 25 ms budget atluau_host.cpp:2113is spent on.Zero, not "zero calls" — the word does not appear in the file at all, including in prose, so the gate is a plain grep with nothing to whitelist. The comment cites the two
lbaselib.cpplines as "the two*fenvenv-reflection builtins" for exactly that reason.Kept: the
onRownarrowing (your answer 2)onRow1..8,onRow9..onRow20deleted. This is now the only behavioural change in the PR, so I re-derived the bound in one pass rather than leaning on either the PR body or the review:shownhas exactly two writers, both inactivityRows:shown = {}(:1083pre-fix) and the singletable.insert(shown, row)guarded bymatching > listOffset and #shown < capunderlocal cap = ROWS_MAX. So#shown <= 8unconditionally.onClick = "onRow" .. isites are the dot box and the ghost tag button, both insidefor i, row in ipairs(shown), so the largest producible index is#shown.onIpcdoeselseif event == "row" then filterFromRow(tonumber(payload) or 1), calling the local directly.filterFromRow(12)readsshown[12], getsnil, and theif row thenguard makes it a no-op.noctalia msg ... row 12behaves identically before and after.The narrowing removes handlers nothing could call:
ROWS_MAXwas already 8 onmain, soonRow9..20were dead there too.Kept: the documentation
The API-3 comment survives and carries the reasoning that was scattered across the PR description, so the next person does not redo this:
RECENT_MAX/DAYS/ROWS_MAX/PAGE_BUTTONS, and the matching block must be extended by hand if a constant moves, orrenderemits anonClicknaming nothing and the click is silently dead;lbaselib.cpp:131/:140citation, the ~2.4x measurement, and the 25 ms budget atluau_host.cpp:2113;plugin_api = 4deletes the scheme outright via function-valuedonClick.The
onRowblock keeps its own comment naming theROWS_MAXbound and the IPC no-op, since that is the pair someone will re-check first. ThepageBase-read-at-click-time note moved onto theonPageliterals where it belongs.Moot: findings 2 and 3
Both were properties of the generator, so they died with it, and I would rather say that than pretend to fix them:
HANDLERSis a one-consumer data structure) — there is no table and nogenerate()helper. Ruling noted for the next time this shape is proposed.fncaptured by value, silent ordering constraint) — gone by construction.function onDay1() loadDay(1) endholdsloadDayas an upvalue and resolves it at call time, so the order of the handler block andloadDay's assignment is once again irrelevant. Given Wave 2 has concurrent tasks in this file, losing that constraint entirely beats documenting it.Follow-up, not this PR
Your 1(a) suggestion to pin the caps in
test.luauis the right call and is not dropped — it is scheduled for after #5 merges, which ownstest.luauand is still open. The invariant to pin is the one this PR made load-bearing with zero margin:type(g[prefix .. i]) == "function"fori <= capandnilatcap + 1, foronTag/onDay/onRow/onPage.getfenvin the test process is free; the ban is only on the shipped entry file.Coverage proof
Throwaway script, not committed. It
requires the module and reaches its globals through the env of an exported function — the same table thefunctionstatements wrote to — and checks one past each family's end.getfenvis in the harness, which is the distinction your 1(a) draws:31 = 4 + 14 + 8 + 5,
nilone past every cap, no numbered global outside the four families, and all 13 hand-written handlers (onClearFilter,onScopeDay,onScopeAll,onPagePrev,onPageNext,onSearchChange,onSearchSubmit,onDraftChange,onDraftSubmit,onStop,onStartDraft,onOpen,onIpc) intact.Gate
44 = 13 hand-written + 31 numbered.
test.luauuntouched (#5 owns it). The net diff againstmainis now the comment expansion plus the twelve deletedonRowlines — the PR no longer removes 43 handler lines, because keeping them literal was the point of the fix. Runtime verification (reload, panel, IPC click paths) is the integrator's.