Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The execution engine

Verified against Minecraft 26.3 · Part XIII · /execute as @a at @s run say hi on a server with four players: a command engine with no Java recursion, a fan-out that materialises one player at a time, and a /return that deletes work out of a queue rather than unwinding a stack.

Write a data pack that calls a function that calls itself, load it, and the server does not crash. It runs a very large number of commands, logs one line at info, and carries on. That is not a recursion limit — there is no recursion limit, and no depth game rule either. What stopped it was a budget: a count of units of work spent by one outermost command, whose ceiling is a game rule and whose exhaustion is the log line. It is that nothing in command execution uses the Java call stack.

Every construct that nests — /execute … run, /function, execute if function, /return run — is expressed as queued work on a heap-allocated deque, and the driver is a flat loop. A stack made of heap objects costs you nothing except the obvious, and buys you three things a real stack cannot give: you can inspect it, you can delete pending entries from it before they run, and a tracer can watch the work as it runs. /return and /debug function are built on that one decision.

The cast

classwhat it decidesnotes
ExecutionContextone per outermost command: the queue, the staging list, the budget, the fork limit, the tracerthe whole engine is in its ExecutionContext.runCommandQueue loop
CommandQueueEntrya Frame and an EntryAction. That is the entire unit of work—
Framenot a stack frame: a depth, a CommandResultCallback a /return feeds, and a Frame.FrameControl that knows how to delete this frame’s pending workone object shared by reference across a whole function body
BuildContextswalks the stages of a parsed chain, forking sources as it goesBuildContexts.TopLevel, BuildContexts.Continuation and BuildContexts.Unbound
ContinuationTaskthe lazy fan-out: emits one element’s entry, then re-queues itselfthe reason three or more players cost one element’s entry at a time rather than N at once
CallFunction / IsolatedCallthe only two things besides the top level that open a frameIsolatedCall’s /return cannot reach the caller
ExecutionCommandSourcethe interface the engine is generic over, which is why none of it mentions CommandSourceStackCommandSourceStack implements it
CommandResultCallbacka success flag and an integer. This pair is what “the result of a command” means everywhere in the gameCommandResultCallback.EMPTY short-circuits

net/minecraft/commands/execution is the whole engine and it is entirely server-side. Nothing here crosses the network; only the effects of commands produce packets. What arrives is a ParseResults and a CommandSourceStack that Brigadier and commands has already built: Commands.performCommand flattens the parse into a context chain and Commands.executeCommandInContext is the door — it reads the two game rules that bound an execution, installs the context in a thread-local and drives the loop.

The queue, four moments apart

/execute as @a at @s run say hi, four players online. The command starts as one entry — a BuildContexts.TopLevel holding the whole parsed chain, at depth 0 — and the figure is the queue at the four moments after that.

flowchart TD
    subgraph T2["1 · that entry ran"]
        B1["ContinuationTask over four players"]
    end
    subgraph T3["2 · the task ran"]
        direction LR
        C1["ExecuteCommand for A"] --> C2["ContinuationTask"]
    end
    subgraph T4["3 · A's say hi ran"]
        D1["ContinuationTask — B is still not made"]
    end
    subgraph T5["4 · the task ran again"]
        direction LR
        E1["ExecuteCommand for B"] --> E2["ContinuationTask"]
    end
    T2 --> T3 --> T4 --> T5

The whole queue at four moments — an arrow inside a panel runs from the head to the entry behind it, an arrow between panels is one entry run, every entry shown shares the top frame at depth 0, and ExecuteCommand here is the leaf task in commands/execution/tasks, not the /execute command’s class of the same name.

A fork does not create frames, and it does not create entries. BuildContexts.execute walks every non-execute stage inside a single queue entry, spending one cost unit per modifier stage — here as @a and at @s, not run — no matter how many sources that stage produces, and turning a one-element source list into an N-element one. Frames are opened in exactly three places: ExecutionContext.createTopFrame, CallFunction and IsolatedCall. A hundred-player fork opens none.

The queue is a stack with a staging buffer, and that is what makes it depth-first. An action does not push directly: it appends to ExecutionContext.newTopCommands while it runs, and ExecutionContext.pushNewCommands splices that list onto the head afterwards, in order. So whatever the current action spawned runs before whatever was already pending — the semantics of a call stack, out of an ArrayDeque. A queued action is handed the ExecutionContext itself. A custom executor or modifier is handed an ExecutionControl instead, the (context, frame) pair, through which it can queue an action, read or install a TraceCallbacks, and reach its current frame — which is how /return finds the frame it discards.

