Skip to content

feat(executor): instrument tasks for tokio-console - #987

Merged
Berrysoft merged 10 commits into
compio-rs:masterfrom
Xerxes-2:console/executor
Aug 8, 2026
Merged

feat(executor): instrument tasks for tokio-console#987
Berrysoft merged 10 commits into
compio-rs:masterfrom
Xerxes-2:console/executor

Conversation

@Xerxes-2

@Xerxes-2 Xerxes-2 commented Jul 31, 2026

Copy link
Copy Markdown
Member

Instruments the executor for tokio-console, behind a console feature.
Without it every type here is zero-sized and does nothing.

Tasks get a runtime.spawn span, wakers emit runtime::waker events, and
block_on and the blocking pool report as tasks of their own kind so the
console's lints do not misjudge them.

console-subscriber needs --cfg console_without_tokio_unstable; that and
the rest of the limitations are documented in the console module.

The spawns are #[track_caller] unconditionally, so the console reports the
call site rather than compio's own line. Gating that on the feature does not
work: features do not propagate backwards, so a build that enables
compio-executor/console without compio-runtime/console would silently
attribute every task to compio-runtime/src/lib.rs. Tokio does the same, and
for what it costs — with the feature off, dropping the attributes moves
.text by 0.05% in the dispatcher example, in either direction depending on
how the implicit argument lands in the inliner.

First of six, tracked in #988. The rest follow as this one lands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds tokio-console-compatible tracing instrumentation to compio-executor behind a new console feature, so tasks/wakers/blocking work show up correctly in the console UI and its lints.

Changes:

  • Introduces a console module with enabled/disabled implementations and a SpawnMeta API to attribute spawns to call sites.
  • Instruments task polling with runtime.spawn spans and waker operations with runtime::waker events.
  • Adds an extensive console feature test suite to validate emitted spans/events match what console-subscriber expects.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
compio-executor/tests/console.rs Adds feature-gated tests asserting span/event shapes and semantics expected by tokio-console.
compio-executor/src/waker.rs Emits waker operation events (clone/wake/wake_by_ref/drop) for console accounting.
compio-executor/src/task/mod.rs Stores a per-task span in the header, enters it during polling, and routes waker-op recording to it.
compio-executor/src/queue.rs Threads SpawnMeta through task allocation so spawn metadata reaches instrumentation.
compio-executor/src/lib.rs Adds console module + re-exports SpawnMeta; adds spawn_at and makes spawn #[track_caller].
compio-executor/src/console/enabled.rs Implements actual tracing spans/events, shim waker for block_on, and blocking/task instrumentation helpers.
compio-executor/src/console/disabled.rs Provides zero-sized no-op types/functions for when the console feature is off.
compio-executor/src/console.rs Public docs + feature switching between enabled/disabled implementations + parity checks in tests.
compio-executor/Cargo.toml Adds optional tracing dependency and console feature wiring.
Cargo.toml Adds workspace tracing dependency configuration.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread compio-executor/src/console/enabled.rs
Comment thread compio-executor/src/console/disabled.rs
Comment thread compio-executor/src/lib.rs
Comment thread compio-executor/src/console/enabled.rs
Comment thread compio-executor/src/lib.rs
Comment thread Cargo.toml
Comment thread compio-executor/src/console/enabled.rs
Comment thread compio-executor/src/lib.rs
Comment thread compio-executor/src/console.rs Outdated
Comment thread compio-executor/src/console.rs Outdated
Comment thread compio-executor/src/console.rs Outdated
#[cfg(feature = "console")]
use enabled as imp;
pub(crate) use imp::TaskSpan;
pub use imp::{SpawnMeta, instrument_block_on, instrument_blocking};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I need another instrument "execute" for compio-compat.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two points need some further look:

  1. execute kind won't trigger tokio-console lints -- "self-wake / lost-waker / never-yielded"
  2. RuntimeCompat::execute is async fn, #[track_caller] won't work unless enable nightly feature async_fn_track_caller

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

execute serves like block_on except it's an async fn. And we might consider enable async_fn_track_caller when "nightly" feature is enabled.

tokio-console collects its data through tracing spans and events that
follow a fixed naming convention; it is not tied to tokio's internals, so
any executor emitting the same spans and events can be observed with it.

Behind a new `console` feature, give every task a `runtime.spawn` span,
entered while the task is polled. That is enough for the console to report
poll counts, busy/idle/scheduled times and the poll time histograms.

The instrumentation lives in the new `console` module, which has an
enabled and a disabled variant. The disabled one is what is compiled
without the feature: `TaskSpan` becomes zero-sized, so the task header
keeps its layout, and every method an empty inlined function.

