Skip to content

Refuse a destroy through a stale reference on Lua - #1593

Merged
Frotty merged 4 commits into
masterfrom
claude/lua-stale-destroy-guard
Nov 2, 2026
Merged

Frotty merged 4 commits into
masterfrom
claude/lua-stale-destroy-guard

Conversation

@Frotty

@Frotty Frotty commented Nov 2, 2026 •

Copy link
Copy Markdown
Member

Why

On Lua every class shares one id space, and an allocation hands out the most recently freed id. A destroy through a stale reference therefore runs the destructor of whatever object holds that id now, of any class, frees it while it is in use, and the next allocation shares the id. WurstStdlib2 #522 (StdlibIngameAllocatorTests.testStaleDestroyPreservesCodeList, commit 6cd0011) shows this in the real game: a stale owner reference and a live CodeList both had id 8104, and after the stale destroy original.run() called the replacement's callback.

What

  • Checked at the destroy, not in the deallocator. Each destroy on Lua evaluates its receiver once into a local and, if it is non-null and not instanceof the type the caller destroys it as, calls __wurst_staleDestroy instead of destroying. The check runs before dispatch picks a destructor and before any ondestroy, so a refused destroy runs no destructor and frees nothing. It is emitted in IM, so devirtualisation and inlining keep it.
  • Report. The failure path records count|id|actual class|destroyed as|file:line and raises Stale destroy: object N is a X, destroyed as a Y at File.wurst:L. A map reads the record through staleDestroyReport() returns string, matched by declaration like the CodeList intrinsics. Its source body (Jass, older compilers) should return "".
  • Double destroy unchanged. A free id passes the check and keeps the deallocator's "Double free" error.
  • Class names are emitted on class tables only when a program has a checked destroy.

Not covered

  • Same-class reuse. A stale reference whose id now holds an object of the same class, or a subclass, passes. The check sees classes, not lifetimes.
  • Allocator parity. Jass keeps a connected id pool per class and interface group, so a stale reference there names an object of a related type. Lua keeps one global allocator for all classes. This PR checks only Lua and does not make the two agree.

Tests

LuaBackendAuditTests:

  • aStaleDestroyOfAnotherClassIsAnError
  • aStaleDestroyFreesNothingAndIsReported
  • aStaleDestroyRunsNoDestructor and its Optimised variant (inline + local optimisations), covering base-class, interface and generic-class references, a receiver evaluated once, and valid destroys through class, base class and interface each running their destructor exactly once
  • aDoubleDestroyKeepsItsError
  • destroyThroughASuperclassReferenceIsNotStale

The full suite passed locally except one hot-reload position test. That is fixed in this push by treating the site string as a stack position; CI is the check for it.

Not done yet: the stdlib in-game regression (#522) on the compiler before and after this change.

Lua hands out the most recently freed id from one id space for every
class, so a destroy through a stale reference frees whatever object holds
that id now, of any class, and the next allocation shares it with the live
one. The deallocator now receives the class the object is destroyed as and
raises before freeing an object of another class. It records the destroy
first, and the new staleDestroyReport intrinsic lets a map read the record
(count|class|destroyed as) after the raising thread has ended.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-02T14:15:07.841181Z e44f50c Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

The deallocator ran after the destructor, and a destroy through dispatch
picked the live replacement's destructor first, so a stale destroy still
ran another object's ondestroy before it was refused. Each destroy on Lua
now evaluates its receiver once and checks it against the type the caller
destroys it as, before the destroy call. A failure records
count|id|class|destroyed as|file:line and raises; a free id goes on to the
deallocator's double-free error as before.
@Frotty

Frotty commented Nov 2, 2026

Copy link
Copy Markdown
Member Author

In-game regression, before and after this change.

Setup

asserts allocator checks after stale destroy
v2.1.0-w3p.2 146/149 all 3 fail (stale typed destroy is rejected, stale destroy cannot replace a live CodeList, independent CodeLists retain independent callbacks) stale id 8104 = live list id 8104; original=1; replacement=1
v2.1.0-w3p.2 + this PR 149/149 all pass stale id 8104 = live list id 8104; original=2; replacement=0

Verdict: both runs still report a verdict problem from the fixture's caught-error accounting. The baseline reports caught-errors-7-expected-1, although the fixture declares expectCaughtErrors(7). The guarded run reports caught-errors-8-expected-1, the extra one being the refused stale destroy. The fixture needs to account for that intentional error.

Artifacts: construction-lifetime-2026-11-02T11-39-48-902Z (baseline) and construction-lifetime-2026-11-02T11-40-30-027Z (guarded).

The site was an IM literal, so the incremental cache kept the old line after an
edit moved the destroy (IncrementalHotReloadCompilationTests). It is now taken
from the call's trace in the Lua translation.
@Frotty

Frotty commented Nov 2, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 53dde2827d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

A parameterized instanceof observes an instantiation's identity and makes
EliminateGenerics specialise for it; with a recursive generic class
(Chain<Chain<T>> destroyed in ondestroy) the worklist never finished. The
check names the erased class, which every instantiation is an instance of.
@Frotty

Frotty commented Nov 2, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep them coming!

Reviewed commit: e44f50c2d6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@Frotty
Frotty merged commit a249d31 into master Nov 2, 2026
2 checks passed
@Frotty
Frotty deleted the claude/lua-stale-destroy-guard branch November 2, 2026 14:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant