diff --git a/ARES.bt b/ARES.bt index 2a88730e8..b700db56c 100644 --- a/ARES.bt +++ b/ARES.bt @@ -34,6 +34,15 @@ const uchar UTAG_LLEVENTS = 29; const uchar UTAG_LLTIMERS = 30; const uchar UTAG_STRBUF = 31; const uchar UTAG_OPAQUE_BUFFER = 32; +const uchar UTAG_YIELD_STATE = 33; + +/* One SlotManager's region of a UTAG_YIELD_STATE buffer. The innermost flag + * says whether this was the deepest active manager. */ +typedef struct { + uint16 length; /* Slots plus the innermost flag */ + uchar slots[length - 1]; + uint8_t innermost; +} YieldRegion; /* Mirrors enum AresType in VM/src/ares.cpp. These are wire values and are * assigned explicitly there - do not derive them from Luau's lua_Type. */ @@ -800,6 +809,22 @@ typedef struct { uchar data[used.v]; break; } + case UTAG_YIELD_STATE: + { + UVarint length; + local int64 end = FTell() + length.v; + uint8_t abi_version; /* The frame's continuation must answer with at least this */ + local int innermost = 0; + while (!innermost) + { + YieldRegion region; + innermost = region.innermost; + } + /* A buffer reused from an earlier yield can be larger than the chain */ + if (FTell() < end) + uchar slack[end - FTell()]; + break; + } case UTAG_OPAQUE_BUFFER: default: { diff --git a/VM/include/llsl.h b/VM/include/llsl.h index f71659d78..0f2f06eb8 100644 --- a/VM/include/llsl.h +++ b/VM/include/llsl.h @@ -32,6 +32,12 @@ enum class YieldableStatus : uint8_t { #define UTAG_LLTIMERS 30 #define UTAG_STRBUF 31 #define UTAG_OPAQUE_BUFFER 32 +// A yieldable C function's slot buffer, see lyieldable.h +#define UTAG_YIELD_STATE 33 + +// Passed as the status to a yieldable's continuation to get its ABI version +// back instead of a resume +#define LUA_YIELDABLE_ABI_QUERY (-1) // Internal global names for event/timer managers (hidden from user code) #define LLEVENTS_GLOBAL_NAME "/$ LLEvents" diff --git a/VM/include/lua.h b/VM/include/lua.h index ec5ceea32..4ca4a3ea7 100644 --- a/VM/include/lua.h +++ b/VM/include/lua.h @@ -746,7 +746,7 @@ static void populateperms(lua_State *L, bool forUnpersist) eris_persist_static(strlib, gmatch_aux) #endif #if defined(eris_c) || defined(lyieldstrlib_c) - eris_persist_cont(lyieldstrlib, yieldable_gmatch_aux_v0, yieldable_gmatch_aux_v0_k) + eris_persist_cont(lyieldstrlib, yieldable_gmatch_aux, yieldable_gmatch_aux_k) #endif #if defined(eris_c) || defined(lutf8lib_c) eris_persist_static(utf8lib, iter_aux) @@ -762,12 +762,12 @@ static void populateperms(lua_State *L, bool forUnpersist) eris_persist_static_cont(corolib, auxwrapy, auxwrapcont) #endif #if defined(eris_c) || defined(lllevents_c) - eris_persist_static_cont(llevents, llevents_handle_event_v0, llevents_handle_event_v0_k) + eris_persist_static_cont(llevents, llevents_handle_event, llevents_handle_event_k) eris_persist_static_cont(llevents, llevents_once_wrapper, llevents_once_wrapper_cont) eris_persist_static(llevents, timer_wrapper_guard) #endif #if defined(eris_c) || defined(llltimers_c) - eris_persist_static_cont(llltimers, lltimers_tick_v0, lltimers_tick_v0_k) + eris_persist_static_cont(llltimers, lltimers_tick, lltimers_tick_k) eris_persist_cont(llltimers, timer_event_wrapper, timer_event_wrapper_cont) #endif #if defined(eris_c) || defined(llprim_c) diff --git a/VM/include/lyieldable.h b/VM/include/lyieldable.h index c093e1e78..012ab9e78 100644 --- a/VM/include/lyieldable.h +++ b/VM/include/lyieldable.h @@ -2,7 +2,7 @@ // // Slots are C++ primitives cached locally via PrimitiveSlot. No stack // interaction on init. On yield, destructors pack cached values into a -// an opaque tagged userdata (UTAG_OPAQUE_BUFFER) at position 1. +// a tagged userdata (UTAG_YIELD_STATE) at position 1. // // This takes a lot of inspiration from UThreadInjector's instrumentation style, // but makes it explicit via macros. @@ -15,6 +15,7 @@ #pragma once #include "lua.h" +#include "llsl.h" #include "Luau/Common.h" #include @@ -27,6 +28,10 @@ static_assert(sizeof(bool) == 1, "DEFINE_SLOT(bool) requires sizeof(bool) == 1") namespace Luau { +// Buffer layout: [u8 abi version][u16 region length][slots...][u8 innermost], +// then each child region as [u16 region length][slots...][u8 innermost]. +inline constexpr size_t kYieldStateHeader = sizeof(uint8_t) + sizeof(uint16_t); + // Forward declaration — slot destructors access SlotManager members. class SlotManager; @@ -72,15 +77,22 @@ class SlotManager ptrdiff_t bufferStackOffset; bool initMode; + uint8_t abiVersion = 0; bool finalized = false; SlotManager* parent = nullptr; SlotManager* currentChild = nullptr; - // Byte 0 is reserved for the version tag; bytes 1-2 for the region length. - // Slot data starts at offset 3 for the root manager. - size_t baseOffset = sizeof(uint8_t) + sizeof(uint16_t); + // Where the base of this frame starts in the buffer + size_t baseOffset = kYieldStateHeader; uint16_t regionLengthOffset = sizeof(uint8_t); uint16_t innermostOffset = 0; + // Where this manager's region sits in the buffer being resumed from, + // which need not match where it will be written + size_t readBaseOffset = kYieldStateHeader; + uint16_t storedLength = 0; + + // Resume only: reads and bounds-checks this manager's stored region length + void readStoredLength(); public: // Set by flushForYield(). Slot destructors check this to decide @@ -98,7 +110,7 @@ class SlotManager lua_Callbacks* callbacks = nullptr; // Root constructor: pushes nil at position 1 (init) or reads buffer (resume). - SlotManager(lua_State* L, bool is_init); + SlotManager(lua_State* L, bool is_init, uint8_t abi_version); // Child constructor: chains to parent, shares buffer. // Force-inlined: called per inner-loop iteration in match helpers. @@ -114,6 +126,9 @@ class SlotManager bool isInit() const { return initMode; } + LUA_NORETURN void corrupt() const; + LUA_NORETURN void tooLarge() const; + // Lock the slot layout. Must be called after all slots are allocated // and before YIELD_DISPATCH_BEGIN. // Force-inlined: called per inner-loop iteration in match helpers. @@ -161,14 +176,14 @@ inline PrimitiveSlot SlotManager::allocSlot(T* storage) static_assert(std::is_trivially_copyable_v, "PrimitiveSlot requires a trivially copyable type"); LUAU_ASSERT(!finalized); auto off = static_cast(baseOffset + requiredSize); - requiredSize += sizeof(T); - if (!initMode) - { - LUAU_ASSERT(off + sizeof(T) <= bufferSize); - memcpy(storage, bufferData + off, sizeof(T)); - } + // If there's not enough data left in the buffer, we're best off just leaving the + // default there. This is intentionally a non-fatal error, intended to support appending + // optional slots without ABI version bumps. + if (!initMode && requiredSize + sizeof(T) < storedLength) + memcpy(storage, bufferData + readBaseOffset + requiredSize, sizeof(T)); + requiredSize += sizeof(T); return PrimitiveSlot{storage, off, this}; } @@ -179,6 +194,16 @@ inline PrimitiveSlot::~PrimitiveSlot() memcpy(mgr->bufferData + offset, data, sizeof(T)); } +inline void SlotManager::readStoredLength() +{ + size_t length_at = readBaseOffset - sizeof(uint16_t); + if (readBaseOffset > bufferSize) + corrupt(); + memcpy(&storedLength, bufferData + length_at, sizeof(uint16_t)); + if (storedLength < 1 || readBaseOffset + storedLength > bufferSize) + corrupt(); +} + // Child constructor. Chains to parent, sharing the same buffer region. // initMode is determined by parent.innermost: if parent is innermost // (no child data in buffer), this is a fresh call; otherwise, resume. @@ -186,6 +211,7 @@ LUAU_FORCEINLINE SlotManager::SlotManager(SlotManager& parent) : L(parent.L) , bufferStackOffset(parent.bufferStackOffset) , initMode(parent.innermost) + , abiVersion(parent.abiVersion) , parent(&parent) , baseOffset(parent.baseOffset + parent.requiredSize + sizeof(uint16_t)) , regionLengthOffset(static_cast(parent.baseOffset + parent.requiredSize)) @@ -200,6 +226,8 @@ LUAU_FORCEINLINE SlotManager::SlotManager(SlotManager& parent) { bufferData = parent.bufferData; bufferSize = parent.bufferSize; + readBaseOffset = parent.readBaseOffset + parent.storedLength + sizeof(uint16_t); + readStoredLength(); } } @@ -232,23 +260,25 @@ LUAU_FORCEINLINE void SlotManager::finalize() innermostOffset = static_cast(baseOffset + requiredSize); requiredSize += sizeof(uint8_t); LUAU_ASSERT(requiredSize <= UINT16_MAX); + // Every offset in this region, including the ones allocSlot already + // truncated, sits below the region's end, so this is the one place the + // uint16 layout has to be checked. Nothing has been written through them + // yet: finalize precedes every yield point. + if (baseOffset + requiredSize > UINT16_MAX) + tooLarge(); finalized = true; if (!initMode) { - // Validate that the stored region length matches what we expect. - uint16_t storedLength; - memcpy(&storedLength, bufferData + regionLengthOffset, sizeof(uint16_t)); - LUAU_ASSERT(storedLength == requiredSize); - - // Read innermost flag from buffer. - LUAU_ASSERT(innermostOffset + sizeof(uint8_t) <= bufferSize); - memcpy(&innermost, bufferData + innermostOffset, sizeof(uint8_t)); + // The stored region was bounds-checked when its length was read + memcpy(&innermost, bufferData + readBaseOffset + storedLength - 1, sizeof(uint8_t)); + // make extra sure we didn't muck up the read by validating `innermost` + if (innermost > 1) + corrupt(); - // Only the innermost manager validates total size and clears buffer. + // Only the innermost manager clears the buffer. if (innermost) { - LUAU_ASSERT(baseOffset + requiredSize <= bufferSize); bufferData = nullptr; bufferSize = 0; // Leave buffer at position 1 for reuse on next yield. diff --git a/VM/include/lyieldablemacros.h b/VM/include/lyieldablemacros.h index 73460ef8d..9a98e6dec 100644 --- a/VM/include/lyieldablemacros.h +++ b/VM/include/lyieldablemacros.h @@ -24,19 +24,21 @@ using Luau::YieldGuard; // DEFINE_YIELDABLE_IMPL: base macro parameterized by linkage specifier. // Generates the init function (name), continuation (name_k), and body // (name_body) from a single function definition. The body receives -// lua_State* L and bool is_init. Body is always static. -#define DEFINE_YIELDABLE_IMPL(linkage, name, version) \ - static int name##_v##version##_body(lua_State* L, bool is_init); \ - linkage int name##_v##version(lua_State* L) \ - { \ - return name##_v##version##_body(L, true); \ - } \ - linkage int name##_v##version##_k(lua_State* L, int status) \ - { \ - lua_checkstack(L, LUA_MINSTACK); \ - return name##_v##version##_body(L, false); \ - } \ - static int name##_v##version##_body(lua_State* L, bool is_init) +// lua_State* L, bool is_init and the ABI version. Body is always static. +#define DEFINE_YIELDABLE_IMPL(linkage, name, version) \ + static int name##_body(lua_State* L, bool is_init, uint8_t abi_version); \ + linkage int name(lua_State* L) \ + { \ + return name##_body(L, true, (version)); \ + } \ + linkage int name##_k(lua_State* L, int status) \ + { \ + if (status == LUA_YIELDABLE_ABI_QUERY) \ + return (version); \ + lua_checkstack(L, LUA_MINSTACK); \ + return name##_body(L, false, (version)); \ + } \ + static int name##_body(lua_State* L, bool is_init, uint8_t abi_version) // DEFINE_YIELDABLE: static linkage (single translation unit). #define DEFINE_YIELDABLE(name, version) DEFINE_YIELDABLE_IMPL(static, name, version) @@ -83,10 +85,10 @@ using Luau::YieldGuard; #define YIELD_DISPATCH(phase_name) \ case Phase::phase_name: goto _yieldable_label_##phase_name -// Closes the dispatch switch. +// Closes the dispatch switch. A phase this build doesn't know is a bad buffer. #define YIELD_DISPATCH_END() \ default: \ - LUAU_ASSERT(!"Unhandled yieldable phase"); \ + _yieldable_slots.corrupt(); \ } (void)0 // Fires the interrupt handler and yields if the VM requests it. diff --git a/VM/src/ares.cpp b/VM/src/ares.cpp index dcab72bce..020caaa8c 100644 --- a/VM/src/ares.cpp +++ b/VM/src/ares.cpp @@ -1774,6 +1774,7 @@ static void p_userdata(Info *info) { /* ... udata case UTAG_PROXY: case UTAG_QUATERNION: case UTAG_OPAQUE_BUFFER: + case UTAG_YIELD_STATE: WRITE_VALUE(size, ares_size_t); WRITE_RAW(value, size); break; @@ -1842,6 +1843,7 @@ static void u_userdata(Info *info) { /* ... */ uint8_t utag = READ_VALUE(uint8_t); switch(utag) { case UTAG_OPAQUE_BUFFER: + case UTAG_YIELD_STATE: case UTAG_PROXY: { size_t size = READ_VALUE(ares_size_t); @@ -3135,6 +3137,25 @@ u_thread(Info *info) { /* ... */ } // We don't actually use the function for anything, just checking! lua_pop(info->L, 1); /* ... thread */ + + // Okay, we may be resuming an lyieldable function. Make sure we can actually load the + // version provided by comparing it against what our impl actually supports + if (thread->status == LUA_OK || thread->status == LUA_YIELD || thread->status == LUA_BREAK) { + StkId base = thread->ci->base; + if (base < thread->top && ttisuserdata(base) && uvalue(base)->tag == UTAG_YIELD_STATE) { + if (!func_cl->c.cont) { + eris_error(info, "malformed data: yield state under a function without a continuation"); + } + // Silly hack: Abuse the continuation function by defining a custom status, so we can ask + // what max version this func supports. + int version = func_cl->c.cont(thread, LUA_YIELDABLE_ABI_QUERY); + int found = (uint8_t)uvalue(base)->data[0]; + if (version < 0 || version > UINT8_MAX || found > version) { + const char *debugname = cclosure_debugname(func_cl); + eris_error(info, "state saved by a newer version of %s", debugname ? debugname : ""); + } + } + } } else { if (ci_kind != ERIS_CI_KIND_NONE) { eris_error(info, "malformed data: invalid call info kind"); diff --git a/VM/src/cjson/lua_cjson.cpp b/VM/src/cjson/lua_cjson.cpp index 3ccb081ff..f4e5acbed 100644 --- a/VM/src/cjson/lua_cjson.cpp +++ b/VM/src/cjson/lua_cjson.cpp @@ -1199,7 +1199,7 @@ static int json_append_data(lua_State* l, SlotManager& parent_slots, // ServerLua: Shared yieldable body for json_encode / json_encode_sl. // sl_tagged selects between standard JSON and SL tagged type encoding. -static int json_encode_common(lua_State* l, bool is_init, bool sl_tagged) +static int json_encode_common(lua_State* l, bool is_init, uint8_t abi_version, bool sl_tagged) { YIELDABLE_RETURNS_DEFAULT; enum class Phase : uint8_t @@ -1212,7 +1212,7 @@ static int json_encode_common(lua_State* l, bool is_init, bool sl_tagged) ROOT_REPLACER_CALL = 5, }; - SlotManager slots(l, is_init); + SlotManager slots(l, is_init, abi_version); DEFINE_SLOT(Phase, phase, Phase::DEFAULT); DEFINE_SLOT(bool, tight_encoding, false); DEFINE_SLOT(bool, skip_tojson, false); @@ -1334,32 +1334,39 @@ static int json_encode_common(lua_State* l, bool is_init, bool sl_tagged) return 1; } -// ServerLua: init / continuation wrappers for json_encode -static int json_encode_v0(lua_State* l) +// ServerLua: init / continuation wrappers for json_encode. Hand-rolled +// equivalents of DEFINE_YIELDABLE's, see that for what the version pins. +static constexpr uint8_t JSON_ENCODE_ABI_VERSION = 0; + +static int json_encode(lua_State* l) { int nargs = lua_gettop(l); luaL_argcheck(l, nargs >= 1 && nargs <= 2, 1, "expected 1-2 arguments"); if (nargs >= 2) luaL_checktype(l, 2, LUA_TTABLE); - return json_encode_common(l, true, false); + return json_encode_common(l, true, JSON_ENCODE_ABI_VERSION, false); } -static int json_encode_v0_k(lua_State* l, int) +static int json_encode_k(lua_State* l, int status) { + if (status == LUA_YIELDABLE_ABI_QUERY) + return JSON_ENCODE_ABI_VERSION; lua_checkstack(l, LUA_MINSTACK); - return json_encode_common(l, false, false); + return json_encode_common(l, false, JSON_ENCODE_ABI_VERSION, false); } -static int json_encode_sl_v0(lua_State* l) +static int json_encode_sl(lua_State* l) { int nargs = lua_gettop(l); luaL_checkany(l, 1); if (nargs >= 2) luaL_checktype(l, 2, LUA_TTABLE); - return json_encode_common(l, true, true); + return json_encode_common(l, true, JSON_ENCODE_ABI_VERSION, true); } -static int json_encode_sl_v0_k(lua_State* l, int) +static int json_encode_sl_k(lua_State* l, int status) { + if (status == LUA_YIELDABLE_ABI_QUERY) + return JSON_ENCODE_ABI_VERSION; lua_checkstack(l, LUA_MINSTACK); - return json_encode_common(l, false, true); + return json_encode_common(l, false, JSON_ENCODE_ABI_VERSION, true); } /* ===== DECODING ===== */ @@ -2324,7 +2331,7 @@ static void json_process_value(lua_State* l, SlotManager& parent_slots, // ServerLua: Shared yieldable body for json_decode / json_decode_sl. // sl_tagged selects between standard JSON and SL tagged type decoding. -static int json_decode_common(lua_State* l, bool is_init, bool sl_tagged) +static int json_decode_common(lua_State* l, bool is_init, uint8_t abi_version, bool sl_tagged) { YIELDABLE_RETURNS_DEFAULT; enum class Phase : uint8_t @@ -2335,7 +2342,7 @@ static int json_decode_common(lua_State* l, bool is_init, bool sl_tagged) ROOT_REVIVER_CALL = 3, }; - SlotManager slots(l, is_init); + SlotManager slots(l, is_init, abi_version); DEFINE_SLOT(Phase, phase, Phase::DEFAULT); DEFINE_SLOT(int32_t, ptr_offset, 0); slots.finalize(); @@ -2483,34 +2490,40 @@ static int json_decode_common(lua_State* l, bool is_init, bool sl_tagged) return 1; } -// ServerLua: init / continuation wrappers for json_decode -static int json_decode_v0(lua_State* l) +// ServerLua: init / continuation wrappers for json_decode, as for json_encode +static constexpr uint8_t JSON_DECODE_ABI_VERSION = 0; + +static int json_decode(lua_State* l) { int nargs = lua_gettop(l); luaL_argcheck(l, nargs >= 1 && nargs <= 2, 1, "expected 1-2 arguments"); luaL_checkstring(l, 1); if (nargs >= 2 && !lua_isfunction(l, 2) && !lua_istable(l, 2)) luaL_argerror(l, 2, "expected function or table"); - return json_decode_common(l, true, false); + return json_decode_common(l, true, JSON_DECODE_ABI_VERSION, false); } -static int json_decode_v0_k(lua_State* l, int) +static int json_decode_k(lua_State* l, int status) { + if (status == LUA_YIELDABLE_ABI_QUERY) + return JSON_DECODE_ABI_VERSION; lua_checkstack(l, LUA_MINSTACK); - return json_decode_common(l, false, false); + return json_decode_common(l, false, JSON_DECODE_ABI_VERSION, false); } -static int json_decode_sl_v0(lua_State* l) +static int json_decode_sl(lua_State* l) { int nargs = lua_gettop(l); luaL_argcheck(l, nargs >= 1 && nargs <= 2, 1, "expected 1-2 arguments"); luaL_checkstring(l, 1); if (nargs >= 2 && !lua_isfunction(l, 2) && !lua_istable(l, 2)) luaL_argerror(l, 2, "expected function or table"); - return json_decode_common(l, true, true); + return json_decode_common(l, true, JSON_DECODE_ABI_VERSION, true); } -static int json_decode_sl_v0_k(lua_State* l, int) +static int json_decode_sl_k(lua_State* l, int status) { + if (status == LUA_YIELDABLE_ABI_QUERY) + return JSON_DECODE_ABI_VERSION; lua_checkstack(l, LUA_MINSTACK); - return json_decode_common(l, false, true); + return json_decode_common(l, false, JSON_DECODE_ABI_VERSION, true); } /* ===== INITIALISATION ===== */ @@ -2573,13 +2586,13 @@ static int lua_cjson_new(lua_State *l) lua_newtable(l); // ServerLua: Register with continuations for yieldable encode/decode - lua_pushcclosurek(l, json_encode_v0, "encode", 0, json_encode_v0_k); + lua_pushcclosurek(l, json_encode, "encode", 0, json_encode_k); lua_setfield(l, -2, "encode"); - lua_pushcclosurek(l, json_decode_v0, "decode", 0, json_decode_v0_k); + lua_pushcclosurek(l, json_decode, "decode", 0, json_decode_k); lua_setfield(l, -2, "decode"); - lua_pushcclosurek(l, json_encode_sl_v0, "slencode", 0, json_encode_sl_v0_k); + lua_pushcclosurek(l, json_encode_sl, "slencode", 0, json_encode_sl_k); lua_setfield(l, -2, "slencode"); - lua_pushcclosurek(l, json_decode_sl_v0, "sldecode", 0, json_decode_sl_v0_k); + lua_pushcclosurek(l, json_decode_sl, "sldecode", 0, json_decode_sl_k); lua_setfield(l, -2, "sldecode"); /* Set cjson.null */ diff --git a/VM/src/lllevents.cpp b/VM/src/lllevents.cpp index 3c0f36603..61c266f13 100644 --- a/VM/src/lllevents.cpp +++ b/VM/src/lllevents.cpp @@ -556,7 +556,7 @@ DEFINE_YIELDABLE(llevents_handle_event, 0) CALL_HANDLER = 2, }; - SlotManager slots(L, is_init); + SlotManager slots(L, is_init, abi_version); DEFINE_SLOT(Phase, phase, Phase::DEFAULT); DEFINE_SLOT(int32_t, handler_index, 1); @@ -738,7 +738,7 @@ void luaSL_setup_llevents_metatable(lua_State *L, int expose_internal_funcs) lua_setfield(L, -2, "eventNames"); // Store _handleEvent in registry for host access - lua_pushcclosurek(L, llevents_handle_event_v0, "_handleEvent", 0, llevents_handle_event_v0_k); + lua_pushcclosurek(L, llevents_handle_event, "_handleEvent", 0, llevents_handle_event_k); lua_setfield(L, LUA_REGISTRYINDEX, LLEVENTS_HANDLEEVENT_KEY); // Store timer wrapper guard in registry for handlers() protection @@ -747,7 +747,7 @@ void luaSL_setup_llevents_metatable(lua_State *L, int expose_internal_funcs) if (expose_internal_funcs) { - lua_pushcclosurek(L, llevents_handle_event_v0, "_handleEvent", 0, llevents_handle_event_v0_k); + lua_pushcclosurek(L, llevents_handle_event, "_handleEvent", 0, llevents_handle_event_k); lua_setfield(L, -2, "_handleEvent"); } diff --git a/VM/src/llltimers.cpp b/VM/src/llltimers.cpp index 521122bc4..65044821c 100644 --- a/VM/src/llltimers.cpp +++ b/VM/src/llltimers.cpp @@ -431,12 +431,12 @@ static void schedule_next_tick(lua_State *L, lua_LLTimers *lltimers) } // Forward-declare continuation for is_already_in_tick. -static int lltimers_tick_v0_k(lua_State *L, int status); +static int lltimers_tick_k(lua_State *L, int status); // Check if we're already inside a _tick() call by walking the call stack static bool is_already_in_tick(lua_State *L) { - // Walk up the call stack looking for lltimers_tick_v0_k + // Walk up the call stack looking for lltimers_tick_k // We start from L->ci - 1 because L->ci is the current (new) call to _tick for (CallInfo* ci = L->ci - 1; ci > L->base_ci; ci--) { @@ -447,7 +447,7 @@ static bool is_already_in_tick(lua_State *L) Closure* cl = clvalue(ci->func); // Check if this is a C function with our continuation - if (cl->isC && cl->c.cont == lltimers_tick_v0_k) + if (cl->isC && cl->c.cont == lltimers_tick_k) { // Found _tick() higher in the call stack - we're reentrant! return true; @@ -467,7 +467,7 @@ DEFINE_YIELDABLE(lltimers_tick, 0) CALL_HANDLER = 2, }; - SlotManager slots(L, is_init); + SlotManager slots(L, is_init, abi_version); DEFINE_SLOT(Phase, phase, Phase::DEFAULT); DEFINE_SLOT(int32_t, timer_index, 1); @@ -740,12 +740,12 @@ void luaSL_setup_llltimers_metatable(lua_State *L, int expose_internal_funcs) lua_setfield(L, -2, "off"); // Store _tick in registry for host and timer wrapper access - lua_pushcclosurek(L, lltimers_tick_v0, "_tick", 0, lltimers_tick_v0_k); + lua_pushcclosurek(L, lltimers_tick, "_tick", 0, lltimers_tick_k); lua_setfield(L, LUA_REGISTRYINDEX, "LLTIMERS_TICK"); if (expose_internal_funcs) { - lua_pushcclosurek(L, lltimers_tick_v0, "_tick", 0, lltimers_tick_v0_k); + lua_pushcclosurek(L, lltimers_tick, "_tick", 0, lltimers_tick_k); lua_setfield(L, -2, "_tick"); } diff --git a/VM/src/lstrlib.cpp b/VM/src/lstrlib.cpp index f2cf86445..7286a16ca 100644 --- a/VM/src/lstrlib.cpp +++ b/VM/src/lstrlib.cpp @@ -1710,16 +1710,16 @@ int luaopen_string(lua_State* L) luaL_register(L, LUA_STRLIBNAME, strlib); // ServerLua: Overwrite pattern-matching functions with yieldable versions. - lua_pushcclosurek(L, yieldable_str_find_v0, "find", 0, yieldable_str_find_v0_k); + lua_pushcclosurek(L, yieldable_str_find, "find", 0, yieldable_str_find_k); lua_setfield(L, -2, "find"); - lua_pushcclosurek(L, yieldable_str_match_v0, "match", 0, yieldable_str_match_v0_k); + lua_pushcclosurek(L, yieldable_str_match, "match", 0, yieldable_str_match_k); lua_setfield(L, -2, "match"); lua_pushcfunction(L, yieldable_gmatch, "gmatch"); lua_setfield(L, -2, "gmatch"); - lua_pushcclosurek(L, yieldable_str_gsub_v0, "gsub", 0, yieldable_str_gsub_v0_k); + lua_pushcclosurek(L, yieldable_str_gsub, "gsub", 0, yieldable_str_gsub_k); lua_setfield(L, -2, "gsub"); createmetatable(L); diff --git a/VM/src/ltablib.cpp b/VM/src/ltablib.cpp index f3412e80b..4cc8c22a7 100644 --- a/VM/src/ltablib.cpp +++ b/VM/src/ltablib.cpp @@ -380,7 +380,25 @@ static int tunpack(lua_State* L) // ServerLua: Budget between yield checks for the default (non-predicate) comparator path. // Same concept as YIELD_BATCH_SIZE in lyieldstrlib.cpp. // This may be raised or lowered without breaking ABI compatibility. -static constexpr int SORT_YIELD_BUDGET = 512; +static constexpr int SORT_YIELD_BUDGET = 32; + +// ServerLua: Return if __lt might be invoked when comparing +static bool sort_cmp_may_call(LuaTable* t, int i, int j) +{ + for (const TValue* val : {&t->array[i], &t->array[j]}) + { + switch (ttype(val)) + { + case LUA_TNUMBER: + case LUA_TSTRING: + case LUA_TLIGHTUSERDATA: + break; + default: + return true; + } + } + return false; +} // ServerLua: Comparison macro for yieldable sort. Expects `t` (LuaTable*), `use_pred` // (bool slot), `saved_sa` (int32_t slot), and `yield_budget` (int local) to @@ -388,14 +406,14 @@ static constexpr int SORT_YIELD_BUDGET = 512; // unique PHASE_NAME. // // Yield check fires on every comparison when use_pred is true (short-circuit -// skips the budget decrement). For the default comparator, the budget gates -// yield checks to every SORT_YIELD_BUDGET comparisons. +// skips the budget decrement) or when the compare can reach a metamethod. +// Otherwise the budget gates yield checks to every SORT_YIELD_BUDGET comparisons. // // The LuaTable* is a stable heap pointer — it never moves. Only t->array // and t->sizearray can change (if the comparator resizes the table), which // is what saved_sa detects. God do I hate that this is a macro but what can you do. #define SORT_CMP(cmp_var, i_idx, j_idx, phase_name) \ - if (use_pred || --yield_budget <= 0) \ + if (use_pred || sort_cmp_may_call(t, i_idx, j_idx) || --yield_budget <= 0) \ { \ YIELD_CHECK(L, phase_name##_YINT, LUA_INTERRUPT_STDLIB); \ yield_budget = SORT_YIELD_BUDGET; \ @@ -420,10 +438,7 @@ static constexpr int SORT_YIELD_BUDGET = 512; else \ { \ int _sa = t->sizearray; \ - /* ServerLua: guard nCcalls so __lt metamethods can't yield from this context */ \ - ++L->nCcalls; \ cmp_var = luaV_lessthan(L, &t->array[i_idx], &t->array[j_idx]); \ - --L->nCcalls; \ if (t->sizearray != _sa) \ luaL_error(L, "table modified during sorting"); \ } @@ -712,7 +727,7 @@ DEFINE_YIELDABLE(tsort, 0) SORT = 1, }; - SlotManager slots(L, is_init); + SlotManager slots(L, is_init, abi_version); DEFINE_SLOT(Phase, phase, Phase::DEFAULT); DEFINE_SLOT(int32_t, n, 0); DEFINE_SLOT(bool, use_pred, false); @@ -781,7 +796,7 @@ DEFINE_YIELDABLE(tfind, 0) LOOP = 1, }; - SlotManager slots(L, is_init); + SlotManager slots(L, is_init, abi_version); DEFINE_SLOT(Phase, phase, Phase::DEFAULT); DEFINE_SLOT(int32_t, i, 1); slots.finalize(); @@ -820,11 +835,7 @@ DEFINE_YIELDABLE(tfind, 0) StkId v = L->base + 2; - // ServerLua: guard nCcalls so __eq metamethods can't yield from this context - ++L->nCcalls; - bool eq = equalobj(L, v, e); - --L->nCcalls; - if (eq) + if (equalobj(L, v, e)) { lua_pushinteger(L, i); return 1; @@ -930,9 +941,9 @@ int luaopen_table(lua_State* L) luaL_register(L, LUA_TABLIBNAME, tab_funcs); // ServerLua: override sort and find registration with yieldable versions (need continuation) - lua_pushcclosurek(L, tsort_v0, "sort", 0, tsort_v0_k); + lua_pushcclosurek(L, tsort, "sort", 0, tsort_k); lua_setfield(L, -2, "sort"); - lua_pushcclosurek(L, tfind_v0, "find", 0, tfind_v0_k); + lua_pushcclosurek(L, tfind, "find", 0, tfind_k); lua_setfield(L, -2, "find"); // Lua 5.1 compat diff --git a/VM/src/lvmutils.cpp b/VM/src/lvmutils.cpp index 336d41546..7faaa7744 100644 --- a/VM/src/lvmutils.cpp +++ b/VM/src/lvmutils.cpp @@ -74,7 +74,12 @@ static StkId callTMres(lua_State* L, StkId res, const TValue* f, const TValue* p L->top += 3; // ServerLua: Check for interrupt to allow pre-emptive abort before calling metamethod luau_callinterrupthandler(L, LUA_INTERRUPT_METAMETHOD); + // ServerLua: Make sure `lua_gettable()` and friends don't think they're yieldable if we just + // happen to currently be inside a C function with a continuation. Bump nCcalls to ensure + // we don't accidentally allow them to yield and cause a big mess on the stack. + ++L->nCcalls; luaD_call(L, L->top - 3, 1); + --L->nCcalls; res = restorestack(L, result); L->top--; setobj2s(L, res, L->top); @@ -99,7 +104,10 @@ static void callTM(lua_State* L, const TValue* f, const TValue* p1, const TValue L->top += 4; // ServerLua: Check for interrupt to allow pre-emptive abort before calling metamethod luau_callinterrupthandler(L, LUA_INTERRUPT_METAMETHOD); + // ServerLua: same yield guard as in callTMres + ++L->nCcalls; luaD_call(L, L->top - 4, 0); + --L->nCcalls; } void luaV_gettable(lua_State* L, const TValue* t, TValue* key, StkId val) diff --git a/VM/src/lyieldable.cpp b/VM/src/lyieldable.cpp index a93d44238..f729d0073 100644 --- a/VM/src/lyieldable.cpp +++ b/VM/src/lyieldable.cpp @@ -11,12 +11,25 @@ using Luau::PrimitiveSlot; #include "llsl.h" #include "lualib.h" +// Script error for a yield buffer the resume path can't trust +void SlotManager::corrupt() const +{ + luaL_error(L, "corrupt yield state"); +} + +// Script error for a chain whose regions don't fit the uint16 offsets +void SlotManager::tooLarge() const +{ + luaL_error(L, "yield state too large"); +} + // Root constructor. On init, pushes nil at position 1. -// On resume, reads the serialized opaque userdata at position 1. -SlotManager::SlotManager(lua_State* L, bool is_init) +// On resume, reads the yield state userdata at position 1. +SlotManager::SlotManager(lua_State* L, bool is_init, uint8_t abi_version) : L(L) , bufferStackOffset(L->base - L->stack) , initMode(is_init) + , abiVersion(abi_version) { if (is_init) { @@ -26,15 +39,22 @@ SlotManager::SlotManager(lua_State* L, bool is_init) } else { - // Position 1 has the opaque userdata written by the previous flushForYield(). + // Position 1 has the yield state written by the previous flushForYield(). TValue* slot = L->stack + bufferStackOffset; - if (!ttisuserdata(slot) || uvalue(slot)->tag != UTAG_OPAQUE_BUFFER) - luaL_error(L, "corrupt yield state"); + if (!ttisuserdata(slot) || uvalue(slot)->tag != UTAG_YIELD_STATE) + corrupt(); Udata* u = uvalue(slot); bufferData = u->data; bufferSize = u->len; - if (bufferData[0] != 0) - luaL_error(L, "unsupported yield buffer version"); + // Generally should not happen, since the only way it could happen is if + // Ares' version check isn't doing its job, but let's be extra sure. + if ((uint8_t)bufferData[0] > abi_version) + { + TString* name = clvalue(L->ci->func)->c.debugname; + luaL_error(L, "yield state of %s was saved by a newer version", name ? getstr(name) : "?"); + } + + readStoredLength(); } callbacks = &L->global->cb; @@ -46,7 +66,7 @@ SlotManager::SlotManager(lua_State* L, bool is_init) lua_checkstack(L, needed); } -// Creates an opaque userdata at position 1 sized for the entire chain. +// Creates a yield state userdata at position 1 sized for the entire chain. // Sets bufferData and yielding on all managers so slot destructors // write on unwind. void SlotManager::flushForYield() @@ -56,7 +76,7 @@ void SlotManager::flushForYield() TValue* slot = L->stack + bufferStackOffset; Udata* u; - if (ttisuserdata(slot) && uvalue(slot)->tag == UTAG_OPAQUE_BUFFER && uvalue(slot)->len >= (int)totalSize) + if (ttisuserdata(slot) && uvalue(slot)->tag == UTAG_YIELD_STATE && uvalue(slot)->len >= (int)totalSize) { // Reuse userdata from previous yield u = uvalue(slot); @@ -66,7 +86,15 @@ void SlotManager::flushForYield() // First yield (slot is nil) or userdata too small — allocate. luaC_checkGC(L); luaC_threadbarrier(L); - u = luaU_newudata(L, totalSize, UTAG_OPAQUE_BUFFER); + { + // NB: We are very careful to allocate this under a non-user memcat so it + // isn't "charged" against the user for memory purposes. Since these only + // get allocated by the yielding mechanism, we would end up in a situation + // where one could non-deterministically OoM due to a pre-emption yield + // being injected at a particularly inopportune point. + MemcatGuard guard(L, 0); + u = luaU_newudata(L, totalSize, UTAG_YIELD_STATE); + } // Recompute slot — GC or allocation may have reallocated the stack. slot = L->stack + bufferStackOffset; setuvalue(L, slot, u); @@ -78,6 +106,7 @@ void SlotManager::flushForYield() // during stack unwinding. char* buf = u->data; memset(buf, 0, totalSize); + buf[0] = (char)abiVersion; for (SlotManager* mgr = this; mgr; mgr = mgr->parent) { mgr->yielding = true; diff --git a/VM/src/lyieldstrlib.cpp b/VM/src/lyieldstrlib.cpp index 401545218..a821b79e1 100644 --- a/VM/src/lyieldstrlib.cpp +++ b/VM/src/lyieldstrlib.cpp @@ -349,6 +349,7 @@ struct IMatchCapture int32_t len; }; +// This must be true on EVERY platform, or ARES data won't be portable. static_assert(sizeof(ImatchFrame[2]) == 2 * 5 * sizeof(int32_t)); static_assert(sizeof(IMatchCapture[2]) == 2 * 2 * sizeof(int32_t)); @@ -383,10 +384,28 @@ static MatchStateWire* new_matchstate_wire(lua_State* L) return wire; } +// A wire buffer that came back through Ares is only trusted once it looks like one +static bool matchstate_wire_ok(lua_State* L, int idx) +{ + return lua_touserdatatagged(L, idx, UTAG_OPAQUE_BUFFER) != nullptr && lua_objlen(L, idx) == sizeof(MatchStateWire); +} + // Max chars processed per inner-loop batch before yielding back to the // scheduler. Balances yield frequency against per-yield overhead. static constexpr int YIELD_BATCH_SIZE = 256; +// If we're scanning a character class, "charge" this many extra scans +// per this many characters in the class. +static constexpr int CLASS_BYTES_PER_STEP = 16; + +static inline void charge_class_scan(int& yield_budget, const char* p, const char* ep) +{ + yield_budget -= (int)(ep - p) / CLASS_BYTES_PER_STEP; +} + +// Short enough we don't have to give it any special consideration for "cost" +static constexpr size_t SHORT_PATTERN = 512; + static_assert(MAXSSIZE <= INT32_MAX, "MAXSSIZE exceeds int32_t range; pattern matcher offsets would overflow"); // Truncate ptrdiff_t to int32_t for the wire format. @@ -423,8 +442,8 @@ struct MatchStateGuard : YieldGuard if (isInit()) return; // Resume: offset -> pointer - LUAU_ASSERT(wire->level >= 0 && wire->level <= WIRE_MAXCAPTURES); - LUAU_ASSERT(wire->stk >= 0 && wire->stk <= WIRE_MAXBACKTRACK); + LUAU_ASSERT_ALWAYS(wire->level >= 0 && wire->level <= WIRE_MAXCAPTURES); + LUAU_ASSERT_ALWAYS(wire->stk >= 0 && wire->stk <= WIRE_MAXBACKTRACK); ms->level = wire->level; ms->stk = wire->stk; for (int i = 0; i < ms->level; i++) @@ -564,6 +583,7 @@ static int iterative_match_helper(lua_State* L, SlotManager& parent_slots, { for (;;) { + charge_class_scan(yield_budget, p, pat_str + greedy_ep_off); BUDGET_YIELD_CHECK(L, GREEDY_YIELD); if (!singlematch(ms, s + greedy_i, p, pat_str + greedy_ep_off)) { @@ -694,6 +714,9 @@ static int iterative_match_helper(lua_State* L, SlotManager& parent_slots, if (*p != '[') luaL_error(L, "missing '[' after '%%f' in pattern"); ep = classend(ms, p); + // Two scans: the previous char and the current one + charge_class_scan(yield_budget, p, ep); + charge_class_scan(yield_budget, p, ep); previous = (s == ms->src_init) ? '\0' : *(s - 1); if (!matchbracketclass(uchar(previous), p, ep - 1) && matchbracketclass(uchar(*s), p, ep - 1)) @@ -732,6 +755,7 @@ static int iterative_match_helper(lua_State* L, SlotManager& parent_slots, imatch_dflt: { const char* ep = classend(ms, p); + charge_class_scan(yield_budget, p, ep); if (!singlematch(ms, s, p, ep)) { if (*ep == '*' || *ep == '?' || *ep == '-') @@ -872,7 +896,7 @@ static int iterative_match_helper(lua_State* L, SlotManager& parent_slots, continue; } default: - LUAU_ASSERT(!"invalid backtrack site"); + LUAU_ASSERT_ALWAYS(!"invalid backtrack site"); break; } } @@ -889,7 +913,7 @@ static int iterative_match_helper(lua_State* L, SlotManager& parent_slots, ** ======================================================= */ -static int str_find_match_body(lua_State* L, bool is_init, MatchMode match_mode) +static int str_find_match_body(lua_State* L, bool is_init, uint8_t abi_version, MatchMode match_mode) { YIELDABLE_RETURNS_DEFAULT; enum Arg @@ -909,6 +933,7 @@ static int str_find_match_body(lua_State* L, bool is_init, MatchMode match_mode) // Fast path: plain find or no-specials pattern — bypass all yieldable // machinery. Pre-SlotManager arg positions: source=1, pattern=2, init=3, plain=4. + bool literal = false; if (is_init && match_mode == MatchMode::FIND) { size_t ls, lp; @@ -922,14 +947,12 @@ static int str_find_match_body(lua_State* L, bool is_init, MatchMode match_mode) lua_pushnil(L); return 1; } - // ServerLua: gate the fast (unyieldable) lmemfind path by pattern length. - // Long patterns must go through yieldable paths: - // - nospecials: strpbrk scans the entire pattern before any YIELD_CHECK - // - plain=true: lmemfind is O(N*M) for adversarial input - // The pattern matching path handles literal characters with yield checks; - // plain=true with long patterns gets a dedicated yieldable search loop. - constexpr size_t MAX_PLAIN_STR = 512; - if (lp <= MAX_PLAIN_STR && (lua_toboolean(L, 4) || nospecials(p, lp))) + literal = lua_toboolean(L, 4) || (lp <= SHORT_PATTERN && nospecials(p, lp)); + // lmemfind has no yield check, so it is gated on the work it can do: + // a memcmp of up to lp bytes at each remaining position. Past that, + // a literal search takes the yieldable plain loop below. + constexpr size_t MAX_PLAIN_WORK = 1 << 20; + if (literal && lp * (ls - init + 1) <= MAX_PLAIN_WORK) { const char* s2 = lmemfind(s + init - 1, ls - init + 1, p, lp); if (s2) @@ -943,13 +966,16 @@ static int str_find_match_body(lua_State* L, bool is_init, MatchMode match_mode) } } - SlotManager slots(L, is_init); + SlotManager slots(L, is_init, abi_version); DEFINE_SLOT(Phase, phase, Phase::DEFAULT); DEFINE_SLOT(int32_t, s1_off, 0); DEFINE_SLOT(bool, is_anchor, false); DEFINE_SLOT(bool, is_plain, false); slots.finalize(); + if (!is_init && !is_plain && !matchstate_wire_ok(L, STACK_WIRE)) + slots.corrupt(); + if (is_init) { size_t ls, lp; @@ -964,7 +990,7 @@ static int str_find_match_body(lua_State* L, bool is_init, MatchMode match_mode) return 1; } - is_plain = (match_mode == MatchMode::FIND) && lua_toboolean(L, ARG_PLAIN); + is_plain = literal; s1_off = init - 1; if (is_plain) @@ -996,6 +1022,7 @@ static int str_find_match_body(lua_State* L, bool is_init, MatchMode match_mode) int end_off = -1; // Declared before YIELD_DISPATCH to avoid goto-crossing-initialization. const char* found = nullptr; + int yield_budget = YIELD_BATCH_SIZE; MatchState ms; prepstate(&ms, L, s, ls, p, lp); @@ -1005,12 +1032,12 @@ static int str_find_match_body(lua_State* L, bool is_init, MatchMode match_mode) YIELD_DISPATCH(PLAIN_YIELD); YIELD_DISPATCH_END(); - // Yieldable plain substring search for long patterns — same algorithm as - // lmemfind, inlined because YIELD_CHECK labels can't cross function boundaries. - // Each iteration does one memchr + one O(lp) memcmp, so per-iteration - // yield checks are appropriate (no budget needed for patterns > 512). + // Yieldable plain substring search, same algorithm as lmemfind, inlined + // because YIELD_CHECK labels can't cross function boundaries. if (is_plain) { + // An empty pattern always fits the fast path's work gate + LUAU_ASSERT(lp > 0); while ((size_t)s1_off + lp <= ls) { found = (const char*)memchr(s + s1_off, p[0], ls - lp - s1_off + 1); @@ -1024,10 +1051,14 @@ static int str_find_match_body(lua_State* L, bool is_init, MatchMode match_mode) return 2; } s1_off = (int)(found - s) + 1; - YIELD_CHECK(L, PLAIN_YIELD, LUA_INTERRUPT_STDLIB); - // Re-read after potential yield (pointers may have moved) - s = lua_tolstring(L, ARG_SOURCE, &ls); - p = lua_tolstring(L, ARG_PATTERN, &lp); + if (lp > SHORT_PATTERN || --yield_budget <= 0) + { + YIELD_CHECK(L, PLAIN_YIELD, LUA_INTERRUPT_STDLIB); + yield_budget = YIELD_BATCH_SIZE; + // Re-read after potential yield (pointers may have moved) + s = lua_tolstring(L, ARG_SOURCE, &ls); + p = lua_tolstring(L, ARG_PATTERN, &lp); + } } lua_pushnil(L); return 1; @@ -1069,22 +1100,30 @@ static int str_find_match_body(lua_State* L, bool is_init, MatchMode match_mode) return 1; } -int yieldable_str_find_v0(lua_State* L) +// find and match share str_find_match_body, so they share its yield ABI. +// Hand-rolled equivalents of DEFINE_YIELDABLE's wrappers, see that for the rules. +static constexpr uint8_t STR_FIND_MATCH_ABI_VERSION = 0; + +int yieldable_str_find(lua_State* L) { - return str_find_match_body(L, true, MatchMode::FIND); + return str_find_match_body(L, true, STR_FIND_MATCH_ABI_VERSION, MatchMode::FIND); } -int yieldable_str_find_v0_k(lua_State* L, int status) +int yieldable_str_find_k(lua_State* L, int status) { - return str_find_match_body(L, false, MatchMode::FIND); + if (status == LUA_YIELDABLE_ABI_QUERY) + return STR_FIND_MATCH_ABI_VERSION; + return str_find_match_body(L, false, STR_FIND_MATCH_ABI_VERSION, MatchMode::FIND); } -int yieldable_str_match_v0(lua_State* L) +int yieldable_str_match(lua_State* L) { - return str_find_match_body(L, true, MatchMode::MATCH); + return str_find_match_body(L, true, STR_FIND_MATCH_ABI_VERSION, MatchMode::MATCH); } -int yieldable_str_match_v0_k(lua_State* L, int status) +int yieldable_str_match_k(lua_State* L, int status) { - return str_find_match_body(L, false, MatchMode::MATCH); + if (status == LUA_YIELDABLE_ABI_QUERY) + return STR_FIND_MATCH_ABI_VERSION; + return str_find_match_body(L, false, STR_FIND_MATCH_ABI_VERSION, MatchMode::MATCH); } // }====================================================== @@ -1109,7 +1148,7 @@ DEFINE_YIELDABLE_EXTERN(yieldable_gmatch_aux, 0) MATCH_CALL = 1, }; - SlotManager slots(L, is_init); + SlotManager slots(L, is_init, abi_version); DEFINE_SLOT(Phase, phase, Phase::DEFAULT); DEFINE_SLOT(int32_t, src_off, 0); slots.finalize(); @@ -1132,6 +1171,10 @@ DEFINE_YIELDABLE_EXTERN(yieldable_gmatch_aux, 0) setnilvalue(uv); // wire at STACK_WIRE } + // A held iterator's wire comes back through Ares as an upvalue, a + // suspended one's as a stack slot + if (!matchstate_wire_ok(L, STACK_WIRE)) + slots.corrupt(); MatchState ms; prepstate(&ms, L, s, ls, p, lp); @@ -1183,7 +1226,7 @@ int yieldable_gmatch(lua_State* L) lua_settop(L, 2); lua_pushinteger(L, 0); new_matchstate_wire(L); - lua_pushcclosurek(L, yieldable_gmatch_aux_v0, "gmatch_aux", 4, yieldable_gmatch_aux_v0_k); + lua_pushcclosurek(L, yieldable_gmatch_aux, "gmatch_aux", 4, yieldable_gmatch_aux_k); return 1; } @@ -1218,7 +1261,7 @@ DEFINE_YIELDABLE_EXTERN(yieldable_str_gsub, 0) REPL_CALL = 2, }; - SlotManager slots(L, is_init); + SlotManager slots(L, is_init, abi_version); DEFINE_SLOT(Phase, phase, Phase::DEFAULT); DEFINE_SLOT(int32_t, n, 0); DEFINE_SLOT(int32_t, src_off, 0); @@ -1228,6 +1271,9 @@ DEFINE_YIELDABLE_EXTERN(yieldable_str_gsub, 0) DEFINE_SLOT(bool, is_anchor, false); slots.finalize(); + if (!is_init && !matchstate_wire_ok(L, STACK_WIRE)) + slots.corrupt(); + lua_YieldSafeStrBuf* buf; if (is_init) @@ -1273,6 +1319,8 @@ DEFINE_YIELDABLE_EXTERN(yieldable_str_gsub, 0) const char* p = lua_tolstring(L, ARG_PATTERN, &lp); int match_end_off = -1; buf = (lua_YieldSafeStrBuf*)lua_touserdatatagged(L, STACK_STRBUF, UTAG_STRBUF); + if (!buf) + slots.corrupt(); MatchState ms; prepstate(&ms, L, src_str, srcl, p, lp); @@ -1313,8 +1361,8 @@ DEFINE_YIELDABLE_EXTERN(yieldable_str_gsub, 0) else if (repl_type == LUA_TTABLE) { push_onecapture(&ms, 0, match_start, match_end); - // Not yieldable, but callTMres already calls the - // interrupt handler if __index is a function. + // An __index function can't yield here: callTMres guards + // it, and fires the interrupt handler before it runs. lua_gettable(L, ARG_REPL); } else diff --git a/VM/src/lyieldstrlib.h b/VM/src/lyieldstrlib.h index aa34c0fb5..5caf48083 100644 --- a/VM/src/lyieldstrlib.h +++ b/VM/src/lyieldstrlib.h @@ -8,13 +8,13 @@ #include "lua.h" -LUAI_FUNC int yieldable_str_find_v0(lua_State* L); -LUAI_FUNC int yieldable_str_find_v0_k(lua_State* L, int status); +LUAI_FUNC int yieldable_str_find(lua_State* L); +LUAI_FUNC int yieldable_str_find_k(lua_State* L, int status); -LUAI_FUNC int yieldable_str_match_v0(lua_State* L); -LUAI_FUNC int yieldable_str_match_v0_k(lua_State* L, int status); +LUAI_FUNC int yieldable_str_match(lua_State* L); +LUAI_FUNC int yieldable_str_match_k(lua_State* L, int status); LUAI_FUNC int yieldable_gmatch(lua_State* L); -LUAI_FUNC int yieldable_str_gsub_v0(lua_State* L); -LUAI_FUNC int yieldable_str_gsub_v0_k(lua_State* L, int status); +LUAI_FUNC int yieldable_str_gsub(lua_State* L); +LUAI_FUNC int yieldable_str_gsub_k(lua_State* L, int status); diff --git a/tests/Conformance.test.cpp b/tests/Conformance.test.cpp index aaff7e89e..115831899 100644 --- a/tests/Conformance.test.cpp +++ b/tests/Conformance.test.cpp @@ -2017,7 +2017,7 @@ TEST_CASE("StdlibYield") [](lua_State* L) -> int { pmTimingMode = true; - pmLastTimestamp = 0; + pmLastTimestamp = lua_cputime(); pmMaxDelta = 0; return 0; }, @@ -2030,6 +2030,10 @@ TEST_CASE("StdlibYield") [](lua_State* L) -> int { pmTimingMode = false; + // The stretch after the last interrupt counts too + double delta = lua_cputime() - pmLastTimestamp; + if (delta > pmMaxDelta) + pmMaxDelta = delta; lua_pushnumber(L, pmMaxDelta); return 1; }, diff --git a/tests/SLConformance.test.cpp b/tests/SLConformance.test.cpp index 807868aa0..b1878ed9e 100644 --- a/tests/SLConformance.test.cpp +++ b/tests/SLConformance.test.cpp @@ -1202,10 +1202,14 @@ TEST_CASE("Table Sizing") }); } +// The ABI version the test yieldables write and answer with, so a test can +// move it between persisting and unpersisting +static uint8_t testAbiVersion = 0; + // Yieldable C function test using lyieldable.h framework. // Takes a callback and a count n, calls callback(i) for i=1..n, // accumulates return values and returns the sum. -DEFINE_YIELDABLE(test_yieldable_sum, 0) +DEFINE_YIELDABLE(test_yieldable_sum, testAbiVersion) { YIELDABLE_RETURNS_DEFAULT; @@ -1218,7 +1222,7 @@ DEFINE_YIELDABLE(test_yieldable_sum, 0) }; // All slots must be finalized before we do any init code. - SlotManager slots(L, is_init); + SlotManager slots(L, is_init, abi_version); // Phase storage is explicit DEFINE_SLOT(Phase, phase, Phase::DEFAULT); @@ -1300,7 +1304,7 @@ static void test_yieldable_inner(lua_State* L, SlotManager& parentSlots, int32_t // Calls callback(0) first (a yieldable call in the parent) before // delegating to the helper — this proves that slots.isInit() in the // helper returns true even when the parent has already yielded & resumed. -DEFINE_YIELDABLE(test_yieldable_chained, 0) +DEFINE_YIELDABLE(test_yieldable_chained, testAbiVersion) { YIELDABLE_RETURNS_DEFAULT; enum class Phase : uint8_t @@ -1310,7 +1314,7 @@ DEFINE_YIELDABLE(test_yieldable_chained, 0) HELPER_CALL = 2, }; - SlotManager slots(L, is_init); + SlotManager slots(L, is_init, abi_version); DEFINE_SLOT(Phase, phase, Phase::DEFAULT); DEFINE_SLOT(int32_t, accumulator, 0); slots.finalize(); @@ -1388,7 +1392,7 @@ DEFINE_YIELDABLE(test_yieldable_recursive, 0) HELPER_CALL = 1, }; - SlotManager slots(L, is_init); + SlotManager slots(L, is_init, abi_version); DEFINE_SLOT(Phase, phase, Phase::DEFAULT); DEFINE_SLOT(int32_t, accumulator, 0); slots.finalize(); @@ -1403,6 +1407,54 @@ DEFINE_YIELDABLE(test_yieldable_recursive, 0) return 1; } +// A slot wide enough that two of them across a chain overflow the uint16 +// region offsets. finalize has to refuse the layout. +struct YieldPad +{ + char bytes[40000]; +}; + +static void test_yieldable_wide_inner(lua_State* L, SlotManager& parentSlots) +{ + YIELDABLE_RETURNS_VOID; + enum class Phase : uint8_t + { + DEFAULT = 0, + }; + + SlotManager slots(parentSlots); + DEFINE_SLOT(Phase, phase, Phase::DEFAULT); + DEFINE_SLOT(YieldPad, pad, {}); + slots.finalize(); + + YIELD_DISPATCH_BEGIN(phase, slots); + YIELD_DISPATCH_END(); +} + +DEFINE_YIELDABLE(test_yieldable_wide, 0) +{ + YIELDABLE_RETURNS_DEFAULT; + enum class Phase : uint8_t + { + DEFAULT = 0, + HELPER_CALL = 1, + }; + + SlotManager slots(L, is_init, abi_version); + DEFINE_SLOT(Phase, phase, Phase::DEFAULT); + DEFINE_SLOT(YieldPad, pad, {}); + slots.finalize(); + + YIELD_DISPATCH_BEGIN(phase, slots); + YIELD_DISPATCH(HELPER_CALL); + YIELD_DISPATCH_END(); + + YIELD_HELPER(L, HELPER_CALL, test_yieldable_wide_inner(L, slots)); + + lua_pushboolean(L, true); + return 1; +} + // Simple yieldable function that yields via YIELD_CHECK (interrupt-driven). // Takes n, sums 1..n. No callback — yields happen purely from the interrupt handler. DEFINE_YIELDABLE(test_yieldable_check_sum, 0) @@ -1414,7 +1466,7 @@ DEFINE_YIELDABLE(test_yieldable_check_sum, 0) CHECK_POINT = 1, }; - SlotManager slots(L, is_init); + SlotManager slots(L, is_init, abi_version); DEFINE_SLOT(Phase, phase, Phase::DEFAULT); DEFINE_SLOT(int32_t, i, 1); DEFINE_SLOT(int32_t, n, 0); @@ -1486,7 +1538,7 @@ DEFINE_YIELDABLE(test_yieldable_chained_check, 0) HELPER_CALL = 2, }; - SlotManager slots(L, is_init); + SlotManager slots(L, is_init, abi_version); DEFINE_SLOT(Phase, phase, Phase::DEFAULT); DEFINE_SLOT(int32_t, accumulator, 0); slots.finalize(); @@ -1504,17 +1556,47 @@ DEFINE_YIELDABLE(test_yieldable_chained_check, 0) return 1; } +// Runs test_yieldable_sum's continuation over hand-built yield state bytes: +// (bytes, callback, n, pending callback result) -> whatever the resume returns +static int call_test_sum_k(lua_State* L) +{ + size_t len; + const char* bytes = luaL_checklstring(L, 1, &len); + void* buffer = lua_newuserdatatagged(L, len, UTAG_YIELD_STATE); + memcpy(buffer, bytes, len); + lua_replace(L, 1); + return test_yieldable_sum_k(L, 0); +} + TEST_CASE("Lyieldable") { + testAbiVersion = 0; runConformance("lyieldable.luau", nullptr, [](lua_State* L) { - lua_pushcclosurek(L, test_yieldable_sum_v0, "test_yieldable_sum", 0, test_yieldable_sum_v0_k); + lua_pushcclosurek(L, test_yieldable_sum, "test_yieldable_sum", 0, test_yieldable_sum_k); lua_setglobal(L, "yieldable_sum"); - lua_pushcclosurek(L, test_yieldable_chained_v0, "test_yieldable_chained", 0, test_yieldable_chained_v0_k); + lua_pushcclosurek(L, test_yieldable_chained, "test_yieldable_chained", 0, test_yieldable_chained_k); lua_setglobal(L, "yieldable_chained"); - lua_pushcclosurek(L, test_yieldable_recursive_v0, "test_yieldable_recursive", 0, test_yieldable_recursive_v0_k); + lua_pushcclosurek(L, test_yieldable_recursive, "test_yieldable_recursive", 0, test_yieldable_recursive_k); lua_setglobal(L, "yieldable_recursive"); + + lua_pushcclosurek(L, test_yieldable_wide, "test_yieldable_wide", 0, test_yieldable_wide_k); + lua_setglobal(L, "yieldable_wide"); + + lua_pushcfunction( + L, + [](lua_State* L) -> int + { + testAbiVersion = (uint8_t)luaL_checkinteger(L, 1); + return 0; + }, + "set_test_abi_version" + ); + lua_setglobal(L, "set_test_abi_version"); + + lua_pushcfunction(L, call_test_sum_k, "call_test_sum_k"); + lua_setglobal(L, "call_test_sum_k"); }); } @@ -1527,10 +1609,10 @@ TEST_CASE("LyieldableCheck") nullptr, [](lua_State* L) { - lua_pushcclosurek(L, test_yieldable_check_sum_v0, "test_yieldable_check_sum", 0, test_yieldable_check_sum_v0_k); + lua_pushcclosurek(L, test_yieldable_check_sum, "test_yieldable_check_sum", 0, test_yieldable_check_sum_k); lua_setglobal(L, "yieldable_check_sum"); - lua_pushcclosurek(L, test_yieldable_chained_check_v0, "test_yieldable_chained_check", 0, test_yieldable_chained_check_v0_k); + lua_pushcclosurek(L, test_yieldable_chained_check, "test_yieldable_chained_check", 0, test_yieldable_chained_check_k); lua_setglobal(L, "yieldable_chained_check"); // clear_check_count() — resets the yield counter diff --git a/tests/SLExecutorFixture.h b/tests/SLExecutorFixture.h index 5fabbb306..099702d06 100644 --- a/tests/SLExecutorFixture.h +++ b/tests/SLExecutorFixture.h @@ -57,9 +57,23 @@ struct FakeQuantaClock inline static FakeQuantaClock* current = nullptr; }; +/// Like the base `Script`, but with tooling for stepping through lyieldable functions. +struct TestHostScript : Luau::Executor::Script +{ + using Script::Script; + + static void installVMCallbacks(lua_State* L) + { + Script::installVMCallbacks(L); + lua_callbacks(L)->interrupt = stdlib_yield_interrupt; + } + + static void stdlib_yield_interrupt(lua_State* L, int gc); +}; + // Provisioner with deterministic fakes: the virtual quanta clock above, plus // capture of print output and dynamic handler registrations. -struct TestProvisioner : Luau::Executor::Provisioner<>, FakeQuantaClock +struct TestProvisioner : Luau::Executor::Provisioner, FakeQuantaClock { // The script-visible stopwatch LLTimers schedules against. Deliberately // separate from the quanta clock above, which advances on every reading. @@ -67,10 +81,13 @@ struct TestProvisioner : Luau::Executor::Provisioner<>, FakeQuantaClock double last_timer_interval = -1.0; std::vector printed; std::vector registrations; + // One-shot, set by arm_stdlib_yield() and consumed by the next stdlib + // yield check, see TestHostScript + bool stdlib_yield_armed = false; // Subclasses pass their own callbacks, composed off makeCallbacks() explicit TestProvisioner(const Luau::Executor::HostCallbacks& callbacks = makeCallbacks()) - : Luau::Executor::Provisioner<>(callbacks) + : Luau::Executor::Provisioner(callbacks) { } @@ -96,6 +113,9 @@ struct TestProvisioner : Luau::Executor::Provisioner<>, FakeQuantaClock lua_pushcfunction(L, lua_break, "preempt"); lua_setglobal(L, "preempt"); + lua_pushcfunction(L, arm_stdlib_yield, "arm_stdlib_yield"); + lua_setglobal(L, "arm_stdlib_yield"); + lua_pushcfunction(L, jump_clock, "jump_clock"); lua_setglobal(L, "jump_clock"); @@ -132,6 +152,13 @@ struct TestProvisioner : Luau::Executor::Provisioner<>, FakeQuantaClock return 0; } + // The next stdlib yield check yields the coroutine it runs on + static int arm_stdlib_yield(lua_State* L) + { + of(L).stdlib_yield_armed = true; + return 0; + } + static int capture_print(lua_State* L) { TestProvisioner::of(L).printed.emplace_back(luaL_checkstring(L, 1)); @@ -161,6 +188,24 @@ struct TestProvisioner : Luau::Executor::Provisioner<>, FakeQuantaClock } }; +inline void TestHostScript::stdlib_yield_interrupt(lua_State* L, int gc) +{ + if (gc == LUA_INTERRUPT_STDLIB && Script::fromLuaState(L) != nullptr) + { + TestProvisioner& host = TestProvisioner::of(L); + if (host.stdlib_yield_armed) + { + host.stdlib_yield_armed = false; + // A stdlib check is a yieldable point by construction + LUAU_ASSERT(luaSL_may_interrupt(L) == YieldableStatus::OK); + lua_yield(L, 0); + return; + } + } + + Script::interruptHandler(L, gc); +} + // Owns the asset bytes an ImageConfig only borrows, so the config is minted on // conversion rather than held with a pointer that a move could invalidate. struct TestAsset diff --git a/tests/SLGoldenFixtures.test.cpp b/tests/SLGoldenFixtures.test.cpp index 5c21e983f..834ee8882 100644 --- a/tests/SLGoldenFixtures.test.cpp +++ b/tests/SLGoldenFixtures.test.cpp @@ -4,8 +4,9 @@ // // Each scenario drives a fresh script into some resting state and says what a // script restored from that state must still be able to do. With -// LUAU_REGENERATE_FIXTURES set, the regenerate case writes every scenario's -// asset and state under the current format versions. The load case restores +// LUAU_REGENERATE_FIXTURES set, the regenerate case writes each scenario's +// asset and state, named for the current format versions, into the +// scenario's directory. The load case restores // every committed state this build claims to read and runs its scenario's // verify against it, so a state written by an older build has to keep working. // @@ -21,13 +22,17 @@ #include "Luau/FileUtils.h" #include "Luau/ParseResult.h" +#include "lstate.h" + #include "doctest.h" #include #include +#include #include #include #include +#include #include using namespace Luau; @@ -37,7 +42,7 @@ namespace { struct GoldenScenario { - // The source file beside the fixtures, whose extension is the flavor + // The scenario's directory under the fixture dir, an `-lsl` suffix makes us use LSL mode. const char* name; // Drives a fresh script into the state the fixture captures void (*arrange)(TestScript&); @@ -46,8 +51,141 @@ struct GoldenScenario }; } +// The phase of every region of every suspended yieldable C frame on a thread, +// outermost frame and root region first +using YieldShape = std::vector>; + +static YieldShape readYieldShape(lua_State* thread) +{ + YieldShape shape; + for (CallInfo* ci = thread->base_ci + 1; ci <= thread->ci; ++ci) + { + if (!ttisfunction(ci->func) || !clvalue(ci->func)->isC || ci->base >= thread->top) + continue; + if (!ttisuserdata(ci->base) || uvalue(ci->base)->tag != UTAG_YIELD_STATE) + continue; + + Udata* u = uvalue(ci->base); + const uint8_t* bytes = (const uint8_t*)u->data; + size_t size = u->len; + std::vector phases; + // Past the version byte, each region is a u16 length, its slots with + // the phase first, and the innermost flag + size_t off = sizeof(uint8_t); + for (;;) + { + REQUIRE(off + sizeof(uint16_t) <= size); + uint16_t length; + memcpy(&length, bytes + off, sizeof(uint16_t)); + off += sizeof(uint16_t); + REQUIRE(length >= 2); + REQUIRE(off + length <= size); + phases.push_back(bytes[off]); + if (bytes[off + length - 1]) + break; + off += length; + } + shape.push_back(std::move(phases)); + } + return shape; +} + +static std::string formatYieldShape(const YieldShape& shape) +{ + std::string out = "{"; + for (size_t i = 0; i < shape.size(); ++i) + { + out += i ? ",{" : "{"; + for (size_t j = 0; j < shape[i].size(); ++j) + out += (j ? "," : "") + std::to_string(shape[i][j]); + out += "}"; + } + return out + "}"; +} + +// The handler thread, then each coroutine in the script's `parked` table, +// against the shapes a scenario expects +static void checkYieldShapes(TestScript& ts, const std::vector& expected) +{ + lua_State* instance = ts.exec.getInstanceState(); + std::vector threads; + threads.push_back(lua_tothread(instance, 1)); + + lua_getglobal(instance, "parked"); + REQUIRE(lua_istable(instance, -1)); + int count = lua_objlen(instance, -1); + for (int i = 1; i <= count; ++i) + { + lua_rawgeti(instance, -1, i); + threads.push_back(lua_tothread(instance, -1)); + lua_pop(instance, 1); + } + lua_pop(instance, 1); + + REQUIRE(threads.size() == expected.size()); + for (size_t i = 0; i < threads.size(); ++i) + { + REQUIRE(threads[i] != nullptr); + YieldShape actual = readYieldShape(threads[i]); + INFO("thread ", i, " expected ", formatYieldShape(expected[i]), " actual ", formatYieldShape(actual)); + CHECK(actual == expected[i]); + } +} + +// Okay so this is probably overkill, but we also want to test some of our lyieldable stuff. +// The best way we can do that is by ensuring that we yield at a variety of points in the +// various functions, and save the state. The problem is trying to ensure we can deterministically +// pause at the correct point in that C++ function stack. Counting interrupts is very brittle, +// so instead we just force the functions to constantly flush their yield buffers and inspect +// them to see if they're in the stack state we want to test. +// +// Naturally, all of these Phase enums are kind of internal implementation details of each +// of these functions, so just keep a record of them here as well. Nasty as hell, but I'm +// okay with it if it makes this testable. +namespace phase +{ +// lllevents.cpp llevents_handle_event, llltimers.cpp lltimers_tick +constexpr uint8_t HANDLE_EVENT_CALL_HANDLER = 2; +constexpr uint8_t TICK_CALL_HANDLER = 2; +// lyieldstrlib.cpp str_find_match_body, str_gsub_body, yieldable_gmatch_aux +constexpr uint8_t FIND_MATCH_CALL = 1; +constexpr uint8_t FIND_PLAIN_YIELD = 2; +constexpr uint8_t GSUB_MATCH_CALL = 1; +constexpr uint8_t GSUB_REPL_CALL = 2; +constexpr uint8_t GMATCH_MATCH_CALL = 1; +// lyieldstrlib.cpp iterative_match_helper, the check at the top of its loop +constexpr uint8_t MATCH_MAIN_YIELD = 1; +// ltablib.cpp tsort, sort_rec, tfind +constexpr uint8_t SORT = 1; +constexpr uint8_t SORT_REC_CMP_UL = 2; +constexpr uint8_t TFIND_LOOP = 1; +// lua_cjson.cpp json_encode_common, json_append_data, json_append_array +constexpr uint8_t ENCODE_APPEND_DATA = 1; +constexpr uint8_t APPEND_DATA_ARRAY_AUTO = 5; +constexpr uint8_t APPEND_ARRAY_REPLACER_CALL = 4; +// lua_cjson.cpp json_decode_common, json_process_value, json_parse_array_context +constexpr uint8_t DECODE_PROCESS_VALUE = 1; +constexpr uint8_t PROCESS_VALUE_ARRAY = 2; +constexpr uint8_t PARSE_ARRAY_REVIVER_CALL = 4; +} + +// Handler thread first, then the coroutines in the order the source parks them +static const std::vector kYieldedStdlibShapes = { + // _handleEvent -> _tick -> the timer function, which calls preempt() + {{phase::HANDLE_EVENT_CALL_HANDLER}, {phase::TICK_CALL_HANDLER}}, + {{phase::FIND_PLAIN_YIELD}}, + {{phase::FIND_MATCH_CALL, phase::MATCH_MAIN_YIELD}}, + {{phase::GSUB_MATCH_CALL, phase::MATCH_MAIN_YIELD}}, + {{phase::GMATCH_MATCH_CALL, phase::MATCH_MAIN_YIELD}}, + {{phase::TFIND_LOOP}}, + {{phase::GSUB_REPL_CALL}}, + {{phase::SORT, phase::SORT_REC_CMP_UL}}, + {{phase::ENCODE_APPEND_DATA, phase::APPEND_DATA_ARRAY_AUTO, phase::APPEND_ARRAY_REPLACER_CALL}}, + {{phase::DECODE_PROCESS_VALUE, phase::PROCESS_VALUE_ARRAY, phase::PARSE_ARRAY_REVIVER_CALL}}, +}; + static const GoldenScenario kGoldenScenarios[] = { - {"between-handlers.lua", + {"between-handlers", [](TestScript& ts) { ts.start(); @@ -57,7 +195,7 @@ static const GoldenScenario kGoldenScenarios[] = { dispatch(ts.exec, LSLEvent::MovingStart); checkCapture(ts.host.printed, {"counter ok", "table ok", "buffer ok", "vector ok", "coroutine ok", "upvalue ok"}); }}, - {"yielded-main.lua", + {"yielded-main", [](TestScript& ts) { ts.loadDefaultState(); @@ -74,7 +212,7 @@ static const GoldenScenario kGoldenScenarios[] = { dispatch(ts.exec, LSLEvent::MovingStart); checkCapture(ts.host.printed, {"done ok"}); }}, - {"yielded-handler.lua", + {"yielded-handler", [](TestScript& ts) { ts.start(); @@ -87,7 +225,25 @@ static const GoldenScenario kGoldenScenarios[] = { dispatch(ts.exec, LSLEvent::MovingStart); checkCapture(ts.host.printed, {"counter ok"}); }}, - {"errored-handler.lua", + // Every yieldable C function suspended mid-call at once, each in its own + // coroutine, the handler itself inside _tick. The shapes pin where. + {"yielded-stdlib", + [](TestScript& ts) + { + ts.start(); + ts.host.script_clock = 1.0; + dispatch(ts.exec, LSLEvent::Timer, HandlerRunStatus::Preempted); + checkYieldShapes(ts, kYieldedStdlibShapes); + }, + [](TestScript& ts) + { + REQUIRE(ts.exec.isHandlerActive()); + checkYieldShapes(ts, kYieldedStdlibShapes); + resumeToCompletion(ts.exec, 1.0); + dispatch(ts.exec, LSLEvent::MovingStart); + checkCapture(ts.host.printed, {"yieldables ok"}); + }}, + {"errored-handler", [](TestScript& ts) { ts.start(); @@ -106,7 +262,7 @@ static const GoldenScenario kGoldenScenarios[] = { checkCapture(ts.host.printed, {"ran after reset"}); }}, #ifdef LUAU_USE_TAILSLIDE - {"between-handlers.lsl", + {"between-handlers-lsl", [](TestScript& ts) { ts.start(); @@ -119,7 +275,7 @@ static const GoldenScenario kGoldenScenarios[] = { dispatch(ts.exec, LSLEvent::MovingStart); checkCapture(ts.host.printed, {"counter ok"}); }}, - {"state-change-pending.lsl", + {"state-change-pending-lsl", [](TestScript& ts) { ts.start(); @@ -138,7 +294,7 @@ static const GoldenScenario kGoldenScenarios[] = { dispatch(exec, LSLEvent::MovingStart); checkCapture(ts.host.printed, {"counter ok"}); }}, - {"yielded-handler.lsl", + {"yielded-handler-lsl", [](TestScript& ts) { ts.start(); @@ -152,7 +308,7 @@ static const GoldenScenario kGoldenScenarios[] = { dispatch(ts.exec, LSLEvent::MovingStart); checkCapture(ts.host.printed, {"done ok"}); }}, - {"errored-main.lsl", + {"errored-main-lsl", [](TestScript& ts) { ts.loadDefaultState(); @@ -175,13 +331,20 @@ static std::string goldenFixtureDir() dir.erase(dir.find_last_of("\\/")); dir += "/conformance"; #endif - return dir + "/fixtures/exec"; + return dir + "/exec"; } -static std::string goldenFixtureBase(const GoldenScenario& scenario) +// The format versions this build writes, which name its files in each +// scenario's directory +static std::string goldenFixtureVersion() { return "exec" + std::to_string(kScriptStateFingerprint.major) + "." + std::to_string(kScriptStateFingerprint.minor) + "-ares" + - std::to_string(ARES_FORMAT_MAJOR) + "." + std::to_string(ARES_FORMAT_MINOR) + "-" + scenario.name; + std::to_string(ARES_FORMAT_MAJOR) + "." + std::to_string(ARES_FORMAT_MINOR); +} + +static std::string goldenFixtureBase(const std::string& dir, const GoldenScenario& scenario) +{ + return dir + "/" + scenario.name + "/" + goldenFixtureVersion(); } static std::string readBinaryFile(const std::string& path) @@ -210,30 +373,9 @@ static std::string fileStem(const std::string& path) return path.substr(start, dot - start); } -// The scenario a fixture belongs to, from the name its stem ends with -static const GoldenScenario* findGoldenScenario(const std::string& stem) -{ - size_t ares = stem.find("-ares"); - if (ares == std::string::npos) - return nullptr; - - size_t name_start = stem.find('-', ares + 1); - if (name_start == std::string::npos) - return nullptr; - - std::string name = stem.substr(name_start + 1); - for (const GoldenScenario& scenario : kGoldenScenarios) - { - if (name == scenario.name) - return &scenario; - } - return nullptr; -} - -// The extension a scenario's name ends with is its flavor static bool isLSLScenario(std::string_view name) { - return name.size() > 4 && name.substr(name.size() - 4) == ".lsl"; + return name.size() > 4 && name.substr(name.size() - 4) == "-lsl"; } // Compiling is the one place a bad source throws, and doctest is built without @@ -241,11 +383,12 @@ static bool isLSLScenario(std::string_view name) // instead of saying which file is wrong. static bool compileGoldenScenario(const std::string& dir, const GoldenScenario& scenario, TestAsset& asset) { - const std::string source = dir + "/" + scenario.name; + const bool lsl = isLSLScenario(scenario.name); + const std::string source = dir + "/" + scenario.name + (lsl ? "/source.lsl" : "/source.lua"); const std::string text = readBinaryFile(source); try { - asset = compileTestAsset(text.c_str(), isLSLScenario(scenario.name)); + asset = compileTestAsset(text.c_str(), lsl); return true; } catch (const ParseErrors& e) @@ -274,7 +417,7 @@ static void writeGoldenFixture(const std::string& dir, const GoldenScenario& sce TestScript ts(asset); scenario.arrange(ts); - std::string base = dir + "/" + goldenFixtureBase(scenario); + std::string base = goldenFixtureBase(dir, scenario); writeBinaryFile(base + ".sluac", asset.bytes); writeBinaryFile(base + ".state", serialize(ts.exec)); } @@ -312,54 +455,40 @@ TEST_CASE_FIXTURE(SLuaFixture, "SLExecutor golden fixtures") // Verify we _do_ have a dump on the current version for each scenario. for (const GoldenScenario& scenario : kGoldenScenarios) { - const std::string base = dir + "/" + goldenFixtureBase(scenario); + const std::string base = goldenFixtureBase(dir, scenario); CAPTURE(base); REQUIRE(isFile(base + ".state")); REQUIRE(isFile(base + ".sluac")); } - // Every state file this build is expected to load. Collected before the - // subcases so the skips don't each cost a run of the body. - std::vector loadable; - auto collect = [&](const std::string& path) + std::vector> loadable; + for (const GoldenScenario& scenario : kGoldenScenarios) { - if (!hasFileExtension(path, {".state"})) - return; - - const std::string name = fileStem(path); - #ifndef LUAU_USE_TAILSLIDE // No LSL compiler in this build, so those scenarios can't run - const GoldenScenario* scenario = findGoldenScenario(name); - if (scenario != nullptr && isLSLScenario(scenario->name)) - return; + if (isLSLScenario(scenario.name)) + continue; #endif + // Yucky yucky lambda :( + auto collect = [&](const std::string& path) + { + if (hasFileExtension(path, {".state"})) + loadable.emplace_back(&scenario, path); + }; + REQUIRE(traverseDirectory(dir + "/" + scenario.name, collect)); + REQUIRE_FALSE(loadable.empty()); + } - loadable.push_back(path); - }; - REQUIRE(traverseDirectory(dir, collect)); - REQUIRE_FALSE(loadable.empty()); - - // A subcase each, so a scenario that breaks says which one it was rather - // than taking the rest of them down with it. - for (const std::string& state_path : loadable) + for (const auto& [scenario, state_path] : loadable) { - const std::string name = fileStem(state_path); - SUBCASE(name.c_str()) + const std::string subcase_name = std::string(scenario->name) + "/" + fileStem(state_path); + SUBCASE(subcase_name.c_str()) { - const std::string asset_path = state_path.substr(0, state_path.size() - 6) + ".sluac"; + const std::string asset_path = state_path.substr(0, state_path.rfind('.')) + ".sluac"; REQUIRE(isFile(asset_path)); - // The committed asset says what flavor it is, so the filename - // doesn't have to, and it goes in whole the way a host would hand - // it over TestAsset asset{readBinaryFile(asset_path)}; - // Get the scenario associated with this state file, and there - // _better_ be one. - const GoldenScenario* scenario = findGoldenScenario(name); - REQUIRE(scenario != nullptr); - TestScript ts(asset); // Start the script and restore the state from the statefile restore(ts.exec, readBinaryFile(state_path)); diff --git a/tests/conformance/fixtures/exec/exec3.0-ares7.0-between-handlers.lsl.sluac b/tests/conformance/exec/between-handlers-lsl/exec3.0-ares7.0.sluac similarity index 100% rename from tests/conformance/fixtures/exec/exec3.0-ares7.0-between-handlers.lsl.sluac rename to tests/conformance/exec/between-handlers-lsl/exec3.0-ares7.0.sluac diff --git a/tests/conformance/fixtures/exec/exec3.0-ares7.0-between-handlers.lsl.state b/tests/conformance/exec/between-handlers-lsl/exec3.0-ares7.0.state similarity index 100% rename from tests/conformance/fixtures/exec/exec3.0-ares7.0-between-handlers.lsl.state rename to tests/conformance/exec/between-handlers-lsl/exec3.0-ares7.0.state diff --git a/tests/conformance/fixtures/exec/between-handlers.lsl b/tests/conformance/exec/between-handlers-lsl/source.lsl similarity index 100% rename from tests/conformance/fixtures/exec/between-handlers.lsl rename to tests/conformance/exec/between-handlers-lsl/source.lsl diff --git a/tests/conformance/fixtures/exec/exec3.0-ares7.0-between-handlers.lua.sluac b/tests/conformance/exec/between-handlers/exec3.0-ares7.0.sluac similarity index 100% rename from tests/conformance/fixtures/exec/exec3.0-ares7.0-between-handlers.lua.sluac rename to tests/conformance/exec/between-handlers/exec3.0-ares7.0.sluac diff --git a/tests/conformance/fixtures/exec/exec3.0-ares7.0-between-handlers.lua.state b/tests/conformance/exec/between-handlers/exec3.0-ares7.0.state similarity index 100% rename from tests/conformance/fixtures/exec/exec3.0-ares7.0-between-handlers.lua.state rename to tests/conformance/exec/between-handlers/exec3.0-ares7.0.state diff --git a/tests/conformance/fixtures/exec/between-handlers.lua b/tests/conformance/exec/between-handlers/source.lua similarity index 100% rename from tests/conformance/fixtures/exec/between-handlers.lua rename to tests/conformance/exec/between-handlers/source.lua diff --git a/tests/conformance/fixtures/exec/exec3.0-ares7.0-errored-handler.lua.sluac b/tests/conformance/exec/errored-handler/exec3.0-ares7.0.sluac similarity index 100% rename from tests/conformance/fixtures/exec/exec3.0-ares7.0-errored-handler.lua.sluac rename to tests/conformance/exec/errored-handler/exec3.0-ares7.0.sluac diff --git a/tests/conformance/fixtures/exec/exec3.0-ares7.0-errored-handler.lua.state b/tests/conformance/exec/errored-handler/exec3.0-ares7.0.state similarity index 100% rename from tests/conformance/fixtures/exec/exec3.0-ares7.0-errored-handler.lua.state rename to tests/conformance/exec/errored-handler/exec3.0-ares7.0.state diff --git a/tests/conformance/fixtures/exec/errored-handler.lua b/tests/conformance/exec/errored-handler/source.lua similarity index 100% rename from tests/conformance/fixtures/exec/errored-handler.lua rename to tests/conformance/exec/errored-handler/source.lua diff --git a/tests/conformance/fixtures/exec/exec3.0-ares7.0-errored-main.lsl.sluac b/tests/conformance/exec/errored-main-lsl/exec3.0-ares7.0.sluac similarity index 100% rename from tests/conformance/fixtures/exec/exec3.0-ares7.0-errored-main.lsl.sluac rename to tests/conformance/exec/errored-main-lsl/exec3.0-ares7.0.sluac diff --git a/tests/conformance/fixtures/exec/exec3.0-ares7.0-errored-main.lsl.state b/tests/conformance/exec/errored-main-lsl/exec3.0-ares7.0.state similarity index 100% rename from tests/conformance/fixtures/exec/exec3.0-ares7.0-errored-main.lsl.state rename to tests/conformance/exec/errored-main-lsl/exec3.0-ares7.0.state diff --git a/tests/conformance/fixtures/exec/errored-main.lsl b/tests/conformance/exec/errored-main-lsl/source.lsl similarity index 100% rename from tests/conformance/fixtures/exec/errored-main.lsl rename to tests/conformance/exec/errored-main-lsl/source.lsl diff --git a/tests/conformance/fixtures/exec/exec3.0-ares7.0-state-change-pending.lsl.sluac b/tests/conformance/exec/state-change-pending-lsl/exec3.0-ares7.0.sluac similarity index 100% rename from tests/conformance/fixtures/exec/exec3.0-ares7.0-state-change-pending.lsl.sluac rename to tests/conformance/exec/state-change-pending-lsl/exec3.0-ares7.0.sluac diff --git a/tests/conformance/fixtures/exec/exec3.0-ares7.0-state-change-pending.lsl.state b/tests/conformance/exec/state-change-pending-lsl/exec3.0-ares7.0.state similarity index 100% rename from tests/conformance/fixtures/exec/exec3.0-ares7.0-state-change-pending.lsl.state rename to tests/conformance/exec/state-change-pending-lsl/exec3.0-ares7.0.state diff --git a/tests/conformance/fixtures/exec/state-change-pending.lsl b/tests/conformance/exec/state-change-pending-lsl/source.lsl similarity index 100% rename from tests/conformance/fixtures/exec/state-change-pending.lsl rename to tests/conformance/exec/state-change-pending-lsl/source.lsl diff --git a/tests/conformance/fixtures/exec/exec3.0-ares7.0-yielded-handler.lsl.sluac b/tests/conformance/exec/yielded-handler-lsl/exec3.0-ares7.0.sluac similarity index 100% rename from tests/conformance/fixtures/exec/exec3.0-ares7.0-yielded-handler.lsl.sluac rename to tests/conformance/exec/yielded-handler-lsl/exec3.0-ares7.0.sluac diff --git a/tests/conformance/fixtures/exec/exec3.0-ares7.0-yielded-handler.lsl.state b/tests/conformance/exec/yielded-handler-lsl/exec3.0-ares7.0.state similarity index 100% rename from tests/conformance/fixtures/exec/exec3.0-ares7.0-yielded-handler.lsl.state rename to tests/conformance/exec/yielded-handler-lsl/exec3.0-ares7.0.state diff --git a/tests/conformance/fixtures/exec/yielded-handler.lsl b/tests/conformance/exec/yielded-handler-lsl/source.lsl similarity index 100% rename from tests/conformance/fixtures/exec/yielded-handler.lsl rename to tests/conformance/exec/yielded-handler-lsl/source.lsl diff --git a/tests/conformance/fixtures/exec/exec3.0-ares7.0-yielded-handler.lua.sluac b/tests/conformance/exec/yielded-handler/exec3.0-ares7.0.sluac similarity index 100% rename from tests/conformance/fixtures/exec/exec3.0-ares7.0-yielded-handler.lua.sluac rename to tests/conformance/exec/yielded-handler/exec3.0-ares7.0.sluac diff --git a/tests/conformance/fixtures/exec/exec3.0-ares7.0-yielded-handler.lua.state b/tests/conformance/exec/yielded-handler/exec3.0-ares7.0.state similarity index 67% rename from tests/conformance/fixtures/exec/exec3.0-ares7.0-yielded-handler.lua.state rename to tests/conformance/exec/yielded-handler/exec3.0-ares7.0.state index 28b0ec3cb..781414257 100644 Binary files a/tests/conformance/fixtures/exec/exec3.0-ares7.0-yielded-handler.lua.state and b/tests/conformance/exec/yielded-handler/exec3.0-ares7.0.state differ diff --git a/tests/conformance/fixtures/exec/yielded-handler.lua b/tests/conformance/exec/yielded-handler/source.lua similarity index 100% rename from tests/conformance/fixtures/exec/yielded-handler.lua rename to tests/conformance/exec/yielded-handler/source.lua diff --git a/tests/conformance/fixtures/exec/exec3.0-ares7.0-yielded-main.lua.sluac b/tests/conformance/exec/yielded-main/exec3.0-ares7.0.sluac similarity index 100% rename from tests/conformance/fixtures/exec/exec3.0-ares7.0-yielded-main.lua.sluac rename to tests/conformance/exec/yielded-main/exec3.0-ares7.0.sluac diff --git a/tests/conformance/fixtures/exec/exec3.0-ares7.0-yielded-main.lua.state b/tests/conformance/exec/yielded-main/exec3.0-ares7.0.state similarity index 100% rename from tests/conformance/fixtures/exec/exec3.0-ares7.0-yielded-main.lua.state rename to tests/conformance/exec/yielded-main/exec3.0-ares7.0.state diff --git a/tests/conformance/fixtures/exec/yielded-main.lua b/tests/conformance/exec/yielded-main/source.lua similarity index 100% rename from tests/conformance/fixtures/exec/yielded-main.lua rename to tests/conformance/exec/yielded-main/source.lua diff --git a/tests/conformance/exec/yielded-stdlib/exec3.0-ares7.0.sluac b/tests/conformance/exec/yielded-stdlib/exec3.0-ares7.0.sluac new file mode 100644 index 000000000..afccae28c Binary files /dev/null and b/tests/conformance/exec/yielded-stdlib/exec3.0-ares7.0.sluac differ diff --git a/tests/conformance/exec/yielded-stdlib/exec3.0-ares7.0.state b/tests/conformance/exec/yielded-stdlib/exec3.0-ares7.0.state new file mode 100644 index 000000000..d3d7aa1cb Binary files /dev/null and b/tests/conformance/exec/yielded-stdlib/exec3.0-ares7.0.state differ diff --git a/tests/conformance/exec/yielded-stdlib/source.lua b/tests/conformance/exec/yielded-stdlib/source.lua new file mode 100644 index 000000000..45bd32582 --- /dev/null +++ b/tests/conformance/exec/yielded-stdlib/source.lua @@ -0,0 +1,123 @@ +-- Every yieldable C function parked mid-call at once. The leaf ones stop at +-- their next stdlib yield check (arm_stdlib_yield), the callback ones inside +-- their callback, and the timer handler itself inside _tick. The golden +-- fixture test checks the phase of every frame, so the parked table's order +-- matters. +local hay = string.rep("a", 10000) .. "b" +local needle = string.rep("a", 600) .. "b" +local src = string.rep("a", 3000) .. "b" .. string.rep("a", 3000) .. "b" + +local sort_input = {} +for i = 1, 200 do + sort_input[i] = (i * 7919) % 200 +end + +local eq_mt = { __eq = function(a, b) return a.v == b.v end } +local tfind_input = {} +for i = 1, 500 do + tfind_input[i] = setmetatable({ v = i }, eq_mt) +end +local tfind_needle = setmetatable({ v = 432 }, eq_mt) + +parked = {} +results = {} +held = nil + +local function park(f) + local co = coroutine.create(f) + assert(coroutine.resume(co)) + assert(coroutine.status(co) == "suspended") + table.insert(parked, co) +end + +LLTimers:every(0.1, function() + park(function() + arm_stdlib_yield() + results.find = string.find(hay, needle, 1, true) + end) + park(function() + arm_stdlib_yield() + results.match = #string.match(hay, "(a+)b") + end) + park(function() + arm_stdlib_yield() + local _, n = string.gsub(src, "a+b", "x") + results.gsub_match = n + end) + park(function() + arm_stdlib_yield() + local count = 0 + for w in string.gmatch(src, "a+b") do + count += #w + end + results.gmatch = count + end) + park(function() + arm_stdlib_yield() + results.tfind = table.find(tfind_input, tfind_needle) + end) + park(function() + results.gsub_repl = string.gsub("aa bb cc", "%w+", function(w) + if w == "bb" then + coroutine.yield() + end + return string.upper(w) + end) + end) + park(function() + local yielded = false + table.sort(sort_input, function(a, b) + if not yielded then + yielded = true + coroutine.yield() + end + return a < b + end) + local sorted = true + for i = 2, #sort_input do + if sort_input[i - 1] > sort_input[i] then + sorted = false + end + end + results.sort = sorted + end) + park(function() + results.encode = lljson.encode({ 10, 20, 30 }, { replacer = function(key, value) + if key == 2 then + coroutine.yield() + end + return value + end }) + end) + park(function() + local t = lljson.decode("[1,2,3]", function(key, value) + if key == 2 then + coroutine.yield() + end + return value + end) + results.decode = t[1] == 1 and t[2] == 2 and t[3] == 3 + end) + -- An iterator held between iterations has no frame, only upvalues + held = string.gmatch("aa bb cc", "%w+") + results.held = #held() + preempt() +end) + +function LLEvents.moving_start() + for _, co in parked do + assert(coroutine.resume(co)) + assert(coroutine.status(co) == "dead") + end + local ok = results.find == 9401 + and results.match == 10000 + and results.gsub_match == 2 + and results.gmatch == 6002 + and results.tfind == 432 + and results.gsub_repl == "AA BB CC" + and results.sort == true + and results.encode == "[10,20,30]" + and results.decode == true + and results.held == 2 and #held() == 2 and #held() == 2 and held() == nil + print(if ok then "yieldables ok" else "yieldables bad") +end diff --git a/tests/conformance/lyieldable.luau b/tests/conformance/lyieldable.luau index d7f3fd8d3..0ecc22ac6 100644 --- a/tests/conformance/lyieldable.luau +++ b/tests/conformance/lyieldable.luau @@ -189,6 +189,74 @@ do assert(ok and result == 15) end +-- A coroutine suspended three callbacks into yieldable_sum, persisted +local function saved_mid_sum() + local co = coroutine.create(function() + return yieldable_sum(function(i) + coroutine.yield(i) + return i + end, 5) + end) + for i = 1, 3 do + local ok, val = coroutine.resume(co) + assert(ok and val == i) + end + return ares.persist(co) +end + +local function finish_sum(restored) + for i = 4, 5 do + local ok, val = coroutine.resume(restored) + assert(ok and val == i) + end + local ok, result = coroutine.resume(restored) + assert(ok and result == 15) +end + +-- A frame older than the build loads and resumes, its bytes read as they are +do + local saved = saved_mid_sum() + set_test_abi_version(1) + finish_sum(ares.unpersist(saved)) + set_test_abi_version(0) +end + +-- A frame newer than the build is refused at load +do + set_test_abi_version(1) + local newer = saved_mid_sum() + set_test_abi_version(0) + local ok, err = pcall(ares.unpersist, newer) + assert(not ok and string.find(err, "test_yieldable_sum", 1, true) ~= nil, err) +end + +-- The resume path checks a buffer that never went through Ares. +-- call_test_sum_k runs the continuation over hand-built bytes with the +-- callback's pending result on the stack. +do + local function state(version, region_len, phase, innermost) + return string.pack("