The span sits in that header, so it is dropped along with the task
allocation, during a panic as well. `drop_future` leaks the future while
unwinding, since dropping it could panic a second time, but leaking the
span would leave the task running in the console forever. The subscriber is
therefore reentered while unwinding, where a panic inside it aborts.

Since the span records where the task was spawned, `Executor::spawn`
becomes `#[track_caller]`. Wrappers around it want the console to blame
their own caller instead of themselves, so also add `spawn_at`, taking the
`SpawnMeta` to attribute the task to.

Adding `tracing` to the workspace also lets `compio-log` take it from there
rather than pinning a version of its own.
Emit a `runtime::waker` event from every waker operation of a task, which
is what the console needs to report waker counts and to run its self-wake
and lost-waker lints.

The `op` values the console expects are collected in the `console` module,
next to a note on why `Waker::wake` must not report a drop of its own.
The future passed to `block_on` is driven by the runtime instead of being
a task of the executor, so it is invisible to the console although it is
usually the most interesting future of the application.

Add `console::instrument_block_on`, wrapping it into a future that owns a
`block_on` task span. Its waker belongs to the caller of `block_on` rather
than to a task, so wrap it too, in a shim reporting the waker operations
the console expects.

Without the `console` feature the wrapper is the identity function.
Record the spans and events with a subscriber doing what
`console-subscriber` does, and assert on what it saw: the fields of the
task spans, the poll counts, and that the waker operations of a task
balance out, which is what the console's lost-waker lint looks at.

Run the new test in CI, under miri as well, since it exercises the waker
vtables of both the tasks and the `block_on` shim.
The console treats tasks whose `kind` is `blocking` or `block_on` as not
being driven by a future, and skips the four lints that only make sense for
one: self-wake ratio, lost waker, never-yielded and large future.

`spawn_blocking` produces exactly the kind of task those lints misjudge, and
reports the wrong times on top of that: the task is the future waiting for
the pool, so all of the time in the closure counts as idle and none as busy.

Instrument the closure instead of the future waiting for it. The span is
created on the spawning thread, so the wait for a worker is reported as idle
time, and entered around the closure, so its time is reported as busy. The
future is then left unreported, since it stands for work that is already
accounted for.
The console gives `task.name` a column of its own, and leaves it empty for
the tasks that do not have it. It is worth setting for the tasks a user did
not spawn themselves, since the location of those points into compio rather
than at the code that asked for the work.

That is the case for every wrapper around `spawn` that is an `async fn`,
since `#[track_caller]` is a no-op on those and a `SpawnMeta` cannot be
forwarded through them, so note that in the limitations as well.

Add `SpawnMeta::named` for the tasks that can make up for it. The crates
that spawn tasks of their own name them in the commits that follow, one
per crate.
The `console` module has an enabled and a disabled variant of every type it
exports, and only one of them is ever compiled. The disabled one is what
nearly every build uses, so a difference between the two surfaces reaches
whoever turns the feature on, in code written long after the difference.

Assert that they match, by coercing each item to a function pointer, which
pins its whole signature, and by naming the traits the rest of the crate
relies on.

The guard returned by entering a task's span needs more than a signature:
the enabled one borrows the span, since the console measures the time the
span is entered as the busy time of the task. A disabled guard that owned
itself would let code hold it past the span it is timing and compile, and
only fail once the feature is turned on. Both variants therefore name the
guard through a `EnterGuard<'a>` alias, which an owned guard cannot fill.
`instrument_block_on` captured the caller itself, so a `block_on` task could
only ever be reported by the location of the call. That is enough for the
runtime a user blocks on themselves, but not for the ones started on their
behalf, which all report the same line inside compio.

Take a `SpawnMeta` like the other spawns do, so that a caller can pass one.
The console does not treat every kind other than `task` as one it does not
drive itself: it knows `blocking` and `block_on` by name, and lints a task
of any other kind, including one it does not know, as a future of its own.

Record the nightly escape hatch for the attribution of an `async fn` too.
`compio-compat`'s `execute` drives the executor from a foreign event loop,
the way `block_on` drives it from a loop of its own. It is the same kind of
task, so report it as one, under a name that says what it instruments.
@Berrysoft
Berrysoft merged commit d5f4dc1 into compio-rs:master Aug 8, 2026
88 checks passed
@github-actions github-actions Bot mentioned this pull request Aug 8, 2026
@Xerxes-2
Xerxes-2 deleted the console/executor branch August 8, 2026 23:30
George-Miao pushed a commit to George-Miao/compio that referenced this pull request Aug 14, 2026
* feat(executor): give tasks a runtime.spawn span

tokio-console collects its data through tracing spans and events that
follow a fixed naming convention; it is not tied to tokio's internals, so
any executor emitting the same spans and events can be observed with it.

