Repository navigation
Release v0.48.2 - #3302
Release v0.48.2#3302
Conversation
PR SummaryMedium Risk Overview Hardens GraphQL shutdown behavior by tracking and draining all in-flight request tasks: Updates the upgradable executor version map to include Reviewed by Cursor Bugbot for commit 197d096. Bugbot is set up for automated code reviews on this repo. Configure here. |
Coredump analysis of a failing run pinpointed the cause:
Thread 4 (main):
#6 rocksdb::PosixEnv::~PosixEnv <- env_posix.cc:222
#7 __run_exit_handlers
Thread 1 (still alive):
... AsyncProcessor::drop -> Runtime::shutdown_timeout ...
... tokio worker polling hyper::server::new_svc::NewSvcTask
spawned via fuel_core_services::async_processor::spawn_reserved
Threads 7..10:
rocksdb::ThreadPoolImpl::Impl::BGThread parked on the
Env::Default() condvar that Thread 4 was about to broadcast.
At the moment of the crash main was tearing down rocksdb's global
`Env::Default()` singleton from the libc atexit chain (a C++ static
destructor), while a tokio worker from the GraphQL `AsyncProcessor`'s
inner runtime was still polling a hyper request future. The worker
was no longer joined because `AsyncProcessor::drop` fell back to
`Runtime::shutdown_timeout(5s)`, which detaches any task still
running at its deadline. The detached worker then outlived the
service and raced rocksdb's `~PosixEnv` -> `JoinAllThreads`, producing
SIGABRT (or SIGSEGV) at process exit.
Fix: give `AsyncProcessor` an explicit `drain()` that waits for every
task spawned via `spawn_reserved` to complete (tracked via an
`AtomicUsize` + `Notify`), and call `processor.drain().await` from
`GraphQL Task::shutdown` before the surrounding teardown reaches
`AsyncProcessor::drop`. The runtime then has nothing in flight when
its workers are joined, so no task gets detached and no worker
outlives the service.
The previous SIGABRT/SIGSEGV-handler workaround (commits 2e54820b82
and 2ec05795a2) is no longer needed and has been reverted.
Verified locally:
- `da_compression__...with_overridden_height` in a tight loop:
50/50 clean, 0 signal aborts, 0 coredumps.
- `da_compression::` + `gas_price::` combined batches: 10/10
iterations clean (~250 test runs total).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`ExecutorWithMetrics::execute` now wraps every spawned request future in a `tokio::select!` against `state.while_started()`. When the service leaves the `Started` state, in-flight handlers resolve immediately and the connection's `Drop` closes the socket cleanly. This is a second layer of defense on top of `processor.drain()` in `Task::shutdown`: the drain handles the common case where handlers finish quickly after axum's graceful shutdown is signaled; the watcher caps how long drain can possibly take if a handler is stuck in a long-running await it can't observe on its own (a leaked acquire, a backpressured queue, a never-completing await). Either way, no task is detached at `AsyncProcessor::drop` and no tokio worker outlives the service into the rocksdb static-destructor phase at process atexit. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit a61de80. Configure here.
The previous decrement was inline at the end of the spawned async block, after `future.await`. A panic or cancellation in `future` would skip both the `fetch_sub` and the `notify_waiters`, leaving `active` permanently above zero — `drain()` would then hang forever and `Task::shutdown` would block the entire service teardown. Move the decrement + notify into the `Drop` impl of an `ActiveGuard` moved into the future. The guard's `Drop` runs whether the future returns normally, panics, or is dropped before its first poll, so the counter is always released. Includes two regression tests: - `drain__returns_when_panicking_task_completes` - `drain__returns_when_cancelled_before_first_poll` Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The assertion `start_time.elapsed() >= TIME_UNTIL_SYNCED` was racy: SyncTask transitions to Synced only `time_until_synced` after the last block received from the network, and every received block restarts the timer. Capturing start_time after `wait_for_blocks(3)` required at least one additional block to arrive at second_producer afterward, which is not guaranteed on slow CI where first_producer may stop before sending another block over P2P. Anchoring start_time before `wait_for_blocks` makes `T_last_received > start_time` invariantly true.

Version 0.48.2
Fixed
FUEL_CORE_ENABLE_COREDUMP=1to opt back in.