Repository: marimo-team/marimo

Issue:
`\verb` command (sometimes) not rendering
### Describe the bug

The $\verb|\verb|$ command in Tex does not render correctly, but this happens _sometimes_. I've been able to reproduce the issue minimally as [follows](https://marimo.io/p/@aliphys/notebook-q9hkn9):

![Image](https://github.com/user-attachments/assets/d756d3fa-fb58-484e-b55e-bc6a06506a5a)

The issue replicates on both the Cloud instance, as well as the locally installed instance of Marimo. I would expect that the render of the third cell is also the render of the second cell. 

### Environment

For the local environment:
<details>

```
{
  "marimo": "0.11.0",
  "OS": "Windows",
  "OS Version": "11",
  "Processor": "Intel64 Family 6 Model 167 Stepping 1, GenuineIntel",
  "Python Version": "3.12.8",
  "Binaries": {
    "Browser": "--",
    "Node": "--"
  },
  "Dependencies": {
    "click": "8.1.8",
    "docutils": "0.21.2",
    "itsdangerous": "2.2.0",
    "jedi": "0.19.2",
    "markdown": "3.7",
    "narwhals": "1.25.1",
    "packaging": "24.2",
    "psutil": "6.1.1",
    "pygments": "2.19.1",
    "pymdown-extensions": "10.14.3",
    "pyyaml": "6.0.2",
    "ruff": "0.9.4",
    "starlette": "0.45.3",
    "tomlkit": "0.13.2",
    "typing-extensions": "4.12.2",
    "uvicorn": "0.34.0",
    "websockets": "14.2"
  },
  "Optional Dependencies": {
    "pandas": "2.2.3"
  },
  "Experimental Flags": {}
}
```

</details>


### Code to reproduce

Code below  (generated via the UI, not manually) is accessible here: https://marimo.io/p/@aliphys/notebook-q9hkn9
```
import marimo

__generated_with = "0.10.12"
app = marimo.App()


@app.cell
def _():
    import marimo as mo
    return (mo,)


@app.cell
def _(mo):
    mo.md("""$\verb|Hello World|$""")
    return


@app.cell
def _(mo):
    mo.md(r"""$\verb|Hello World|$""")
    return


@app.cell
def _(mo):
    print(mo.__version__)
    return


if __name__ == "__main__":
    app.run()
```

Hints from issue comments:
@mscolnick: @aliphys, your first markdown uses `r-string` while your second does not. 

We recommend using `r'''` to write latex, otherwise you will need to escape special characters@aliphys: Thanks @mscolnick for your answer. 

As a temporary solution ✅, I can perform the following steps:
- Write equation in a Markdown cell -> `$\verb|Hello World|$`
- If it does not render correctly, switch to a Python cell (CTRL+Shift + m) -> `mo.md("""$\verb|Hello World|$""")`
- Add the `r` character manually, as stated [here](https://docs.marimo.io/api/markdown/) -> `mo.md(r"""$\verb|Hello World|$""")`
- Switch back to the Markdown (CTRL+Shift + m) to achieve the correct rendering -> $\verb|Hello World|$

Is there a way to _not_ have to add the `r` character manually, since it disrupts the flow?

@mscolnick: > Is there a way to not have to add the r character manually, since it disrupts the flow?

@aliphys the `r` character is automatically added for when you create a new markdown cell. 

there is one code-path that it does not (when converting python -> markdown, when it was never markdown). i can update this, but first will check if there are any side-effects

EDIT: PR here #3701

Solution patch:
diff --git a/frontend/src/core/codemirror/language/__tests__/extension.test.ts b/frontend/src/core/codemirror/language/__tests__/extension.test.ts
index 32765bcb..e81991e0 100644
--- a/frontend/src/core/codemirror/language/__tests__/extension.test.ts
+++ b/frontend/src/core/codemirror/language/__tests__/extension.test.ts
@@ -141,7 +141,7 @@ describe("switchLanguage", () => {
     switchLanguage(mockEditor, "python", { keepCodeAsIs: false });
     expect(mockEditor.state.doc.toString()).toMatchInlineSnapshot(`
       "mo.md(
-          """
+          r"""
           print('Hello')
           print('Goodbye')
           """
diff --git a/frontend/src/core/codemirror/language/__tests__/markdown.test.ts b/frontend/src/core/codemirror/language/__tests__/markdown.test.ts
index 01c0a683..e2923922 100644
--- a/frontend/src/core/codemirror/language/__tests__/markdown.test.ts
+++ b/frontend/src/core/codemirror/language/__tests__/markdown.test.ts
@@ -24,6 +24,7 @@ describe("MarkdownLanguageAdapter", () => {
       const [innerCode, offset] = adapter.transformIn(pythonCode);
       expect(innerCode).toBe("# Markdown Title\n\nSome content here.");
       expect(offset).toBe(9);
+      expect(adapter.lastQuotePrefix).toBe("");
     });
 
     it("should handle single double-quoted strings", () => {
@@ -31,6 +32,7 @@ describe("MarkdownLanguageAdapter", () => {
       const [innerCode, offset] = adapter.transformIn(pythonCode);
       expect(innerCode).toBe("Some *markdown* content");
       expect(offset).toBe(7);
+      expect(adapter.lastQuotePrefix).toBe("");
     });
 
     it("should handle triple single-quoted strings", () => {
@@ -38,6 +40,7 @@ describe("MarkdownLanguageAdapter", () => {
       const [innerCode, offset] = adapter.transformIn(pythonCode);
       expect(innerCode).toBe("# Another Title\nContent");
       expect(offset).toBe(9);
+      expect(adapter.lastQuotePrefix).toBe("");
     });
 
     it("should still transform if no markdown is detected", () => {
@@ -48,6 +51,7 @@ describe("MarkdownLanguageAdapter", () => {
           0,
         ]
       `);
+      expect(adapter.lastQuotePrefix).toBe("r");
     });
 
     it("should handle an empty string", () => {
@@ -55,6 +59,7 @@ describe("MarkdownLanguageAdapter", () => {
       const [innerCode, offset] = adapter.transformIn(pythonCode);
       expect(innerCode).toBe("");
       expect(offset).toBe(0);
+      expect(adapter.lastQuotePrefix).toBe("");
     });
 
     it("should unescape code blocks", () => {
@@ -82,6 +87,7 @@ describe("MarkdownLanguageAdapter", () => {
       const [innerCode, offset] = adapter.transformIn(pythonCode);
       expect(innerCode).toBe(String.raw`Markdown with an escaped \"quote\"`);
       expect(offset).toBe('mo.md("""'.length);
+      expect(adapter.lastQuotePrefix).toBe("");
     });
 
     it("should handle strings with nested markdown", () => {
@@ -92,6 +98,7 @@ describe("MarkdownLanguageAdapter", () => {
         '# Title\n\n```python\nprint("Hello, Markdown!")\n```',
       );
       expect(offset).toBe('mo.md("""'.length);
+      expect(adapter.lastQuotePrefix).toBe("");
     });
 
     it("should convert to markdown anyways", () => {
@@ -99,6 +106,7 @@ describe("MarkdownLanguageAdapter", () => {
       const [innerCode, offset] = adapter.transformIn(pythonCode);
       expect(innerCode).toBe(`print("No markdown here")`);
       expect(offset).toBe(0);
+      expect(adapter.lastQuotePrefix).toBe("r");
     });
 
     it("should handle multiple markdown blocks in a single string", () => {
@@ -116,6 +124,7 @@ describe("MarkdownLanguageAdapter", () => {
         `# Hello, Markdown!\nmo.md(\n    '''\n    # Hello, Markdown!\n    Use marimo's "md" function to embed rich text into your marimo\n    '''\n)`,
       );
       expect(offset).toBe(9);
+      expect(adapter.lastQuotePrefix).toBe("");
     });
 
     it("simple markdown", () => {
@@ -123,6 +132,7 @@ describe("MarkdownLanguageAdapter", () => {
       const [innerCode, offset] = adapter.transformIn(pythonCode);
       expect(innerCode).toBe("# Title without proper delimiters");
       expect(offset).toBe(7);
+      expect(adapter.lastQuotePrefix).toBe("");
     });
 
     it("should trim strings with leading and trailing whitespace", () => {
@@ -130,6 +140,7 @@ describe("MarkdownLanguageAdapter", () => {
       const [innerCode, offset] = adapter.transformIn(pythonCode);
       expect(innerCode).toBe("# Title\nContent");
       expect(offset).toBe(9);
+      expect(adapter.lastQuotePrefix).toBe("");
     });
 
     it("should handle space around the f-strings", () => {
@@ -137,6 +148,7 @@ describe("MarkdownLanguageAdapter", () => {
       const [innerCode, offset] = adapter.transformIn(pythonCode);
       expect(innerCode).toBe("# Title\n{  Content  }");
       expect(offset).toBe(11);
+      expect(adapter.lastQuotePrefix).toBe("");
     });
 
     it("should dedent indented strings", () => {
@@ -145,6 +157,7 @@ describe("MarkdownLanguageAdapter", () => {
       const [innerCode, offset] = adapter.transformIn(pythonCode);
       expect(innerCode).toBe("- item 1\n-item 2\n-item3");
       expect(offset).toBe(11);
+      expect(adapter.lastQuotePrefix).toBe("");
     });
 
     it("should preserve escaped characters", () => {
@@ -154,6 +167,7 @@ describe("MarkdownLanguageAdapter", () => {
         String.raw`$\nu = \mathllap{}\cdot\mathllap{\alpha}$`,
       );
       expect(offset).toBe(8);
+      expect(adapter.lastQuotePrefix).toBe("r");
     });
   });
 
diff --git a/frontend/src/core/codemirror/language/markdown.ts b/frontend/src/core/codemirror/language/markdown.ts
index 31501159..13db2072 100644
--- a/frontend/src/core/codemirror/language/markdown.ts
+++ b/frontend/src/core/codemirror/language/markdown.ts
@@ -88,6 +88,7 @@ export class MarkdownLanguageAdapter implements LanguageAdapter {
     }
 
     // no match
+    this.lastQuotePrefix = "r";
     return [pythonCode, 0];
   }
 
diff --git a/marimo/_ast/app.py b/marimo/_ast/app.py
index f5245978..d28f4e32 100644
--- a/marimo/_ast/app.py
+++ b/marimo/_ast/app.py
@@ -4,6 +4,7 @@ from __future__ import annotations
 import ast
 import base64
 import inspect
+import threading
 from dataclasses import asdict, dataclass, field
 from pathlib import Path
 from textwrap import dedent
@@ -37,6 +38,7 @@ from marimo._runtime import dataflow
 from marimo._runtime.app.kernel_runner import AppKernelRunner
 from marimo._runtime.app.script_runner import AppScriptRunner
 from marimo._runtime.context.types import (
+    ContextNotInitializedError,
     get_context,
     runtime_context_installed,
 )
@@ -132,6 +134,31 @@ class AppEmbedResult:
     defs: Mapping[str, object]
 
 
+class AppKernelRunnerRegistry:
+    def __init__(self) -> None:
+        # Mapping from thread to its kernel runners, so that app.embed() calls are
+        # isolated across run sessions.
+        self._runners: dict[int, dict[App, AppKernelRunner]] = {}
+
+    def get_runner(self, app: App) -> AppKernelRunner:
+        app_runners = self._runners.setdefault(threading.get_ident(), {})
+        runner = app_runners.get(app, None)
+        if runner is None:
+            runner = AppKernelRunner(InternalApp(app))
+            app_runners[app] = runner
+        return runner
+
+    def remove_runner(self, app: App) -> None:
+        app_runners = self._runners.get(tid := threading.get_ident(), {})
+        if app in app_runners:
+            del app_runners[app]
+        if tid in self._runners and not (self._runners[tid]):
+            del self._runners[tid]
+
+    def shutdown(self) -> None:
+        self._runners.clear()
+
+
 @mddoc
 class App:
     """A marimo notebook.
@@ -175,7 +202,12 @@ class App:
             self._filename = inspect.getfile(inspect.stack()[1].frame)
         except Exception:
             ...
-        self._app_kernel_runner: AppKernelRunner | None = None
+
+    def __del__(self) -> None:
+        try:
+            get_context().app_kernel_runner_registry.remove_runner(self)
+        except ContextNotInitializedError:
+            ...
 
     def clone(self) -> App:
         """Clone an app to embed multiple copies of it.
@@ -328,9 +360,7 @@ class App:
             self._initialized = True
 
     def _get_kernel_runner(self) -> AppKernelRunner:
-        if self._app_kernel_runner is None:
-            self._app_kernel_runner = AppKernelRunner(InternalApp(self))
-        return self._app_kernel_runner
+        return get_context().app_kernel_runner_registry.get_runner(self)
 
     def _flatten_outputs(self, outputs: dict[CellId_t, Any]) -> Sequence[Any]:
         return tuple(
diff --git a/marimo/_runtime/app/kernel_runner.py b/marimo/_runtime/app/kernel_runner.py
index 0e05d89d..e840ccff 100644
--- a/marimo/_runtime/app/kernel_runner.py
+++ b/marimo/_runtime/app/kernel_runner.py
@@ -92,10 +92,9 @@ class AppKernelRunner:
             parent=ctx,
         )
         ctx.add_child(self._runtime_context)
-        finalizer = weakref.finalize(
+        weakref.finalize(
             self, ctx.remove_child, self._runtime_context
-        )
-        finalizer.atexit = False
+        ).atexit = False
 
         # Register cells through the kernel runner, so that compilation only
         # occurs once.
diff --git a/marimo/_runtime/context/kernel_context.py b/marimo/_runtime/context/kernel_context.py
index e291c144..428428b5 100644
--- a/marimo/_runtime/context/kernel_context.py
+++ b/marimo/_runtime/context/kernel_context.py
@@ -5,6 +5,7 @@ from contextlib import contextmanager
 from dataclasses import dataclass
 from typing import TYPE_CHECKING, Any, Iterator, Optional
 
+from marimo._ast.app import AppKernelRunnerRegistry
 from marimo._config.config import MarimoConfig
 from marimo._messaging.types import Stderr, Stdout
 from marimo._plugins.ui._core.ids import IDProvider, NoIDProviderException
@@ -152,6 +153,7 @@ def create_kernel_context(
         state_registry=StateRegistry(),
         function_registry=FunctionRegistry(),
         cell_lifecycle_registry=CellLifecycleRegistry(),
+        app_kernel_runner_registry=AppKernelRunnerRegistry(),
         virtual_file_registry=VirtualFileRegistry(),
         virtual_files_supported=virtual_files_supported,
         stream=stream,
diff --git a/marimo/_runtime/context/script_context.py b/marimo/_runtime/context/script_context.py
index 521936ef..57946523 100644
--- a/marimo/_runtime/context/script_context.py
+++ b/marimo/_runtime/context/script_context.py
@@ -6,6 +6,7 @@ from contextlib import contextmanager
 from dataclasses import dataclass
 from typing import TYPE_CHECKING, Any, Iterator, Optional
 
+from marimo._ast.app import AppKernelRunnerRegistry
 from marimo._cli.parse_args import args_from_argv
 from marimo._config.config import MarimoConfig
 from marimo._config.manager import get_default_config_manager
@@ -140,6 +141,7 @@ def initialize_script_context(
         state_registry=StateRegistry(),
         function_registry=FunctionRegistry(),
         cell_lifecycle_registry=CellLifecycleRegistry(),
+        app_kernel_runner_registry=AppKernelRunnerRegistry(),
         virtual_file_registry=VirtualFileRegistry(),
         virtual_files_supported=False,
         stream=stream,
diff --git a/marimo/_runtime/context/types.py b/marimo/_runtime/context/types.py
index 217da534..8fd17277 100644
--- a/marimo/_runtime/context/types.py
+++ b/marimo/_runtime/context/types.py
@@ -21,7 +21,7 @@ from marimo._runtime.functions import FunctionRegistry
 from marimo._runtime.requests import HTTPRequest
 
 if TYPE_CHECKING:
-    from marimo._ast.app import InternalApp
+    from marimo._ast.app import AppKernelRunnerRegistry, InternalApp
     from marimo._ast.cell import CellId_t
     from marimo._messaging.types import Stream
     from marimo._output.hypertext import Html
@@ -71,6 +71,7 @@ class RuntimeContext(abc.ABC):
     cell_lifecycle_registry: CellLifecycleRegistry
     virtual_file_registry: VirtualFileRegistry
     virtual_files_supported: bool
+    app_kernel_runner_registry: AppKernelRunnerRegistry
     # stream, stdout, stderr are _not_ owned by the context
     stream: Stream
     stdout: Stdout | None
@@ -154,6 +155,7 @@ class RuntimeContext(abc.ABC):
 
     def remove_child(self, runtime_context: RuntimeContext) -> None:
         self.children.remove(runtime_context)
+        runtime_context.virtual_file_registry.shutdown()
         assert runtime_context not in self.children
 
     @contextmanager
diff --git a/marimo/_runtime/runtime.py b/marimo/_runtime/runtime.py
index d0e677f1..6b1668e9 100644
--- a/marimo/_runtime/runtime.py
+++ b/marimo/_runtime/runtime.py
@@ -2310,7 +2310,14 @@ def launch_kernel(
         profiler.disable()
         profiler.dump_stats(profile_path)
 
+    # Defensively clear context data structures, in case a leak prevents
+    # the context from being destroyed.
+    #
+    # TODO(akshayka): define ownership semantics for contexts, so the
+    # context knows how to shut itself down. The virtual file registry
+    # is shared between the main thread and mo.Thread's right now ...
     get_context().virtual_file_registry.shutdown()
+    get_context().app_kernel_runner_registry.shutdown()
     teardown_context()
     kernel.teardown()
     if isinstance(pipe, connection.Connection):


Test patch:
diff --git a/tests/_ast/test_app.py b/tests/_ast/test_app.py
index e59de6ef..bf5add3c 100644
--- a/tests/_ast/test_app.py
+++ b/tests/_ast/test_app.py
@@ -1,14 +1,14 @@
 # Copyright 2024 Marimo. All rights reserved.
 
 from __future__ import annotations
-
 import pathlib
 import subprocess
 import textwrap
-from typing import Any
+from typing import Any, TYPE_CHECKING
 
 import pytest
-from marimo._ast.app import App, _AppConfig, InternalApp
+
+from marimo._ast.app import App, AppKernelRunnerRegistry, _AppConfig, InternalApp
 from marimo._ast.errors import (
     CycleError,
     DeleteNonlocalError,
@@ -18,9 +18,11 @@ from marimo._ast.errors import (
 from marimo._dependencies.dependencies import DependencyManager
 from marimo._plugins.stateless.flex import vstack
 from marimo._runtime.requests import SetUIElementValueRequest
-from marimo._runtime.runtime import Kernel
 from tests.conftest import ExecReqProvider
 
+if TYPE_CHECKING:
+    from marimo._runtime.runtime import Kernel
+
 
 # don't complain for useless expressions (cell outputs)
 # ruff: noqa: B018
@@ -734,3 +736,20 @@ class TestAppComposition:
 
         assert x.value == 2
         assert y.value == 3
+
+
+class TestAppKernelRunnerRegistry:
+    def test_get_runner(self, k: Kernel) -> None:
+        # `k` fixture installs a context, needed for AppKernelRunner
+        del k
+        app = App()
+        registry = AppKernelRunnerRegistry()
+        # Calling with the same app yields the same runner
+        assert registry.get_runner(app) == registry.get_runner(app)
+
+        # Calling with different app objects yields different runners
+        assert registry.get_runner(app) != registry.get_runner(other := App())
+
+        registry.remove_runner(app)
+        registry.remove_runner(other)
+        assert not registry._runners