Skip to content

Why the spinner does not animate

What it actually does

Spinner draws one line on stderr: the Braille glyph , a space, and the current message. It redraws that line when you call set_message, and it clears it when it is finished or dropped. There is no frame sequence, no timer, and no thread or task advancing anything.

Calling it a spinner is a small lie. It is a status line that happens to start with a spinner-shaped character.

Why there is no background task

The obvious implementation spawns a task that redraws a rotating glyph every eighty milliseconds. That buys a moving indicator and costs three things this crate is not willing to pay.

It picks a runtime for you. Spawning needs an executor. tokio::task::spawn in the library body would make tokio a hard dependency of every consumer and would panic outside a tokio runtime. As it stands tokio is a dev-dependency only, and Wizard::run is a plain async fn that any executor can drive.

It races the thing it is decorating. A background writer to stderr and a foreground writer to stderr interleave. The moment your code logs a warning mid-operation, the log line and a spinner frame land on the same row and both become unreadable. A spinner that only draws when you tell it to cannot do that, because the drawing happens on your thread between your own writes.

It needs a shutdown path. A spawned task has to be cancelled on every exit route — success, error, panic, early return — and getting that wrong leaves a task drawing frames over the shell prompt after the program has finished. The manual-tick design has no such path to get wrong: the Drop impl clears the line, and it runs wherever the value goes out of scope.

What that costs

A long await between two set_message calls shows a static line for its whole duration, and a user cannot tell a slow download from a hung one. That is a real loss, and the mitigation is coarse: split the work into more stages so there is something to report more often.

If a genuinely moving indicator matters more than the three problems above, this is not the crate for it. indicatif does the spawned-task version well, and nothing stops you using it directly.

Why the TTY check is in the constructor

Spinner::new calls console::Term::stderr() and stores the handle only if is_term() is true. Every method then short-circuits on the None.

Deciding once, at construction, rather than on every draw means the answer cannot change halfway through an operation and produce a line that is half-drawn and half-suppressed. It also means the cost is paid once.

The reason it matters at all is the transports these tools run behind. A CI job log keeps every escape sequence verbatim, so an animated spinner becomes several thousand lines of noise in the artefact you read when something breaks. Worse, a tool speaking MCP over stdio has its stderr captured by the client — arbitrary control sequences in that stream corrupt the client's own logs. Neither of those is an environment where "just render it anyway" is acceptable.

The same check is why the crate's test suite can drive a spinner at all: cargo test captures stderr, is_term() is false, and the spinner is inert. That is also its blind spot — the drawing path is never exercised by an automated test, because there is no way to make the spinner believe it has a terminal.

Why the cleanup is idempotent but finish is not repeatable

finish takes self by value, so the compiler prevents a second call. Underneath, a finished flag guards the actual clear-line, and Drop runs the same guarded cleanup.

That combination is deliberate. finish is there so you can decide when the line disappears rather than having it happen at the end of the scope, and Drop is there so that an early return or a ? on an error still leaves the terminal clean. The flag is what stops the two paths clearing the line twice.

The rustdoc describing finish as "safe to call multiple times" describes the flag rather than the method, and reads as a promise the signature does not keep. It is listed in Known documentation defects.