Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions .github/THREAT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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

Expand Down
7 changes: 2 additions & 5 deletions Jint.Tests.Test262/Test262Test.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
75 changes: 72 additions & 3 deletions Jint.Tests/Runtime/AtomicsWaiterRegistryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<JavaScriptException>().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()
{
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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");

Expand Down
40 changes: 40 additions & 0 deletions Jint.Tests/Runtime/Test262AgentManagerTests.cs
Original file line number Diff line number Diff line change
@@ -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");
}
}
5 changes: 3 additions & 2 deletions Jint/Options.cs
Original file line number Diff line number Diff line change
Expand Up @@ -216,12 +216,13 @@ public ResultLimits ResultLimits

/// <summary>
/// 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().
/// </summary>
/// <remarks>
/// https://tc39.es/ecma262/#sec-agentcansuspend
/// </remarks>
public bool AgentCanSuspend { get; set; } = true;
public bool AgentCanSuspend { get; set; }

/// <summary>
/// Called by the <see cref="Engine"/> instance that loads this <see cref="Options" />
Expand Down
1 change: 1 addition & 0 deletions Jint/Test262AgentManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ private void Run()
{
cfg.ExperimentalFeatures = ExperimentalFeature.All;
cfg.TimeoutInterval(TimeSpan.FromSeconds(30));
cfg.AgentCanSuspend = true;
});

// Setup agent-side $262.agent
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down