The fan-out is lazy, and the arithmetic is exact. ContinuationTask.schedule queues nothing for an empty list, one entry for one element, two entries for two, and for three or more queues exactly one: a ContinuationTask that emits the current element’s entry and then re-queues itself behind it. Because staging preserves order, element i and everything it spawns runs to completion before element i+1 runs — and, from three elements up, before it is even materialised. The queue cost is constant in N.

A chain can split across entries, and only a custom modifier does it. When the stage walk meets a CustomModifierExecutor it hands off and returns mid-walk; the rest of the chain resumes later as a BuildContexts.Continuation. That is how execute if function and /return run interrupt a chain that otherwise runs to its leaf inside one entry.

Deleting work, which is what /return is

/return does not unwind and does not throw. Frame.returnSuccess pushes the value sideways into the callback the caller installed on that frame, and Frame.discard splices the abandoned work out of the queue. There is no search.

The splice is one rule. Call the discarding frame’s own depth d: pop from the head while the entry’s depth is at least d. That works because the queue is depth-first, so entries deeper than a frame are always in front of that frame’s own remaining entries — which means the rule removes exactly the callee’s pending work plus the rest of this frame’s body, and nothing older. Depth-zero frames are the special case: their frame control clears the queue outright.

The laziness pays off here too. Discarding one ContinuationTask self-entry abandons every element not yet materialised, so /return out of a thousand-line function is the same cost as /return out of a two-line one.

Who installed the callback decides where a returned value goes, and two details settle it:

  • The single-source reduction on the /return run path lives in BuildContexts.execute, not in ReturnCommand, and it is gated twice — on return mode, and on the leaf not being a CustomCommandExecutor. So return run execute as @a run function foo queues one function call per player, unreduced — and the first to finish, by returning or by falling through, discards the rest.
  • The chaining runs the opposite way from “inner onto outer”: what is chained is the source’s own callback with the current frame’s return consumer, and on the /return run function path the outer frame’s consumer is chained into the inner frame.

A result is a flag and a number, and only a function tag sums them

The result of a command is always a CommandResultCallback pair: a success flag and an integer. The engine aggregates nothing, and there is one exception, which /function makes. A fork over N players delivers N independent results to N sources, so an execute store result whose target every forked source shares — one placed before the fork, or one naming a fixed holder — writes N times and the last one wins — there is no success count. The exception is /function on a tag, which sums its members’ results, and only when the caller installed a real callback and it is not under return run.

A command typed in chat has an empty frame callback and Commands.performCommand returns nothing — yet execute store still works on it, because the result also reaches the source’s own callback, which is what ExecuteCommand.wrapStores decorated (scores, teams and stored data). FallthroughTask makes a chain that produced no sources, or a called function that ended without /return, fail rather than return nothing, and every site that queues it is inside a return or a conditional.

Six classes implement the escape hatch for a command that wants the engine rather than Brigadier’s plain “return an int”, and between them they belong to four commands: /function, /return, /debug and /execute, whose if function is one. Two of the six extend CustomCommandExecutor.WithErrorHandling — FunctionCommand.FunctionCustomExecutor and DebugCommand.TraceCustomExecutor — which routes a thrown CommandSyntaxException to both the source’s error handler and its callback, so a failing /function under execute store still writes. The other four handle their own.

What actually stops a command

Three things end an execution before its work is done. One is an exception thrown out of an action, which Commands.executeCommandInContext passes up: Commands.performCommand tells the source the command failed, and ServerFunctionManager.execute only logs it. The other two are the budget running out and the queue overflowing, and they are not the same event.

The budget runs out. ExecutionContext.runCommandQueue checks at the top of every iteration, logs at info, and breaks. The queue is not cleared; it is simply abandoned with the context. Nothing reaches the player.

The queue overflows. ExecutionContext.queueNext trips when staged plus queued entries exceed ten million — a cap on queue length, not on depth, whatever the constant that names it suggests; ExecutionContext.handleQueueOverflow clears both lists and sets a latch that silently drops every subsequent queue attempt, and the driver then logs at error. Different level, different clean-up, and a latch the budget path has no equivalent of.

What the two numbers are

