-
Notifications
You must be signed in to change notification settings - Fork 2.1k
fix(native): Jinja - catch panic from render workers #7380
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,11 @@ | ||
| use crate::template::neon_mj::*; | ||
| use cubesql::CubeError; | ||
|
|
||
| use log::trace; | ||
| use log::{error, trace}; | ||
| use minijinja as mj; | ||
| use neon::prelude::*; | ||
| use neon::types::Deferred; | ||
| use std::panic; | ||
|
|
||
| pub struct JinjaEngineWorkerJob { | ||
| pub(crate) template_name: String, | ||
|
|
@@ -17,38 +18,67 @@ struct JinjaEngineWorker { | |
| } | ||
|
|
||
| impl JinjaEngineWorker { | ||
| /// Renders a template, converting an unwinding panic into an error. | ||
| /// | ||
| /// Rendering executes user provided code (Python filters/functions via pyo3, custom | ||
| /// value implementations), which can panic. Without catching it, the worker thread | ||
| /// dies: the promise is rejected with a useless `Deferred` was dropped without being | ||
| /// settled error and the pool silently shrinks. Once the last worker is gone, the job | ||
| /// channel is closed and every next render fails with "sending into a closed channel". | ||
| fn render_catch_panic( | ||
| env: &mj::Environment, | ||
| template_name: &str, | ||
| ctx: mj::value::Value, | ||
| ) -> Result<Result<String, mj::Error>, CubeError> { | ||
| let render_block = panic::AssertUnwindSafe(|| { | ||
| let template = env.get_template(template_name)?; | ||
|
|
||
| template.render(ctx) | ||
| }); | ||
|
|
||
| panic::catch_unwind(render_block).map_err(|panic_payload| { | ||
| CubeError::panic_with_message( | ||
| panic_payload, | ||
| "Unexpected panic while rendering jinja template", | ||
| ) | ||
| }) | ||
| } | ||
|
|
||
| fn process_render(job: JinjaEngineWorkerJob, js_channel: &Channel, env: &mj::Environment) { | ||
| let JinjaEngineWorkerJob { | ||
| template_name, | ||
| ctx, | ||
| deferred, | ||
| } = job; | ||
|
|
||
| match Self::render_catch_panic(env, &template_name, ctx) { | ||
| Ok(result) => { | ||
| deferred.settle_with(js_channel, move |mut cx| -> NeonResult<Handle<JsString>> { | ||
| match result { | ||
| Ok(r) => Ok(cx.string(r)), | ||
| Err(err) => cx.throw_from_mj_error(err), | ||
| } | ||
| }); | ||
| } | ||
| Err(err) => { | ||
| error!("{} (template: {})", err, template_name); | ||
|
|
||
| deferred.settle_with(js_channel, move |mut cx| -> NeonResult<Handle<JsString>> { | ||
| cx.throw_error(err.to_string()) | ||
| }); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| fn new( | ||
| id: usize, | ||
| env: mj::Environment<'static>, | ||
| js_channel: neon::event::Channel, | ||
| js_channel: Channel, | ||
| receiver: async_channel::Receiver<JinjaEngineWorkerJob>, | ||
| ) -> Self { | ||
| let thread = std::thread::spawn(move || loop { | ||
| if let Ok(job) = receiver.recv_blocking() { | ||
| let template = match env.get_template(&job.template_name) { | ||
| Ok(t) => t, | ||
| Err(err) => { | ||
| job.deferred.settle_with( | ||
| &js_channel, | ||
| move |mut cx| -> NeonResult<Handle<JsString>> { | ||
| cx.throw_from_mj_error(err) | ||
| }, | ||
| ); | ||
|
|
||
| continue; | ||
| } | ||
| }; | ||
|
|
||
| let result = template.render(job.ctx); | ||
| job.deferred.settle_with( | ||
| &js_channel, | ||
| move |mut cx| -> NeonResult<Handle<JsString>> { | ||
| match result { | ||
| Ok(r) => Ok(cx.string(r)), | ||
| Err(err) => cx.throw_from_mj_error(err), | ||
| } | ||
| }, | ||
| ); | ||
| Self::process_render(job, &js_channel, &env); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Rendering is now protected, but the worker loop itself still has no supervision: a panic anywhere outside Not required for this PR, but wrapping the whole loop body in |
||
| } else { | ||
| trace!( | ||
| "Closing jinja thread, id: {}, threadId: {:?}", | ||
|
|
@@ -101,3 +131,77 @@ impl JinjaEngineWorkerPool { | |
| .map_err(|err| CubeError::internal(format!("Unable to schedule rendering: {}", err))) | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
|
|
||
| fn test_environment() -> mj::Environment<'static> { | ||
| let mut env = mj::Environment::new(); | ||
|
|
||
| env.add_function("panic_fn", || -> Result<mj::value::Value, mj::Error> { | ||
| panic!("Boom from a function") | ||
| }); | ||
|
|
||
| env.add_template("render.jinja", "Hello {{ name }}") | ||
| .unwrap(); | ||
| env.add_template("panic.jinja", "{{ panic_fn() }}").unwrap(); | ||
|
|
||
| env | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_render() { | ||
| let env = test_environment(); | ||
|
|
||
| let actual = JinjaEngineWorker::render_catch_panic( | ||
| &env, | ||
| "render.jinja", | ||
| mj::context! { name => "world" }, | ||
| ) | ||
| .expect("Render must not panic") | ||
| .expect("Render must not fail"); | ||
|
|
||
| assert_eq!(actual, "Hello world"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_render_unknown_template() { | ||
| let env = test_environment(); | ||
|
|
||
| let err = JinjaEngineWorker::render_catch_panic( | ||
| &env, | ||
| "unknown.jinja", | ||
| mj::value::Value::UNDEFINED, | ||
| ) | ||
| .expect("Unknown template must not panic") | ||
| .expect_err("Unknown template must fail"); | ||
|
|
||
| assert_eq!(err.kind(), mj::ErrorKind::TemplateNotFound); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_render_panic() { | ||
| let env = test_environment(); | ||
|
|
||
| let err = | ||
| JinjaEngineWorker::render_catch_panic(&env, "panic.jinja", mj::value::Value::UNDEFINED) | ||
| .expect_err("Panic must be caught"); | ||
|
|
||
| assert_eq!( | ||
| err.message, | ||
| "Unexpected panic while rendering jinja template. Reason: Boom from a function" | ||
| ); | ||
|
|
||
| // Environment must stay usable after a panic, because the worker is reused | ||
| let actual = JinjaEngineWorker::render_catch_panic( | ||
| &env, | ||
| "render.jinja", | ||
| mj::context! { name => "world" }, | ||
| ) | ||
| .expect("Render must not panic") | ||
| .expect("Render must not fail"); | ||
|
|
||
| assert_eq!(actual, "Hello world"); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| {# Conversion of a JsFunction to a minijinja value is not supported and panics, | ||
| it's used to test that a panic inside a worker doesn't kill it #} | ||
| {{ COMPILE_CONTEXT.js_fn }} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The safety net is right, but note what the user now sees for the most common trigger.
to_minijinja_value(src/template/mj_value/mod.rs:31)panic!s whenever a JS function ends up inCOMPILE_CONTEXT— a plain user mistake, not a bug. Because it's wrapped lazily inJinjaDictObject, the panic fires on the worker thread during render and is now converted toCubeError::internal, so the JS side gets:That reads as "Cube crashed" and carries no template/line info. Consider making
to_minijinja_valuereturn a propermj::Error(e.g.ErrorKind::InvalidOperation/BadSerialization) for theJsFunctioncase, so it flows throughthrow_from_mj_errorwith template context andcatch_unwindhere stays reserved for genuine bugs.