| CPSC 329 | Software Development | Fall 2026 |
This lab provides practice with using threads in a JavaFX program: running slow work in the background without freezing the window, dealing with results that are out of date by the time they arrive, updating the GUI from a thread that runs for the whole program, and animation.
Successful completion of this lab means that you:
can move slow work off the JavaFX application thread and report progress
can cancel a task when the user changes something it depends on
can safely update the GUI from a thread that isn't a Task
can animate with a Timeline
Labs are for learning and practice — you may help and be helped by others when it comes to figuring out what to do, but in the end you need to understand and be able to do the things for yourself.
This lab can be done individually or in pairs. If you work together, use pair programming rather than splitting things up and working in parallel.
You may not use AI to figure out what to do or to do the task for you.
due Fri 11/6 at the start of class
To hand in your work:
If you worked with someone else, add @author tags in the class comment for MazeView naming both partners. Only one needs to hand in the lab.
Copy your entire ~/cs329/dev/lab9 directory into your handin directory. Make sure you end up with lab9 (directly) inside your handin directory and that it contains a src directory (with your code) — you shouldn't have a bunch of stuff loose in the top level of your handin directory or have folders nested deeper.
This lab handout itself and the slides and examples from class (11/2; posted on the schedule page) should be your primary reference for these exercises.
The slides contain some references for additional information and examples for Timeline.
You have been provided with complete maze solver application. This application has some additional features beyond the simpler application used in lab 6, and the focus of this lab is the GUI (and specifically threads-related aspects of the GUI) rather than the code organization.
The files, organized by package:
maze — the maze: the walls, start, and goal. It can be edited and notifies its listeners when it changes.
solver — solving a maze one step at a time, where each step explores the next discovered room and discovers its neighbors. A solver's work is reported as a SolveResult, which never changes once it has been created.
controller — handles the user's requests to change the maze.
view
MazeView — the window contents: the controls and status at the top, the maze below; the drawing and mouse handling are complete
SolveTask — will solve a maze in the background
console
CommandReader — reads commands typed in the console
ConsoleCommands — carries out the commands
main
MazeApp — the main program: creates and connects the objects and starts the command reader
io — reading maze files.
The maze files are in the mazes folder.
Create an Eclipse project and share it to a local Git repository:
Create a new Eclipse project named lab9r. Remember that the project directory should end up under ~/cs329/dev, not in Eclipse's workspace.
Import all of the files from /classes/cs329/lab9 into your project, preserving the directory structure — the maze files should end up in a folder mazes in the top level of your project and the Java files should end up in packages under src.
Confirm that the program runs correctly — the main program is MazeApp (in the main package). You should see a window with a maze and the message "Type commands here (help for the list)." in the Eclipse console. If there are JavaFX errors, refer back to the JavaFX setup from lab 6. If you don't see a maze display (or get error messages relating to a maze file), check the organization of files and folders in your project.
Create a new (local) Git repository, create/set up .gitignore, and do the initial commit. Include a meaningful commit message (e.g. "Added lab starter files").
The maze is drawn with walls in light gray and open rooms in black. The start is the blue circle and the goal is the green circle. After solving, discovered rooms are yellow, explored rooms are orange, and the path from start to goal is a red line. Click a grid cell in the maze display to toggle a wall and drag the start or goal to move it. File → Load Maze loads a different maze. Reset clears the displayed solution.
Solving a maze of this size is actually very fast so the solver has been given some extra work to do: SolveTask.simulateWork() waits for a few milliseconds for each room explored. It stands in for an expensive computation that might be part of another application.
Work through the parts below in order. Places where you need to add code are marked with TODO comments in the provided code, labeled with the specific steps (e.g. 1a, 1b).
The provided Solve button works but...
Run the program and observe: with breadth-first chosen, press Solve. What happens? (Or doesn't happen, as the case may be?) Try clicking on the maze display to edit the maze or the menu or other buttons. Keep waiting...what eventually happens?
solveRequested in MazeView runs the solver to solve the maze. What thread runs this code? (Highlight the method name and right-click → References → Project to quickly locate where the method is being called.)
In this part you'll move the solving into a Task so that the JavaFX application thread is free to keep the window responsive.
Read the SolveTask constructor. Note that it makes a copy of the maze and the task's solver works only with that copy — this is because the user may edit the maze while the solver is working, potentially invalidating the solver's work-in-progress. Since the constructor is run by the JavaFX thread (it is called from an event handler), doing the copy there avoids having the task's thread touch an object also used by the JavaFX thread — the task's thread only ever sees the copy.
Write code (1a): in SolveTask, write call, replacing the placeholder return null;:
while ( !solver_.isDone() ) {
if ( isCancelled() ) {
return null;
}
solver_.step();
updateMessage("searching... " + solver_.getExploredCount()
+ " rooms explored");
try {
simulateWork();
} catch ( InterruptedException e ) {
// cancel() interrupts the thread if it is sleeping
if ( isCancelled() ) {
return null;
}
}
}
return solver_.getResult();
This is the same loop as in solveRequested, with two differences. First, the task checks whether it has been canceled — cancel() only marks the task as canceled so it is up to call to actually stop. (cancel() also interrupts a thread that is sleeping, so the check is repeated in the catch.) Second, the status message goes through updateMessage, which passes it to the JavaFX application thread, rather than changing the label directly.
Notice that call doesn't call updateProgress. A task's progress starts at -1 (meaning "unknown"); a progress bar showing -1 shows activity rather than a percentage so nothing needs to be done. (Why use an indefinite progress bar instead of showing a percentage? The solver knows how many open rooms there are so updateProgress(explored,open) would show a percentage. Once you finish all of part 1, try solving maze6 with breadth-first and then with best-first — compare the "explored" count at the end with the number of open rooms. What would the progress bar do with best-first?)
Write code (1b): in MazeView, write startSolve:
SolveTask task = new SolveTask(maze_,algorithm_.getValue());
solveTask_ = task;
clearResult();
progress_.progressProperty().bind(task.progressProperty());
status_.textProperty().bind(task.messageProperty());
task.setOnSucceeded(e -> {
endSolve();
showFinished(task.getValue());
});
task.setOnCancelled(e -> {
endSolve();
status_.setText("Cancelled.");
});
task.setOnFailed(e -> {
endSolve();
status_.setText("Error: " + task.getException());
});
Thread thread = new Thread(task);
thread.setDaemon(true);
thread.start();
updateControls();
solveTask_ keeps track of the current task (so Cancel can find it) while the handlers keep track of the task they belong to (using task). (The handlers will need this in part 2, and solveTask_ can change after the handlers are set up — it will be null after the task ends and may later refer to a different task.)
The bind calls make the progress bar and status label follow the task's progress and message.
The three handlers run on the JavaFX application thread when the task ends. Each one calls endSolve so the controls are always restored, however the task ends.
A daemon thread doesn't keep the program running after the window is closed.
updateControls (provided) disables Solve, Reset, and Animate and enables Cancel when solveTask_ isn't null.
Write code (1b): write endSolve:
progress_.progressProperty().unbind(); progress_.setProgress(0); status_.textProperty().unbind(); solveTask_ = null; updateControls();
A bound value can't be changed by hand so the bindings must be removed before the status label can show the outcome. Setting solveTask_ to null means "not solving."
Write code (1b): in solveRequested, comment out (or delete) the code that solves the maze — everything after the if statement — and replace it with a call to startSolve().
Run and check: press Solve. The progress bar shows activity, the status counts the rooms explored, Solve is disabled, and you can click on the maze and move the window while the solve runs. When it finishes, the path appears and Solve is enabled again.
Write code (1c): in cancelRequested, cancel the current task, if there is one:
if ( solveTask_ != null ) {
solveTask_.cancel();
}
The setOnCancelled handler takes care of the rest.
Run and check: Cancel stops a solve partway through, the status says "Cancelled.", and Solve is enabled again.
Commit your work.
Now that the window stays responsive during a solve, the user can edit the maze while the task is working on its copy. By the time the result arrives, it may be the solution to a maze that no longer exists. The slides identify three strategies for this: disable the inputs, label the result, or cancel (and optionally restart) the task. Solve, Reset, and Animate are already disabled during a solve but disabling the whole maze would be annoying, so here an edit will cancel the solve. The user can press Solve again when they're done editing.
This is different from the Cancel button. There, the user decides to stop the task; here, the program stops the task because its result would be wrong.
Run and observe: start solving maze6 with breadth-first, and while it runs, drag the goal somewhere else. The path that appears leads to where the goal used to be. Then, with the solved maze showing, add a wall on the path. The old path is still drawn — a finished and displayed result can be stale too.
Write code (2b): in invalidateResult, cancel the solve if there is one, and clear the result:
if ( solveTask_ != null ) {
solveTask_.cancel();
status_.setText("Solving was cancelled.");
}
clearResult();
Write code (2b): in mazeChanged, call invalidateResult() before refresh().
There are several ways to change the maze: clicking a room, dragging the start or goal, loading a file, and (in part 3) typing commands in the console. Rather than having to add a call to invalidateResult() in the handler for each one (and having to remember to add one for any new way of editing the maze), we can take advantage of the Observer pattern that is already in place: MazeView is a listener for the Maze, with mazeChanged as the listener method. Any change to the maze results in a notification to the view, so only mazeChanged needs to call invalidateResult(), no matter what changed the maze.
Run and check: an edit during a solve stops it, with the status "Solving was cancelled.", and an edit after a solve clears the result. Try each kind of edit.
Commit your work.
The usual case is now handled, but there's a subtle gap. A task has finished when call returns — on the task's thread. The task then asks the JavaFX application thread to change its state to SUCCEEDED, which runs the setOnSucceeded handler. If the user's edit is handled after call returns but before that handler runs, then calling cancel() on the task has no effect (the task is already done). The setOnSucceeded handler still runs anyway, showing the stale result.
That gap is very short, so the Debug menu has an option to make the problem visible: it makes invalidateResult wait three seconds before doing anything, giving the task time to finish.
Run and observe: load maze2 and solve it with breadth-first (it only takes a second or so) and look at the path. Then choose Debug → Delay before invalidating. Press Solve and immediately click a room on the path to make it a wall. The window freezes for three seconds (that's the delay), and then the old path appears, through the new wall, with "Found a path".
The problem is that the task's setOnSucceeded handler still runs even though the result is no longer wanted. The fix has two parts: the handlers need to check whether their task is still the current task before doing anything (the guard) and a task whose result is no longer wanted needs to stop being the current task, so the guard can tell.
Write code (2c): in startSolve, add a guard at the beginning of each of the three handlers, so that a task that is no longer the current one can't affect the GUI. For example:
task.setOnSucceeded(e -> {
if ( task != solveTask_ ) { // not the current task anymore
return;
}
endSolve();
showFinished(task.getValue());
});
Do the same in setOnCancelled and setOnFailed. This is why the handlers use the local task: each handler compares its own task with the current one.
Write code (2c): the guard only helps if the stale task stops being the current task. In invalidateResult, replace the line solveTask_.cancel(); with
if ( !solveTask_.cancel() ) {
endSolve();
}
cancel() returns false if the task can't be canceled because it has already ended. The code added here finishes fixing the subtle timing gap:
If the task is still running, cancel() returns true. When cancel() is called on the JavaFX application thread, it runs the setOnCancelled handler right away, before it returns. The task is still the current task at that point, so the handler calls endSolve().
If the task has already finished, cancel() returns false and setOnCancelled never runs, so invalidateResult calls endSolve() itself.
Either way, endSolve() unbinds the progress bar and status, re-enables the controls, and sets solveTask_ to null so the stale task is no longer current and the guard ignores it when its setOnSucceeded handler runs.
Run and check: repeat the steps above. After the delay, the status says "Solving was cancelled." and no path is shown. Then turn off Debug → Delay before invalidating.
Write code (2d): changing the algorithm also makes a result stale. In algorithmChanged, call invalidateResult().
Run and check: changing the algorithm during a solve cancels it, and changing the algorithm after a solve clears the result.
Write code (2f): the user usually makes several edits in a row, so restarting after every edit would just start solves that are immediately canceled. But choosing a different algorithm is a single change, so it's reasonable to start solving again right away if a solve was in progress. Replace the body of algorithmChanged with:
boolean wassolving = solveTask_ != null;
invalidateResult();
if ( wassolving ) {
startSolve();
}
Here the guard also protects the new task: without it, a canceled task that ends late would call endSolve(), unbinding the progress bar and status from the new task and re-enabling Solve while the new task is still running.
Run and check: start solving maze6 with breadth-first, then choose depth-first. The count starts over, and the result is the depth-first path.
Commit your work.
A Task is for a piece of work with a result. Some threads instead run for the whole program, waiting for something to happen and then telling the GUI about it — for example, a client may have a thread that waits for messages from the server. (This will come up in project P3.) This program has a similar thread, though it waits for the user to type commands on the console rather than reading over the network.
Read CommandReader. readLine() waits (blocks) until the user types a line, so the reading has to happen on its own thread — waiting like that on the JavaFX application thread would freeze the window. Then read ConsoleCommands.handleCommand: each command calls the same controller or view method as the corresponding action in the GUI. Find where MazeApp.start starts the reader's thread.
Run and observe: click in the Eclipse console and type help. Then try wall 1 1, info, and alg dfs.
Each prints an IllegalStateException: "Not on FX application thread". But look carefully at the window after wall 1 1: the command did happen — the maze has a new wall — but the open rooms count and the message line still show what they showed before. The exceptions happened inside JavaFX library code (when the labels tried to update themselves for their new text) and JavaFX reports such exceptions rather than letting them stop the code that changed the text. The result is a window that no longer matches the program's state, which is worse than a crash because nothing obviously stops working. (And if you aren't watching the console, there aren't even any clues that something went wrong.)
Write code (3a): in CommandReader.run, carry out each command on the JavaFX application thread:
Platform.runLater(() -> commands_.handleCommand(line));
(replacing commands_.handleCommand(line);). The reading stays on this thread; runLater hands the work of carrying out the command to the JavaFX application thread, which runs it when it gets to it.
Run and check: the commands work, with no exceptions. Try wall 99 1 (the reason shows in the window, as for any refused edit) and a command that doesn't exist (reported in the console). Also start a solve and type wall 5 5 — the solve is canceled, with no new code. (Why?)
Commit your work.
Lab 6's maze solver showed the algorithm at work, room by room. In this part you'll add that as an animation with a Timeline.
A Timeline runs a handler at set times on the JavaFX application thread. It is made of KeyFrames, each with a time and a handler. For example:
Timeline timeline = new Timeline(new KeyFrame(Duration.millis(20),e -> ...)); timeline.setCycleCount(Animation.INDEFINITE); // repeat until stopped timeline.play();
In this example, the handler runs 20 ms after play(), and with an indefinite cycle count, every 20 ms after that until stop(). pause() pauses the timeline and play() continues it. The rate property speeds up or slows down the timeline (2 is twice as fast).
Write code (4a): in MazeView, write animateRequested (after the provided check):
clearResult();
// the animation works on a copy so that it can't be affected by edits
animSolver_ = new MazeSolver(maze_.copy(),algorithm_.getValue());
timeline_ = new Timeline(new KeyFrame(Duration.millis(STEP_MS),
e -> animationStep()));
timeline_.setCycleCount(Animation.INDEFINITE);
timeline_.play();
status_.setText("Animating...");
updateControls();
This is what will get an animation started when the user requests an animation. You'll need to add some imports — when using Eclipse's quick fix, make sure to choose the versions from a javafx package.
Write code (4a): write animationStep, which takes one step and shows it:
animSolver_.step();
SolveResult result = animSolver_.getResult();
if ( result.isDone() ) {
stopAnimation();
showFinished(result);
} else {
showResult(result);
}
This is what defines one step of the animation. animationStep() was registered as what to do for each step when the KeyFrame was created in animateRequested.
Write code (4a): write stopAnimation:
timeline_.stop(); timeline_ = null; animSolver_ = null; updateControls();
This ends an animation, stopping it and cleaning up.
Write code (4a): in cancelRequested, after the code from 1c, stop the animation if there is one:
if ( timeline_ != null ) {
stopAnimation();
status_.setText("Animation stopped.");
}
This is what ends an animation when a user cancels a solve.
Run and check: load maze2 and press Animate. Rooms turn yellow and orange as they are discovered and explored and the path appears at the end. Cancel stops the animation. Try all three algorithms.
The animation's solver runs on the JavaFX application thread, but the window doesn't freeze. Why is that OK here when part 0 froze? (Compare what happens in one step of the animation and one step of the loop from part 0, and think about what the JavaFX application thread does between steps.)
Write code (4b): write pauseRequested:
if ( timeline_ == null ) {
return;
}
if ( timeline_.getStatus() == Animation.Status.RUNNING ) {
timeline_.pause();
pause_.setText("Resume");
} else {
timeline_.play();
pause_.setText("Pause");
}
Write code (4b): in animateRequested, make the speed slider control the animation's speed by adding
timeline_.rateProperty().bind(speed_.valueProperty());
just before timeline_.play().
Run and check: Pause and Resume work and moving the speed slider speeds up or slows down the animation, even while it is running. Animate maze6 with breadth-first and a high speed.
Write code (4c): an edit during an animation also makes it stale. In invalidateResult, after the code from part 2, add
if ( timeline_ != null ) {
stopAnimation();
status_.setText("Animation was stopped.");
}
Run and check: an edit during an animation stops it and clears the display.
The animation needed no guard against a late step. Why not? (Which threads are involved?)
Commit your work.
This part is optional. If you have time, add one or more of the following:
Live preview: let a background solve show its progress on the maze. In call, every 50 steps, call updateValue(solver_.getResult()). In startSolve, listen to the task's value:
task.valueProperty().addListener((obs,oldvalue,newvalue) -> {
if ( task == solveTask_ && newvalue != null ) {
showResult(newvalue);
}
});
Like updateMessage, updateValue passes the value to the JavaFX application thread, and if several values are sent faster than the JavaFX application thread can deal with them, only the latest one is delivered. Why does the task send a snapshot of the result (solver_.getResult()) rather than giving the view the solver to draw from?
Chaos mode: use a java.util.Timer to toggle a random wall every two seconds. A Timer runs its TimerTasks on the timer's own thread, so each change must go through Platform.runLater:
Timer timer = new Timer(true); // true: a daemon thread
timer.scheduleAtFixedRate(new TimerTask() {
public void run () {
Platform.runLater(() -> ... toggle a random wall ...);
}
},2000,2000);
(MazeApp.start is a convenient place for this.) Try solving and animating while the maze changes. Then do the same thing with a Timeline instead. Is there any difference in the behavior of the application? What about in the code? When would you choose a Timer over Timeline or vice versa?
A runner: when a path is found, show a dot traveling along it over and over, until the result is cleared.
The dot is a Circle on top of the canvas. Put the canvas and the circle together in a Pane and put that Pane in the ScrollPane instead of the canvas. The children of any container share the same coordinate system; a Pane doesn't position its children so the canvas stays at (0,0) and the circle can use the same coordinates as the drawing code (see the center helper in MazeView). (A StackPane would center the circle, throwing off its position.) Create the circle with its center at (0,0) (the default) and hide it until there's a path to follow. Don't forget that the circle is on top of the canvas — remember lab 8's row hint.
The route for the dot is a Polyline: a series of connected line segments. Its getPoints() is a list of numbers, x and y alternating, so add the center of each room on the path, in order. The Polyline is only the route the dot will follow; it's not an element to be displayed and doesn't need to be added to the scene.
A PathTransition moves a node along a shape:
new PathTransition(duration,shape,node)
It moves the node by setting its translation so that the node's center is on the shape. Like a Timeline, it has setCycleCount, play, and stop, and it runs on the JavaFX application thread. Making the duration depend on the length of the path keeps the dot's speed the same for long and short paths. See the slides from 11/2 for pointers to more information on transitions.
Write startRunner(List<MazePos> path), which builds the Polyline, creates and starts the PathTransition, and shows the dot and stopRunner(), which stops the transition (if there is one) and hides the dot. Then find the one place where a path has been found and the one place where every displayed result is cleared, and call them there. Why don't you need any additional stale-results handling for the runner?