Behind a new `console` feature, give every task a `runtime.spawn` span,
entered while the task is polled. That is enough for the console to report
poll counts, busy/idle/scheduled times and the poll time histograms.

The instrumentation lives in the new `console` module, which has an
enabled and a disabled variant. The disabled one is what is compiled
without the feature: `TaskSpan` becomes zero-sized, so the task header
keeps its layout, and every method an empty inlined function.

The span sits in that header, so it is dropped along with the task
allocation, during a panic as well. `drop_future` leaks the future while
unwinding, since dropping it could panic a second time, but leaking the
span would leave the task running in the console forever. The subscriber is
therefore reentered while unwinding, where a panic inside it aborts.

Since the span records where the task was spawned, `Executor::spawn`
becomes `#[track_caller]`. Wrappers around it want the console to blame
their own caller instead of themselves, so also add `spawn_at`, taking the
`SpawnMeta` to attribute the task to.

Adding `tracing` to the workspace also lets `compio-log` take it from there
rather than pinning a version of its own.

* feat(executor): emit runtime::waker events

Emit a `runtime::waker` event from every waker operation of a task, which
is what the console needs to report waker counts and to run its self-wake
and lost-waker lints.

The `op` values the console expects are collected in the `console` module,
next to a note on why `Waker::wake` must not report a drop of its own.

* feat(executor): instrument the future blocked on

The future passed to `block_on` is driven by the runtime instead of being
a task of the executor, so it is invisible to the console although it is
usually the most interesting future of the application.

Add `console::instrument_block_on`, wrapping it into a future that owns a
`block_on` task span. Its waker belongs to the caller of `block_on` rather
than to a task, so wrap it too, in a shim reporting the waker operations
the console expects.

Without the `console` feature the wrapper is the identity function.

* test(executor): assert the console instrumentation

Record the spans and events with a subscriber doing what
`console-subscriber` does, and assert on what it saw: the fields of the
task spans, the poll counts, and that the waker operations of a task
balance out, which is what the console's lost-waker lint looks at.

Run the new test in CI, under miri as well, since it exercises the waker
vtables of both the tasks and the `block_on` shim.

* feat(executor): report blocking closures as blocking tasks

The console treats tasks whose `kind` is `blocking` or `block_on` as not
being driven by a future, and skips the four lints that only make sense for
one: self-wake ratio, lost waker, never-yielded and large future.

`spawn_blocking` produces exactly the kind of task those lints misjudge, and
reports the wrong times on top of that: the task is the future waiting for
the pool, so all of the time in the closure counts as idle and none as busy.

Instrument the closure instead of the future waiting for it. The span is
created on the spawning thread, so the wait for a worker is reported as idle
time, and entered around the closure, so its time is reported as busy. The
future is then left unreported, since it stands for work that is already
accounted for.

* feat(executor): let a task be named

The console gives `task.name` a column of its own, and leaves it empty for
the tasks that do not have it. It is worth setting for the tasks a user did
not spawn themselves, since the location of those points into compio rather
than at the code that asked for the work.

That is the case for every wrapper around `spawn` that is an `async fn`,
since `#[track_caller]` is a no-op on those and a `SpawnMeta` cannot be
forwarded through them, so note that in the limitations as well.

Add `SpawnMeta::named` for the tasks that can make up for it. The crates
that spawn tasks of their own name them in the commits that follow, one
per crate.

* test(executor): assert the console variants present one surface

The `console` module has an enabled and a disabled variant of every type it
exports, and only one of them is ever compiled. The disabled one is what
nearly every build uses, so a difference between the two surfaces reaches
whoever turns the feature on, in code written long after the difference.

Assert that they match, by coercing each item to a function pointer, which
pins its whole signature, and by naming the traits the rest of the crate
relies on.

The guard returned by entering a task's span needs more than a signature:
the enabled one borrows the span, since the console measures the time the
span is entered as the busy time of the task. A disabled guard that owned
itself would let code hold it past the span it is timing and compile, and
only fail once the feature is turned on. Both variants therefore name the
guard through a `EnterGuard<'a>` alias, which an owned guard cannot fill.

* feat(executor): let the task a runtime blocks on be named

`instrument_block_on` captured the caller itself, so a `block_on` task could
only ever be reported by the location of the call. That is enough for the
runtime a user blocks on themselves, but not for the ones started on their
behalf, which all report the same line inside compio.

Take a `SpawnMeta` like the other spawns do, so that a caller can pass one.

* docs(executor): correct what the console does with a task's kind

The console does not treat every kind other than `task` as one it does not
drive itself: it knows `blocking` and `block_on` by name, and lints a task
of any other kind, including one it does not know, as a future of its own.

Record the nightly escape hatch for the attribution of an `async fn` too.

* feat(executor): instrument the future a compatibility layer executes

