diff --git a/.github/THREAT_MODEL.md b/.github/THREAT_MODEL.md index a1280cf3c4..89717cc825 100644 --- a/.github/THREAT_MODEL.md +++ b/.github/THREAT_MODEL.md @@ -95,7 +95,7 @@ Defaults are compatibility choices, not a hardened profile. | Promise wait timeout | 10 seconds | Bounds host APIs that wait for promise or module settlement | | Concurrent `Engine` use | Rejected | Public host entries throw instead of racing or silently serializing | | Dynamic `eval` / `Function` compilation | Enabled | Script can create and parse additional source at runtime | -| `Atomics.wait` suspension | Enabled | Script may block the engine thread, including indefinitely | +| `Atomics.wait` suspension | Disabled | A default engine rejects synchronous waits with `TypeError`; `Atomics.waitAsync` remains available | | Debugger | Disabled | Debug callbacks and debugger expressions are inactive | | Detailed CLR resolution errors | Disabled | Reduces script-visible CLR surface disclosure | | Detailed caught CLR exception messages | Disabled | Replaces host exception text with a generic script-visible error | @@ -362,23 +362,29 @@ Keep process isolation so a runtime or host stack failure cannot kill unrelated **Threat.** A projected delegate, property getter, converter, synchronous module loader, or CLR method can block indefinitely. Constraint checks occur after ordinary interop calls return; they cannot interrupt the call. `Atomics.wait` can block the engine thread with an -infinite timeout and `AgentCanSuspend` defaults to `true`. +infinite timeout when a worker-like host explicitly opts in with `AgentCanSuspend = true`. **Existing mitigations.** - Time and cancellation constraints detect an overrun after many interop calls return. -- `AgentCanSuspend = false` disables `Atomics.wait`. +- `AgentCanSuspend` defaults to `false`; `Atomics.wait` throws `TypeError` before registering + a waiter unless the host opts in. +- `Atomics.waitAsync` remains available without blocking the engine thread. - `MaxAtomicsPauseIterations` caps `Atomics.pause` spin work. - Async host and module APIs avoid holding a thread while waiting for I/O. **Missing or residual mitigation.** +- Setting `AgentCanSuspend = true` restores the previous behavior, including an indefinite + wait when script omits the timeout. - There is no safe in-process mechanism to abort a blocked .NET call or thread. - Timing out Jint does not necessarily stop an underlying host Task or external operation. -**Required host action.** Set `AgentCanSuspend = false`. Do not expose blocking APIs. Make -host operations asynchronous, bounded, and cancellation-aware. Kill the isolated worker -when the outer deadline expires. +**Required host action.** Keep `AgentCanSuspend = false` for untrusted scripts and prefer +`Atomics.waitAsync`. A worker-like host that requires synchronous waits must opt in explicitly +and accept that only termination of its isolated worker can preempt an indefinite wait. Do +not expose blocking APIs. Make host operations asynchronous, bounded, and cancellation-aware. +Kill the isolated worker when the outer deadline expires. ### TM-09: A sequence of engine entries escapes a per-entry budget diff --git a/Jint.Tests.Test262/Test262Test.cs b/Jint.Tests.Test262/Test262Test.cs index 6081060135..b4cd5b23da 100644 --- a/Jint.Tests.Test262/Test262Test.cs +++ b/Jint.Tests.Test262/Test262Test.cs @@ -62,11 +62,8 @@ private static Engine BuildTestExecutor(Test262File file, Test262AgentManager? a cfg.EnableModules(new Test262ModuleLoader(State.Test262Stream.Options.FileSystem, relativePath)); cfg.ExperimentalFeatures = ExperimentalFeature.All; cfg.TimeoutInterval(GetTimeoutInterval(file)); - // Configure agent blocking based on test flags - if (file.Flags.Contains("CanBlockIsFalse")) - { - cfg.AgentCanSuspend = false; - } + // Test262 runs worker-like agents unless a test explicitly marks the agent as unable to block. + cfg.AgentCanSuspend = !file.Flags.Contains("CanBlockIsFalse"); // Use ICU-based CLDR provider for better Intl support cfg.Intl.CldrProvider = IcuCldrProvider.Instance; // Use NodaTime for accurate IANA timezone support (sub-minute offsets, historical DST) diff --git a/Jint.Tests/Runtime/AtomicsWaiterRegistryTests.cs b/Jint.Tests/Runtime/AtomicsWaiterRegistryTests.cs index ebd435ac6d..1ef4b04018 100644 --- a/Jint.Tests/Runtime/AtomicsWaiterRegistryTests.cs +++ b/Jint.Tests/Runtime/AtomicsWaiterRegistryTests.cs @@ -19,6 +19,75 @@ namespace Jint.Tests.Runtime; [Collection(nameof(GarbageCollectionTests))] public class AtomicsWaiterRegistryTests { + [Fact] + public void ADefaultAgentCannotSuspendAndDoesNotRegisterAnIndefiniteWaiter() + { + var options = new Options(); + options.AgentCanSuspend.Should().BeFalse(); + + var engine = new Engine(options); + engine.Execute("var sab = new SharedArrayBuffer(8); var i32a = new Int32Array(sab);"); + var block = BlockOf(engine, "sab"); + Exception? error = null; + + var thread = new Thread(() => error = Record.Exception(() => engine.Evaluate("Atomics.wait(i32a, 0, 0);"))) + { + IsBackground = true + }; + thread.Start(); + + var completedWithoutBlocking = thread.Join(TimeSpan.FromSeconds(2)); + if (!completedWithoutBlocking) + { + var notifier = new Engine(); + notifier.SetValue("i32a", SharedView(notifier, block)); + + // Wait until a regressed implementation has registered its waiter before notifying it. A single + // notification can race the worker and leave an indefinite background wait behind in the process. + var cleanupDeadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); + while (thread.IsAlive && DateTime.UtcNow < cleanupDeadline) + { + if (AtomicsInstance.WaiterListCount(block) != 0) + { + notifier.Evaluate("Atomics.notify(i32a, 0)"); + } + + thread.Join(TimeSpan.FromMilliseconds(10)); + } + + thread.Join(TimeSpan.FromSeconds(5)).Should().BeTrue(); + } + + completedWithoutBlocking.Should().BeTrue("the default agent must reject an indefinite wait instead of blocking"); + var exception = error.Should().BeOfType().Which; + exception.Error.InstanceofOperator(engine.Intrinsics.TypeError).Should().BeTrue(); + exception.Message.Should().Be("Atomics.wait cannot be used in this agent"); + AtomicsInstance.WaiterListCount(block).Should().Be(0, "a rejected wait must not register a waiter"); + } + + [Fact] + public void AWorkerLikeAgentCanOptIntoSynchronousWait() + { + var engine = new Engine(static options => options.AgentCanSuspend = true); + + engine.Evaluate(""" + var i32a = new Int32Array(new SharedArrayBuffer(8)); + Atomics.wait(i32a, 0, 0, 0); + """).AsString().Should().Be("timed-out"); + } + + [Fact] + public void WaitAsyncRemainsAvailableToTheDefaultAgent() + { + var engine = new Engine(); + + engine.Evaluate(""" + var i32a = new Int32Array(new SharedArrayBuffer(8)); + var result = Atomics.waitAsync(i32a, 0, 1); + result.async + ":" + result.value; + """).AsString().Should().Be("false:not-equal"); + } + [Fact] public void AWaitAsyncNobodyNotifiesDoesNotKeepItsEngineAlive() { @@ -58,7 +127,7 @@ public void AWaitThatEndedDoesNotKeepItsSharedDataBlockAlive() [MethodImpl(MethodImplOptions.NoInlining)] static WeakReference WaitAndForgetTheBlock() { - var engine = new Engine(); + var engine = new Engine(static options => options.AgentCanSuspend = true); engine.Execute(""" var sab = new SharedArrayBuffer(8); var i32a = new Int32Array(sab); @@ -105,7 +174,7 @@ public void AWaitRegisteredAfterAnEarlierOneEndedIsStillNotified() { // Removing the emptied list is only safe if the next wait on the same index builds a new one and the // notify that follows finds it. Nothing observable may depend on whether a previous wait pruned. - var engine = new Engine(); + var engine = new Engine(static options => options.AgentCanSuspend = true); engine.Execute(""" var i32a = new Int32Array(new SharedArrayBuffer(8)); Atomics.wait(i32a, 0, 0, 0); @@ -131,7 +200,7 @@ public void AWaiterListThatHasEmptiedIsRemoved() { // Every index ever waited on used to leave its own list behind, for as long as the block lived. The // block below stays reachable throughout, so nothing but the pruning can bring the count back down. - var engine = new Engine(); + var engine = new Engine(static options => options.AgentCanSuspend = true); engine.Execute("var sab = new SharedArrayBuffer(4096); var i32a = new Int32Array(sab);"); var block = BlockOf(engine, "sab"); diff --git a/Jint.Tests/Runtime/Test262AgentManagerTests.cs b/Jint.Tests/Runtime/Test262AgentManagerTests.cs new file mode 100644 index 0000000000..db4701eaab --- /dev/null +++ b/Jint.Tests/Runtime/Test262AgentManagerTests.cs @@ -0,0 +1,40 @@ +#nullable enable + +using Jint.Native; +using Jint.Runtime; + +namespace Jint.Tests.Runtime; + +public class Test262AgentManagerTests +{ + [Fact] + public void SpawnedAgentsCanUseSynchronousWait() + { + using var manager = new Test262AgentManager(); + var engine = new Engine(); + var container = engine.Realm.Intrinsics.Object.Construct(Arguments.Empty); + manager.InstallAgent(engine, container); + engine.SetValue("$262", container); + + engine.Execute(""" + $262.agent.start(` + var i32a = new Int32Array(new SharedArrayBuffer(8)); + $262.agent.report(Atomics.wait(i32a, 0, 0, 0)); + $262.agent.leaving(); + `); + """); + + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); + JsValue report; + do + { + report = engine.Evaluate("$262.agent.getReport()"); + if (report.IsNull()) + { + Thread.Sleep(10); + } + } while (report.IsNull() && DateTime.UtcNow < deadline); + + report.AsString().Should().Be("timed-out"); + } +} diff --git a/Jint/Options.cs b/Jint/Options.cs index af41b6788f..da623b839a 100644 --- a/Jint/Options.cs +++ b/Jint/Options.cs @@ -216,12 +216,13 @@ public ResultLimits ResultLimits /// /// Whether the agent can suspend (block) via Atomics.wait(). - /// Defaults to true. Set to false for main-thread-like environments where blocking is not allowed. + /// Defaults to false. Worker-like hosts that deliberately allow blocking suspension must opt in. + /// This option does not affect Atomics.waitAsync(). /// /// /// https://tc39.es/ecma262/#sec-agentcansuspend /// - public bool AgentCanSuspend { get; set; } = true; + public bool AgentCanSuspend { get; set; } /// /// Called by the instance that loads this diff --git a/Jint/Test262AgentManager.cs b/Jint/Test262AgentManager.cs index d552f6f53c..1b6add6c13 100644 --- a/Jint/Test262AgentManager.cs +++ b/Jint/Test262AgentManager.cs @@ -197,6 +197,7 @@ private void Run() { cfg.ExperimentalFeatures = ExperimentalFeature.All; cfg.TimeoutInterval(TimeSpan.FromSeconds(30)); + cfg.AgentCanSuspend = true; }); // Setup agent-side $262.agent diff --git a/README.md b/README.md index fbf03359b5..68f78f6544 100644 --- a/README.md +++ b/README.md @@ -3150,6 +3150,18 @@ grammar shape, encoded response bytes, module count, graph depth, redirects, or transport and module-graph policy in the host and keep hostile parsing inside a disposable worker with OS-level memory and CPU limits. +**Migration note — `Atomics.wait`:** synchronous suspension is disabled by default. Calling +`Atomics.wait` on a default engine throws a JavaScript `TypeError` before registering a waiter, while +`Atomics.waitAsync` remains available. A worker-like host that deliberately runs Jint where blocking is +acceptable can restore the previous behavior explicitly: + +```c# +var engine = new Engine(options => options.AgentCanSuspend = true); +``` + +Do not enable this on a request, UI, or event-loop thread: a script can call `Atomics.wait` without a +timeout and block that thread indefinitely. + ## Branches and releases - The recommended branch is __main__, any PR should target this branch