Skip to content

Compute UserBlockCompleted's expensive fields lazily - #15162

Open
C0W0 wants to merge 8 commits into
masterfrom
terry/app-3587-lazy-user-block-completed-fields
Open

Compute UserBlockCompleted's expensive fields lazily#15162
C0W0 wants to merge 8 commits into
masterfrom
terry/app-3587-lazy-user-block-completed-fields

Conversation

@C0W0

@C0W0 C0W0 commented Aug 14, 2026

Copy link
Copy Markdown

Summary

UserBlockCompleted::serialized_block, command, command_with_obfuscated_secrets, output_truncated, and output_truncated_with_obfuscated_secrets were previously computed eagerly for every completed block, even when a 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, and never again.
  • UserBlockCompleted's fields become public Lazy<T, BlockList> fields. Each field's deferred compute closure captures the completed block's stable BlockId (not BlockIndex, which can go stale after block removal/reindexing — e.g. clearing the screen) so the live Block can be re-resolved from a &BlockList on first read.
  • Two access patterns, depending on what the caller already has on hand:
    • field.get(&block_list) when a &BlockList is already available.
    • field.get_with(|compute| { let model = terminal_model.lock(); compute(model.block_list()) }) when only a FairMutex<TerminalModel> is available. get_with hands the caller a compute callback so the lock guard can be scoped locally around the call — this sidesteps the "returning a borrow out of a closure" problem a simpler FnOnce() -> &S signature would hit. The lock is only ever acquired 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.
  • Also fixes an unrelated pre-existing bug found along the way: get_same_commands_from_history (in persistence/commands.rs) now correctly returns commands newest-to-oldest as documented, instead of double-reversing relative to its caller.
  • Bundles CurrentPrompt::new_with_model_events's model_events and terminal_model parameters into a single Option so the two can't disagree.

Correctness considerations

  • Stale-index audit: resolving by 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 via clear_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.
  • Deadlock audit: since get_with closures lock TerminalModel on 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 in maa.rs's handle_user_block_completed, which was holding the lock across a call to BlockContext::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_with takes with_source: FnOnce(&dyn Fn(&S) -> T) -> T — the inner compute callback must be Fn (not FnOnce) since it's passed by reference into caller-controlled scope, but the underlying compute closure it wraps is only safe to run once. A Cell-guarded take (rather than an atomic/Mutex) is sufficient because OnceLock::get_or_init already guarantees this whole path executes on at most one thread at a time.
  • Send/Sync requirements: Lazy<T, S> is Arc-backed, so Arc<LazyInner<T, S>>: Send requires LazyInner: Sync, which is why the interior mutability around the deferred compute closure itself uses a Mutex (not a Cell) — a Cell there would silently make UserBlockCompleted non-Send, breaking the channel it's sent over from the PTY reader thread to the main thread.
  • Cross-crate consumers: warp_tui (a separate crate) also reads these fields; this was invisible to cargo check/cargo test scoped to the warp crate and only surfaced via that crate's own build. Fixed its one call site (emit_block_completed_telemetry).

Testing

  • Targeted cargo test runs per touched module, all passing: terminal::model::blocks (65 tests, including the new stale-index regression test and test_background_blocks_finished, which exercises the lazy fields end-to-end), terminal::input (211), util::lazy (7, including new get_with coverage 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::view has 5 pre-existing order-dependent failures (309 passing) under the full-module run; confirmed via git stash against unmodified master that these fail identically, so they're unrelated to this change.
  • ./script/presubmit passes (format, inline-test-module check, clippy across the workspace); the only remaining failure is a pre-existing, unrelated missing clang-format binary in this environment.

Co-Authored-By: Warp agent@warp.dev

@cla-bot cla-bot Bot added the cla-signed label Aug 14, 2026
@oz-for-oss

oz-for-oss Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

@C0W0

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 /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

@C0W0
C0W0 requested review from acarl005 and vorporeal August 14, 2026 19:09

@oz-for-oss oz-for-oss Bot 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.

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_history now 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_block is 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

Comment thread app/src/terminal/model/block.rs Outdated
Comment thread app/src/persistence/commands.rs Outdated
Comment thread app/src/terminal/event.rs Outdated
Comment thread app/src/terminal/event.rs Outdated
@C0W0
C0W0 force-pushed the terry/app-3587-lazy-user-block-completed-fields branch from ef58649 to 37a7807 Compare August 14, 2026 22:36
/// 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>>);

@acarl005 acarl005 Aug 14, 2026

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.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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

C0W0 and others added 3 commits August 14, 2026 19:10
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>
@C0W0
C0W0 force-pushed the terry/app-3587-lazy-user-block-completed-fields branch from b66dd23 to 46809c9 Compare August 14, 2026 23:11

C0W0 commented Aug 14, 2026

Copy link
Copy Markdown
Author

@C0W0

C0W0 commented Aug 17, 2026

Copy link
Copy Markdown
Author

/oz-review

@oz-for-oss

oz-for-oss Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

@C0W0

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 /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

@C0W0
C0W0 requested a review from acarl005 August 17, 2026 17:51

@oz-for-oss oz-for-oss Bot 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.

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()

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.

⚠️ [IMPORTANT] Returning 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.

Comment thread app/src/terminal/model/block.rs Outdated
pub prompt_snapshot: Option<String>,
}

/// Computes [`UserBlockCompleted::command`] lazily from the live block. See

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.

⚠️ [IMPORTANT] These private helper doc comments restate the helper names instead of documenting non-obvious why/context, which violates the repo's Minimalist/Why-only comment guidance. Remove the redundant helper doc comments or replace them with one rationale at the lazy construction site.

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 {

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.

Suggested change
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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

yes for now - but I want keep this in case we want to use it in the future.

Comment on lines +58 to +71
/// 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)
})
}

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.

Suggested change
/// 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.

@C0W0 C0W0 Aug 18, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

Comment thread crates/warp_util/src/lazy.rs
Comment thread app/src/terminal/event.rs
Comment on lines +291 to +295
///
/// `serialized_block`, `command`, `command_with_obfuscated_secrets`, `output_truncated`, and
/// `output_truncated_with_obfuscated_secrets` are all computed lazily: each defers looking up the
/// live `Block` and computing its value until first read. This result will then be cached and accessed
/// directly in subsequent reads

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.

Suggested change
///
/// `serialized_block`, `command`, `command_with_obfuscated_secrets`, `output_truncated`, and
/// `output_truncated_with_obfuscated_secrets` are all computed lazily: each defers looking up the
/// live `Block` and computing its value until first read. This result will then be cached and accessed
/// directly in subsequent reads

Fields are already documented in their respective field doc comments. Try to avoid double-documenting. If agents are generating your comments, please have it do a pass over the comments added on the diff and pay special attention to the commenting guidelines in AGENTS.md. The serialized_block doc comment is sufficient to explain all this behavior.

Comment thread app/src/terminal/model/block.rs Outdated
block_list: &BlockList,
id: &BlockId,
compute: impl FnOnce(&Block) -> T,
) -> T {

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.

Suggested change
) -> 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| {

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.

Suggested change
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

Comment thread app/src/terminal/model/block.rs Outdated
Comment thread app/src/terminal/input.rs
.command
.get_with(|compute| {
let model = self.model.lock();
compute(model.block_list())

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.

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.

@C0W0 C0W0 Aug 18, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

acarl005 added a commit that referenced this pull request Aug 18, 2026
## 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>
C0W0 and others added 2 commits August 18, 2026 19:36
Co-authored-by: Andy <8334252+acarl005@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants