Skip to content

bug(lua-pool): numeric-keyed globals escape pool baseline reset on all runtimes; pine-java snapshotKeys also uses coercion isstring() (#175 family) #177

Description

@Liam0205

Summary

Follow-up from the #175 review (PR #176, finding 175-run1-M2). Two related facts, one per layer:

  1. Cross-runtime: numeric-keyed globals written by a script (_G[42] = ...) escape the Lua pool's baseline reset on all three runtimes. Every implementation snapshots and clears string keys only, so a numeric-keyed global set during one borrow is still visible to the next borrow of the same pooled state — a hole in the pool state-isolation contract.
  2. pine-java only: TransformByLua.LuaPool.snapshotKeys (pine-java/src/main/java/page/liam/pine/operators/TransformByLua.java:429) filters keys with the coercion predicate k.isstring() — the same luaj trap family as bug(pine-java): TransformByLua.fromLua corrupts numeric strings — isnumber() coercion turns 19-digit ID strings into precision-lost longs, diverging from Go #175 (LuaInteger.isstring() is always true). A numeric key 42 is collected as the phantom string "42", and cleanup then calls g.set("42", NIL) which clears the string slot while the numeric slot _G[42] survives. The bookkeeping touches the wrong slot; the net observable behavior happens to match Go/C++ (leak), but the predicate is wrong for the same reason bug(pine-java): TransformByLua.fromLua corrupts numeric strings — isnumber() coercion turns 19-digit ID strings into precision-lost longs, diverging from Go #175 was.

Per-runtime evidence

pine-java (standalone repro, luaj-jse 3.0.1):

Globals g = JsePlatform.standardGlobals();
g.load("_G[42] = 'numeric-slot'; leaked_str = 'x'").call();
// snapshotKeys-style iteration: k.isstring() is true for the numeric key 42
// -> collected as "42"; cleanup does g.set("42", NIL)

Output:

collected contains "42": true
_G["42"] (string slot): nil
_G[42]   (numeric slot): numeric-slot
after cleanup, _G[42] numeric slot: numeric-slot  <- leaked
after cleanup, leaked_str: nil

pine-go / gopher-lua (pool_gopher_lua.gosnapshotGlobals/resetToBaseline iterate glua.LString keys only; numeric keys are skipped in both passes):

after cleanup, _G[42] numeric slot: numeric-slot  <- leaked
after cleanup, leaked_str: nil

pine-go / wangshu (v0.2.0): documented as a deliberate limitation — MarkGlobalsBaseline godoc: 「限定:仅快照字符串 key——stdlib 与宿主自己的全局都是字符串 key,数字/表/函数等 key 跳过(非典型,实际场景不存在)」. ResetGlobalsToBaseline likewise only deletes/restores string keys.

pine-cpp (src/lua/lua_bridge.cpp:29 and :54): both LuaSnapshot's constructor and reset_to_baseline gate on lua_type(L, -2) == LUA_TSTRING; numeric keys are skipped in both the baseline set and the removal scan. Same leak by source reading (not separately executed).

Impact

Narrow. Normal operator paths only ever create string-identifier globals (function names from lua_script, input fields via set_global(field, ...)), so the only way to hit this is a script explicitly writing _G[<number>] = .... Consequences when hit:

  • state leaks across borrows of the same pooled VM (violates the isolation contract that baseline reset otherwise enforces — script hijack/leak of string globals is correctly wiped);
  • leaked values are per-state, so behavior depends on which pooled state a request happens to borrow — a nondeterminism source for diff-fuzz if a generator ever emits numeric-global writes (none do today).

Options

  • A (align + hygiene, cheapest): adopt wangshu's stance as the documented cross-runtime contract ("baseline reset covers string-keyed globals only; numeric/table/function keys are out of contract"), record it in llmdoc/reference/operator-contract.md next to the existing Lua bridge conventions, and fix the pine-java predicate to k.type() == LuaValue.TSTRING so the bookkeeping stops collecting phantom keys (mirrors the bug(pine-java): TransformByLua.fromLua corrupts numeric strings — isnumber() coercion turns 19-digit ID strings into precision-lost longs, diverging from Go #175 fix pattern; behavior-neutral today but removes the coercion trap from the remaining call site).
  • B (close the hole everywhere): extend snapshot/reset on all three runtimes to track non-string keys (needs a key-encoding scheme or a parallel non-string key set; wangshu would need an upstream API change since its baseline is string-only). Higher cost, questionable value given the trigger surface.

Option A seems right unless someone has a real script pattern that writes numeric globals.

Refs

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions