Skip to content

Fix plugin provider freeze caused by blocking fetch on the QuickJS runtime thread#185

Open
fmustafayaman wants to merge 1 commit into
NuvioMedia:Devfrom
fmustafayaman:fix/plugin-fetch-thread-starvation
Open

Fix plugin provider freeze caused by blocking fetch on the QuickJS runtime thread#185
fmustafayaman wants to merge 1 commit into
NuvioMedia:Devfrom
fmustafayaman:fix/plugin-fetch-thread-starvation

Conversation

@fmustafayaman

Copy link
Copy Markdown

Summary

Fixes a freeze where JS plugin scrapers stop returning streams after a while and only recover after a full app restart. The streams screen gets stuck in the loading state and no plugin ever completes, even though the same providers worked moments earlier.

The root cause is in the plugin runtime's fetch bridge: __native_fetch was a synchronous binding that wrapped the suspending httpRequestRaw in runBlocking, so every plugin network request blocked a Dispatchers.Default thread for the whole request and ignored cancellation. Under concurrency (or a stalled endpoint) the bounded Default pool gets fully blocked and the runtime can no longer make progress.

This PR binds __native_fetch as an async (suspend) binding and calls httpRequestRaw directly (no runBlocking), and awaits it from the JS fetch polyfill.

PR type

  • Reproducible bug fix
  • UI glitch/bug fix
  • Behavior bug/regression fix
  • Small maintenance only, with no UI or behavior change
  • Docs accuracy fix
  • Translation/localization only
  • Approved larger or directional change

Why

JsRuntime runs QuickJS on the shared Dispatchers.Default dispatcher:

// JsRuntime.kt
quickJs(dispatcher) { block() } // dispatcher = Dispatchers.Default

FetchBridge then registered the network entry point as a synchronous function that blocks on the suspending HTTP call:

// before
runtime.function("__native_fetch") { args ->
    ...
    performNativeFetch(...) // val response = runBlocking { httpRequestRaw(...) }
}

Consequences:

  • Each plugin fetch() parks one Dispatchers.Default thread for the entire request duration.
  • Dispatchers.Default is bounded by the CPU core count, so a handful of concurrent or slow plugin requests can block every thread in the pool at once.
  • runBlocking does not observe coroutine cancellation, so neither the per-plugin withTimeout(PLUGIN_TIMEOUT_MS) in PluginRuntime nor activeJob.cancel() in StreamsRepository can interrupt an in-flight request.

Once the pool is starved, no other coroutine scheduled on Dispatchers.Default can run and the plugin runtime appears frozen until the process is restarted. This is easy to hit when several plugins run together (e.g. moving to the next episode a few times) or when a provider talks to a slow/cold endpoint.

Old vs new behavior

  • Old: a plugin network call blocks a Default-pool thread until it returns; enough concurrent/slow calls starve the dispatcher and freeze all plugins until restart.
  • Broken/unwanted: plugins stop returning streams; timeouts and cancellation have no effect; only a restart recovers.
  • New: the network call suspends instead of holding a thread, so the dispatcher keeps making progress, and withTimeout / cancellation can actually interrupt a stuck request.

The fix

composeApp/src/fullCommonMain/kotlin/.../network/FetchBridge.kt

  • Register __native_fetch with asyncFunction instead of function.
  • Make performNativeFetch a suspend function and call httpRequestRaw(...) directly instead of through runBlocking.
  • Rethrow CancellationException so cancellation is not swallowed by the existing error-mapping catch.

composeApp/src/fullCommonMain/kotlin/.../js/JsBindings.kt

  • await __native_fetch(...) in the fetch polyfill, since the binding now returns a promise. The polyfill function is already async, so this is a one-line change.

No public API, dependency, or architecture change. quickjs-kt already supports async bindings (asyncFunction), so no new dependency is introduced.

Desktop scope

The changed files are in the plugin runtime (fullCommonMain) used by the desktop app. This is a desktop stability fix for installed JS scrapers.

Issue or approval

Related to #78 (same user-visible symptom: provider requests stop working until the app is restarted). That report and #104 target the addon HTTP path (socket/port exhaustion from per-request HttpClient); this PR fixes a separate root cause in the plugin runtime (Default-pool starvation from blocking fetch), so the two are complementary and do not overlap in code.

UI / behavior impact

  • No UI change
  • No behavior change
  • UI changed only to fix a documented glitch/bug
  • Behavior changed only to fix a documented bug/regression

Testing

  • Built and ran the desktop app on macOS (Apple Silicon).
  • Before: after a few rounds of concurrent plugin fetches (switching episodes), plugins stopped returning streams and the screen stayed in the loading state until restart.
  • After: plugins keep returning streams; a single slow/stalled request times out on its own without taking down the rest of the runtime, and switching episodes repeatedly no longer freezes plugin fetching.

Policy check

  • I have read and understood CONTRIBUTING.md.
  • This PR is small, focused, and limited to one problem.
  • This PR is scoped to the desktop app and shared code required for desktop behavior.
  • This PR is not cosmetic-only.
  • Behavior change fixes a documented bug/regression.
  • This PR does not bundle unrelated refactors, cleanups, or formatting.
  • This PR does not add dependencies, architecture changes, or migrations.
  • I listed the testing performed above.

Made with Cursor

… thread

Plugin scrapers stop returning streams after a while and only recover after
an app restart. The plugin runtime runs QuickJS on the shared
Dispatchers.Default pool, and the fetch bridge bound `__native_fetch` as a
synchronous function that wrapped the suspending `httpRequestRaw` in
`runBlocking`. Every plugin network call therefore parked a Default-pool
thread for the whole request duration and ignored coroutine cancellation.

When several providers fetch at once (for example moving to the next episode
a few times) or an endpoint stalls, all Default threads end up blocked inside
runBlocking simultaneously. The dispatcher is then starved: no other coroutine
can run, including the per-plugin withTimeout and the request cancellation in
StreamsRepository, so the runtime appears frozen until the process restarts.

Bind `__native_fetch` as an async (suspend) function and call httpRequestRaw
directly instead of through runBlocking, and await it from the JS fetch
polyfill. Network I/O now suspends instead of holding a thread, and the plugin
timeout and job cancellation can actually interrupt in-flight requests.
CancellationException is rethrown so cancellation is not swallowed by the
existing error mapping.

Co-authored-by: Cursor <cursoragent@cursor.com>
@fmustafayaman

fmustafayaman commented Jun 29, 2026

Copy link
Copy Markdown
Author
Ekran Resmi 2026-06-29 18 43 56

PR solves this issue. After watching an episode from plugin provider it wont fetch next one. with this patch its just works as intended

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.

1 participant