`compio-compat`'s `execute` drives the executor from a foreign event loop,
the way `block_on` drives it from a loop of its own. It is the same kind of
task, so report it as one, under a name that says what it instruments.
Berrysoft added a commit that referenced this pull request Aug 18, 2026
* fix(runtime): features of `futures-util` (#951)

* chore: release (#953)

Co-authored-by: Yuyi Wang <Strawberry_Str@hotmail.com>

* feat(executor): implement is_finished for JoinHandle (#958)

* fix(compio-runtime): correct sched affinity mask on multi cores (#956)

* fix(compio-runtime): correct sched affinity mask on multi cores

* fix: remove core-affinity dependency

* fix: remove redundant comment

---------

Co-authored-by: Mark Ye <yeya@deshaw.com>

* fix(driver): unify buffer pool error to ResourceBusy across backends (#961)

* fix(net): avoid potential hang in runtime thread on blocking socket operations (#960)

* test(net): add a runtime thread hang regression test

* fix(net): call `set_nonblocking(true)` in from_std conversion methods

Specifically,
- TcpListener::from_std
- TcpStream::from_std
- TcpSocket::from_std_stream
- UdpSocket::from_std
- UnixListener::from_std
- UnixStream::from_std

* docs: warning on CC (#963)

* docs(contributing): mention flake.nix for development dependencies (#965)

* feat(runtime): improve cancellation for multishot streams (#962)

* feat(runtime): support streams in future combinators

* fix(runtime): ensure SubmitMultiStream respects cancellation

* feat(runtime,net): test stream cancellation
- ensures cancelling TcpStream::read_multi completes in a timely manner

* fix(driver,iour): set IORING_ENTER_NO_IOWAIT on CQE wait (#957)

* fix(driver,iour): set IORING_ENTER_NO_IOWAIT on CQE wait

The proactor keeps a multishot PollAdd armed on the internal notifier
eventfd, so the ring always has a request in flight. On Linux >= 6.5
io_uring_enter runs its CQE wait under io_schedule(), and since 6.6
that wait is marked in_iowait whenever a request is pending. The
always-armed notifier poll satisfies that condition, so an idle ring
parked on the CQE wait is accounted as iowait with no I/O outstanding.
This inflates per-task iowait and drives /proc/pressure/io toward 100%
on an idle runtime, making PSI and iowait useless as health signals
(reported downstream as apache/iggy#3507).

Linux 6.15 added IORING_ENTER_NO_IOWAIT, an opt-out that suppresses the
marking for that wait. It is off by default. Set it on the blocking
wait by splitting the combined submit-and-wait into a submit() followed
by a wait-only enter() carrying GETEVENTS | NO_IOWAIT (plus EXT_ARG when
a timeout is given). A no_iowait flag gates this: it starts enabled only
when SQPOLL is unused, and is disabled permanently the first time the
NO_IOWAIT wait is rejected with EINVAL, so kernels older than 6.15 fall
back to the combined wait in the same call. The non-blocking path
(want == 0) is unchanged.

io-uring 0.7.12 exposes the NO_IOWAIT flag but no
Parameters::is_feature_no_iowait() accessor, so kernel support is
detected by the EINVAL probe rather than the feature bit.

* fix(driver,iour): skip NO_IOWAIT split on zero-timeout waits

The NO_IOWAIT split path triggered on any want_sqe > 0, which
includes the zero-timeout drains from push_raw and flush. Those
never sleep (the kernel returns immediately), so they are never
charged as iowait, yet the split still paid an extra wait-only
enter syscall on that hot path.

Gate the split on timeout != Some(ZERO) so only waits that can
actually block opt out of iowait accounting; zero-timeout calls
keep the single combined enter.

Also rename submit_blocking/wait_no_iowait to
submit_and_wait/submit_no_iowait and link io_uring_enter(2), per
review.

* refactor(driver,iour): fold driver bools into DriverFlags bitset

The driver tracked need_push_notifier and no_iowait as separate bools.
Merge them into a u8-backed bitflags type so related driver state lives in
one field, per review feedback on #957.

* refactor(driver,iour): detect NO_IOWAIT via feature bit

The first cut probed IORING_ENTER_NO_IOWAIT support with an
EINVAL round-trip because io-uring 0.7.12 had no accessor for
the feature bit. io-uring 0.7.13 adds
Parameters::is_feature_no_iowait(), so detect support upfront
at ring init and drop the runtime probe.

NO_IOWAIT now starts enabled only when SQPOLL is unused and
the kernel reports IORING_FEAT_NO_IOWAIT (6.15+); the
wait-only enter no longer needs the EINVAL fallback. Older
kernels report the bit clear and keep the combined
submit+wait.

* perf(driver,iour): merge NO_IOWAIT submit and wait into one enter

The blocking NO_IOWAIT path issued two syscalls: submit() to flush
the SQ, then a wait-only enter carrying NO_IOWAIT. The split existed
only because the crate's submit_with_args/submit_and_wait hardcode
their EnterFlags and cannot add NO_IOWAIT.

Collapse them into a single raw enter with to_submit = sq_len, which
is the crate's own combined submit-and-wait plus the NO_IOWAIT flag.
This drops one syscall on every sleeping wait; the idle case, where
no new SQEs are staged, goes from two enters to one.

The safe submit() the split relied on is replaced by the same single
unsafe enter. push_raw already syncs the SQ, so the widened "SQ
synced and holds N valid SQEs" safety obligation holds with nothing
new required.

* chore: release (#959)

Co-authored-by: Yuyi Wang <Strawberry_Str@hotmail.com>

* feat(net): allow peek TcpStream (#974)

* feat: allow peek from TcpStream

* test: updated unit test for TcpStream peek.

* test: updated unit test for TcpStream peek

* docs: make method description succint

* fix: make implementation simplier

* docs: fix platform specific info

Co-authored-by: Yuyi Wang <Strawberry_Str@hotmail.com>

---------

Co-authored-by: Yuyi Wang <Strawberry_Str@hotmail.com>

* fix: semicolon_in_expressions_from_macros (#977)

* perf(executor): skip empty sync-queue drain with a pending counter (#976)

* chore: bump cfg_aliases (#980)

* docs: fix discrod link (#985)

* style(driver,iour): rewrap a doc comment past the comment width (#986)

`cargo fmt` has been failing on master since #957: the line is 81 columns
and `comment_width` is 80.

* style: format the cfg_select! bodies (#990)

Nightly 2026-07-31 formats them, so the crate-wide import rules apply
inside for the first time and `cargo fmt --check` now fails on master.

* fix(quic): inverted assert in SendStreamUnframed (#992)

Reference implementation, h3-quinn, panics if buffer `is_some`:
https://github.com/hyperium/h3/blob/c38a1af/h3-quinn/src/lib.rs#L559-L561
Pre-fix implementation, instead, proceeds if buffer `is_some` and
panics otherwise.

* feat(runtime): complete AsyncWrite for PollFd (#993)

* feat(runtime): write vectored through PollFd

`AsyncWrite::poll_write_vectored` fell back to the trait default, which
only writes the first non-empty buffer, and `poll_flush` never reached
the source. Forward both to the underlying source instead.

* feat(runtime): shut down the write side when closing a PollFd

`AsyncWrite::poll_close` was a no-op, so the peer of a connected socket
never observed the end of the stream until the descriptor was dropped.
Shut down the write half instead. Sources without a write half, like
files and pipes, keep reporting success.

Like `std`'s `shutdown`, this does not flush: the write readiness
registration is shared with any pending write, so flushing here would
steal its waker.

BREAKING CHANGE: closing a `PollFd` through `futures_util::AsyncWrite`
now shuts down the write half of the underlying socket, which affects
every holder of the shared descriptor. Callers that relied on `close`
being a no-op should stop calling it.

* ci: disable fail fast (#998)

* build(deps): update syn requirement from 2.0.38 to 3.0.3 (#981)

* build(deps): update syn requirement from 2.0.38 to 3.0.3

Updates the requirements on [syn](https://github.com/dtolnay/syn) to permit the latest version.
- [Release notes](https://github.com/dtolnay/syn/releases)
- [Commits](dtolnay/syn@2.0.38...3.0.3)

---
updated-dependencies:
- dependency-name: syn
  dependency-version: 3.0.3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* build(deps): update darling

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Yuyi Wang <Strawberry_Str@hotmail.com>

* feat(executor): instrument tasks for tokio-console (#987)

* feat(executor): give tasks a runtime.spawn span

tokio-console collects its data through tracing spans and events that
follow a fixed naming convention; it is not tied to tokio's internals, so
any executor emitting the same spans and events can be observed with it.

Behind a new `console` feature, give every task a `runtime.spawn` span,
entered while the task is polled. That is enough for the console to report
poll counts, busy/idle/scheduled times and the poll time histograms.

The instrumentation lives in the new `console` module, which has an
enabled and a disabled variant. The disabled one is what is compiled
without the feature: `TaskSpan` becomes zero-sized, so the task header
keeps its layout, and every method an empty inlined function.

The span sits in that header, so it is dropped along with the task
allocation, during a panic as well. `drop_future` leaks the future while
unwinding, since dropping it could panic a second time, but leaking the
span would leave the task running in the console forever. The subscriber is
therefore reentered while unwinding, where a panic inside it aborts.

Since the span records where the task was spawned, `Executor::spawn`
becomes `#[track_caller]`. Wrappers around it want the console to blame
their own caller instead of themselves, so also add `spawn_at`, taking the
`SpawnMeta` to attribute the task to.

Adding `tracing` to the workspace also lets `compio-log` take it from there
rather than pinning a version of its own.

* feat(executor): emit runtime::waker events

Emit a `runtime::waker` event from every waker operation of a task, which
is what the console needs to report waker counts and to run its self-wake
and lost-waker lints.

The `op` values the console expects are collected in the `console` module,
next to a note on why `Waker::wake` must not report a drop of its own.

* feat(executor): instrument the future blocked on

The future passed to `block_on` is driven by the runtime instead of being
a task of the executor, so it is invisible to the console although it is
usually the most interesting future of the application.

Add `console::instrument_block_on`, wrapping it into a future that owns a
`block_on` task span. Its waker belongs to the caller of `block_on` rather
than to a task, so wrap it too, in a shim reporting the waker operations
the console expects.

Without the `console` feature the wrapper is the identity function.

* test(executor): assert the console instrumentation

Record the spans and events with a subscriber doing what
`console-subscriber` does, and assert on what it saw: the fields of the
task spans, the poll counts, and that the waker operations of a task
balance out, which is what the console's lost-waker lint looks at.

Run the new test in CI, under miri as well, since it exercises the waker
vtables of both the tasks and the `block_on` shim.

* feat(executor): report blocking closures as blocking tasks

The console treats tasks whose `kind` is `blocking` or `block_on` as not
being driven by a future, and skips the four lints that only make sense for
one: self-wake ratio, lost waker, never-yielded and large future.

`spawn_blocking` produces exactly the kind of task those lints misjudge, and
reports the wrong times on top of that: the task is the future waiting for
the pool, so all of the time in the closure counts as idle and none as busy.

Instrument the closure instead of the future waiting for it. The span is
created on the spawning thread, so the wait for a worker is reported as idle
time, and entered around the closure, so its time is reported as busy. The
future is then left unreported, since it stands for work that is already
accounted for.

* feat(executor): let a task be named

The console gives `task.name` a column of its own, and leaves it empty for
the tasks that do not have it. It is worth setting for the tasks a user did
not spawn themselves, since the location of those points into compio rather
than at the code that asked for the work.

That is the case for every wrapper around `spawn` that is an `async fn`,
since `#[track_caller]` is a no-op on those and a `SpawnMeta` cannot be
forwarded through them, so note that in the limitations as well.

Add `SpawnMeta::named` for the tasks that can make up for it. The crates
that spawn tasks of their own name them in the commits that follow, one
per crate.

* test(executor): assert the console variants present one surface

The `console` module has an enabled and a disabled variant of every type it
exports, and only one of them is ever compiled. The disabled one is what
nearly every build uses, so a difference between the two surfaces reaches
whoever turns the feature on, in code written long after the difference.

Assert that they match, by coercing each item to a function pointer, which
pins its whole signature, and by naming the traits the rest of the crate
relies on.

The guard returned by entering a task's span needs more than a signature:
the enabled one borrows the span, since the console measures the time the
span is entered as the busy time of the task. A disabled guard that owned
itself would let code hold it past the span it is timing and compile, and
only fail once the feature is turned on. Both variants therefore name the
guard through a `EnterGuard<'a>` alias, which an owned guard cannot fill.

* feat(executor): let the task a runtime blocks on be named

`instrument_block_on` captured the caller itself, so a `block_on` task could
only ever be reported by the location of the call. That is enough for the
runtime a user blocks on themselves, but not for the ones started on their
behalf, which all report the same line inside compio.

Take a `SpawnMeta` like the other spawns do, so that a caller can pass one.

* docs(executor): correct what the console does with a task's kind

The console does not treat every kind other than `task` as one it does not
drive itself: it knows `blocking` and `block_on` by name, and lints a task
of any other kind, including one it does not know, as a future of its own.

Record the nightly escape hatch for the attribution of an `async fn` too.

* feat(executor): instrument the future a compatibility layer executes

`compio-compat`'s `execute` drives the executor from a foreign event loop,
the way `block_on` drives it from a loop of its own. It is the same kind of
task, so report it as one, under a name that says what it instruments.

* feat(runtime): forward the console instrumentation (#1001)

* feat(runtime): forward the console feature

Add a `console` feature forwarding the executor's, re-export the `console`
module, and instrument the future passed to `Runtime::block_on`, so that it
shows up as a task too.

`spawn` and `spawn_blocking` become `#[track_caller]` and pass the captured
`SpawnMeta` down, so that tasks are attributed to their real caller instead
of to this crate. `spawn_at` and `spawn_blocking_at` are public so that
wrappers outside of this crate can do the same.

* feat(runtime): report the blocking closure rather than the future

`spawn_blocking` returns a future that waits for the pool, so the task the
console saw was that future: all of the time in the closure counted as idle
and none as busy, and the four lints that only make sense for a future
misjudged it.

Instrument the closure instead, and leave the future waiting for it
unreported, since it stands for work that is already accounted for.

* feat(runtime): add Runtime::block_on_at

`block_on` captures its own caller, which is what a user blocking on their
own future wants. A runtime started on someone else's behalf needs to name
the task and point it elsewhere, so give it the same `_at` counterpart the
other spawns have.

* ci: run the console tests (#1003)

Neither `console` nor `compat` is part of `all`, so the console suites that
every existing job builds hold no tests at all. Give them a setup of their
own, and run the executor's under miri too.

The two features go together: the compatibility layer instruments the future
it executes, and only a build with both covers that.

* feat(dispatcher): attribute and name its tasks (#1002)

* feat(dispatcher): attribute dispatched tasks to the caller

A dispatched closure is spawned on a worker thread, so the console blamed
the dispatcher's own internals for every task it runs. Capture the location
of the `dispatch` call and send it along with the closure, so that the tasks
point at whoever dispatched them.

The capture is unconditional: `SpawnMeta` is a zero-sized no-op without the
`console` feature, so the location is dead and the generated code identical.

* feat(dispatcher): name the dispatched task

The location of a dispatched task points at the `dispatch` call that
produced it, which is enough to find it. The name makes the task list
readable without following the location of every row.

* feat(dispatcher): name the tasks the workers block on

The dispatcher's workers all reported the same line inside compio-dispatcher,
and only the thread field told them apart.

Their meta is captured in `new_impl` rather than in the closure each thread
runs, since `#[track_caller]` does not reach into a closure, and `new_impl`,
`build` and `new` forward the caller so that the location is the `Dispatcher`
call in the user's code.

* test(dispatcher): assert its tasks are named and point at their caller

Both attributions travel: a dispatched task's metadata is captured on the
thread that submits it and sent through the channel, and a worker's is
captured in `new_impl` rather than in the closure its thread runs. Neither
is reachable from the thread the tasks end up on, so a regression shows up
as a location inside compio-dispatcher rather than as a build failure.

The recorder is installed as the global subscriber, since the spans are
created on threads of their own; that it can only be installed once is why
the file holds a single test.

* fix(runtime,executor): re-export JoinError (#1005)

* feat(actor): init (#1000)

* feat(actor): init

* docs(actor): update README's usage section

* refactor(runtime): timer (#995)

* refactor(runtime): timer

* fix(runtime): leaked tasks

* fix(runtime): invalid param for PollFd on Windows (#999)

* fix(runtime): invalid param for PollFd on Windows

Co-authored-by: DeepSeek <service@deepseek.com>

* fix(net): apply suggestions

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix(net): build

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: DeepSeek <service@deepseek.com>

* feat: name the tasks the crates spawn (#1004)

* docs(executor): correct what nightly's async_fn_track_caller reports

It reports the caller of `poll`, not the `.await` of the future: the two
coincide only when a future is awaited directly, and a combinator driving
it is blamed instead. Recommending it was therefore wrong.

Document what does work in its place — a plain `fn` returning a future,
capturing the metadata before the `async` block — since that is what the
compatibility layer and the QUIC endpoint now do.

* feat(net): name the task resolving an address

`resolve_sock_addrs` is an `async fn`, so the task it spawns is attributed
to the resolver rather than to whoever asked for the address. The name makes
up for that: the task used to show up as `compio-net/src/resolve` and
nothing else.

* feat(process): name the task waiting for a child

Its location points into compio rather than into the code that waited
for the child, since `child_wait` is an `async fn`.

* feat(fs): name the tasks the blocking fallbacks spawn

The operations that have no completion-based counterpart fall back to
the blocking pool from an `async fn`, so their location points into
compio rather than into the caller. Name them through one helper, so
that the call sites stay one-liners.

The helper is a plain `fn` returning a future, though, so that the
location it does report is the fallback that ran rather than the helper
itself: `#[track_caller]` is a no-op on an `async fn`, which would
collapse all fourteen of them onto one line.

* feat(quic): name the tasks the endpoint and connections spawn

Their location points into compio rather than into the code that opened
the endpoint or the connection, so the console has nothing to show for
them without a name.

* feat(quic): attribute a connection's task to whoever opened it

The task driving a connection is named, since the location a spawn records
would otherwise point into compio-quic. It points there anyway, and both a
connection a user opened and one they accepted report the same line.

Every path from the user to that spawn is a plain `fn`, though, so
`#[track_caller]` reaches it: annotate `Endpoint::connect`,
`Incoming::accept` and the three internal calls between them, and the
console reports the connection against the line that opened it.

The endpoint's own worker keeps its internal location, since `client`,
`server` and `bind` are `async fn`s that the caller does not survive.
Note both cases where the tasks are spawned.

* feat(quic): attribute an endpoint's worker to whoever created it

Its location pointed into compio-quic, since the constructors that reach
`Endpoint::new` are `async fn`s that `#[track_caller]` does not propagate
through, and the name alone had to say what the task was.

Make them plain `fn`s returning a future instead, and thread the metadata
they capture down to the spawn, so that an endpoint is attributed to the
call that created it. The chain the builders take is the longest of them:
`bind` reaches the worker through `client` or `server`.

Nothing about awaiting them changes; their return type is now opaque, and
they resolve the address when first polled rather than when called.

* test(quic): assert its tasks are named and its connections attributed

`#[track_caller]` reaches the spawn through five plain `fn`s, and dropping it
from any one of them moves a connection's location into compio-quic without
failing to build. One endpoint serves both ends here, so that the two
connections differ only in the call that opened them.

The endpoint's own worker is asserted twice over, for the constructor and for
the builders' `bind` behind it, that being the longer chain.

The instrumentation needs no feature of its own, `SpawnMeta` being a no-op
without one, but the test does, and a crate depending on compio-quic alone
had no way to ask for it either.

* feat(compat): report the future it executes as a task

Driving compio from a foreign event loop left the future being executed
unaccounted for: the console showed the tasks it spawned, but nothing for
the one they were spawned from.

`#[track_caller]` is a no-op on an `async fn`, so the task is named to make
up for its location pointing here rather than at whoever executed it.

The crate gets a `console` feature of its own, as the dispatcher has, so
that a dependent of it can ask for the instrumentation without going through
the facade.

* test(compat): assert the future it executes is reported

The instrumentation of `execute` was only covered where it is implemented,
which left the name it reports the task under, the location it points at, and
the fact that it reports one at all, to whoever reads the call.

Count the polls and exits of its span while at it, and how deeply the spans
nest. Holding the span for as long as `execute` awaits, rather than entering
it per poll, is the mistake to make here: the console would report the time
the task is idle as time spent polling it, and charge it for the tasks the
runtime polls in the meantime as well.

* feat: forward the console feature, document observing compio (#1006)

* feat: forward the console feature

Observing a compio application with tokio-console now takes one feature on
the facade crate, which enables the instrumentation of the runtime and of
every member that has a feature for it: the dispatcher, QUIC and the
compatibility layer.

Listing those changes nothing about a build through the facade, Cargo
resolving a package's features once for the whole graph: the
`compio-runtime` they use is already the one `console` asks for. It makes
the list say what it turns on rather than leaving it to unification, and
gives a crate depending on one of them alone the same switch.

* docs: document observing compio with tokio-console

Add an Observability section to the readme and to the crate docs, pointing
at the module documenting what is instrumented and what is not, and the cfg
that `console-subscriber` needs to instrument a runtime other than tokio.

The example runs an echo server on a dispatcher, so that the console shows
the tasks of a thread-per-core application spread over its worker threads,
which is what compio looks like and tokio does not. Alongside it run tasks
that are wrong in the four ways the console warns about: one that wakes
itself, one that drops its waker, one that never yields and one whose
future is large enough to want boxing. Both the threads and the tasks are
named, since a name is the only thing that tells them apart in the console.

* fix(runtime,driver): preserve backport compatibility

* chore: release 0.19.2

* fix(buf): mutual recursive on memmap2 impl (#1008)

* ci: fix runner io_uring bug (#1010)

* ci: check for breaking change (#1009)

* ci: check for breaking change

* ci: check for breaking change

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Yuyi Wang <Strawberry_Str@hotmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Drew Pirrone-Brusse <drew.pirrone.brusse@gmail.com>
Co-authored-by: Eikasia30 <eikasia30@gmail.com>
Co-authored-by: Mark Ye <yeya@deshaw.com>
Co-authored-by: Sherlock Holo <sherlockya@gmail.com>
Co-authored-by: Drew Pirrone-Brusse <dpb@ludumipsum.com>
Co-authored-by: numb86 <16703337+numb86@users.noreply.github.com>
Co-authored-by: Robbie <robertlanger03@gmail.com>
Co-authored-by: Hubert Gruszecki <h.gruszecki@gmail.com>
Co-authored-by: ararog <rogerio.araujo@gmail.com>
Co-authored-by: shupengx <dspxue@gmail.com>
Co-authored-by: Sobolev Y. <guardspirit@protonmail.com>
Co-authored-by: 朝倉水希 <mizuk1@mzk1.dev>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: DeepSeek <service@deepseek.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request package: executor Related to compio-executor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants