Skip to content

feat(wasm): add opt-in execution deadlines and memory budgets - #7948

Merged
Aias00 merged 5 commits into
apache:masterfrom
yangjj-iso:feat/7560-wasm-execution-limits
Oct 26, 2026
Merged

Aias00 merged 5 commits into
apache:masterfrom
yangjj-iso:feat/7560-wasm-execution-limits

Conversation

@yangjj-iso

@yangjj-iso yangjj-iso commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #7560.

Add opt-in startup export/initialization deadlines and per-instance linear-memory ceilings to WasmLoader. Global JVM properties support resource-class overrides; zero defaults preserve existing trusted deployments. Timed guest calls use an owned bounded executor/queue, cancel the actual worker on timeout/caller interruption, retain capacity for interruption-ignoring hosts and replace poisoned instances only after physical exit. Compilation/creation/_initialize have a separate construction deadline. Request execute/doExecute publishers defer/offload work only when export deadlines are configured; base plugins now expose close() for owned worker cleanup.

Memory ceilings never raise the module maximum or silently lower its initial allocation. A configured budget currently requires one module-owned, non-shared memory; unsupported ownership/layouts fail explicitly. The ceiling covers linear memory, not arbitrary Java/host allocations. Synchronous compatibility hooks/data callbacks keep bounded waits; arbitrary host/native work that ignores interruption still needs process isolation for hard termination. The settings and these limits are documented with rollback guidance.

Validation on Microsoft OpenJDK 17.0.18 through mvnw.cmd:

  • All 66 WASM Java tests pass with zero failures/errors/skips, including actual compiler/interpreter infinite loops, memory.grow, excessive initial memory, cancellation reaching a real worker, host containment, initialization timeout, clean subsequent instances and request argument cleanup/lazy construction/cancellation.
  • All four new WAT fixtures rebuild byte-identically with pinned WABT 1.0.42; all 14 offline fixture-verifier regression tests pass.
  • Affected-reactor Checkstyle and Apache RAT pass; git diff --check passes.

The full repository and Docker E2E suites were not run. This uses master's existing invocation adapters; the separately open JSON bridge PR #7569 will need its own integration verification when merged.

Review follow-up: malformed numeric properties now name the effective key and offending value. Memory-free guests can execute with or without a budget; getMemory() fails explicitly when such a guest requests absent memory. Compiler/interpreter tests cover both modes and callback memory access. Worker retention is explicit in the operations guide: a host that never exits permanently consumes capacity and timed-out workers are never replaced. All 71 WASM cases pass. The additional WAT fixture rebuilds byte-identically with WABT 1.0.42.

Shared CI fixture corrections: CryptorResponsePluginTest now checks the post-#7903 configuration error for both encrypt/decrypt when a field is absent, including absence of the original user data. SDK tests wait up to 60 seconds for usable registered routes before exercising assertions. ZooKeeper registration tests keep their declared backend ports reachable so the real health checker cannot race registry-removal assertions. Validation: 16 cryptor unit cases and 16 Admin discovery/health cases pass; the HTTP and SDK integration test reactors compile with Checkstyle. Full Docker integration execution is not claimed: the local Linux Docker engine is unavailable. Remote check results are tracked separately.

Aias00
Aias00 previously approved these changes Oct 21, 2026

@Aias00 Aias00 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

Solid piece of work. The hard part of this problem is that a Java thread cannot be forcibly stopped, so "cancel the guest" is not something you can implement — you can only interrupt and then decide what to do with the instance and the worker. This PR makes the right call on all three of those decisions.

What I verified

1. The nested-call short-circuit is what prevents self-deadlock

if (timeoutMillis == 0 || Objects.nonNull(cancellation.get())) {
    return task.call();
}

Without the cancellation.get() != null check, a guest that calls back into an export would submit a task to the same bounded pool and then block waiting for it — with core = max = poolSize and a queue of poolSize, that deadlocks as soon as enough workers are occupied. Running nested calls inline on the worker thread is correct.

2. Interruption-ignoring hosts keep their worker, and that is the right trade-off

// Guest execution is cooperative: interruption-ignoring host calls retain their
// bounded worker until physical exit. Never replace those threads on timeout.

Replacing the thread would let a guest that ignores interrupts create unbounded workers — a slow-motion OOM. Retaining the worker and surfacing RejectedExecutionException as ShenyuWasmException("... capacity exhausted ...") fails closed, which is the correct choice for a gateway. interruptionIgnoringHostCannotCreateReplacementWorkerCapacity pins exactly this, including the calls.get() == 1 assertion that proves the first host call is still occupying its slot.

3. The pool stays balanced across the replacement path

