// Copyright (c) FIRST and other WPILib contributors. // Open Source Software; you can modify and/or share it under the terms of // the WPILib BSD license file in the root directory of this project. package org.wpilib.commands3; import static edu.wpi.first.units.Units.Microseconds; import static edu.wpi.first.units.Units.Milliseconds; import edu.wpi.first.util.ErrorMessages; import edu.wpi.first.util.protobuf.ProtobufSerializable; import edu.wpi.first.wpilibj.RobotController; import edu.wpi.first.wpilibj.TimedRobot; import edu.wpi.first.wpilibj.event.EventLoop; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.SequencedMap; import java.util.SequencedSet; import java.util.Set; import java.util.Stack; import java.util.function.Consumer; import java.util.stream.Collectors; import org.wpilib.annotation.NoDiscard; import org.wpilib.commands3.button.CommandGenericHID; import org.wpilib.commands3.proto.SchedulerProto; /** * Manages the lifecycles of {@link Coroutine}-based {@link Command Commands}. Commands may be * scheduled directly using {@link #schedule(Command)}, or be bound to {@link Trigger Triggers} to * automatically handle scheduling and cancellation based on internal or external events. User code * is responsible for calling {@link #run()} periodically to update trigger conditions and execute * scheduled commands. Most often, this is done by overriding {@link TimedRobot#robotPeriodic()} to * include a call to {@code Scheduler.getDefault().run()}: * *
{@code
* public class Robot extends TimedRobot {
* @Override
* public void robotPeriodic() {
* // Update the scheduler on every loop
* Scheduler.getDefault().run();
* }
* }
* }
*
* The scheduler must be used in a single-threaded program. Commands must be scheduled and * canceled by the same thread that runs the scheduler, and cannot be run in a virtual thread. * *
Using the commands framework in a multithreaded environment can cause crashes in the * Java virtual machine at any time, including on an official field during a match. The * Java JIT compilers make assumptions that rely on coroutines being used on a single thread. * Breaking those assumptions can cause incorrect JIT code to be generated with undefined behavior, * potentially causing control issues or crashes deep in JIT-generated native code. * *
Normal concurrency constructs like locks, atomic references, and synchronized blocks * or methods cannot save you. * *
The {@link #run()} method runs five steps: * *
There are two mechanisms for telemetry for a scheduler. A protobuf serializer can be used to * take a snapshot of a scheduler instance, and report what commands are queued (scheduled but have * not yet started to run), commands that are running (along with timing data for each command), and * total time spent in the most recent {@link #run()} call. However, it cannot detect one-shot * commands that are scheduled, run, and complete all in a single {@code run()} invocation - * effectively, commands that never call {@link Coroutine#yield()} are invisible. * *
A second telemetry mechanism is provided by {@link #addEventListener(Consumer)}. The scheduler
* will issue events to all registered listeners when certain events occur (see {@link
* SchedulerEvent} for all event types). These events are emitted immediately and can be used to
* detect lifecycle events for all commands, including one-shots that would be invisible to the
* protobuf serializer. However, it is up to the user to log those events themselves.
*/
public final class Scheduler implements ProtobufSerializable {
private final Map Note: Like commands, any loops in the callback must appropriately yield
* control back to the scheduler with {@link Coroutine#yield} or risk stalling your program in an
* unrecoverable infinite loop!
*
* @param callback the callback to sideload
*/
public void sideload(Consumer For example:
*
* Does nothing if the command is already scheduled or running, or requires at least one
* mechanism already used by a higher priority command.
*
* @param command the command to schedule
* @return the result of the scheduling attempt. See {@link ScheduleResult} for details.
* @throws IllegalArgumentException if scheduled by a command composition that has already
* scheduled another command that shares at least one required mechanism
*/
// Implementation detail: a child command will immediately start running when scheduled by a
// parent command, skipping the queue entirely. This avoids dead loop cycles where a parent
// schedules a child, appending it to the queue, then waits for the next cycle to pick it up and
// start it. With deeply nested commands, dead loops could quickly to add up and cause the
// innermost commands that actually _do_ something to start running hundreds of milliseconds after
// their root ancestor was scheduled.
public ScheduleResult schedule(Command command) {
// Note: we use a throwable here instead of Thread.currentThread().getStackTrace() for easier
// stack frame filtering and modification.
var binding =
new Binding(
BindingScope.global(), BindingType.IMMEDIATE, command, new Throwable().getStackTrace());
return schedule(binding);
}
// Package-private for use by Trigger
ScheduleResult schedule(Binding binding) {
var command = binding.command();
if (isScheduledOrRunning(command)) {
return ScheduleResult.ALREADY_RUNNING;
}
if (lowerPriorityThanConflictingCommands(command)) {
return ScheduleResult.LOWER_PRIORITY_THAN_RUNNING_COMMAND;
}
for (var scheduledState : m_queuedToRun) {
if (!command.conflictsWith(scheduledState.command())) {
// No shared requirements, skip
continue;
}
if (command.isLowerPriorityThan(scheduledState.command())) {
// Lower priority than an already-scheduled (but not yet running) command that requires at
// one of the same mechanism. Ignore it.
return ScheduleResult.LOWER_PRIORITY_THAN_RUNNING_COMMAND;
}
}
// Evict conflicting on-deck commands
// We check above if the input command is lower priority than any of these,
// so at this point we're guaranteed to be >= priority than anything already on deck
evictConflictingOnDeckCommands(command);
// If the binding is scoped to a particular command, that command is the parent. If we're in the
// middle of a run cycle and running commands, the parent is whatever command is currently
// running. Otherwise, there is no parent command.
var parentCommand =
binding.scope() instanceof BindingScope.ForCommand scope
? scope.command()
: currentCommand();
var state = new CommandState(command, parentCommand, buildCoroutine(command), binding);
emitScheduledEvent(command);
if (currentState() != null) {
// Scheduling a child command while running. Start it immediately instead of waiting a full
// cycle. This prevents issues with deeply nested command groups taking many scheduler cycles
// to start running the commands that actually _do_ things
evictConflictingRunningCommands(state);
m_runningCommands.put(command, state);
runCommand(state);
} else {
// Scheduling outside a command, add it to the pending set. If it's not overridden by another
// conflicting command being scheduled in the same scheduler loop, it'll be promoted and
// start to run when #runCommands() is called
m_queuedToRun.add(state);
}
return ScheduleResult.SUCCESS;
}
/**
* Checks if a command conflicts with and is a lower priority than any running command. Used when
* determining if the command can be scheduled.
*/
private boolean lowerPriorityThanConflictingCommands(Command command) {
Set This has no effect if the given command is not currently scheduled or running.
*
* @param command the command to cancel
*/
@SuppressWarnings("PMD.CompareObjectsWithEquals")
public void cancel(Command command) {
if (command == currentCommand()) {
throw new IllegalArgumentException(
"Command `" + command.name() + "` is mounted and cannot be canceled");
}
boolean running = isRunning(command);
// Evict the command. The next call to run() will schedule the default command for all its
// required mechanisms, unless another command requiring those mechanisms is scheduled between
// calling cancel() and calling run()
m_runningCommands.remove(command);
m_queuedToRun.removeIf(state -> state.command() == command);
if (running) {
// Only run the hook if the command was running. If it was on deck or not
// even in the scheduler at the time, then there's nothing to do
command.onCancel();
emitCanceledEvent(command);
}
// Clean up any orphaned child commands; their lifespan must not exceed the parent's
removeOrphanedChildren(command);
}
/**
* Updates the command scheduler. This will run operations in the following order:
*
* This method is intended to be called in a periodic loop like {@link
* TimedRobot#robotPeriodic()}
*/
public void run() {
final long startMicros = RobotController.getTime();
// Sideloads may change some state that affects triggers. Run them first.
runPeriodicSideloads();
// Poll triggers next to schedule and cancel commands
m_eventLoop.poll();
// Schedule default commands for any mechanisms that don't have a running command and didn't
// have a new command scheduled by a sideload function or a trigger
scheduleDefaultCommands();
// Move all scheduled commands to the running set
promoteScheduledCommands();
// Run every command in order until they call Coroutine.yield() or exit
runCommands();
final long endMicros = RobotController.getTime();
m_lastRunTimeMs = Milliseconds.convertFrom(endMicros - startMicros, Microseconds);
}
private void promoteScheduledCommands() {
// Clear any commands that conflict with the scheduled set
for (var queuedState : m_queuedToRun) {
evictConflictingRunningCommands(queuedState);
}
// Move any scheduled commands to the running set
for (var queuedState : m_queuedToRun) {
m_runningCommands.put(queuedState.command(), queuedState);
}
// Clear the set of on-deck commands,
// since we just put them all into the set of running commands
m_queuedToRun.clear();
}
private void runPeriodicSideloads() {
// Update periodic callbacks
for (Coroutine coroutine : m_periodicCallbacks) {
coroutine.mount();
try {
coroutine.runToYieldPoint();
} finally {
Continuation.mountContinuation(null);
}
}
// And remove any periodic callbacks that have completed
m_periodicCallbacks.removeIf(Coroutine::isDone);
}
private void runCommands() {
// Tick every command that hasn't been completed yet
// Run in reverse so parent commands can resume in the same loop cycle an awaited child command
// completes. Otherwise, parents could only resume on the next loop cycle, introducing a delay
// at every layer of nesting.
for (var state : List.copyOf(m_runningCommands.values()).reversed()) {
runCommand(state);
}
}
/**
* Mounts and runs a command until it yields or exits.
*
* @param state The command state to run
*/
@SuppressWarnings("PMD.AvoidCatchingGenericException")
private void runCommand(CommandState state) {
final var command = state.command();
final var coroutine = state.coroutine();
if (!m_runningCommands.containsKey(command)) {
// Probably canceled by an owning composition, do not run
return;
}
var previousState = currentState();
m_currentCommandAncestry.push(state);
long startMicros = RobotController.getTime();
emitMountedEvent(command);
coroutine.mount();
try {
coroutine.runToYieldPoint();
} catch (RuntimeException e) {
// Command encountered an uncaught exception.
handleCommandException(state, e);
} finally {
long endMicros = RobotController.getTime();
double elapsedMs = Milliseconds.convertFrom(endMicros - startMicros, Microseconds);
state.setLastRuntimeMs(elapsedMs);
if (state.equals(currentState())) {
// Remove the command we just ran from the top of the stack
m_currentCommandAncestry.pop();
}
if (previousState != null) {
// Remount the parent command, if there is one
previousState.coroutine().mount();
} else {
Continuation.mountContinuation(null);
}
}
if (coroutine.isDone()) {
// Immediately check if the command has completed and remove any children commands.
// This prevents child commands from being executed one extra time in the run() loop
emitCompletedEvent(command);
m_runningCommands.remove(command);
removeOrphanedChildren(command);
} else {
// Yielded
emitYieldedEvent(command);
}
}
/**
* Handles uncaught runtime exceptions from a mounted and running command. The command's ancestor
* and child commands will all be canceled and the exception's backtrace will be modified to
* include the stack frames of the schedule call site.
*
* @param state The state of the command that encountered the exception.
* @param e The exception that was thrown.
* @throws RuntimeException rethrows the exception, with a modified backtrace pointing to the
* schedule site
*/
@SuppressWarnings("PMD.CompareObjectsWithEquals")
private void handleCommandException(CommandState state, RuntimeException e) {
var command = state.command();
// Fetch the root command
// (needs to be done before removing the failed command from the running set)
Command root = command;
while (getParentOf(root) != null) {
root = getParentOf(root);
}
// Remove it from the running set.
m_runningCommands.remove(command);
// Intercept the exception, inject stack frames from the schedule site, and rethrow it
var binding = state.binding();
e.setStackTrace(CommandTraceHelper.modifyTrace(e.getStackTrace(), binding.frames()));
emitCompletedWithErrorEvent(command, e);
// Clean up child commands after emitting the event so child Canceled events are emitted
// after the parent's CompletedWithError
removeOrphanedChildren(command);
// Bubble up to the root and cancel all commands between the root and this one
// Note: Because we remove the command from the running set above, we still need to
// clean up all the failed command's children
if (root != null && root != command) {
cancel(root);
}
// Then rethrow the exception
throw e;
}
/**
* Gets the currently executing command state, or null if no command is currently executing.
*
* @return the currently executing command state
*/
private CommandState currentState() {
if (m_currentCommandAncestry.isEmpty()) {
// Avoid EmptyStackException
return null;
}
return m_currentCommandAncestry.peek();
}
/**
* Gets the currently executing command, or null if no command is currently executing.
*
* @return the currently executing command
*/
public Command currentCommand() {
var state = currentState();
if (state == null) {
return null;
}
return state.command();
}
private void scheduleDefaultCommands() {
// Schedule the default commands for every mechanism that doesn't currently have a running or
// scheduled command.
m_defaultCommands.forEach(
(mechanism, defaultCommand) -> {
if (m_runningCommands.keySet().stream().noneMatch(c -> c.requires(mechanism))
&& m_queuedToRun.stream().noneMatch(c -> c.command().requires(mechanism))
&& defaultCommand != null) {
// Nothing currently running or scheduled
// Schedule the mechanism's default command, if it has one
schedule(defaultCommand);
}
});
}
/**
* Removes all commands descended from a parent command. This is used to ensure that any command
* scheduled within a composition or group cannot live longer than any ancestor.
*
* @param parent the root command whose descendants to remove from the scheduler
*/
@SuppressWarnings("PMD.CompareObjectsWithEquals")
private void removeOrphanedChildren(Command parent) {
m_runningCommands.entrySet().stream()
.filter(e -> e.getValue().parent() == parent)
.toList() // copy to an intermediate list to avoid concurrent modification
.forEach(e -> cancel(e.getKey()));
}
/**
* Builds a coroutine object that the command will be bound to. The coroutine will be scoped to
* this scheduler object and cannot be used by another scheduler instance.
*
* @param command the command for which to build a coroutine
* @return the binding coroutine
*/
private Coroutine buildCoroutine(Command command) {
return new Coroutine(this, m_scope, command::run);
}
/**
* Checks if a command is currently running.
*
* @param command the command to check
* @return true if the command is running, false if not
*/
public boolean isRunning(Command command) {
return m_runningCommands.containsKey(command);
}
/**
* Checks if a command is currently scheduled to run, but is not yet running.
*
* @param command the command to check
* @return true if the command is scheduled to run, false if not
*/
@SuppressWarnings("PMD.CompareObjectsWithEquals")
public boolean isScheduled(Command command) {
return m_queuedToRun.stream().anyMatch(state -> state.command() == command);
}
/**
* Checks if a command is currently scheduled to run, or is already running.
*
* @param command the command to check
* @return true if the command is scheduled to run or is already running, false if not
*/
public boolean isScheduledOrRunning(Command command) {
return isScheduled(command) || isRunning(command);
}
/**
* Gets the set of all currently running commands. Commands are returned in the order in which
* they were scheduled. The returned set is read-only.
*
* @return the currently running commands
*/
public Collection Listeners are primarily expected to be for data logging and telemetry. In particular, a
* one-shot command (one that never calls {@link Coroutine#yield()}) will never appear in the
* standard protobuf telemetry because it is scheduled, runs, and finishes all in a single
* scheduler cycle. However, {@link SchedulerEvent.Scheduled},{@link SchedulerEvent.Mounted}, and
* {@link SchedulerEvent.Completed} events will be emitted corresponding to those actions, and
* user code can listen for and log such events.
*
* @param listener The listener to add. Cannot be null.
* @throws NullPointerException if given a null listener
*/
public void addEventListener(Consumer super SchedulerEvent> listener) {
ErrorMessages.requireNonNullParam(listener, "listener", "addEventListener");
m_eventListeners.add(listener);
}
private void emitEvent(SchedulerEvent event) {
// TODO: Prevent listeners from interacting with the scheduler.
// Scheduling or canceling commands while the scheduler is processing will probably cause
// bugs in user code or even a program crash.
for (var listener : m_eventListeners) {
listener.accept(event);
}
}
}
{@code
* scheduler.addPeriodic(() -> leds.setData(ledDataBuffer));
* scheduler.addPeriodic(() -> {
* SmartDashboard.putNumber("X", getX());
* SmartDashboard.putNumber("Y", getY());
* });
* }
*
* @param callback the periodic function to run
*/
public void addPeriodic(Runnable callback) {
sideload(
coroutine -> {
while (true) {
callback.run();
coroutine.yield();
}
});
}
/** Represents possible results of a command scheduling attempt. */
public enum ScheduleResult {
/** The command was successfully scheduled and added to the queue. */
SUCCESS,
/** The command is already scheduled or running. */
ALREADY_RUNNING,
/** The command is a lower priority and conflicts with a command that's already running. */
LOWER_PRIORITY_THAN_RUNNING_COMMAND,
}
/**
* Schedules a command to run. If one command schedules another (a "parent" and "child"), the
* child command will be canceled when the parent command completes. It is not possible to fork a
* child command and have it live longer than its parent.
*
*
*
*
*