Skip to content

Commit b846e13

Browse files
authored
Convert to an iterative eval loop instead of a recursive one (#1409)
* Convert to an iterative eval loop instead of a recursive one. This is a major change in the script evaluation process, which changes how "special execution" functions work. Previously, functions could choose to implement execs instead of exec, which received a ParseTree, instead of Mixed. This allowed the individual function to decide how or even if the ParseTree nodes were further executed. This works in general, however it has several drawbacks. In particular, the core evaluation loop loses control over the script once it decends into individual functions. Therefore features like Ctrl+C in command line scripts relied on each of these "flow" functions to implement that feature correctly, and only some of them did. This also prevents new features from being implemented as easily, like a debugger, since the evaluation loop would need to be modified, and every single flow function would need to make the same changes as well. This also has several performance benefits. Using a recursive approach meant that each frame of MethodScript had about 3 Java frames, which is inefficient. The biggest performance change with this is moving away from exception based control flow. Previously, return, break, and continue were all implemented with Java exceptions. This is more expensive than it needs to be, especially for very unexceptional cases such as return(). Now, when a proc or closure returns, it triggers a different phase in the state machine, instead of throwing an exception. This also unlocks future features that were not possible today. A debugger could have been implemented before (though it would have been difficult) but now an asynchronous debugger can be implemented. async/await is also possible now. Tail call optimizations can be done, execution time quotas, and the profiler can probably be improved. * Use our own stack counter to determine when a StackOverflow happens. * Add CallbackYield class for functions that execute callbacks. Previously, callback invocations required re-entering the eval loop from the top, which defeats the iterative loop. In principal, the functions that call Callables need to become flow functions to behave correctly, but for basic yield-style invocations, this infrastructure is too heavy, so CallbackYield is a new class which puts the function in terms of an exec-like mechanism, only introducing the Yield object, which is just a queue of operations, effectively. More functions need to convert to this, but as a first start, array_map has been converted. Some of the simpler FlowFunctions might be able to be simplified to this as well. * Convert various function to CallbackYield functions. These are the "easy" functions to convert. * Finish converting CallbackYield and FlowFunctions
1 parent f12d3ef commit b846e13

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

55 files changed

+4781
-2204
lines changed
Lines changed: 357 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,357 @@
1+
package com.laytonsmith.core;
2+
3+
import com.laytonsmith.core.constructs.CVoid;
4+
import com.laytonsmith.core.constructs.Target;
5+
import com.laytonsmith.core.constructs.generics.GenericParameters;
6+
import com.laytonsmith.core.environments.Environment;
7+
import com.laytonsmith.core.environments.GlobalEnv;
8+
import com.laytonsmith.core.exceptions.ConfigRuntimeException;
9+
import com.laytonsmith.core.functions.AbstractFunction;
10+
import com.laytonsmith.core.functions.ControlFlow;
11+
import com.laytonsmith.core.natives.interfaces.Callable;
12+
import com.laytonsmith.core.natives.interfaces.Mixed;
13+
14+
import java.util.ArrayDeque;
15+
import java.util.Arrays;
16+
import java.util.Queue;
17+
import java.util.function.BiConsumer;
18+
import java.util.function.Supplier;
19+
20+
/**
21+
* Base class for functions that need to call closures/callables without re-entering
22+
* {@code eval()}. Subclasses implement {@link #execWithYield} instead of {@code exec()}.
23+
* The callback-style exec builds a chain of deferred steps via a {@link Yield} object,
24+
* which this class then drives as a {@link FlowFunction}.
25+
*
26+
* <p>The interpreter loop sees this as a FlowFunction and drives it via
27+
* begin/childCompleted/childInterrupted. The subclass never deals with those
28+
* methods — it just uses the Yield API.</p>
29+
*
30+
* <p>Example (array_map):</p>
31+
* <pre>
32+
* protected void execCallback(Target t, Environment env, Mixed[] args, Yield yield) {
33+
* CArray array = ArgumentValidation.getArray(args[0], t, env);
34+
* CClosure closure = ArgumentValidation.getObject(args[1], t, CClosure.class);
35+
* CArray newArray = new CArray(t, (int) array.size(env));
36+
*
37+
* for(Mixed key : array.keySet(env)) {
38+
* yield.call(closure, env, t, array.get(key, t, env))
39+
* .then((result, y) -&gt; {
40+
* newArray.set(key, result, t, env);
41+
* });
42+
* }
43+
* yield.done(() -&gt; newArray);
44+
* }
45+
* </pre>
46+
*/
47+
public abstract class CallbackYield extends AbstractFunction implements FlowFunction<CallbackYield.CallbackState> {
48+
49+
/**
50+
* Implement this instead of {@code exec()}. Use the {@link Yield} object to queue
51+
* closure calls and set the final result.
52+
*
53+
* @param t The code target
54+
* @param env The environment
55+
* @param args The evaluated arguments (same as what exec() would receive)
56+
* @param yield The yield object for queuing closure calls
57+
*/
58+
protected abstract void execWithYield(Target t, Environment env, Mixed[] args, Yield yield);
59+
60+
/**
61+
* Bridges the standard exec() interface to the callback mechanism. This is called by the
62+
* interpreter loop's simple-exec path, but since CallbackYield is also a FlowFunction,
63+
* the loop will use the FlowFunction path instead. This implementation exists only as a
64+
* fallback for external callers that invoke exec() directly (e.g. compile-time optimization).
65+
* In that case, closures are executed synchronously via executeCallable() as before.
66+
*/
67+
@Override
68+
public final Mixed exec(Target t, Environment env, GenericParameters generics, Mixed... args)
69+
throws ConfigRuntimeException {
70+
// Fallback: build the yield chain but execute closures synchronously.
71+
// This only runs when called outside the iterative interpreter loop.
72+
Yield yield = new Yield();
73+
execWithYield(t, env, args, yield);
74+
yield.executeSynchronously(env, t);
75+
return yield.getResult();
76+
}
77+
78+
@Override
79+
public StepAction.StepResult<CallbackState> begin(Target t, ParseTree[] children, Environment env) {
80+
// The interpreter has already evaluated all children (args) before recognizing
81+
// this as a FlowFunction. But actually — since CallbackYield extends AbstractFunction
82+
// AND implements FlowFunction, the loop will see instanceof FlowFunction and route
83+
// to the FlowFunction path. We need to evaluate args ourselves.
84+
// Start by evaluating the first child.
85+
CallbackState state = new CallbackState();
86+
if(children.length > 0) {
87+
state.children = children;
88+
state.argIndex = 0;
89+
return new StepAction.StepResult<>(new StepAction.Evaluate(children[0]), state);
90+
}
91+
// No args — run the callback immediately
92+
return runCallback(t, env, new Mixed[0], state);
93+
}
94+
95+
@Override
96+
public StepAction.StepResult<CallbackState> childCompleted(Target t, CallbackState state,
97+
Mixed result, Environment env) {
98+
// Phase 1: collecting args
99+
if(!state.yieldStarted) {
100+
state.addArg(result);
101+
state.argIndex++;
102+
if(state.argIndex < state.children.length) {
103+
return new StepAction.StepResult<>(
104+
new StepAction.Evaluate(state.children[state.argIndex]), state);
105+
}
106+
// All args collected — run the callback
107+
return runCallback(t, env, state.getArgs(), state);
108+
}
109+
110+
// Phase 2: draining yield steps — a closure just completed
111+
YieldStep step = state.currentStep;
112+
if(step != null && step.callback != null) {
113+
step.callback.accept(result, state.yield);
114+
}
115+
return drainNext(t, state, env);
116+
}
117+
118+
@Override
119+
public StepAction.StepResult<CallbackState> childInterrupted(Target t, CallbackState state,
120+
StepAction.FlowControl action, Environment env) {
121+
StepAction.FlowControlAction fca = action.getAction();
122+
// A return() inside a closure is how it produces its result.
123+
if(fca instanceof ControlFlow.ReturnAction ret) {
124+
YieldStep step = state.currentStep;
125+
cleanupCurrentStep(state, env);
126+
if(step != null && step.callback != null) {
127+
step.callback.accept(ret.getValue(), state.yield);
128+
}
129+
return drainNext(t, state, env);
130+
}
131+
132+
cleanupCurrentStep(state, env);
133+
134+
// break/continue cannot escape a closure — this is a script error.
135+
if(fca instanceof ControlFlow.BreakAction || fca instanceof ControlFlow.ContinueAction) {
136+
throw ConfigRuntimeException.CreateUncatchableException(
137+
"Loop manipulation operations (e.g. break() or continue()) cannot"
138+
+ " bubble up past closures.", fca.getTarget());
139+
}
140+
141+
// ThrowAction and anything else — propagate
142+
return null;
143+
}
144+
145+
@Override
146+
public void cleanup(Target t, CallbackState state, Environment env) {
147+
if(state != null && state.currentStep != null) {
148+
cleanupCurrentStep(state, env);
149+
}
150+
}
151+
152+
private StepAction.StepResult<CallbackState> runCallback(Target t, Environment env,
153+
Mixed[] args, CallbackState state) {
154+
Yield yield = new Yield();
155+
state.yield = yield;
156+
state.yieldStarted = true;
157+
execWithYield(t, env, args, yield);
158+
return drainNext(t, state, env);
159+
}
160+
161+
private StepAction.StepResult<CallbackState> drainNext(Target t, CallbackState state,
162+
Environment env) {
163+
Yield yield = state.yield;
164+
if(!yield.steps.isEmpty()) {
165+
YieldStep step = yield.steps.poll();
166+
state.currentStep = step;
167+
168+
// Try stack-based execution first (closures, procedures)
169+
Callable.PreparedCallable prep = step.callable.prepareForStack(env, t, step.args);
170+
if(prep != null) {
171+
step.preparedEnv = prep.env();
172+
return new StepAction.StepResult<>(
173+
new StepAction.Evaluate(prep.node(), prep.env()), state);
174+
} else {
175+
// Sync-only Callable (e.g. CNativeClosure) — execute inline
176+
Mixed result = step.callable.executeCallable(env, t, step.args);
177+
if(step.callback != null) {
178+
step.callback.accept(result, yield);
179+
}
180+
return drainNext(t, state, env);
181+
}
182+
}
183+
184+
// All steps drained
185+
return new StepAction.StepResult<>(
186+
new StepAction.Complete(yield.getResult()), state);
187+
}
188+
189+
private void cleanupCurrentStep(CallbackState state, Environment env) {
190+
YieldStep step = state.currentStep;
191+
if(step != null) {
192+
if(step.preparedEnv != null) {
193+
// Pop the stack trace element that prepareExecution pushed
194+
step.preparedEnv.getEnv(GlobalEnv.class).GetStackTraceManager().popStackTraceElement();
195+
step.preparedEnv = null;
196+
}
197+
if(step.cleanupAction != null) {
198+
step.cleanupAction.run();
199+
}
200+
}
201+
state.currentStep = null;
202+
}
203+
204+
/**
205+
* Per-call state for the FlowFunction. Tracks argument collection and yield step draining.
206+
*/
207+
protected static class CallbackState {
208+
ParseTree[] children;
209+
int argIndex;
210+
private Mixed[] args;
211+
private int argCount;
212+
boolean yieldStarted;
213+
Yield yield;
214+
YieldStep currentStep;
215+
216+
void addArg(Mixed arg) {
217+
if(args == null) {
218+
args = new Mixed[children.length];
219+
}
220+
args[argCount++] = arg;
221+
}
222+
223+
Mixed[] getArgs() {
224+
if(args == null) {
225+
return new Mixed[0];
226+
}
227+
if(argCount < args.length) {
228+
Mixed[] trimmed = new Mixed[argCount];
229+
System.arraycopy(args, 0, trimmed, 0, argCount);
230+
return trimmed;
231+
}
232+
return args;
233+
}
234+
235+
@Override
236+
public String toString() {
237+
if(!yieldStarted) {
238+
return "CallbackState{collecting args: " + argCount + "/" + (children != null ? children.length : 0) + "}";
239+
}
240+
return "CallbackState{draining yields: " + (yield != null ? yield.steps.size() : 0) + " remaining}";
241+
}
242+
}
243+
244+
/**
245+
* The object passed to {@link #execWithYield}. Functions use this to queue closure calls
246+
* and declare the final result.
247+
*/
248+
public static class Yield {
249+
private final Queue<YieldStep> steps = new ArrayDeque<>();
250+
private Supplier<Mixed> resultSupplier = () -> CVoid.VOID;
251+
private boolean doneSet = false;
252+
253+
/**
254+
* Queue a closure/callable invocation.
255+
*
256+
* @param callable The closure or callable to invoke
257+
* @param env The environment (unused for closures, which capture their own)
258+
* @param t The target
259+
* @param args The arguments to pass to the callable
260+
* @return A {@link YieldStep} for chaining a {@code .then()} callback
261+
*/
262+
public YieldStep call(Callable callable, Environment env, Target t, Mixed... args) {
263+
YieldStep step = new YieldStep(callable, args);
264+
steps.add(step);
265+
return step;
266+
}
267+
268+
/**
269+
* Set the final result of this function via a supplier. The supplier is evaluated
270+
* after all yield steps have completed. This must be called exactly once.
271+
*
272+
* @param resultSupplier A supplier that returns the result value
273+
*/
274+
public void done(Supplier<Mixed> resultSupplier) {
275+
this.resultSupplier = resultSupplier;
276+
this.doneSet = true;
277+
}
278+
279+
Mixed getResult() {
280+
return resultSupplier.get();
281+
}
282+
283+
/**
284+
* Clears all remaining queued steps. Used for short-circuiting (e.g. array_every,
285+
* array_some) where the final result is known before all steps have been processed.
286+
*/
287+
public void clear() {
288+
steps.clear();
289+
}
290+
291+
/**
292+
* Fallback for when CallbackYield functions are called outside the iterative
293+
* interpreter (e.g. during compile-time optimization). Drains all steps synchronously
294+
* by calling executeCallable directly.
295+
*/
296+
void executeSynchronously(Environment env, Target t) {
297+
while(!steps.isEmpty()) {
298+
YieldStep step = steps.poll();
299+
Mixed r = step.callable.executeCallable(env, t, step.args);
300+
if(step.callback != null) {
301+
step.callback.accept(r, this);
302+
}
303+
}
304+
}
305+
306+
@Override
307+
public String toString() {
308+
return "Yield{steps=" + steps.size() + ", doneSet=" + doneSet + "}";
309+
}
310+
}
311+
312+
/**
313+
* A single queued closure call with an optional continuation.
314+
*/
315+
public static class YieldStep {
316+
final Callable callable;
317+
final Mixed[] args;
318+
BiConsumer<Mixed, Yield> callback;
319+
Runnable cleanupAction;
320+
Environment preparedEnv;
321+
322+
YieldStep(Callable callable, Mixed[] args) {
323+
this.callable = callable;
324+
this.args = args;
325+
}
326+
327+
/**
328+
* Register a callback to run after the closure completes.
329+
*
330+
* @param callback Receives the closure's return value and the Yield object
331+
* (for queuing additional steps or calling done())
332+
* @return This step, for fluent chaining
333+
*/
334+
public YieldStep then(BiConsumer<Mixed, Yield> callback) {
335+
this.callback = callback;
336+
return this;
337+
}
338+
339+
/**
340+
* Register a cleanup action that runs after this step completes, whether
341+
* normally or due to an exception. This is analogous to a {@code finally} block.
342+
*
343+
* @param cleanup The cleanup action to run
344+
* @return This step, for fluent chaining
345+
*/
346+
public YieldStep cleanup(Runnable cleanup) {
347+
this.cleanupAction = cleanup;
348+
return this;
349+
}
350+
351+
@Override
352+
public String toString() {
353+
return "YieldStep{callable=" + callable.getClass().getSimpleName()
354+
+ ", args=" + Arrays.toString(args) + ", hasCallback=" + (callback != null) + "}";
355+
}
356+
}
357+
}

0 commit comments

Comments
 (0)