Skip to content

Add #[must_use] to the transform and source combinators - #871

Open
0-jake-0 wants to merge 1 commit into
mainfrom
must-use-combinators
Open

Add #[must_use] to the transform and source combinators#871
0-jake-0 wants to merge 1 commit into
mainfrom
must-use-combinators

Conversation

@0-jake-0

Copy link
Copy Markdown
Contributor

What this changes

Every value-returning transform and source in the fluent surface now
carries #[must_use], with one shared reason:

#[must_use = "a dropped stream stays wired and cycles every tick, producing an unread value"]

126 methods in total — 95 hand-written declarations plus 31 generated ones:

Surface Count
StreamOps (fluent.rs) 35
SourceOps (fluent.rs) 11
StatisticsOps (adapters/statistics.rs) 36
Inherent combinators — GraphBuilder::combine / replay_results, Stream::collapse_accumulate / split / filter_none 5
Hand-written Signal combinators (signal.rs) 8
Generated Signal combinators (expand_signal) 31

Why

Dropping a combinator's result is not a no-op here. Stream::wire
(fluent.rs:739-752) has already registered the node with the shared
Builder, and there is no reachability pruning anywhere in src/, so
s.map(f); with no binding leaves a node wired that cycles every tick for the
whole run producing a value nobody reads. That is a silent logic bug — the
author believes they transformed the stream — plus a permanent per-cycle cost,
and nothing warned about it. latency.rs and ~50 adapter methods already used
the attribute; fluent.rs had zero.

Closes #830

How it was verified

  • cargo fmt --all
  • cargo lint and cargo lint-all — both clean. This is the load-bearing
    check for scope: they run clippy -D warnings over --all-targets,
    which is every example, bench and test in the tree. An over-wide
    annotation would have failed here on the repo's own code.
  • cargo test -p wingfoil --all-features — all suites pass except the
    *_integration ones, which need Docker/live services and fail
    identically on main in this sandbox.
  • cargo test -p wingfoil-derive
  • New behaviour is covered by a compile test — see below

The compile test

crates/wingfoil/tests/trybuild/must_use_combinators.rs, picked up by the
existing t.compile_fail("tests/trybuild/*.rs") harness. #[must_use]
produces a warning, which an ordinary test cannot see, so the fixture
#[deny(unused_must_use)]s — the warning becomes compiler output trybuild
compares against a checked-in .stderr.

It pins both directions:

  • transforms and sources warn — through the hand-written StreamOps /
    SourceOps declarations and through the derive-generated Signal ones,
    so both halves of the change are covered;
  • every excluded sink is called as a bare statement under the same deny, so
    if one of them ever grows the attribute the fixture stops compiling.

Regenerate with TRYBUILD=overwrite after an intentional message change.

Notes for the reviewer

The derive: only one of the two generators can carry it

The issue asked for the attribute in both expand_fluent and expand_signal.
Only expand_signal can have it, and the reason is worth recording:

  • expand_fluent's expansion lands inside a trait impl. #[must_use]
    there is inert — rustc resolves a method call to the trait's item, not the
    impl's — and it also warns unused_attributes ("cannot be used on trait
    methods in impl blocks", already flagged as a future hard error), which
    under -D warnings would break every downstream op author. So the fluent
    attribute lives on the hand-written declaration, which is where the trait's
    documented public surface already lives.
  • expand_signal's methods are inherent, so they carry it themselves.

Both generators now say so in their doc comments.

New #[op(sink)] flag

expand_signal generates a method for every #[op(fluent)] op, sinks
included, so an unconditional attribute would have annotated print /
for_each / inspect / timed / finally. Those five are now declared
#[op(build = …, fluent, sink)], which suppresses it. The flag is documented
on OpArgs, in /new-op (new step 4c) and in docs/adding-an-op.md.

Deliberately left OFF — sinks

Their side effect happens regardless of what you do with the returned handle,
and they are called as bare statements in this very tree
(examples/adapters/fix/main.rs:73,81; the kdb examples likewise):

Method Why
for_each, for_each_mut the graph's outbound edge; the closure has already run
print, logged output taps
inspect, timed debug/perf taps, pass-through by construction
finally teardown hook; emits nothing at all

Deliberately left OFF — ambiguous, per the "a false positive is worse than

a miss" rule

Method Why
StreamOps::feedback it is a pass-through that also sends to the sink. s.feedback(&sink); as a statement closes the loop correctly — the handle is optional
SourceOps::spawn, spawn_bounded the sub-graph runs on the worker whether or not the forwarded output is read, so g.spawn(|wg| wg.ticker(p).for_each(..)); is a legitimate "run this side-effecting graph on a thread"
StreamOps::spawn_map, spawn_map_bounded same shape — offloading sink work to a worker is a real use
GraphBuilder::custom_node a caller-driven node whose cycle closure may exist purely for its side effect
GraphBuilder::source, Stream::wire the two extension primitives, not combinators. Sink adapters wire through them too, and every in-tree caller returns the result anyway, so there is nothing to catch

Not in scope, but noticed

GraphBuilder::build / Stream::build have the same "silently does nothing"
shape — a dropped Runner means the graph never runs — and GraphBuilder::snapshot
is pure. Both are outside "combinators", and build in particular needs a
check for #[should_panic] tests that call it bare for its call-once
precondition, so I left them for a follow-up rather than widening this diff.


Generated by Claude Code

Dropping a combinator's result is not a no-op in this engine. `Stream::wire`
has already registered the node with the shared `Builder`, and nothing in
`src/` prunes unreachable nodes, so `s.map(f);` with no binding leaves a node
wired that cycles every tick for the whole run producing a value nobody reads
— a silent logic bug plus a permanent per-cycle cost, with no warning.

Annotate the value-returning declarations in `StreamOps`, `SourceOps`,
`StatisticsOps`, the inherent `Stream`/`GraphBuilder` combinators, and the
hand-written `Signal` ones, all with one shared reason naming the actual
consequence.

Scope is transforms and sources only. `for_each` / `for_each_mut` / `print` /
`logged` / `inspect` / `timed` / `finally` are called as bare statements
throughout this tree and their side effect happens regardless of the handle;
`feedback` closes its loop whether or not the pass-through is kept; `spawn` /
`spawn_bounded` / `spawn_map` / `spawn_map_bounded` legitimately run a
side-effecting sub-graph on a worker thread. Those stay unannotated — a false
positive would train people to ignore the warning that matters.

The derive follows the same split. `expand_signal`'s methods are inherent, so
they carry the attribute; a new `#[op(sink)]` flag suppresses it for the five
sink ops. `expand_fluent`'s cannot: its expansion lands in a trait `impl`,
where `#[must_use]` is inert (rustc resolves a method call to the trait's
item) and rustc warns `unused_attributes` — a future hard error. That is why
the fluent attribute lives on the hand-written declaration, and it is now
recorded next to both generators.

`tests/trybuild/must_use_combinators.rs` pins it: `#![deny(unused_must_use)]`
turns the warning into compiler output trybuild can compare, covering both the
hand-written and generated halves, and exercising every sink under the same
`deny` so a mis-scoped attribute breaks the fixture.

Closes #830

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GRyBUej7RQrqxvrSjvqByM
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add #[must_use] to transform/source combinators (fluent traits + derive-generated methods)

2 participants