Repository navigation
feat(wasm): add opt-in execution deadlines and memory budgets - #7948
Conversation
Aias00
left a comment
There was a problem hiding this comment.
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 →
replacedstays false, sorelease(acquired)returns the old (still-poisoned) instance, andpoisonedInstancesstill 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
fromSystemPropertiesthrowsNumberFormatExceptionwith no context.Long.parseLong(property(...))on a malformed-Dshenyu.wasm.max-memory-pages=8mfails deep in construction with a message that names neither the property nor the value. Wrapping it in anIllegalArgumentExceptionthat includes the key and value would save operators a lot of time.- 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, soorElse(false)(or an explicit empty-section check) would be more permissive without weakening the guarantee. runLimitedallocates aFutureTaskper 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.docs/wasm-execution-limits.mdshould 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 thatpoolSizeeffectively 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
left a comment
There was a problem hiding this comment.
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.fromSystemPropertiesdefaults to0 / 0 / 0,runLimitedshort-circuits to a plaintask.call()when the timeout is0, andexecutionExecutoris 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 activeFutureTasks and callsshutdownNow(), 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> cancellationis set inside the worker task and read inexecuteOnPool'sfinally, so an instance that was interrupted mid-call is not returned to the pool as if it were clean.poisonedInstancesplus the replace-on-acquire path (create,initialize, swap, andreleasethe poisoned lease if the replacement fails) is the right recovery, and the replacement happens on the worker rather than on the caller. - Memory budget.
withMemoryFactoryclamps the declared maximum tomaxMemoryPages, rejectsinitialPages > maximumand rejectsshared()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() == nullthrow at construction and moving it intogetMemory()is the correct trade: a guest without a linear memory is legal, and only callers that actually need it should fail. The newMemoryFreeGuesttest pins it. - Interpreter fallback still works. The
catch (LinkageError | RuntimeException)re-throwsShenyuWasmExceptionfirst, so a construction timeout or a memory-budget rejection is not misread as "compiler unavailable" and retried on the interpreter.
Non-blocking:
- When a deadline is configured,
AbstractWasmPlugin.executehops toSchedulers.boundedElastic()andrunLimitedthen hops to its ownexecutionExecutor, so each request crosses two thread boundaries, and the blockingfuture.getsits on aboundedElasticthread. One hop would do: run the export directly onexecutionExecutorand drop thesubscribeOn. poisonedInstancesonly 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 inclose()would keep it tidy.docs/wasm-execution-limits.mdshould 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 firstgetMemory()call instead. That is a different failure point for plugin authors.- Same shared-CI-commit issue as the sibling PRs: this branch carries the cryptor / SDK-route waits. Rebase after #7970 lands.
|
CI note on head Both jobs fail on the same test: The test drives Optional hardening (separate PR, unrelated to this one): clear |
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
left a comment
There was a problem hiding this comment.
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.
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:
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.