Repository: electron/electron
Pull request: fix: use fresh LazyNow for OnEndWorkItemImpl to fix TimeKeeper DCHECK

#### Description of Change

Fixes a flaky DCHECK on Linux CI:

```
FATAL:base/task/sequence_manager/thread_controller.cc:643] DCHECK failed: !delta.is_negative(). -0.000642 s
```

**Root cause:** In `ThreadControllerWithMessagePumpImpl::DoWorkImpl`, the same `LazyNow` instance is passed to both `DidRunTask()` and `OnEndWorkItemImpl()`. Inside `DidRunTask`, the `LazyNow` gets cached early (via `BrowserUIThreadScheduler::OnTaskCompleted` → `RecordTaskEnd`) **before** task observers run. Electron's `MicrotasksRunner::DidProcessTask` observer then drains V8 microtasks via `node::CallbackScope`, which can execute arbitrary JS. If that JS triggers nested pump activity (nested RunLoop, sync IPC, etc.), `TimeKeeper::last_phase_end_` advances past the cached value. When `OnEndWorkItemImpl` then uses the stale cached time, it computes a negative delta and hits the DCHECK.

**Fix:** Use a fresh `LazyNow` for `OnEndWorkItemImpl` that samples the clock after all observers have run. This matches the existing comment's intent that "microtasks are extensions of the RunTask" — if so, the work item ends *after* them, so the time should be sampled then.

**Upstreamable:** This bug exists whenever any `TaskObserver::DidProcessTask` triggers nested pump activity, which is not forbidden by the `TaskObserver` contract. Electron happens to trigger it via `MicrotasksRunner`, but the fix is generally correct and should be upstreamed to Chromium.

**Cost:** One extra `clock_gettime(CLOCK_MONOTONIC)` per task when `DidRunTask` caches its `LazyNow` (~20-30ns via VDSO on Linux). When it doesn't cache, no added cost.

#### Checklist

- [x] PR description included and stakeholders cc'd
- [x] `npm test` passes
- [x] Relevant documentation is changed or added (N/A — internal fix)
- [x] PR title follows semantic commit guidelines

#### Release Notes

Notes: none

Changed files:
filename: patches/chromium/.patches
patch: @@ -148,3 +148,4 @@ fix_set_correct_app_id_on_linux.patch
 fix_pass_trigger_for_global_shortcuts_on_wayland.patch
 feat_plumb_node_integration_in_worker_through_workersettings.patch
 fix_restore_sdk_inputs_cross-toolchain_deps_for_macos.patch
+fix_use_fresh_lazynow_for_onendworkitemimpl_after_didruntask.patch

filename: patches/chromium/fix_use_fresh_lazynow_for_onendworkitemimpl_after_didruntask.patch
patch: @@ -0,0 +1,52 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: Sam Attard <sattard@anthropic.com>
+Date: Sun, 22 Mar 2026 19:21:45 +0000
+Subject: fix: use fresh LazyNow for OnEndWorkItemImpl after DidRunTask
+
+DidRunTask can cache the LazyNow early (via RecordTaskEnd in
+BrowserUIThreadScheduler::OnTaskCompleted) before running task observers.
+If a task observer's DidProcessTask triggers nested pump activity (nested
+RunLoop, sync IPC, etc.), TimeKeeper's last_phase_end_ advances past the
+cached value. The subsequent OnEndWorkItemImpl then computes a negative
+delta and hits DCHECK(!delta.is_negative()) in RecordTimeInPhase.
+
+Using a fresh LazyNow for OnEndWorkItemImpl samples the time after all
+observers have run, which matches the existing comment's intent that
+microtasks are extensions of the RunTask and the work item ends after them.
+
+This is upstreamable: the bug exists whenever any TaskObserver::DidProcessTask
+triggers nested pump activity, which is not forbidden by the contract.
+
+diff --git a/base/task/sequence_manager/thread_controller_with_message_pump_impl.cc b/base/task/sequence_manager/thread_controller_with_message_pump_impl.cc
+index 16d1d5cf94d6fde1678f3ea7a9eceb7accbf510f..03bf3eeef18063f60213e11f72a62bfc0f9fa9f4 100644
+--- a/base/task/sequence_manager/thread_controller_with_message_pump_impl.cc
++++ b/base/task/sequence_manager/thread_controller_with_message_pump_impl.cc
+@@ -487,15 +487,22 @@ std::optional<WakeUp> ThreadControllerWithMessagePumpImpl::DoWorkImpl(
+     // `PendingTask` reference dangling.
+     selected_task.reset();
+ 
+-    LazyNow lazy_now_after_run_task(time_source_);
+-    main_thread_only().task_source->DidRunTask(lazy_now_after_run_task);
++    {
++      LazyNow lazy_now_did_run_task(time_source_);
++      main_thread_only().task_source->DidRunTask(lazy_now_did_run_task);
++    }
+     // End the work item scope after DidRunTask() as it can process microtasks
+-    // (which are extensions of the RunTask).
++    // (which are extensions of the RunTask). Use a fresh LazyNow here because
++    // DidRunTask may cache the LazyNow (via RecordTaskEnd) before running task
++    // observers, and those observers may trigger nested pump activity that
++    // advances TimeKeeper's last_phase_end_ past the cached value, resulting
++    // in a negative delta in RecordTimeInPhase.
++    LazyNow lazy_now_after_run_task(time_source_);
+     OnEndWorkItemImpl(lazy_now_after_run_task, run_depth);
+ 
+-    // If DidRunTask() read the clock (lazy_now_after_run_task.has_value()) or
+-    // if |batch_duration| > 0, store the clock value in `recent_time` so it can
+-    // be reused by SelectNextTask() at the next loop iteration.
++    // If OnEndWorkItemImpl() read the clock (lazy_now_after_run_task.has_value())
++    // or if |batch_duration| > 0, store the clock value in `recent_time` so it
++    // can be reused by SelectNextTask() at the next loop iteration.
+     if (lazy_now_after_run_task.has_value() || !batch_duration.is_zero()) {
+       recent_time = lazy_now_after_run_task.Now();
+     } else {