feat(executor): instrument tasks for tokio-console - #987
Conversation
f61b1e8 to
cc89bad
Compare
9f77622 to
4782bee
Compare
There was a problem hiding this comment.
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
consolemodule with enabled/disabled implementations and aSpawnMetaAPI to attribute spawns to call sites. - Instruments task polling with
runtime.spawnspans and waker operations withruntime::wakerevents. - Adds an extensive
consolefeature test suite to validate emitted spans/events match whatconsole-subscriberexpects.
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.
4782bee to
d2bbc4f
Compare
| #[cfg(feature = "console")] | ||
| use enabled as imp; | ||
| pub(crate) use imp::TaskSpan; | ||
| pub use imp::{SpawnMeta, instrument_block_on, instrument_blocking}; |
There was a problem hiding this comment.
I think I need another instrument "execute" for compio-compat.
There was a problem hiding this comment.
Two points need some further look:
executekind won't trigger tokio-console lints -- "self-wake / lost-waker / never-yielded"RuntimeCompat::executeis async fn,#[track_caller]won't work unless enable nightly featureasync_fn_track_caller
There was a problem hiding this comment.
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.
d2bbc4f to
255abf4
Compare
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.
* 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.
* 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>
Instruments the executor for
tokio-console, behind aconsolefeature.Without it every type here is zero-sized and does nothing.
Tasks get a
runtime.spawnspan, wakers emitruntime::wakerevents, andblock_onand the blocking pool report as tasks of their own kind so theconsole's lints do not misjudge them.
console-subscriberneeds--cfg console_without_tokio_unstable; that andthe rest of the limitations are documented in the
consolemodule.The spawns are
#[track_caller]unconditionally, so the console reports thecall 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/consolewithoutcompio-runtime/consolewould silentlyattribute every task to
compio-runtime/src/lib.rs. Tokio does the same, andfor what it costs — with the feature off, dropping the attributes moves
.textby 0.05% in thedispatcherexample, in either direction depending onhow the implicit argument lands in the inliner.
First of six, tracked in #988. The rest follow as this one lands.