Skip to content

fix(lsp): fix CPU busy loop by creating tokio runtime before JsRuntime in TSC thread#35595

Merged
bartlomieju merged 3 commits into
denoland:mainfrom
haru0017:fix-lsp-tsc-tokio-runtime-ordering
Jul 6, 2026
Merged

fix(lsp): fix CPU busy loop by creating tokio runtime before JsRuntime in TSC thread#35595
bartlomieju merged 3 commits into
denoland:mainfrom
haru0017:fix-lsp-tsc-tokio-runtime-ordering

Conversation

@haru0017

Copy link
Copy Markdown
Contributor

Fixes the CPU busy loop reported in #35408 (comment).

After #35408, spawn_delayed_task falls back to immediate queuing when handle is None. The LSP's TSC thread creates JsRuntime before the tokio runtime in run_tsc_thread, so the isolate is registered with handle: None. V8 internal delayed tasks that reschedule themselves then cause a busy loop.

This PR moves the tokio runtime creation before JsRuntime::new() so the isolate gets a proper handle.

AI disclosure: Claude Code was used to help the PR description.

@bartlomieju

Copy link
Copy Markdown
Member

This is correct and the right minimal fix for the LSP — entering the runtime context before JsRuntime::new() makes Handle::try_current() succeed, so the TSC isolate registers with handle: Some(..) and delayed tasks take the real handle.spawn(sleep..) path instead of the immediate-queue fallback. 👍

One thing worth considering: this fixes the symptom at this call site, but the underlying footgun in spawn_delayed_task is still there. Any isolate created outside a tokio context (snapshot creation, embedders of deno_core, a future LSP worker thread that forgets the enter() guard) will register with handle: None and hit the same delay_in_seconds-ignoring fallback that pushes + wakes immediately, busy-looping on any self-rescheduling V8 task. We just have to remember the ordering at every such site forever.

An alternative (or complement) is to make spawn_delayed_task itself robust so the delay is always honored regardless of call-site ordering — e.g. fall back to a process-global timer runtime instead of dropping the delay:

// Lazily created only when a handle-less isolate actually posts a delayed task.
static DELAYED_TASK_RT: std::sync::LazyLock<tokio::runtime::Runtime> =
  std::sync::LazyLock::new(|| {
    tokio::runtime::Builder::new_multi_thread()
      .worker_threads(1)
      .enable_time()
      .build()
      .unwrap()
  });

fn spawn_delayed_task(key: usize, task: v8::Task, delay_in_seconds: f64) {
  let map = ISOLATE_ENTRIES.lock().unwrap();
  let Some(entry) = map.get(&key) else { return };
  let handle = entry
    .handle
    .clone()
    .unwrap_or_else(|| DELAYED_TASK_RT.handle().clone());
  let tasks = entry.tasks.clone();
  let waker = entry.waker.clone();
  handle.spawn(async move {
    tokio::time::sleep(Duration::from_secs_f64(delay_in_seconds)).await;
    tasks.lock().unwrap().push(task);
    waker.wake();
  });
}

This keeps the #35408 guarantee (no silently-dropped foreground tasks) while making it impossible for a handle-less isolate to spin — the delay is respected no matter how the isolate was created. Cost is one background thread, and only when a handle-less isolate actually schedules a delayed task.

I'd suggest landing this PR as-is for the immediate LSP regression, and optionally hardening spawn_delayed_task in deno_core separately so this class of bug can't recur.

@haru0017

Copy link
Copy Markdown
Contributor Author

@bartlomieju Thank you for the detailed review and the suggestion. The global fallback runtime approach makes a lot of sense to me.

@tredondo

tredondo commented Jul 6, 2026

Copy link
Copy Markdown

Confirming this also affects stable 2.9.1.

Setup: Zed editor on Linux, deno lsp spawned per-project. Small TS project with 2 npm: imports.

