diff --git a/CHANGELOG.md b/CHANGELOG.md index f470bb4d..2d22323c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - Minor: Added broadcaster-only `/poll`, `/cancelpoll`, and `/endpoll` commands. (#6583, #6605) - Minor: Added broadcaster-only `/prediction`, `/cancelprediction`, `/lockprediction`, and `/completeprediction` commands. (#6583, #6612, #6632) - Minor: Added support for BetterTTV Pro subscriber badges. (#6625) +- Minor: Added `debug.traceback` for plugins. (#6652) - Minor: Added title and duration options for `/clip` command. (#6669) - Minor: Added Markdown support to user notes. (#6490) - Bugfix: Moderation checks now include the lead moderator badge. (#6642) diff --git a/docs/wip-plugins.md b/docs/wip-plugins.md index 00bfb74f..c2b06c54 100644 --- a/docs/wip-plugins.md +++ b/docs/wip-plugins.md @@ -850,6 +850,31 @@ and [`file:write()`](https://www.lua.org/manual/5.4/manual.html#pdf-file:write). See [official documentation](https://www.lua.org/manual/5.4/manual.html#pdf-io.write) +### Debug API + +To aid debugging, Chatterino provides Lua's `debug.traceback` function. +Other functions from the `debug` library are not exposed. + +#### `traceback([thread,] [message [, level]])` + +Returns a stack trace of `thread` or the current thread with an optional `message` prepended. +`level` can be used to skip some frames. By default, it's 1 (skipping the current function). + +This can be used as a message handler in `xpcall`: + +```lua +local function main() + c2.ThisDoesNotExistAndWillError() +end + +local ok, result = xpcall(main, debug.traceback) +if not ok then + print(result) +end +``` + +See [official documentation](https://www.lua.org/manual/5.4/manual.html#pdf-debug.traceback) + ### Changed globals #### `load(chunk [, chunkname [, mode [, env]]])` diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index df851816..93f3e6af 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -254,6 +254,8 @@ set(SOURCE_FILES controllers/plugins/api/Accounts.hpp controllers/plugins/api/ChannelRef.cpp controllers/plugins/api/ChannelRef.hpp + controllers/plugins/api/DebugLibrary.cpp + controllers/plugins/api/DebugLibrary.hpp controllers/plugins/api/EventType.hpp controllers/plugins/api/HTTPRequest.cpp controllers/plugins/api/HTTPRequest.hpp diff --git a/src/controllers/plugins/PluginController.cpp b/src/controllers/plugins/PluginController.cpp index d13d095e..1338f2f4 100644 --- a/src/controllers/plugins/PluginController.cpp +++ b/src/controllers/plugins/PluginController.cpp @@ -9,6 +9,7 @@ # include "controllers/commands/CommandController.hpp" # include "controllers/plugins/api/Accounts.hpp" # include "controllers/plugins/api/ChannelRef.hpp" +# include "controllers/plugins/api/DebugLibrary.hpp" # include "controllers/plugins/api/HTTPRequest.hpp" # include "controllers/plugins/api/HTTPResponse.hpp" # include "controllers/plugins/api/IOWrapper.hpp" @@ -207,6 +208,13 @@ void PluginController::openLibrariesFor(Plugin *plugin) r["_IO_input"] = sol::nil; r["_IO_output"] = sol::nil; } + // set up debug lib + { + auto debuglib = lua.create_table(); + g["debug"] = debuglib; + + debuglib.set_function("traceback", lua::api::debugTraceback); + } PluginController::initSol(lua, plugin); } diff --git a/src/controllers/plugins/api/DebugLibrary.cpp b/src/controllers/plugins/api/DebugLibrary.cpp new file mode 100644 index 00000000..e78e00d6 --- /dev/null +++ b/src/controllers/plugins/api/DebugLibrary.cpp @@ -0,0 +1,46 @@ +#include "controllers/plugins/api/DebugLibrary.hpp" + +#ifdef CHATTERINO_HAVE_PLUGINS + +# include + +namespace chatterino::lua::api { + +/// Signature in Lua: ([thread,] [message [, level]]) +int debugTraceback(lua_State *L) +{ + int argOffset = 1; + lua_State *targetThread = L; + + // If the first argument is a thread, take that as our target + if (lua_isthread(L, argOffset)) + { + targetThread = lua_tothread(L, argOffset); + argOffset += 1; + } + + // The message is an optional string. If it's not a string/number/nil, we + // return the message as-is and don't create a traceback. + const char *msg = lua_tostring(L, argOffset); + if (!msg && !lua_isnoneornil(L, argOffset)) + { + lua_pushvalue(L, argOffset); + return 1; + } + argOffset += 1; + + int defaultLevel = 0; + if (L == targetThread) + { + defaultLevel = 1; + } + + int level = static_cast(luaL_optinteger(L, argOffset, defaultLevel)); + // push a traceback with `msg` at the start + luaL_traceback(L, targetThread, msg, level); + return 1; +} + +} // namespace chatterino::lua::api + +#endif diff --git a/src/controllers/plugins/api/DebugLibrary.hpp b/src/controllers/plugins/api/DebugLibrary.hpp new file mode 100644 index 00000000..605ebec2 --- /dev/null +++ b/src/controllers/plugins/api/DebugLibrary.hpp @@ -0,0 +1,14 @@ +#pragma once +#ifdef CHATTERINO_HAVE_PLUGINS +# include + +namespace chatterino::lua::api { + +/// A reimplementation of Lua's debug.traceback (ldblib.c). +/// Creates a traceback of a thread. +/// It's intended to be used as a message handler in `xpcall`. +/// See https://www.lua.org/manual/5.4/manual.html#pdf-debug.traceback +int debugTraceback(lua_State *L); + +} // namespace chatterino::lua::api +#endif diff --git a/tests/src/Plugins.cpp b/tests/src/Plugins.cpp index 17446f78..480baf06 100644 --- a/tests/src/Plugins.cpp +++ b/tests/src/Plugins.cpp @@ -157,6 +157,24 @@ std::string luaTestPath(const QString &category, const QString &entry) return luaTestBaseDir(category).filePath(entry + ".lua").toStdString(); } +bool runLuaTest(const QString &category, const QString &entry, + sol::state_view lua) +{ + auto loadResult = lua.load_file(luaTestPath(category, entry)); + auto pfn = loadResult.get(); + pfn.set_error_handler(lua["debug"]["traceback"]); + auto pfr = pfn.call(); + EXPECT_TRUE(pfr.valid()); + if (!pfr.valid()) + { + qDebug().noquote() << "Test" << entry << "failed:"; + sol::error err = pfr; + qDebug().noquote() << err.what(); + return false; + } + return true; +} + } // namespace namespace chatterino { @@ -1528,23 +1546,15 @@ TEST_P(PluginJsonTest, Run) auto reg = lua->registry().size(); auto globals = lua->globals().size(); - auto pfr = lua->safe_script_file(luaTestPath("json", GetParam())); - EXPECT_TRUE(pfr.valid()); - if (!pfr.valid()) - { - qDebug() << "Test" << GetParam() << "failed:"; - sol::error err = pfr; - qDebug() << err.what(); - return; - } + runLuaTest("json", GetParam(), *this->lua); for (size_t i = 0; i < 5; i++) { lua->collect_garbage(); } // make sure we don't leak anything to globals or the registry - // but give the registry some room of 1 slot - EXPECT_LE(lua->registry().size(), reg + 1); + // but give the registry some room of 3 slots (two from getting the debug library) + EXPECT_LE(lua->registry().size(), reg + 3); EXPECT_EQ(lua->globals().size(), globals); } @@ -1557,17 +1567,8 @@ class PluginMessageTest : public PluginTest, }; TEST_P(PluginMessageTest, Run) { - configure(); - - auto pfr = lua->safe_script_file(luaTestPath("message", GetParam())); - EXPECT_TRUE(pfr.valid()); - if (!pfr.valid()) - { - qDebug() << "Test" << GetParam() << "failed:"; - sol::error err = pfr; - qDebug() << err.what(); - return; - } + this->configure(); + runLuaTest("message", GetParam(), *this->lua); } INSTANTIATE_TEST_SUITE_P(PluginMessage, PluginMessageTest, @@ -1593,4 +1594,63 @@ TEST_F(PluginTest, testAccounts) ASSERT_TRUE(res.valid()) << res.get().what(); } +TEST_F(PluginTest, debugTraceback) +{ + configure(); + + QString traceback = lua->script(R"lua( + local function other() + error("oh no") + end + local function main() + local function inner() + other() + end + inner() + end + + local ok, res = xpcall(main, debug.traceback) + return res + )lua") + .get(); + ASSERT_TRUE(traceback.contains("[C]: in function 'error'")); + ASSERT_TRUE(traceback.contains("[string \"...\"]:3: in upvalue 'other'")); + ASSERT_TRUE(traceback.contains("[string \"...\"]:7: in local 'inner'")); + ASSERT_TRUE(traceback.contains( + "[string \"...\"]:9: in function <[string \"...\"]:5>")); + ASSERT_TRUE(traceback.contains("[C]: in function 'xpcall'")); + ASSERT_TRUE(traceback.contains("[string \"...\"]:12: in main chunk")); + + traceback = lua->script(R"lua( + return debug.traceback() + )lua") + .get(); + ASSERT_TRUE(traceback.contains("[string \"...\"]:2: in main chunk")); + + traceback = lua->script(R"lua( + return debug.traceback("my message") + )lua") + .get(); + ASSERT_TRUE(traceback.contains("my message")); + ASSERT_TRUE(traceback.contains("[string \"...\"]:2: in main chunk")); + + traceback = lua->script(R"lua( + local coro = coroutine.create(function () + coroutine.yield() + end) + coroutine.resume(coro) + return debug.traceback(coro) + )lua") + .get(); + ASSERT_TRUE(traceback.contains("[C]: in function 'coroutine.yield'")); + ASSERT_TRUE(traceback.contains( + "[string \"...\"]:3: in function <[string \"...\"]:2>")); + + auto msg = lua->script(R"lua( + return debug.traceback(c2.Message.new({id = "who would do this"})) + )lua") + .get>(); + ASSERT_EQ(msg->id, "who would do this"); +} + #endif