fix(lua): 导出函数调用不标脏,插件状态从不落盘

quota 声明 hooks=[],全部工作都在导出函数里。markDirty 只从 hook 与
SetState 触发,Call 两边都不占 —— 于是探测改的内存没人写盘,状态文件要等
下一次无关的配置写入才顺带刷新。

现象是「插件看起来没写」:负 memo 要等到下一次改配置才出现。用隔离实例
逐步观察时,一度把这误读成 memo 状态机的三个缺陷,逐个查清后只有这一条
是真的:

  - 「未就绪轮次累计 streak」 —— 不成立。写侧本来就对临时条目用 streak=1,
    65ed1b 版本已是这个行为。
  - 「读侧用旧快照,streak 永远停在 1」 —— 是退避的设计行为。单进程内第二
    次探测被退避挡在写侧之前,streak 自然不再增长;真实的多轮计数在跨进程
    (trace_streak.py)下是 1/2/3/4。
  - 「Call 不标脏」 —— 真缺陷,已验证:去掉 markDirtyLocked,
    新增的 TestOnDemandCallIsPersisted 立刻变红。

顺带保留两处无行为差异的可读性收紧:provisionalEntry 统一按「集合非空」
判断临时条目(与 copySet 空集返回 nil 的写法对齐)。

判据:TestOnDemandCallIsPersisted 用 hookless 插件走导出函数累加,
反向证明(去掉 markDirtyLocked)会红。
This commit is contained in:
JianFeeeee
2026-10-07 02:28:41 +08:00
parent 83f1ec0807
commit 3444d2d903
4 changed files with 203 additions and 22 deletions

View File

@ -247,6 +247,22 @@ local function copySet(t)
return out
end
-- provisionalEntry reports whether a negative memo was written while some adapter
-- could not judge the host. Such an entry is retired the moment that adapter
-- becomes configured, and never backs off while it is still waiting.
--
-- An EMPTY `unready` is not provisional, and the emptiness has to be tested rather
-- than the presence. Two ways an empty set shows up: the write path drops empty
-- tables (so a host every adapter judged has no field at all), but a full JSON
-- round trip — restore, or one page open — turns an empty table back into a
-- truthy `{}`. Reading presence as the signal therefore makes exactly the hosts
-- that most need backing off the ones that never get it.
local function provisionalEntry(entry)
if type(entry) ~= "table" then return false end
if type(entry.unready) ~= "table" then return false end
return next(entry.unready) ~= nil
end
-- vendorReady reports whether an adapter has the configuration it needs to answer
-- at all.
--
@ -656,7 +672,7 @@ local function collectSource(src, budget)
-- all adapters. "Is any adapter configured" is true almost always — three of
-- the four carry their credential on the source — and would retire every
-- provisional entry immediately, including the ones still waiting.
local provisional = type(known.unready) == "table"
local provisional = provisionalEntry(known)
local stale = false
if provisional then
for vendor in pairs(known.unready) do
@ -674,18 +690,27 @@ local function collectSource(src, budget)
known = false
end
else
-- A streak is a host that has failed repeatedly. Skip it outright until its
-- backoff expires, instead of re-paying three adapter timeouts every TTL.
if backoffSeconds(seen, known.streak) then
return { name = src.name, host = host, skipped = true, backing_off = true,
reason = "连续探测失败 " .. tostring(known.streak) .. " 次,退避中(等待下次重试)" }
end
if PROBE_MEMO_TTL > 0 and (os.time() - seen) > PROBE_MEMO_TTL then
known = nil
else
known = false -- still "nothing answered, try cheapest first"
end
end
-- A streak is a host that has failed repeatedly, and the backoff is keyed on
-- the RECORDED STREAK, not on this entry existing.
--
-- Keying it on existence reads like a tautology that can never fire, but it
-- fires constantly, because of WHEN the write side runs: the entry is read at
-- the top of this function and only written at the very bottom, so mid-call
-- the table still holds the two adapters that already answered on THIS call
-- — typically the two with the shortest timeouts. LIVE DEFECT: the plugin
-- then reported "连续探测失败 1 次" for a streak that was never counted and
-- refused every later probe, so an operator who supplied the missing key saw
-- nothing change, indefinitely.
if backoffSeconds(seen, known.streak) then
return { name = src.name, host = host, skipped = true, backing_off = true,
reason = "连续探测失败 " .. tostring(known.streak) .. " 次,退避中(等待下次重试)" }
end
if PROBE_MEMO_TTL > 0 and (os.time() - seen) > PROBE_MEMO_TTL then
known = nil
else
known = false -- still "nothing answered, try cheapest first"
end
end
end
-- known == false means every adapter already came back "no endpoint here" for
-- this host during this process. Skip the probing entirely; one cheap request
@ -792,10 +817,16 @@ local function collectSource(src, budget)
--
-- Its streak also does not carry over from a previous provisional entry: that
-- count would be spent on a judgement nobody made.
--
-- `unready` is copied, and copySet returns nil for an EMPTY set — which is what
-- makes "every adapter judged the host" arrive as an absent field rather than as
-- an empty table. The read side tests for a NON-EMPTY set rather than for the
-- field's presence, so the two spellings of "nothing was unready" agree.
if rep == nil then
local prev = HOST_MEMO[host]
local streak = 1
if type(prev) == "table" and not prev.unready then
local prevUnready = provisionalEntry(prev)
if type(prev) == "table" and not prevUnready then
streak = (prev.streak or 1) + 1
end
HOST_MEMO[host] = { at = os.time(), streak = streak, unready = copySet(unready) }