Repro: Editing any small .ts file in the project reliably drives one deno lsp thread to a sustained ~100% single-core CPU for minutes at a time. Confirmed on two separate days against the same long-lived deno lsp process (same PID, ~26h+ uptime), each time triggered right after editing the .ts file (presumably a completion request).

Diagnostics from Claue:

  • gdb -p <pid> -batch -ex "thread apply all bt" while pinned: every other thread (V8 DefaultWorke*, tokio-runtime-w*) is parked in epoll_wait/read/nanosleep — genuinely idle. The one hot thread's top frame is a raw address with no syscall in its stack at all — it's spinning in userspace, not blocked on I/O or a timer. That's consistent with the busy-loop mechanism described in this PR (delayed V8 tasks re-queuing immediately instead of sleeping).
  • Two backtraces taken ~24h apart, both triggered by editing the same small .ts file, hit overlapping/adjacent addresses in the hot thread — same code path each time, not different workloads.
  • Binary is stripped so I can't map addresses to symbols, but happy to provide full gdb/perf output if useful.

Confirms fix location: Downgraded to deno 2.8.3 (last stable release before aa90115, the commit from #35408 that introduced this) — the pinning is completely gone under normal editing in the same project.

Happy to test a canary build with #35595's patch applied if that'd help get it merged.

When an isolate is registered without a tokio runtime handle, the delay
of a posted delayed task cannot be honored: queuing it immediately
busy-loops the event loop on self-rescheduling V8 tasks (the bug this
PR fixes in the LSP), and dropping it loses foreground work (denoland#35345).
Fail loudly instead, with a message pointing at the misconfiguration.
A regular panic can't be used because V8 calls this from C++ frames
that Rust can't unwind through, which aborts with the message swallowed.

Fix all call sites that created a JsRuntime outside a tokio runtime
context: dcore's main (same latent bug as the LSP one), the checkin
test harness, deno_core benches, snapshot creation in build scripts
(create_snapshot now enters a private runtime), and two GC-heavy
deno_core tests that trigger V8 memory-reducer delayed tasks.
@bartlomieju

Copy link
Copy Markdown
Member

I pushed two commits to this branch (thanks for enabling maintainer edits):

  1. a merge of latest main
  2. hardening of spawn_delayed_task in deno_core so this class of bug can't silently recur

After more thought I moved away from the fallback-timer-runtime idea from my earlier comment. Instead, when an isolate registered without a tokio handle posts a delayed task, we now fail loudly (print a clear error and abort) rather than queue the task immediately: the immediate-queue fallback is exactly what turned V8's self-rescheduling GC tasks into the busy loop reported here, and dropping the task would reintroduce #35345-style hangs. A regular panic! can't be used at that call site — V8 invokes the platform hook from C++ frames Rust can't unwind through, so the process aborts with the panic message swallowed; an explicit eprintln! + abort() keeps the diagnostic visible.

Making the failure loud immediately surfaced every other place that created a JsRuntime outside a tokio runtime context, all fixed in the same commit:

  • dcore's main had the exact same latent bug as the LSP TSC thread (runtime created before tokio::Runtime)
  • create_snapshot now enters a private current-thread runtime so build-script snapshotting keeps working
  • the core_testing checkin harness now creates+enters its tokio runtime before the JsRuntime
  • deno_core benches and two GC-heavy tests (test_heap_limits, test_heap_limit_cb_multiple, which reliably make V8 post memory-reducer delayed tasks) enter a runtime context

Your LSP fix stays as-is and is still the important part — the ordering it establishes is now effectively mandatory. Verified locally: deno_core (429 tests) and deno_core_testing suites green, benches compile, smoke-tested the binary incl. a GC-pressure run. Letting CI do the rest.

@bartlomieju bartlomieju left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks

@bartlomieju
bartlomieju merged commit ac8677d into denoland:main Jul 6, 2026
135 checks passed
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.

3 participants