[{"content":"Context DragonBot is a customer-support ticket bot for a purchasing-agent business on Discord, written with discord.py.\nA customer opens a ticket, which is a private channel visible only to the customer and staff. Every status change renames the channel to {type}-{id:04d}-{status}, so staff can see the status from the channel list. Closing a ticket sets the status to CLOSED, exports the conversation with DiscordChatExporter, posts the transcript to an archive channel, and DMs the customer a feedback form. What Happened After a staff member confirmed closing a ticket, the bot sometimes sat on 「正在生成頻道紀錄以及傳送回饋單給顧客\u0026hellip;」 for up to 10 minutes before the after-close buttons appeared. The owner reported it too.\nOn 2026-10-04, a ticket closed at 20:02 only finished at 20:12:45. There was nothing in the logs: no error, no warning.\nInvestigation My first guess was that the bot made too many API calls and got rate limited. Two things did not fit:\ndiscord.py handles HTTP 429 by sleeping and retrying. A normal rate limit costs seconds, not 10 minutes. The close finished about 10 minutes after the click, far longer than any normal retry. The DM to the customer was also not the cause: customer.send already catches Forbidden and skips customers who have DMs closed.\nSo I read the close path again, counting how many times the channel gets renamed. A ticket that is answered and closed quickly is renamed three times:\nEvent Status Rename Staff\u0026rsquo;s first reply OPEN → IN_PROGRESS 1 Click 關閉頻道 → RESOLVED 2 Confirm close (first line of close_ticket) → CLOSED 3 Root cause Discord allows only 2 channel renames per channel per 10 minutes. The third rename has to wait for the window to reset.\nset_ticket_status awaited the rename, so close_ticket waited on it too. discord.py reads the exhausted bucket from the response headers and waits before sending the request. Discord never returns a 429, so there is nothing to log. Even discord.py\u0026rsquo;s own \u0026ldquo;We are being rate limited\u0026rdquo; warnings never showed up. The bot starts with bot.start(), which, unlike bot.run(), does not configure discord.py\u0026rsquo;s logging. The actual fix in code Rename in the background set_ticket_status now schedules the rename as a per-channel task instead of awaiting it:\ndef _schedule_channel_rename(self, ticket: Ticket) -\u0026gt; None: # Only the latest status matters, so drop a rename still waiting on the limit self._cancel_pending_rename(channel_id=ticket.channel_id) task = asyncio.create_task( self._rename_channel_in_background(ticket=ticket), name=f\u0026#34;rename-ticket-{ticket.db_id}\u0026#34;, ) self._rename_tasks[ticket.channel_id] = task A newer status cancels a rename still waiting on the limit, so only the latest name is applied. Deleting a ticket cancels its pending rename. A rename slower than 5 seconds is logged as a warning, so the limit is visible from now on. The trade-off: after a quick close, the channel name can lag the real status by up to 10 minutes. The close itself is immediate.\nTimeout for the exporter DiscordChatExporter ran through subprocess.run with no timeout, so a hung export would block the close forever. It now times out after 180 seconds and reports the error in the channel:\n- subprocess.run(command, capture_output=True, text=True, check=True) + subprocess.run( + command, + capture_output=True, + text=True, + check=True, + timeout=EXPORT_TIMEOUT_SECONDS, + ) Log discord.py too # bot.start(), unlike bot.run(), doesn\u0026#39;t configure discord.py\u0026#39;s logging. discord_logger = logging.getLogger(\u0026#34;discord\u0026#34;) discord_logger.setLevel(logging.INFO) if not discord_logger.hasHandlers(): discord_logger.addHandler(console_handler) discord_logger.addHandler(file_handler) Follow-up: stale archive names Moving the rename to the background broke one thing. close_ticket used channel.name for the archive title, the transcript file name and the feedback DM. The name now could still be the old one, so ticket 8416\u0026rsquo;s transcript came out as 代購-8416-處理完畢 instead of 已關閉.\nThe fix is get_ticket_channel_name(ticket), which builds the name from the ticket\u0026rsquo;s status instead of reading the channel. set_ticket_channel_name uses it too, so the two cannot drift apart.\nAfter both changes, closing a ticket finishes in seconds.\nWhat I learned A symptom that looks like \u0026ldquo;too many requests\u0026rdquo; can be a per-resource limit. Discord\u0026rsquo;s rename limit is per channel, not per bot. A missing log line is evidence too. Here it meant the library never got a 429, because it waited before sending. Check what a library does silently, like waiting out rate limits, before blaming the network. bot.start() and bot.run() are not equivalent: only bot.run() sets up logging. ","permalink":"https://blog.hoshiyomi-dev.work/post/dragonbot-ticket-close-hang/","summary":"\u003ch2 id=\"context\"\u003eContext\u003c/h2\u003e\n\u003cp\u003e\u003ca href=\"https://github.com/xDDoubleTea/DragonBot\"\u003eDragonBot\u003c/a\u003e is a customer-support ticket bot for a purchasing-agent business on Discord, written with \u003ccode\u003ediscord.py\u003c/code\u003e.\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eA customer opens a ticket, which is a private channel visible only to the customer and staff.\u003c/li\u003e\n\u003cli\u003eEvery status change renames the channel to \u003ccode\u003e{type}-{id:04d}-{status}\u003c/code\u003e, so staff can see the status from the channel list.\u003c/li\u003e\n\u003cli\u003eClosing a ticket sets the status to \u003ccode\u003eCLOSED\u003c/code\u003e, exports the conversation with DiscordChatExporter, posts the transcript to an archive channel, and DMs the customer a feedback form.\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"what-happened\"\u003eWhat Happened\u003c/h2\u003e\n\u003cp\u003eAfter a staff member confirmed closing a ticket, the bot sometimes sat on 「正在生成頻道紀錄以及傳送回饋單給顧客\u0026hellip;」 for up to 10 minutes before the after-close buttons appeared. The owner reported it too.\u003c/p\u003e","title":"DragonBot Ticket Close Hang"},{"content":"Context LeetCodeBot talks to its database through AsyncDatabaseManager, an async context manager that hands out SQLAlchemy sessions:\nasync with self.async_database_manager as db: db.add(row) # leaving the block commits, or rolls back on error main.py creates one manager and shares it across every cog. The old version looked like this:\nasync def __aenter__(self) -\u0026gt; AsyncSession: self.session = self._sessionmaker() return self.session async def __aexit__(self, exc_type, exc_val, exc_tb): try: assert self.session if exc_type: await self.session.rollback() else: await self.session.commit() await self.session.close() except AssertionError: return True finally: return False It has two bugs (#38).\nBug 1: __aexit__ commits the wrong session __aenter__ returns a new session, so the caller\u0026rsquo;s db is bound to that object. But __aexit__ reads self.session, which belongs to the shared manager.\nAnything that reassigns self.session between enter and exit breaks this:\nAnother command running concurrently. discord.py runs each command in its own task. A nested async with inside the same command. The outer block then commits and closes someone else\u0026rsquo;s session, and its own is silently dropped.\nSimulating ten concurrent commands that each add one row: 1 row of 10 was committed. The other nine sessions were never flushed and kept their connections checked out.\nThe bot already had two nested call sites:\nget_thread_by_problem_id: read-only, but the outer session is never closed. A leaked connection. create_thread_in_db: the row is staged on the outer session while the inner one is committed. The row is lost. Like the MissingGreenlet bug, both sit behind cache checks. A warm bot skips the inner async with, so the bug stayed hidden.\nBug 2: return inside finally finally: return False A return in finally discards any exception in flight:\nA failing commit(), for example an IntegrityError from a unique constraint, is swallowed. The caller is told the write succeeded. close() is skipped on that path, leaking the connection. The except AssertionError: return True branch is dead code: finally overrides its return value. One correction to my own issue: an exception raised in the async with body still reaches the caller, because returning False from __aexit__ means \u0026ldquo;don\u0026rsquo;t suppress\u0026rdquo;. Only exceptions raised inside __aexit__ are lost.\nThe fix: a ContextVar stack The session moves off the shared instance into a module-level ContextVar (#46).\nEach asyncio.Task copies the context when it starts, so one command\u0026rsquo;s set() is invisible to every other. A small demo, with four tasks writing both a global and a ContextVar:\ntask-0: shared=\u0026#39;task-3\u0026#39; ctx=\u0026#39;task-0\u0026#39; task-1: shared=\u0026#39;task-3\u0026#39; ctx=\u0026#39;task-1\u0026#39; task-2: shared=\u0026#39;task-3\u0026#39; ctx=\u0026#39;task-2\u0026#39; task-3: shared=\u0026#39;task-3\u0026#39; ctx=\u0026#39;task-3\u0026#39; The global ends up as whoever wrote last. The ContextVar keeps each task\u0026rsquo;s own value.\nA single value is still not enough, because nesting happens inside one task:\nSingle value (nested in ONE task): exiting inner: single.get() -\u0026gt; \u0026#39;inner\u0026#39; exiting outer: single.get() -\u0026gt; \u0026#39;inner\u0026#39; \u0026lt;- wrong session Stack (nested in ONE task): exiting inner: popped -\u0026gt; \u0026#39;inner\u0026#39; exiting outer: popped -\u0026gt; \u0026#39;outer\u0026#39; So the ContextVar holds a tuple used as a stack:\n_session_stack: ContextVar[tuple[AsyncSession, ...]] = ContextVar( \u0026#34;_session_stack\u0026#34;, default=() ) async def __aenter__(self) -\u0026gt; AsyncSession: session = self._sessionmaker() _session_stack.set((*_session_stack.get(), session)) return session async def __aexit__(self, exc_type, exc_val, exc_tb) -\u0026gt; bool: stack = _session_stack.get() if not stack: return False session = stack[-1] _session_stack.set(stack[:-1]) try: if exc_type: await session.rollback() else: await session.commit() finally: await session.close() return False close() is now in finally, and return False is outside it, so commit errors reach the caller. None of the 17 call sites needed to change.\nTests that could never fail Two of the obvious tests passed against the buggy code. I only found out by reverting the fix and checking which tests went red.\nConcurrency test. Run ten tasks and assert they got ten distinct sessions. It passes on the old code: __aenter__ really does hand out a fresh session each time. The bug is in which session __aexit__ commits. Asserting on committed rows catches it: assert 1 == 10.\nNesting test. Open an outer and an inner block, then check the outer session still works. It does, so the test passes on the old code. To catch the bug, the test has to write to the outer session after the inner block closes, then check the row survived:\nasync with database_manager as outer: async with database_manager as inner: assert inner is not outer outer.add(GuildForumChannel(...)) # the write create_thread_in_db loses async with database_manager as session: assert await session.scalar(select(GuildForumChannel)) is not None Against the old manager: assert None is not None, the same data loss as create_thread_in_db.\nWorse than a lost row I also drove the real create_thread_in_db with all caches emptied, so both nested blocks run. With the fix, the row persists. With the old manager, the script does not fail: it hangs. The leaked outer session never returns its connection to the pool, and the next session waits for it forever.\nSo the reported symptom was a failed assertion, but the real defect was a connection leak that could freeze the bot.\nWhat I learned Per-request state must not live on a shared object. In asyncio, ContextVar gives each task its own copy. Never return inside finally. It silently swallows exceptions. A regression test that has never been seen to fail is a guess. Revert the fix and watch the test go red before trusting it. ","permalink":"https://blog.hoshiyomi-dev.work/post/leetcodebot-shared-session/","summary":"\u003ch2 id=\"context\"\u003eContext\u003c/h2\u003e\n\u003cp\u003e\u003ca href=\"https://github.com/xDDoubleTea/LeetCodeBot\"\u003eLeetCodeBot\u003c/a\u003e talks to its database through \u003ccode\u003eAsyncDatabaseManager\u003c/code\u003e, an async context manager that hands out SQLAlchemy sessions:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-python\" data-lang=\"python\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003easync\u003c/span\u003e \u003cspan class=\"k\"\u003ewith\u003c/span\u003e \u003cspan class=\"bp\"\u003eself\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003easync_database_manager\u003c/span\u003e \u003cspan class=\"k\"\u003eas\u003c/span\u003e \u003cspan class=\"n\"\u003edb\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"n\"\u003edb\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eadd\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003erow\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# leaving the block commits, or rolls back on error\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003ccode\u003emain.py\u003c/code\u003e creates \u003cstrong\u003eone\u003c/strong\u003e manager and shares it across every cog. The old version looked like this:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-python\" data-lang=\"python\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003easync\u003c/span\u003e \u003cspan class=\"k\"\u003edef\u003c/span\u003e \u003cspan class=\"fm\"\u003e__aenter__\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"bp\"\u003eself\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e \u003cspan class=\"n\"\u003eAsyncSession\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"bp\"\u003eself\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003esession\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"bp\"\u003eself\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003e_sessionmaker\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"bp\"\u003eself\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003esession\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003easync\u003c/span\u003e \u003cspan class=\"k\"\u003edef\u003c/span\u003e \u003cspan class=\"fm\"\u003e__aexit__\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"bp\"\u003eself\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003eexc_type\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003eexc_val\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003eexc_tb\u003c/span\u003e\u003cspan class=\"p\"\u003e):\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003etry\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"k\"\u003eassert\u003c/span\u003e \u003cspan class=\"bp\"\u003eself\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003esession\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"n\"\u003eexc_type\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e            \u003cspan class=\"k\"\u003eawait\u003c/span\u003e \u003cspan class=\"bp\"\u003eself\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003esession\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003erollback\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"k\"\u003eelse\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e            \u003cspan class=\"k\"\u003eawait\u003c/span\u003e \u003cspan class=\"bp\"\u003eself\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003esession\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ecommit\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"k\"\u003eawait\u003c/span\u003e \u003cspan class=\"bp\"\u003eself\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003esession\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eclose\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003eexcept\u003c/span\u003e \u003cspan class=\"ne\"\u003eAssertionError\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"kc\"\u003eTrue\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003efinally\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"kc\"\u003eFalse\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eIt has two bugs (\u003ca href=\"https://github.com/xDDoubleTea/LeetCodeBot/issues/38\"\u003e#38\u003c/a\u003e).\u003c/p\u003e\n\u003ch2 id=\"bug-1-__aexit__-commits-the-wrong-session\"\u003eBug 1: \u003ccode\u003e__aexit__\u003c/code\u003e commits the wrong session\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003e__aenter__\u003c/code\u003e returns a new session, so the caller\u0026rsquo;s \u003ccode\u003edb\u003c/code\u003e is bound to that object. But \u003ccode\u003e__aexit__\u003c/code\u003e reads \u003ccode\u003eself.session\u003c/code\u003e, which belongs to the shared manager.\u003c/p\u003e","title":"LeetCodeBot: One Session, Many Commands"},{"content":"Context LeetCodeBot is a Discord bot for looking up and discussing LeetCode problems. It stores problems and their topic tags with SQLAlchemy on SQLite.\nIn August 2026, #35 moved the database layer from synchronous SQLAlchemy to async (sqlite+aiosqlite). The bot kept running in production afterwards, with no errors.\nWhat happened A fresh clone could not start the bot. Deleting the database file to \u0026ldquo;start clean\u0026rdquo; made it fail every time:\nsqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called; can\u0026#39;t call await_only() here. Was IO attempted in an unexpected place? File \u0026#34;core/leetcode_problem.py\u0026#34;, line 420, in add_problem_to_db if db_tag not in db_problem.tags: The failing expression is a plain attribute read: db_problem.tags.\nThe line nobody touched Here is what the migration did to add_problem_to_db:\n- with self.database_manager as db: + async with self.async_database_manager as db: - db_problem = db.query(Problem).filter_by(...).first() + result = await db.scalars(select(Problem).where(...)) - db.flush() + await db.flush() if db_tag not in db_problem.tags: # untouched db_problem.tags.append(db_tag) # untouched Every line that did IO gained an await. The crashing line has no + or - at all: it is unchanged context.\nIt was missed because it does not look like IO. There is no call and nothing to await. But Problem.tags is a lazy-loaded relationship, so reading it emits a SELECT.\nThe same read against each engine:\nSYNC -\u0026gt; db_problem.tags : [] works, lazy load just runs ASYNC -\u0026gt; FAILED: MissingGreenlet same line, different engine In sync SQLAlchemy, lazy loading is the default and just works. The async engine quietly takes that away. So the migration\u0026rsquo;s hazard was not in the lines it rewrote; it was a capability removed from a line it left alone.\nWhat MissingGreenlet means SQLAlchemy\u0026rsquo;s ORM is written as synchronous code, and the IO happens many frames deep. Making all of that async def would force every caller above it to become async too.\nInstead, SQLAlchemy uses greenlets, coroutines that can suspend from anywhere in a call stack:\ngreenlet_spawn(fn) runs synchronous ORM code inside a greenlet that it marks as a bridge. await_only(awaitable) is called deep inside that code when it needs IO. It switches back to the event loop, awaits, and returns the result. await_only first checks that it is inside such a bridge:\ncurrent = getcurrent() if not getattr(current, \u0026#34;__sqlalchemy_greenlet_provider__\u0026#34;, False): _safe_cancel_awaitable(awaitable) raise exc.MissingGreenlet(...) return current.parent.switch(awaitable) So the name means: synchronous ORM code tried to do IO, but there is no bridge in the current frame to hand that IO to the event loop. The bridge is observable with SQLAlchemy\u0026rsquo;s own in_greenlet():\nin my own async frame : False inside SQLAlchemy\u0026#39;s internals : True \u0026lt;- statement about to run Every awaited API (await session.scalars(...), await session.refresh(...)) enters through greenlet_spawn. A bare attribute read does not: it runs in my own frame, where in_greenlet() is False.\nThe rule for async SQLAlchemy: any attribute that may trigger IO must be loaded by something you awaited.\nWhy production never crashed Both branches of add_problem_to_db reached the loop with tags unloaded:\nBranch State at the loop Result Existing problem, loaded without selectinload persistent, tags unloaded lazy SELECT, MissingGreenlet New problem: db.add() then await db.flush() the flush makes it persistent, tags unloaded lazy SELECT, MissingGreenlet Yet production never reached either branch:\nadd_problem_to_db runs only when the daily problem is missing from the database. The SQLite file lives in a bind-mounted host directory, so it survives every deploy. The daily refresh bulk-upserts every problem through Core inserts, which never touch the ORM relationship. On a warm database the function was effectively dead code. On an empty one it was the only path, and it failed every time.\nThree things had to line up to hide it:\nThe async engine made implicit lazy loading illegal. Only this function relied on it. The other four queries in the file already used selectinload, for unrelated reasons. This function is unreachable on a populated database. Remove any one, and the bug would have shown up the day the migration merged.\nThe fix Both branches need their own fix (#45):\n# 1. Eager load on the existing-problem query. result = await db.scalars( select(Problem) .options(selectinload(Problem.tags)) .where(Problem.problem_id == problem.problem_id) ) db_problem = result.first() if not db_problem: db.add(problem) await db.flush() db_problem = problem # 2. Load the collection for the newly persistent object. await db.refresh(db_problem, attribute_names=[\u0026#34;tags\u0026#34;]) The new tests cover both branches and a .tags read after the session closes, since the bot caches the returned object. All three fail with MissingGreenlet against the old code.\nTo confirm end to end, I deleted the database and started the real bot. It passed the old crash site and logged in.\nWhat I learned When migrating to async, review the unchanged lines too. A line can become invalid without being edited. In async SQLAlchemy, an attribute access can be IO. Load relationships explicitly. A bug that only appears on cold state can live in main for weeks. Test from an empty database, not just against the one in production. ","permalink":"https://blog.hoshiyomi-dev.work/post/leetcodebot-missing-greenlet/","summary":"\u003ch2 id=\"context\"\u003eContext\u003c/h2\u003e\n\u003cp\u003e\u003ca href=\"https://github.com/xDDoubleTea/LeetCodeBot\"\u003eLeetCodeBot\u003c/a\u003e is a Discord bot for looking up and discussing LeetCode problems. It stores problems and their topic tags with SQLAlchemy on SQLite.\u003c/p\u003e\n\u003cp\u003eIn August 2026, \u003ca href=\"https://github.com/xDDoubleTea/LeetCodeBot/pull/35\"\u003e#35\u003c/a\u003e moved the database layer from synchronous SQLAlchemy to async (\u003ccode\u003esqlite+aiosqlite\u003c/code\u003e). The bot kept running in production afterwards, with no errors.\u003c/p\u003e\n\u003ch2 id=\"what-happened\"\u003eWhat happened\u003c/h2\u003e\n\u003cp\u003eA fresh clone could not start the bot. Deleting the database file to \u0026ldquo;start clean\u0026rdquo; made it fail every time:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003esqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called;\ncan\u0026#39;t call await_only() here. Was IO attempted in an unexpected place?\n\n  File \u0026#34;core/leetcode_problem.py\u0026#34;, line 420, in add_problem_to_db\n    if db_tag not in db_problem.tags:\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eThe failing expression is a plain attribute read: \u003ccode\u003edb_problem.tags\u003c/code\u003e.\u003c/p\u003e","title":"LeetCodeBot: The Bug the Async Migration Did Not Write"},{"content":"Context kcalc is a calculator running directly in kernelspace. Achieved by loading as a Linux kernel module, and by exposing a character device /dev/kcalc_chardev to the userspace, supporting read, write operations.\nA process write the expression they want to evaluate to /dev/kcalc_chardev. That process then read from /dev/kcalc_chardev. The character device is locked whenever a process is writing to/reading from it via mutex_lock();. The kernel is tested using pytest, calls tee and cat for write and read operations respectively What Happened When running the pytest suite, the process hangs indefinitely, no cases is passed or failed. It is also unkillable. Opening btop reveals that the cat process is there hanging, and causing the pytest which calls cat as a subprocess hangs. The cat process is unkillable even with SIGKILL.\nHow it is solved At first, I just reboot the virtual machine using systemctl reboot, but that didn\u0026rsquo;t work since it cannot be shut down. As a result, I forcibly shut down the VM, brought it up, and then use the command line directly to interact with the character device. The cat command also freezes so I had to forcibly shut it down again. I then look into the kcalc_device_read function in the source code.\nstatic ssize_t kcalc_device_read(struct file *filp, /* see include/linux/fs.h */ char __user *user_buf, /* buffer to fill with data */ size_t len, /* length of the buffer */ loff_t *offset) { ssize_t ret = 0; mutex_lock(\u0026amp;kcalc_buf_lock); if (*offset \u0026gt;= result_len) { ret = 0; goto out; } if (len \u0026gt; result_len - *offset) len = result_len - *offset; if (copy_to_user(user_buf, result_buf + *offset, len)) { ret = -EFAULT; goto out; } *offset += len; ret = len; out: mutex_lock(\u0026amp;kcalc_buf_lock); return ret; } The problem is this function will never unlock the mutex, and all paths goes to the out label. The thread calls mutex_lock first, then do the reading or error to out label, but either way it will hit the second mutex_lock(\u0026amp;kcalc_buf_lock); statement. The same thread sees the mutex is locked, so it just waits for it to unlock, which is held by itself. This made it get put into TASK_UNINTERRUPTIBLE, so even SIGKILL takes no effect. This is a self-deadlock, because the thread relocks the mutex held by itself.\nThe actual fix in code The fix is simple, just change the second mutext_lock(\u0026amp;kcalc_buf_lock); statement to mutex_unlock(\u0026amp;kcalc_buf_lock);. The pytest immediately worked, it won\u0026rsquo;t hang and can finally confirm all tests are passing.\nstatic ssize_t kcalc_device_read(struct file *filp, char __user *user_buf, size_t len, loff_t *offset) { ssize_t ret = 0; mutex_lock(\u0026amp;kcalc_buf_lock); if (*offset \u0026gt;= result_len) { ret = 0; goto out; } if (len \u0026gt; result_len - *offset) len = result_len - *offset; if (copy_to_user(user_buf, result_buf + *offset, len)) { ret = -EFAULT; goto out; } *offset += len; ret = len; out: -\tmutex_lock(\u0026amp;kcalc_buf_lock); +\tmutex_unlock(\u0026amp;kcalc_buf_lock); return ret; } What I learned mutex_lock statements should be paired with a mutex_unlock statement, or it will go into a self-deadlock, just like how malloc/kmalloc and free/kfree should be paired.\nThis is also why the goto error handling pattern is used in the kernel. If I just did early return whenever something goes wrong, then I would have to find which branch forgets to unlock itself. But by using goto error handling pattern, only a single line change fixes all paths.\nAdditional information Additionally, I learned that for kernel version after 6.4, which my debian VM uses 6.12, there is a safer pattern, which uses guard(mutex)(\u0026amp;kcalc_buf_lock), to prevent writing mutex_lock and mutex_unlock manually and triggering these kind of self-deadlock. We can also remove the goto statement completely, since even though it saves us debugging time, it is still not that intuitive to deal with, if coming from a higher level language like python.\n","permalink":"https://blog.hoshiyomi-dev.work/post/kcalc-self-deadlock/","summary":"\u003ch2 id=\"context\"\u003eContext\u003c/h2\u003e\n\u003cp\u003e\u003ca href=\"https://github.com/xDDoubleTea/kcalc\"\u003ekcalc\u003c/a\u003e is a calculator running directly in kernelspace.\nAchieved by loading as a Linux kernel module, and by exposing a character device \u003ccode\u003e/dev/kcalc_chardev\u003c/code\u003e to the userspace, supporting\n\u003ccode\u003eread, write\u003c/code\u003e operations.\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eA process \u003ccode\u003ewrite\u003c/code\u003e the expression they want to evaluate to \u003ccode\u003e/dev/kcalc_chardev\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003eThat process then \u003ccode\u003eread\u003c/code\u003e from \u003ccode\u003e/dev/kcalc_chardev\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003eThe character device is locked whenever a process is writing to/reading from it via \u003ccode\u003emutex_lock();\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003eThe kernel is tested using \u003ccode\u003epytest\u003c/code\u003e, calls \u003ccode\u003etee\u003c/code\u003e and \u003ccode\u003ecat\u003c/code\u003e for \u003ccode\u003ewrite\u003c/code\u003e and \u003ccode\u003eread\u003c/code\u003e operations respectively\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"what-happened\"\u003eWhat Happened\u003c/h2\u003e\n\u003cp\u003eWhen running the \u003ccode\u003epytest\u003c/code\u003e suite, the process hangs indefinitely, no cases is passed or failed. It is also unkillable. Opening \u003ccode\u003ebtop\u003c/code\u003e reveals that the \u003ccode\u003ecat\u003c/code\u003e process is there hanging, and causing the \u003ccode\u003epytest\u003c/code\u003e which calls \u003ccode\u003ecat\u003c/code\u003e as a subprocess hangs. The \u003ccode\u003ecat\u003c/code\u003e process is unkillable even with \u003ccode\u003eSIGKILL\u003c/code\u003e.\u003c/p\u003e","title":"Kcalc Self Deadlock"},{"content":"This is the first post on my new Hugo blog. Hello, world!\n","permalink":"https://blog.hoshiyomi-dev.work/post/hello-world/","summary":"\u003cp\u003eThis is the first post on my new \u003ca href=\"https://gohugo.io/\"\u003eHugo\u003c/a\u003e blog. Hello, world!\u003c/p\u003e","title":"Hello World"}]