Both are game rules, and both are 65536 by default. GameRules.MAX_COMMAND_SEQUENCE_LENGTH is the budget — how many units of work one outermost command may spend before the budget path above fires — and GameRules.MAX_COMMAND_FORKS is the fork limit, how many sources one stage may contribute. Both are read once, by the outermost command, so a /gamerule changed part way through a long fan-out does not take effect until the next top-level command. (There is nothing dimensional in it: ServerLevel.getGameRules returns the server’s one GameRules instance, so no level has rules of its own to pick up.) The fork limit is checked per contributing source with a greater-or-equal comparison, so the effective ceiling is one below the configured value, and when it trips the handler returns without queueing even a FallthroughTask — a /return run chain that hits it yields nothing at all rather than a failure.

Depth is not on that list, and that is the answer to the function at the top of this page. Recursion is unbounded structurally: depth is used only to order discards and to indent the tracer. What bounds a self-calling function is the budget, transitively — every call it makes spends a unit — and what bounds a fan-out is the fork limit, with the budget again at one unit a leaf.

And a budget belongs to a context, not to a command. Commands.CURRENT_EXECUTION_CONTEXT is a thread-local: a command that starts another top-level execution while one is running queues its work into the running context, to run next, rather than making a new context, and the limits were read once by the outermost call. Its top frame sits one depth deeper than the running entry’s, except under a depth-zero entry, where it shares depth zero; every vanilla re-entry is a function call whose body opens a frame one level further down, so its discards never reach the outer queue. The thread-local is null again the moment a queue drains, so two commands run back to back each open their own context — which is why every function in #minecraft:tick gets a budget of its own (functions and macros).

Where a unit is spent

The budget is spent in exactly three places — BuildContexts on a modifier stage, CallFunction on a function call, and the leaf ExecuteCommand task on an executed command — and the first has a gate worth knowing. The increment happens only when the stage carries a non-null redirect modifier, and only after the custom-modifier hand-off has been ruled out. So a plain execute run costs nothing for its redirect, and the execute if function and /return run stages are free: neither custom modifier ever reaches the counter, though what they go on to run pays like anything else. A ContinuationTask is free too, and that is the +1 an N-way fan-out does not pay: N leaves cost N, the continuation that materialised them costs nothing, and each modifier stage that forked in the first place was charged one unit, however many sources it produced.

The two commands that are part of the engine

ExecuteCommand.scheduleFunctionConditionsAndTest is how execute if function works and it is the cleverest thing in the area: instantiate each function once, wrap every source in an IsolatedCall whose callback appends that source to a list, then queue a BuildContexts.Continuation over the same mutable list, which the isolated calls fill before the continuation reads it. The staging order is what makes that safe, and the inner CallFunction opens against the isolated frame, which is why a /return inside a condition function cannot reach the caller.

/debug function installs a DebugCommand.Tracer on the whole ExecutionContext rather than per frame, so it traces everything in that context. The command refuses to nest a trace and refuses return mode, and the tracer implements CommandSource as well, so a traced function’s chat output lands in the trace file alongside the call lines.

Questions players ask

Can a function yield? No. Work it queues drains inside the same driver loop, in the same tick, before the call returns. The only escape is /schedule (functions and macros).

Why did my command fail silently inside execute if? Because conditionals are fork nodes. execute as @s, execute at @s, execute if block and friends set the forked flag on ChainModifiers for the rest of the chain, and a forked source suppresses failure messages. Putting a harmless-looking conditional in front of a command converts its errors into nothing: in forked mode Brigadier catches a plain command’s error and returns zero, which is all a trace shows of it, though the engine’s own commands, a forked /function naming a missing function among them, still write their error to a trace through CommandSourceStack.handleError.

Does /return inside a fork stop the other sources? Not normally. A forked /function has its N calls queued at once, in a plain Java loop, and each opens its own frame at the next depth when it runs, so a /return in one discards only that callee. It is return run … that sets CallFunction.returnParentFrame against the caller’s own frame, making the inner discard run at the outer frame’s depth and delete the siblings.

Where to look

ExecutionContext and Frame first — two short files that explain the whole design. Then BuildContexts for how a parse becomes work, ContinuationTask for the laziness both the fan-out and the function body ride on, and ExecutionCommandSource for why none of it names a command source.


Rules: names, never code · how the system works, not how the code reads · newest version only · every backticked name passes tools/verify_names.py.