Compute UserBlockCompleted's expensive fields lazily - #15162
Conversation
|
I'm starting a first review of this pull request. You can view the conversation on Warp. I completed the review and no human review was requested for this pull request. Comment Powered by Oz |
There was a problem hiding this comment.
Overview
This PR changes UserBlockCompleted to compute expensive block fields lazily and updates consumers to resolve those fields through terminal-model-backed accessors.
Concerns
- Deferred field resolution uses
BlockIndex, which is not stable after block removals/reindexing and can resolve data from the wrong block. get_same_commands_from_historynow reverses its result while the existing caller still reverses it again, changing the intended oldest-to-newest processing order.- One new API doc says
serialized_blockis supplied up front even though the implementation defers it, making the public contract inaccurate.
Verdict
Found: 0 critical, 3 important, 0 suggestions
Request changes
Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).
Powered by Oz
ef58649 to
37a7807
Compare
| /// Cloning a `Lazy` is a single `Arc::clone` and shares the same cache, so once any clone | ||
| /// computes the value, every other clone (and the original) observes the cached result instead | ||
| /// of recomputing it. | ||
| pub struct Lazy<T, S>(Arc<LazyInner<T, S>>); |
There was a problem hiding this comment.
This seems like something std::sync::OnceLock already provides. Any particular reason to write a custom struct instead of using OnceLock? Custom data structures may be cheap to generate with AI, but still require maintenance whereas stdlib components are completely free. It's not clear to me that this custom struct is adding any additional protections or invariants either.
There was a problem hiding this comment.
This does use OnceLock in the LazyInner struct. The reason why OnceLock alone won't work is that the consumer wouldn't know how to construct the data; instead, the producer must specify the closure used to build it when consumer invokes get for the first time. If you checkout LazyInner itself, it just consists of the OnceLock and the compute closure
serialized_block, command, command_with_obfuscated_secrets,
output_truncated, and output_truncated_with_obfuscated_secrets on
UserBlockCompleted were previously computed eagerly for every completed
block, even when a given subscriber never reads them.
Introduces a small Lazy<T, S> utility (app/src/util/lazy.rs) that computes
and caches a value from a &S the first time it's read. UserBlockCompleted's
fields become public Lazy<T, BlockList> fields, each capturing the
completed block's stable BlockId (not BlockIndex, which can go stale after
block removal/reindexing) so the live Block can be re-resolved lazily.
Two access patterns are provided:
- `.field.get(&block_list)` when the caller already holds a `&BlockList`.
- `.field.get_with(|compute| { let model = terminal_model.lock(); compute(model.block_list()) })`
when the caller only has a locked `TerminalModel` and needs to briefly
acquire the BlockList to compute the value. The lock is only taken the
first time a field is read; cached reads never lock again.
Updates every consumer (view.rs, input.rs, legacy.rs, maa.rs,
block_context.rs, next_command_model.rs, aws_credentials.rs,
current_prompt.rs, open_in_warp.rs, blocks_tests.rs, and warp_tui's
terminal_session_view.rs) to use these accessors instead of the previous
eager fields/methods.
Also bundles CurrentPrompt::new_with_model_events's model_events and
terminal_model parameters into a single Option so the two can't disagree.
Co-Authored-By: Warp <agent@warp.dev>
b66dd23 to
46809c9
Compare
This stack of pull requests is managed by Graphite. Learn more about stacking. |
|
/oz-review |
|
I'm re-reviewing this pull request in response to a review request. You can view the conversation on Warp. I completed the review and no human review was requested for this pull request. Comment Powered by Oz |
There was a problem hiding this comment.
Overview
This PR defers several expensive UserBlockCompleted fields behind a shared Lazy<T, BlockList> cache and updates consumers to resolve those fields from the terminal model on first use. I did not find security issues, and spec_context.md contains no approved or repository spec context to compare against.
Concerns
- Missing-block lazy resolution currently caches
T::default(), which lets callers continue with synthetic empty/default metadata when the original completed block is gone instead of forcing them to skip that event. - Several new private helper doc comments restate what the helper names already say, which does not meet the repo's comments guidance.
Verdict
Found: 0 critical, 2 important, 0 suggestions
Request changes
Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).
Powered by Oz
| "Tried to lazily compute a UserBlockCompleted field for a block that no longer exists", | ||
| extra: { "block_id" => ?id } | ||
| ); | ||
| T::default() |
There was a problem hiding this comment.
T::default() here caches an indistinguishable empty/default value when the completed block has been removed before the first lazy read, so callers that read serialized_block can still proceed with synthetic metadata instead of skipping the missing block. Make the lazy result optional or preserve the completed-block metadata needed by downstream callers so missing blocks cannot be processed as real empty/default blocks.
| impl<T, S> Lazy<T, S> { | ||
| /// Wraps an already-computed `value`. Reading it via [`Lazy::get`] never invokes a compute | ||
| /// closure (and therefore never needs a `&S`). | ||
| pub fn provided(value: T) -> Self { |
There was a problem hiding this comment.
| pub fn provided(value: T) -> Self { | |
| #[cfg(any(test, feature = "test-util"))] | |
| pub fn provided(value: T) -> Self { |
Looks like this is also test-only.
There was a problem hiding this comment.
yes for now - but I want keep this in case we want to use it in the future.
| /// Returns the value, computing (and caching) it from `source` first if necessary. `source` | ||
| /// is only consulted (and the compute closure only invoked) the first time this is called; | ||
| /// later calls return the cached value directly. | ||
| pub fn get(&self, source: &S) -> &T { | ||
| self.0.cell.get_or_init(|| { | ||
| let compute = self | ||
| .0 | ||
| .compute | ||
| .lock() | ||
| .take() | ||
| .expect("Lazy value has no cached value and no deferred compute fn"); | ||
| compute(source) | ||
| }) | ||
| } |
There was a problem hiding this comment.
| /// Returns the value, computing (and caching) it from `source` first if necessary. `source` | |
| /// is only consulted (and the compute closure only invoked) the first time this is called; | |
| /// later calls return the cached value directly. | |
| pub fn get(&self, source: &S) -> &T { | |
| self.0.cell.get_or_init(|| { | |
| let compute = self | |
| .0 | |
| .compute | |
| .lock() | |
| .take() | |
| .expect("Lazy value has no cached value and no deferred compute fn"); | |
| compute(source) | |
| }) | |
| } |
Looks like this method is unused? It's only used in tests but that's not useful to test if it's unused in production code. I think fn provided is valid even if test-only b/c it helps set up useful tests.
There was a problem hiding this comment.
Same as provided - I want to keep this so that we can potentially reuse Lazy<T, S> in the future in other place, where it might be feasible to just do lazy.get(&source) instead of lazy.get_with(|compute| compute(&source)). The reason I made get_with in the first place is because we want to have explicit mutex locking - otherwise get is suffice.
| block_list: &BlockList, | ||
| id: &BlockId, | ||
| compute: impl FnOnce(&Block) -> T, | ||
| ) -> T { |
There was a problem hiding this comment.
| ) -> T { | |
| ) -> Result<T> { |
We're logging an error and returning a T::default(), effectively swallowing an error. Callers won't actual be able to tell that the failure occurred. Are we sure this is how we want to handle the failure instead of wrapping this in either a Result or Option?
| // Have ApiKeyManager subscribe to block completion events for AWS credential refresh. | ||
| // This must happen after `model` is created, since the subscription needs it to resolve | ||
| // lazily-computed `UserBlockCompleted` fields. | ||
| ai::api_keys::ApiKeyManager::handle(ctx).update(ctx, |manager, ctx| { |
There was a problem hiding this comment.
| ai::api_keys::ApiKeyManager::handle(ctx).update(ctx, |manager, ctx| { | |
| ApiKeyManager::handle(ctx).update(ctx, |manager, ctx| { |
super nit. convert this to use an import
| .command | ||
| .get_with(|compute| { | ||
| let model = self.model.lock(); | ||
| compute(model.block_list()) |
There was a problem hiding this comment.
This type of closure gets repeated a lot.... I wish there was a nice way to reduce the repetition here. Maybe with a macro? I'm not sure. If there isn't a better way than I'm fine with this. Behavior-wise though it's pretty ideal.
There was a problem hiding this comment.
I was thinking about using a macro as well, but the whole idea of putting this out is to make sure the caller performs an explicit lock so that they would be aware of potential deadlock risks. That's why I kept this.
## Description Adds a new guideline to the **Comments** section of `AGENTS.md`, based on feedback from a Slack thread: an agent had added a struct-level doc comment that enumerated and explained several of the struct's fields, while those same fields also carried their own doc comments repeating the same explanation (see `app/src/terminal/event.rs` in PR #15162, the `UserBlockCompleted` struct). The new bullet, `**Container docs describe the whole, member docs describe the parts**`, states that a member's own doc comment is where that member gets explained, and a container's item-level doc comment must not enumerate or re-explain its members. It generalizes the existing `**Single-source of documentation**` bullet to the container/member relationship, and is placed directly after it to keep related guidance together. This is a docs-only change to `AGENTS.md`; no other files are touched. ## Testing This is a Markdown documentation change with no executable code, so no automated or manual testing applies. I confirmed the new bullet's lines stay within the section's documented ~100-column wrap, consistent with the surrounding bullets. - [ ] I have manually tested my changes locally with `./script/run` ## Agent Mode - [x] Warp Agent Mode - This PR was created via Warp's AI Agent Mode <!-- warp:pr-description-artifacts start --> <!-- warp:pr-description-artifacts end --> Co-authored-by: warp-agent-staging[bot] <240773466+warp-agent-staging[bot]@users.noreply.github.com> Co-authored-by: Andy <andy@warp.dev>
Co-authored-by: Andy <8334252+acarl005@users.noreply.github.com>

Summary
UserBlockCompleted::serialized_block,command,command_with_obfuscated_secrets,output_truncated, andoutput_truncated_with_obfuscated_secretswere previously computed eagerly for every completed block, even when a subscriber never reads them.Lazy<T, S>utility (app/src/util/lazy.rs) that computes and caches a value from a&Sthe first time it's read, and never again.UserBlockCompleted's fields become publicLazy<T, BlockList>fields. Each field's deferred compute closure captures the completed block's stableBlockId(notBlockIndex, which can go stale after block removal/reindexing — e.g. clearing the screen) so the liveBlockcan be re-resolved from a&BlockListon first read.field.get(&block_list)when a&BlockListis already available.field.get_with(|compute| { let model = terminal_model.lock(); compute(model.block_list()) })when only aFairMutex<TerminalModel>is available.get_withhands the caller acomputecallback so the lock guard can be scoped locally around the call — this sidesteps the "returning a borrow out of a closure" problem a simplerFnOnce() -> &Ssignature would hit. The lock is only ever acquired the first time a field is read; cached reads never lock again.view.rs,input.rs,legacy.rs,maa.rs,block_context.rs,next_command_model.rs,aws_credentials.rs,current_prompt.rs,open_in_warp.rs,blocks_tests.rs, andwarp_tui'sterminal_session_view.rs) to use these accessors.get_same_commands_from_history(inpersistence/commands.rs) now correctly returns commands newest-to-oldest as documented, instead of double-reversing relative to its caller.CurrentPrompt::new_with_model_events'smodel_eventsandterminal_modelparameters into a singleOptionso the two can't disagree.Correctness considerations
BlockIndex(the original design) risked silently returning a different block's data if the original block was removed and its old index reassigned. Added a regression test (deferred_fields_resolve_by_block_id_not_stale_index) that reproduces this exact scenario viaclear_screen(ClearMode::ResetAndClear)and asserts the stale block's fields correctly report as missing rather than resolving to the unrelated block now at the same index.get_withclosures lockTerminalModelon cache miss, any call site that already held that lock while calling an accessor would deadlock (the mutex isn't reentrant). Audited every consumer for this; found and fixed one real instance inmaa.rs'shandle_user_block_completed, which was holding the lock across a call toBlockContext::from_completed_block— restructured to resolve what's needed from inside the lock, drop it, then call the accessors.get_with's single-invocation guard:get_withtakeswith_source: FnOnce(&dyn Fn(&S) -> T) -> T— the innercomputecallback must beFn(notFnOnce) since it's passed by reference into caller-controlled scope, but the underlying compute closure it wraps is only safe to run once. ACell-guarded take (rather than an atomic/Mutex) is sufficient becauseOnceLock::get_or_initalready guarantees this whole path executes on at most one thread at a time.Send/Syncrequirements:Lazy<T, S>isArc-backed, soArc<LazyInner<T, S>>: SendrequiresLazyInner: Sync, which is why the interior mutability around the deferred compute closure itself uses aMutex(not aCell) — aCellthere would silently makeUserBlockCompletednon-Send, breaking the channel it's sent over from the PTY reader thread to the main thread.warp_tui(a separate crate) also reads these fields; this was invisible tocargo check/cargo testscoped to thewarpcrate and only surfaced via that crate's own build. Fixed its one call site (emit_block_completed_telemetry).Testing
cargo testruns per touched module, all passing:terminal::model::blocks(65 tests, including the new stale-index regression test andtest_background_blocks_finished, which exercises the lazy fields end-to-end),terminal::input(211),util::lazy(7, including newget_withcoverage for lock-then-cache and skip-when-cached behavior),passive_suggestions(4),next_command_model(10),current_prompt(14),aws_credentials(5),block_context(3),open_in_warp(8).terminal::viewhas 5 pre-existing order-dependent failures (309 passing) under the full-module run; confirmed viagit stashagainst unmodifiedmasterthat these fail identically, so they're unrelated to this change../script/presubmitpasses (format, inline-test-module check, clippy across the workspace); the only remaining failure is a pre-existing, unrelated missingclang-formatbinary in this environment.Co-Authored-By: Warp agent@warp.dev