Skip to content

fix(core): guard lifecycle passes loop against destroyed renderables#882

Open
anerli wants to merge 1 commit intoanomalyco:mainfrom
magnitudedev:main
Open

fix(core): guard lifecycle passes loop against destroyed renderables#882
anerli wants to merge 1 commit intoanomalyco:mainfrom
magnitudedev:main

Conversation

@anerli
Copy link
Copy Markdown

@anerli anerli commented Mar 27, 2026

fix(core): guard lifecycle passes loop against destroyed renderables

Problem

During file-write streaming in a React-based TUI app, the renderer intermittently crashes with TextBuffer is destroyed. The crash is nondeterministic — it depends on event loop scheduling between React's commit phase and the async render loop.

The crash occurs in RootRenderable.render() when the lifecycle passes loop calls onLifecyclePass() on a TextRenderable whose TextBuffer has already been destroyed by a React unmount.

Root Cause

The render loop's lifecycle passes loop has no isDestroyed guard:

for (const renderable of this._ctx.getLifecyclePasses()) {
  renderable.onLifecyclePass?.call(renderable)  // crashes if renderable is destroyed
}

The codebase already guards against this in 15+ other places, including the render commands loop just lines later:

if (!command.renderable.isDestroyed) {
  command.renderable.render(buffer, deltaTime)
}

Additionally, TextBufferRenderable.destroy() destroys the native TextBuffer before calling super.destroy(), which is what sets isDestroyed = true and unregisters from the lifecycle passes Set. This creates a window where the buffer is dead but the renderable still appears alive.

Fix

  1. Guard the lifecycle passes loop — skip destroyed renderables, matching the existing pattern used throughout the codebase.

  2. Reorder TextBufferRenderable.destroy() — call super.destroy() first so isDestroyed is set and the renderable is unregistered from lifecycle passes before native resources are freed. This closes the window where the buffer is dead but the renderable looks alive.

Test

Added renderer.lifecycle.test.ts with a regression test that reproduces the crash by simulating a destroyed TextRenderable remaining in the lifecycle passes Set. Confirmed the test fails without the fix and passes with it.

All 3985 existing tests continue to pass.

Comment on lines +500 to -504
super.destroy()
this.textBuffer.setSyntaxStyle(null)
this._textBufferSyntaxStyle.destroy()
this.textBufferView.destroy()
this.textBuffer.destroy()
super.destroy()
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

There was something very critical about this order... 🤔 can't remember exactly, but my alarm bells are ringing.

@kommander
Copy link
Copy Markdown
Collaborator

/review especially the destroy order for TextBufferRenderable, that order was critical. Check how it came to be this exact way and if it was changed around over time.

@@ -0,0 +1,35 @@

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor style issue: The file starts with an empty line before the imports. Other test files in this directory start immediately with the import statement on line 1.

@@ -0,0 +1,35 @@

import { test, expect, beforeEach, afterEach, describe } from "bun:test"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The expect import is unused in this test file. Since this is a crash-prevention test (verifying that no exception is thrown), you could either remove the unused import, or add an explicit assertion like expect(text.isDestroyed).toBe(true) to make the test expectations clearer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants