Lesson 10.4 · 20 min
Stopping a long answer
Answers now stream in word by word, and some of them go on for a long time. Today the only way to stop one is Ctrl-C, and Ctrl-C ends the whole program: the conversation is never saved, because saving happens after the loop. This lesson gives Chatterbox a real stop button. It is the last lesson of the course.
Adds to ChatterboxCtrl-C stops an answer, or saves and quits at the prompt. Version 1.1.
Whichever comes first
While an answer streams in, Chatterbox is waiting for one thing: the next piece. To stop it, it has to wait for two things at once, the next piece or you pressing Ctrl-C, and do whatever the first one to happen says.
That is what tokio::select! does. Like println!, it is a macro (the ! says so), and its arms look like a match: pattern = future => code. It waits on all the futures at once. When the first one finishes, its value is matched against the pattern and that arm's code runs. Every other future is dropped: thrown away where it stood. A future is a recipe that only does something while it is awaited (lesson 10.1), so a dropped one simply never takes another step.
What does this program print?
bot types: Once bot types: upon bot types: a stopped main endsbot types: Once bot types: upon bot types: a stopped bot types: time main endsbot types: Once bot types: upon bot types: a bot types: time the end main ends
Reveal the answer
The program prints: bot types: Once
bot types: upon
bot types: a
stopped
main ends The words come at 0, 200 and 400 ms. At 500 ms the short sleep finishes first, so its arm prints stopped and long_answer is dropped in the middle of its own sleep. A dropped future is gone: during the extra second nothing types time, and the end is never reached. _ as the pattern means "I do not need the value", the same as in a match.
Ctrl-C as an event
Pressing Ctrl-C does not type anything. The terminal sends the program a signal, a short message from the operating system (this one is called SIGINT, "interrupt"). A program that does nothing about it is ended on the spot, which is what has happened to Chatterbox so far.
tokio::signal::ctrl_c() turns that signal into a future: it finishes when Ctrl-C is pressed. A future can go in a select!. So in answer(), the while let loop from lesson 10.2 becomes a loop around a select! with two arms:
piece = pieces.recv(): the next piece arrived. Thematchinside does what the old loop did: print a piece, report an error, orbreakonNonebecause the channel closed and the answer is complete._ = tokio::signal::ctrl_c(): you pressed Ctrl-C. Print[stopped]andbreak.
Either way the function returns Ok(Some(whole)), so the part of the answer you saw is added to the history, as if the bot had stopped mid-sentence.
Going deeper Does the losing arm lose a piece?
The select! runs once per piece, and each time the arm that loses is dropped. For recv() that loses nothing: a piece that was on its way stays in the channel for the next recv(). tokio's documentation calls a future like that cancel safe. Not every future is: one that has read half of something before it is dropped can lose that half, which is why tokio's documentation says, method by method, whether each one is cancel safe.
Who stops the task?
When you stop an answer, a task is still running somewhere: the pretend model's typing loop, or the real model's download. Nothing in answer() tells it to stop, and nothing needs to. When answer() returns, its local pieces (the receiving end of the channel) is dropped, and a channel with no receiver is closed. The task finds out the next time it sends:
$ cargo run
nobody is listeningThat Err is the if tx.send(...).await.is_err() check you wrote in lessons 10.2 and 10.3: the task returns, and everything it owned goes with it, including the real model's connection to the server. No stop flag, no message saying "please stop". One owner let go, and ownership did the clean-up.
Ctrl-C at the prompt
There is a catch. The first time a program calls ctrl_c(), tokio replaces what the operating system does on Ctrl-C, for the rest of the program's life. From then on Ctrl-C never ends Chatterbox by itself. If only answer() listened, Ctrl-C at the prompt would do nothing at all.
So the main loop gets a select! too: the next line from the keyboard, or Ctrl-C, which is turned into the line "/quit". Everything after that already knows what /quit means: say goodbye, leave the loop, save. A key that used to throw your conversation away now saves it.
The keyboard gets a thread
Lesson 10.2 read the keyboard with tokio's own stdin, and its documentation warns that it can keep the program from ending until Enter is pressed. Once Ctrl-C can quit from the prompt, that is exactly the moment it would bite: you press Ctrl-C, Chatterbox says goodbye, and then sits there.
The fix is a thread: a second line of work that the operating system runs alongside the main one, each with its own place to wait. std::thread::spawn starts one and gives it a closure to run (lesson 6.4); move in front hands the closure what it uses, here tx, the same as async move did for tasks. The new function keyboard() starts a thread that reads lines the plain, blocking way, as Chatterbox did before chapter 10, and sends each one down a channel, and returns the receiving end. The main loop then waits on keys.recv(), which is a future, so it fits in select!.
Inside the thread, the send is tx.blocking_send(line): a send that just waits, the plain way, until there is room. The thread is not async, so it cannot use .await. Try it and the compiler says so:
This is the exact output of the real compiler. Toggle the English, then pick a fix and see what actually happens.
error[E0728]: `await` is only allowed inside `async` functions and blocks --> src/main.rs:10:30 | 7 | thread::spawn(move || { | ------- this is not `async` ... 10 | if tx.send(line).await.is_err() { | ^^^^^ only allowed inside `async` functions and blocks
E0728 in the compiler error atlas →
.awaitonly works inside anasync fnor anasyncblock, because only async code can pause and let something else run.- The closure given to
thread::spawnis ordinary code that runs on its own thread. It is not async, so it has nowhere to pause to. - The thread does not need to pause politely: it has nothing else to do while it waits. It can simply wait.
- tokio gives a channel's sender a method for exactly this,
blocking_send: it waits the plain way until there is room.
Fixes that were compiled for real
- Use blocking_send on the thread — compiles. The thread waits the plain way until the channel has room. This is what Chatterbox v1.1 does.
- Trap: drop the move — still E0373. Without <code>move</code> the closure only borrows <code>tx</code>, but the thread may run on after <code>keyboard()</code> has returned and <code>tx</code> is gone. The same rule as for tasks in lesson 10.2.
Going deeper Why a thread of your own, and why blocking_send stays there
tokio's stdin does its reading with an ordinary blocking read on a helper thread of tokio's, and that read cannot be called off. When main returns, shutting the runtime down can hang until that read finishes, which is when you press Enter. A thread started with std::thread::spawn is not the runtime's: when main returns, the program ends, and a thread still waiting for Enter ends with it.
blocking_send is the right send on that thread and the wrong one anywhere else. Its documentation says it panics if it is called from async code. That is lesson 10.1's one rule, enforced: waiting the plain way there would hold up every other task on the same thread.
A test cannot press Ctrl-C, so here the stop button is a second channel. Its messages are (), the empty value: the press itself is the news. Write collect, the heart of answer(): keep adding pieces to whole until the channel closes (return the text and false) or a press arrives on stop (return the text so far and true). In main, a pretend model types a word every 100 ms and someone presses stop after 250 ms. In the tests, a sender named _press or _tx is kept alive but never used (a name that starts with _ still owns its value until the end of the test). That matters: if every Sender of stop were dropped, stop.recv() would give None at once, select! would treat that arm as finished, and the stop branch would fire although nobody pressed.
A test cannot press Ctrl-C, so here the stop button is a second channel. Its messages are (), the empty value: the press itself is the news. Write collect, the heart of answer(): keep adding pieces to whole until the channel closes (return the text and false) or a press arrives on stop (return the text so far and true). In main, a pretend model types a word every 100 ms and someone presses stop after 250 ms. In the tests, a sender named _press or _tx is kept alive but never used (a name that starts with _ still owns its value until the end of the test). That matters: if every Sender of stop were dropped, stop.recv() would give None at once, select! would treat that arm as finished, and the stop branch would fire although nobody pressed.
Hidden tests (4): a_whole_answer_is_kept an_empty_answer_is_not_a_stop stop_before_anything_arrives stopping_keeps_what_was_shown
This exercise runs real Rust in your browser through the official Rust Playground, so it needs JavaScript.
Chatterbox v1.1
Here is the whole program, with this lesson's changes in green: the two select! loops, keyboard() and its thread, and two new lines in the README. In Cargo.toml tokio's features change: io-std and io-util (tokio's keyboard reading) go, and signal (for ctrl_c) comes in.
A test script cannot press keys, but it can send the same signal Ctrl-C sends. In this run, the build started Chatterbox against a stand-in server that streams slowly, typed the lines under "You type", waited until the answer had begun, and sent SIGINT: that is the "(presses Ctrl-C)" line. The answer stops, the chat carries on, and /history shows the part of the answer you saw, kept.
src/command.rsunchanged
src/message.rsunchanged
src/replier.rsunchanged
src/store.rsunchanged
Sam tell me a long story (presses Ctrl-C) /history /quit
Chatterbox v1.1 (replies by: real) What should I call you? Hi Sam! I'm Chatterbox. Ask me anything. bot: mock model [stopped] 1. you: tell me a long story 2. bot: mock model bot: bye, Sam! messages so far: 1 saved 2 messages to chatterbox.json
Download this version (zip)Unzip, then cargo run inside the folder.
And Ctrl-C at the prompt, with the keyboard still open: goodbye, and the conversation saved.
Sam hello (presses Ctrl-C)
Chatterbox v1.1 (replies by: echo) What should I call you? Hi Sam! I'm Chatterbox. Ask me anything. bot: you said: hello bot: bye, Sam! messages so far: 1 saved 2 messages to chatterbox.json
Ctrl-C in Python
In Python, Ctrl-C raises KeyboardInterrupt wherever the program happens to be. tokio turns it into a future instead, and you choose the places where it is waited for.
What you built
That is the course. You started with "what is a program", and you finish with one you wrote: Chatterbox remembers facts, saves the conversation, answers through a trait so the echo, the pretend model and a real one are interchangeable, streams the real model's answer as it is written, and stops it when you ask. Every version of Chatterbox in the course was built, tested and run with scripted conversations, and every compiler message you saw is the compiler's own.
Some of what you met along the way was hard: one owner per value, borrowing, lifetimes, futures that do nothing until awaited. Those are also the ideas that made this lesson short. Stopping a download took no special code, because when the receiver went away, the owner of everything else noticed.
Where to go next
A program that does a real thing is a better place to learn from than any exercise. Roughly in order of size:
- A
/clearcommand. Empty the history: a newCommandvariant, one arm inparse, one inmain, a line inHELPand the README, and a test thatparse("/clear")gives it. - A system message. Chat-completions services accept a first message with the role
systemthat tells the model how to behave. Add it in front of the history insideModel::reply, so it is sent every time but never saved. - More tests.
store.rshas none: save a history to a file in a temporary folder and load it back./statshas none either, because its counting lives insidemain; move it into a function that takes&[Message], and it can be tested likelast_n. - A timeout. A third arm in
answer()'sselect!withtokio::time::sleep, so a model that goes quiet for 30 seconds is stopped too. - The tokio tutorial. It builds a small server and goes deeper into tasks, channels and
select!. - The Rust Book. It is free, and you have met most of it. Its async chapter covers what chapter 10 did from another side.
Thank you for building it.
Say it out loud, then reveal
What does <code>tokio::select!</code> do with the futures that do not finish first?Reveal
recv() nothing is lost, the piece stays in the channel.When you stop an answer, how does the task sending the pieces find out?Reveal
answer() drops the receiver, which closes the channel. The task's next send returns an Err, and it returns.Why does the prompt have to handle Ctrl-C too?Reveal
ctrl_c() has been called, Ctrl-C no longer ends the program by itself. Without its own arm, Ctrl-C at the prompt would do nothing; with it, it means /quit.Why does the keyboard thread use <code>blocking_send</code> and not <code>send(...).await</code>?Reveal
.await (error E0728). blocking_send waits the plain way, which is fine on a thread that has nothing else to do.That is lesson 10.4. Tick it off so the course remembers where you are.
Go deeper
Questions people ask
How do I handle Ctrl-C in a tokio program?
Await tokio::signal::ctrl_c(), usually as one arm of tokio::select!, next to the work it should interrupt. It needs the signal feature of tokio.
Does stopping an answer leave the download running?
Only until the next piece arrives. Returning drops the channel receiver; the task sending pieces gets an error on its next send, which happens when the next piece comes in, and returns, and the connection it owned is dropped then. A server that stalls and sends nothing keeps the connection open until then. A timeout would fix that; this course does not add one.
Why not read the keyboard with tokio::io::stdin?
Its documentation warns that it uses a blocking read that cannot be cancelled, which can keep the program from exiting until Enter is pressed. A thread of your own ends with the program.
What is tokio::select! for?
Waiting on several futures at once. The first one to finish runs its arm of code, and the others are dropped, which for a stopped answer is exactly what you want.