executeOnPool acquires one instance, and every exit path returns exactly one:

  • replacement succeeds → old instance is dropped and release(replacement) puts one back
  • replacement throws → replaced stays false, so release(acquired) returns the old (still-poisoned) instance, and poisonedInstances still contains it so the next call retries the replacement
  • normal path → release(lease)

So freeInstances never loses or gains a slot, and release's offer failing loudly on a full queue is a good corruption guard.

4. Poisoning is scoped to calls that actually went through the deadline path

AtomicBoolean cancelled = cancellation.get();
if (Objects.nonNull(cancelled) && (Objects.nonNull(failure) || cancelled.get() || Thread.currentThread().isInterrupted())) {
    poisonedInstances.add(lease);
}

cancellation is only set on a runLimited worker, so nested inline calls (which never see a cancel signal) do not poison instances on ordinary failures. That preserves the pre-existing behaviour for deployments that have not configured a deadline.

5. Memory ceiling cannot silently raise or lower anything

int maximum = Math.min(declared.maximumPages(), limits.getMaxMemoryPages());
if (declared.initialPages() > maximum || declared.shared()) {
    throw new ShenyuWasmInitException("WASM initial/shared memory is incompatible with the configured memory budget");
}
return new ByteBufferMemory(new MemoryLimits(declared.initialPages(), maximum));

min means the budget can only tighten, never loosen. Refusing when initialPages > maximum instead of clamping avoids the failure mode where a module starts with less memory than it declares and then traps on the first allocation. Refusing shared is right — a shared memory cannot be bounded per instance. And the constructor-level guard requires exactly one module-owned memory, which is the only layout where the budget is actually enforceable.

6. Reactor offload is correctly conditional

if (wasmLoader.hasExecutionDeadline()) {
    return Mono.defer(() -> executeWasm(exchange, chain, selector, rule)).subscribeOn(Schedulers.boundedElastic());
}
return executeWasm(exchange, chain, selector, rule);

Mono.defer plus subscribeOn means the blocking guest call happens on boundedElastic, not on a Netty event loop — which is the whole point of having a deadline, since a timeout you cannot reach because the event loop is blocked is useless. Keeping the old inline path when no deadline is configured means zero behavioural change for existing deployments. Note this keys off timeoutMillis only, not initializationTimeoutMillis, which is right: initialization happens at startup, not per request.

7. Test coverage targets the failure modes, not the happy path

infiniteGuestStopsAndItsInstanceIsReplaced (timeout → instance replaced → next call returns 7, and the memory read is back to 0 so the replacement really is fresh), interruptionCancelsTheActualGuestWorker (asserts the worker exited, with the comment "cancelling only the caller is insufficient" — that is the assertion most people forget), memoryGrowthCannotExceedTheConfiguredBudget (grow 1 → 1, second grow 1 → -1, so the ceiling is enforced by the runtime and not just by the factory), and the .wat sources plus Makefile mean the binaries are reproducible rather than opaque blobs. Good.

Non-blocking

  1. fromSystemProperties throws NumberFormatException with no context. Long.parseLong(property(...)) on a malformed -Dshenyu.wasm.max-memory-pages=8m fails deep in construction with a message that names neither the property nor the value. Wrapping it in an IllegalArgumentException that includes the key and value would save operators a lot of time.
  2. A module with no memory section is rejected when a budget is configured. module.memorySection().map(section -> section.memoryCount() != 1).orElse(true) treats "no memory at all" the same as "more than one memory". A memory-free module cannot exceed a memory budget by definition, so orElse(false) (or an explicit empty-section check) would be more permissive without weakening the guarantee.
  3. runLimited allocates a FutureTask per call on the deadline path. That is per-request garbage when a deadline is configured, but it is opt-in, the allocation is small, and avoiding it would need a hand-rolled task wrapper. Fine to leave; mentioning it only so it is a conscious choice.
  4. docs/wasm-execution-limits.md should state the worker-retention behaviour explicitly. The rollback guidance is there, but the fact that an interrupt-ignoring host permanently consumes one worker (and therefore that poolSize effectively caps how many such guests you can have) is the single most surprising operational property of this design.

CI

All 12 checks are QUEUED, so the new wasm tests have not run on CI yet. They are the substance of this PR, so I want to see them green before merge. Approving on the code.

Aias00
Aias00 previously approved these changes Oct 23, 2026

@Aias00 Aias00 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review after becc542f: opt-in, cooperative deadlines, and the memory-free case is handled

The shape of this change is sound for what it claims: nothing is enabled unless shenyu.wasm.* is set, and when it is set the limits are resolved once at construction time and never mutated.

