From 14173a19eb5f19742fba4f12d6e4a9ceed354e8e Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Mon, 17 Aug 2026 02:02:04 +0300 Subject: [PATCH] Update Jint to 4.15.3 and stop blocking the calling thread on promises (#7894) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(javascript): update Jint to 4.15.3 and stop blocking on promises `Engine.Evaluate(...).UnwrapIfPromise()` blocks the calling thread while the engine's event loop drains, which is exactly the wrong thing to do inside an `async` method — an expression that awaits a .NET `Task`, such as one calling `getSecret()`, held a thread pool thread for the duration of the I/O. `Engine.EvaluateAsync` awaits the returned promise instead, and takes the cancellation token while it is at it. The Jint version is moved from 4.4.2 to 4.15.3. `EvaluateAsync` arrived in 4.14.0, but the pin lands past 4.15.2 deliberately: once expressions genuinely suspend and resume instead of draining the event loop on the calling thread, they exercise the async suspension machinery 4.15.2 corrected — an `await` on a right-hand side no longer stores the suspension sentinel, async generators and `for await...of` preserve loop iteration state across a suspension, and a suspension node is unwrapped correctly. Shipping the non-blocking change on an earlier 4.14/4.15 would enable exactly the code paths those releases fixed. One default changed along the way: since 4.14 `Interop.ArrayConversion` defaults to `LiveView`, so a CLR array reaches script as a live view over the original array rather than as a copy. That is observable — a script that sorts an array would now reorder the workflow's own array, and the value round-trips back as its original element type rather than as `object[]`. The evaluator therefore pins the previous `Copy` behaviour so the upgrade is not a behavioural change; hosts that prefer the live view can opt in through `JintOptions.ConfigureEngineOptions`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0179sA2T7HuRfRfSc2JirFik * test(javascript): pin the array copy lane and adopt JsString.Create The array audit. The parent commit fixes `ArrayConversion` to `Copy`, because 4.14 changed the default to `LiveView` and the two differ in behaviour a script can observe. The existing tests assert what a copy *produces*, which a live view also satisfies for a value nothing mutates; asking the engine how many conversions of each kind it performed (4.15.1's interop conversion counters) pins the lane itself. The second assertion is the more interesting one: an ordinary evaluation converts no CLR array at all, because Elsa converts collection-valued variables itself in `ObjectConverterHelper` long before Jint's array lane could see them. That makes the `ArrayConversion` setting a narrow compatibility pin rather than something every evaluation depends on. `JsString.Create` (public since Jint 4.15.3) is adopted in `JsonElementConverter`, where the string case was the only one still routed through `JsValue.FromObject` — re-entering the whole conversion pipeline, the registered object converters and this one included, to arrive at the same call the number and boolean cases beside it already make directly. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016uV6H9cTntzsoKiaJRBn4f --------- Co-authored-by: Claude Opus 5 (1M context) --- Directory.Packages.props | 2 +- .../ObjectConverters/JsonElementConverter.cs | 6 +- .../Services/JintJavaScriptEvaluator.cs | 17 +++- .../ArrayConversionTests.cs | 87 +++++++++++++++++++ 4 files changed, 106 insertions(+), 6 deletions(-) create mode 100644 test/integration/Elsa.JavaScript.IntegrationTests/ArrayConversionTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 5629d6d81..6f4c89e3b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -132,7 +132,7 @@ - + diff --git a/src/modules/Elsa.Expressions.JavaScript/ObjectConverters/JsonElementConverter.cs b/src/modules/Elsa.Expressions.JavaScript/ObjectConverters/JsonElementConverter.cs index 121fb2648..a8cc336e6 100644 --- a/src/modules/Elsa.Expressions.JavaScript/ObjectConverters/JsonElementConverter.cs +++ b/src/modules/Elsa.Expressions.JavaScript/ObjectConverters/JsonElementConverter.cs @@ -26,7 +26,11 @@ internal class JsonElementConverter : IObjectConverter { JsonValueKind.Object => JsValue.FromObject(engine, JsonObject.Create(element)), JsonValueKind.Array => JsValue.FromObject(engine, JsonArray.Create(element)), - JsonValueKind.String => JsValue.FromObject(engine, element.GetString()), + // JsString.Create is the counterpart of the JsNumber.Create and JsBoolean uses below: it produces the + // string value directly, where JsValue.FromObject would re-enter the whole conversion pipeline — the + // registered object converters, this one included, followed by the default converter's type switch — + // to arrive at the same call. It became public in Jint 4.15.3. + JsonValueKind.String => JsString.Create(element.GetString()!), JsonValueKind.Number => element.TryGetInt32(out var intValue) ? JsNumber.Create(intValue) : JsNumber.Create(element.GetDouble()), JsonValueKind.True => JsBoolean.True, JsonValueKind.False => JsBoolean.False, diff --git a/src/modules/Elsa.Expressions.JavaScript/Services/JintJavaScriptEvaluator.cs b/src/modules/Elsa.Expressions.JavaScript/Services/JintJavaScriptEvaluator.cs index 475dad827..bbc395fc6 100644 --- a/src/modules/Elsa.Expressions.JavaScript/Services/JintJavaScriptEvaluator.cs +++ b/src/modules/Elsa.Expressions.JavaScript/Services/JintJavaScriptEvaluator.cs @@ -38,7 +38,7 @@ public class JintJavaScriptEvaluator(IConfiguration configuration, INotification { var engine = await GetConfiguredEngine(configureEngine, context, options, cancellationToken); await mediator.SendAsync(new EvaluatingJavaScript(engine, context, expression), cancellationToken); - var result = ExecuteExpressionAndGetResult(engine, expression); + var result = await ExecuteExpressionAndGetResultAsync(engine, expression, cancellationToken); await mediator.SendAsync(new EvaluatedJavaScript(engine, context, expression, result), cancellationToken); return result.ConvertTo(returnType); @@ -53,6 +53,12 @@ public class JintJavaScriptEvaluator(IConfiguration configuration, INotification ExperimentalFeatures = ExperimentalFeature.TaskInterop }; + // Jint 4.14 changed this default to LiveView, which exposes a CLR array to script as a live view over + // the original array rather than as a copy. Keeping the copy semantics means a script that mutates an + // array does not reach back into the workflow's own data, and that an array survives a round trip as + // object[] the way it always has. Hosts that want the live view can opt in via ConfigureEngineOptions. + engineOptions.Interop.ArrayConversion = ArrayConversionMode.Copy; + ConfigureClrAccess(engineOptions); ConfigureObjectWrapper(engineOptions); ConfigureObjectConverters(engineOptions); @@ -106,11 +112,14 @@ public class JintJavaScriptEvaluator(IConfiguration configuration, INotification engine.SetValue("getConfig", (Func)(name => configuration.GetSection(name).Value)); } - private object? ExecuteExpressionAndGetResult(Engine engine, string expression) + private async Task ExecuteExpressionAndGetResultAsync(Engine engine, string expression, CancellationToken cancellationToken) { var preparedScript = GetOrCreatePrepareScript(expression); - var result = engine.Evaluate(preparedScript); - return result.UnwrapIfPromise().ToObject(); + + // EvaluateAsync awaits a returned promise instead of blocking the calling thread on it, which matters + // for expressions that await a .NET Task, such as the ones calling getSecret(). + var result = await engine.EvaluateAsync(preparedScript, cancellationToken); + return result.ToObject(); } private Prepared