What I verified specifically:

  • Default is a no-op. WasmExecutionLimits.fromSystemProperties defaults to 0 / 0 / 0, runLimited short-circuits to a plain task.call() when the timeout is 0, and executionExecutor is not even created unless a deadline is configured. So a deployment that does not opt in keeps the previous execution path exactly.
  • Cooperative cancellation, honestly documented. close() cancels active FutureTasks and calls shutdownNow(), and the comment says out loud that interruption-ignoring host calls keep their bounded worker until physical exit and are never replaced. That is the honest statement for a pure-Java runtime; pretending otherwise would be worse.
  • Poisoning after a timeout. The ThreadLocal<AtomicBoolean> cancellation is set inside the worker task and read in executeOnPool's finally, so an instance that was interrupted mid-call is not returned to the pool as if it were clean. poisonedInstances plus the replace-on-acquire path (create, initialize, swap, and release the poisoned lease if the replacement fails) is the right recovery, and the replacement happens on the worker rather than on the caller.
  • Memory budget. withMemoryFactory clamps the declared maximum to maxMemoryPages, rejects initialPages > maximum and rejects shared() memory; a module that imports memory or declares more than one memory is rejected at load time with a clear message. That is a real budget, not a hint.
  • Memory-free guests. Dropping the unconditional instance.memory() == null throw at construction and moving it into getMemory() is the correct trade: a guest without a linear memory is legal, and only callers that actually need it should fail. The new MemoryFreeGuest test pins it.
  • Interpreter fallback still works. The catch (LinkageError | RuntimeException) re-throws ShenyuWasmException first, so a construction timeout or a memory-budget rejection is not misread as "compiler unavailable" and retried on the interpreter.

Non-blocking:

  1. When a deadline is configured, AbstractWasmPlugin.execute hops to Schedulers.boundedElastic() and runLimited then hops to its own executionExecutor, so each request crosses two thread boundaries, and the blocking future.get sits on a boundedElastic thread. One hop would do: run the export directly on executionExecutor and drop the subscribeOn.
  2. poisonedInstances only drops an entry when the instance is re-acquired and replaced successfully. A poisoned instance that is never leased again stays in the set for the loader's lifetime - bounded by pool size, so harmless, but an explicit clear in close() would keep it tidy.
  3. docs/wasm-execution-limits.md should call out the third behaviour change explicitly: a guest that previously failed at startup because it had no memory now starts and fails at the first getMemory() call instead. That is a different failure point for plugin authors.
  4. Same shared-CI-commit issue as the sibling PRs: this branch carries the cryptor / SDK-route waits. Rebase after #7970 lands.

@Aias00

Aias00 commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

CI note on head becc542f: build and pr_build are both red, and neither is caused by the WASM change.

Both jobs fail on the same test:

[ERROR] UpstreamCheckServiceSyncTest.testLateHealthCheckCannotOverwriteDiscoveryRefresh:181
        expected: <[DivideUpstream{... upstreamUrl='127.0.0.1:8081' ...}]> but was: <null>
[ERROR] Tests run: 2236, Failures: 1, Errors: 0, Skipped: 1

The test drives UpstreamCheckService through its static UPSTREAM_MAP, captures tasks via a mocked invokeExecutor, then drains them and calls waitFinish(). The same run also logs upstream scheduled check error and check the url=null is fail from background scheduler threads, so the final assertEquals(latest, upstreams.get(SELECTOR_ID)) races with a scheduled check that can drop the entry — the map read comes back null. This reads as a flaky / order-dependent test, not a defect of this PR; a re-run should clear it.

Optional hardening (separate PR, unrelated to this one): clear UPSTREAM_MAP in @AfterEach alongside PENDING_SYNC / ZOMBIE_SET, or await the scheduled task instead of draining the captured Runnables.

Include upstream test fixture corrections while preserving opt-in WASM execution limits and the release-note entry. Normalize final newlines in the WASM change.

Validation: all 71 WASM tests pass on Java 17; affected-reactor Checkstyle and Apache RAT pass; 14 offline fixture-verifier regressions and git diff --check pass. Remote Docker integration checks will run on the refreshed head.

@Aias00 Aias00 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved. The opt-in WASM execution limits are implemented correctly: WasmLoader builds WasmExecutionLimits from (optionally per-plugin) system properties, runs deadline-bound work on an owned executor via runLimited() (future.get(timeoutMillis)), and caps linear memory to min(declared maximum, maxMemoryPages) - rejecting incompatible shared/initial memory and more than one module-owned memory. Offloading to a bounded executor when a deadline is set is the right call since guest execution is blocking. Validation (nonnegative deadlines, pages 0-65536) fails construction on bad config. The real compiler/interpreter guest tests cover deadlines, cancellation, and memory-budget enforcement. Defaults stay disabled, preserving trusted deployments. Good.

@Aias00
Aias00 merged commit 7a5cd42 into apache:master Oct 26, 2026
29 checks passed
@Aias00 Aias00 added this to the 2.7.2 milestone Nov 13, 2026
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.

[Feature] Add configurable execution deadlines and memory budgets for WASM plugins

2 participants