Skip to content

perf: resolve decofile state/revision synchronously when already resolved#1089

Open
hugo-ccabral wants to merge 1 commit intomainfrom
perf/sync-decofile-state-revision
Open

perf: resolve decofile state/revision synchronously when already resolved#1089
hugo-ccabral wants to merge 1 commit intomainfrom
perf/sync-decofile-state-revision

Conversation

@hugo-ccabral
Copy link
Contributor

@hugo-ccabral hugo-ccabral commented Feb 25, 2026

Summary

engine/decofile/fs.ts

  • Cache resolvedState and resolvedRevision as synchronous variables
  • When the decofile promise resolves (or set()/doUpdateState() is called), store the values directly
  • state() and revision() return Promise.resolve(cachedValue) instead of decofile.then(r => r.state) — avoids scheduling a microtask through .then()

engine/decofile/fetcher.ts

  • Cache the resolved DecofileProvider reference once the promise completes
  • All subsequent calls (state(), revision(), set(), etc.) go directly to the resolved provider instead of .then() on the promise

Context

CPU profile showed decofile state/revision at ~0.79% of total CPU (0.19% fs.ts state + 0.13% fetcher.ts state + 0.20% revision + others). Called 50+ times per page load via resolve() in mod.ts.

Even when a Promise is already resolved, calling .then() schedules a microtask in the event loop. Under 30k req/min with 50+ resolves per page, that's 1.5M+ unnecessary microtask schedulings per minute.

Test plan

  • Run existing deco test suite
  • Verify decofile updates still propagate correctly (file watcher, set(), notify())
  • Verify forceFresh option still bypasses cache

Summary by cubic

Cache the resolved decofile state, revision, and provider so reads return immediately without scheduling microtasks. This removes .then() on already-resolved promises in hot paths and reduces CPU overhead.

  • Refactors
    • engine/decofile/fs.ts: store resolvedState/resolvedRevision; update on set() and file changes; state()/revision() return Promise.resolve when cached; forceFresh still reads from disk.
    • engine/decofile/fetcher.ts: memoize the resolved DecofileProvider; route state/revision/set/notify/onChange/dispose directly to it; clear memo on dispose.

Written for commit 8f34400. Summary will update on new commits.

Summary by CodeRabbit

  • Refactor
    • Improved application performance through enhanced caching mechanisms for state operations.

…lved

Both state() and revision() in fs.ts and fetcher.ts used .then() on already-resolved promises, which schedules unnecessary microtasks. Cache resolved values synchronously so subsequent reads return Promise.resolve(cachedValue) without scheduling a microtask through .then(). Called 50+ times per page load via resolve() in mod.ts.

Co-authored-by: Cursor <cursoragent@cursor.com>
@github-actions
Copy link
Contributor

Tagging Options

Should a new tag be published when this PR is merged?

  • 👍 for Patch 1.164.1 update
  • 🎉 for Minor 1.165.0 update
  • 🚀 for Major 2.0.0 update

@coderabbitai
Copy link

coderabbitai bot commented Feb 25, 2026

📝 Walkthrough

Walkthrough

The PR introduces caching mechanisms in decofile management to reduce asynchronous operations. In fetcher.ts, a resolvedProvider cache is populated asynchronously with fallback to promise-based access. In fs.ts, decoded state and revision are cached locally, with public methods updated to prefer cached values when available.

Changes

Cohort / File(s) Summary
Provider Caching
engine/decofile/fetcher.ts
Introduces resolvedProvider local cache populated from decofileProviderPromise. Public methods (set, notify, state, onChange, revision, dispose) updated to use cached provider when available, with fallback to promise-based implementations.
State and Revision Caching
engine/decofile/fs.ts
Adds local resolvedState and resolvedRevision cache with setResolved synchronizer. State and revision getters now return cached values when available; set method signature updated to explicitly accept optional revision and return Promise<void>. doUpdateState updated to maintain cache consistency.

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly Related PRs

Poem

🐰 Hops through the cache with glee,
No more promises to wait and see!
Resolved states hop right through,
Swift and speedy, fresh and true!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main performance optimization: caching resolved decofile state and revision to enable synchronous access instead of async promise resolution.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch perf/sync-decofile-state-revision

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
engine/decofile/fetcher.ts (1)

249-256: onChange fallback discards the inner Disposable.

When resolvedProvider is not yet set, decofileProviderPromise.then((r) => r.onChange(cb)) discards the Disposable returned by the inner provider, so the caller's no-op [Symbol.dispose] can never actually unsubscribe the callback. This is a pre-existing issue not introduced by this PR, but it's worth tracking: the resurrection bug above can cause callbacks registered via this path to linger even after the object is disposed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@engine/decofile/fetcher.ts` around lines 249 - 256, The onChange fallback
currently discards the Disposable from decofileProviderPromise; change it to
capture and forward that inner Disposable so callers can unsubscribe: when
resolvedProvider is falsy, call decofileProviderPromise.then(r => { const inner
= r.onChange(cb); store inner in a local variable }); return a Disposable whose
[Symbol.dispose] calls the stored inner?.[Symbol.dispose]() (and guard for it
being set later), so both resolvedProvider.onChange and the promise-based path
return disposables that actually unsubscribe; reference onChange,
resolvedProvider, decofileProviderPromise and [Symbol.dispose].
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@engine/decofile/fetcher.ts`:
- Around line 229-268: The promise resolution currently assigns resolvedProvider
unconditionally which can resurrect a provider after dispose; add a local
boolean flag (e.g., isDisposed = false) and in the decofileProviderPromise.then
callback check isDisposed before assigning resolvedProvider—if isDisposed then
call r?.dispose?.() and skip caching; in the returned dispose() implementation
set isDisposed = true, null out resolvedProvider and perform the existing
cleanup so the then-handler will not cache a disposed provider. Ensure
references to resolvedProvider/state/revision/set/onChange continue to work with
this guard.

---

Nitpick comments:
In `@engine/decofile/fetcher.ts`:
- Around line 249-256: The onChange fallback currently discards the Disposable
from decofileProviderPromise; change it to capture and forward that inner
Disposable so callers can unsubscribe: when resolvedProvider is falsy, call
decofileProviderPromise.then(r => { const inner = r.onChange(cb); store inner in
a local variable }); return a Disposable whose [Symbol.dispose] calls the stored
inner?.[Symbol.dispose]() (and guard for it being set later), so both
resolvedProvider.onChange and the promise-based path return disposables that
actually unsubscribe; reference onChange, resolvedProvider,
decofileProviderPromise and [Symbol.dispose].

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9e02918 and 8f34400.

📒 Files selected for processing (2)
  • engine/decofile/fetcher.ts
  • engine/decofile/fs.ts

Comment on lines +229 to 268
let resolvedProvider: DecofileProvider | null = null;
decofileProviderPromise.then((r) => {
resolvedProvider = r;
});

return {
set(state, revision) {
if (resolvedProvider) return resolvedProvider.set?.(state, revision) ?? Promise.resolve();
return decofileProviderPromise.then((r) => r?.set?.(state, revision));
},
notify() {
if (resolvedProvider) return resolvedProvider.notify?.() ?? Promise.resolve();
return decofileProviderPromise.then((r) =>
r?.notify?.() ?? Promise.resolve()
);
},
state: (options) => decofileProviderPromise.then((r) => r.state(options)),
state: (options) => {
if (resolvedProvider) return resolvedProvider.state(options);
return decofileProviderPromise.then((r) => r.state(options));
},
onChange: (cb) => {
if (resolvedProvider) return resolvedProvider.onChange(cb);
decofileProviderPromise.then((r) => r.onChange(cb));
return {
[Symbol.dispose]: () => {
},
};
},
revision: () => decofileProviderPromise.then((r) => r.revision()),
revision: () => {
if (resolvedProvider) return resolvedProvider.revision();
return decofileProviderPromise.then((r) => r.revision());
},
dispose: () => {
decofileProviderPromise.then((r) => r?.dispose?.());
if (resolvedProvider) {
resolvedProvider.dispose?.();
} else {
decofileProviderPromise.then((r) => r?.dispose?.());
}
resolvedProvider = null;
delete decofileCache[endpoint];
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

dispose() before promise resolution resurrects a disposed resolvedProvider.

The .then that populates resolvedProvider (line 230) is registered before dispose() can register its cleanup .then. Microtask ordering therefore guarantees:

  1. resolvedProvider = r fires → live reference is cached.
  2. r?.dispose?.() fires → provider is disposed.

resolvedProvider is left pointing to the disposed provider, so any call to state(), revision(), set(), etc. on the returned object after dispose() will route through the disposed instance.

🐛 Proposed fix — guard the cache-setter against post-dispose execution
  let resolvedProvider: DecofileProvider | null = null;
+ let disposed = false;
  decofileProviderPromise.then((r) => {
-   resolvedProvider = r;
+   if (!disposed) {
+     resolvedProvider = r;
+   }
  });

  // … inside dispose: …
  dispose: () => {
+   disposed = true;
    if (resolvedProvider) {
      resolvedProvider.dispose?.();
    } else {
      decofileProviderPromise.then((r) => r?.dispose?.());
    }
    resolvedProvider = null;
    delete decofileCache[endpoint];
  },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@engine/decofile/fetcher.ts` around lines 229 - 268, The promise resolution
currently assigns resolvedProvider unconditionally which can resurrect a
provider after dispose; add a local boolean flag (e.g., isDisposed = false) and
in the decofileProviderPromise.then callback check isDisposed before assigning
resolvedProvider—if isDisposed then call r?.dispose?.() and skip caching; in the
returned dispose() implementation set isDisposed = true, null out
resolvedProvider and perform the existing cleanup so the then-handler will not
cache a disposed provider. Ensure references to
resolvedProvider/state/revision/set/onChange continue to work with this guard.

Copy link

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

1 issue found across 2 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="engine/decofile/fetcher.ts">

<violation number="1" location="engine/decofile/fetcher.ts:230">
P2: `resolvedProvider` can be repopulated after `dispose()` if the promise resolves later, leaving a stale disposed provider cached. Guard the `.then` assignment with a disposed flag (or clear in the dispose promise) so the cached provider cannot be restored after disposal.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

);

let resolvedProvider: DecofileProvider | null = null;
decofileProviderPromise.then((r) => {
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 25, 2026

Choose a reason for hiding this comment

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

P2: resolvedProvider can be repopulated after dispose() if the promise resolves later, leaving a stale disposed provider cached. Guard the .then assignment with a disposed flag (or clear in the dispose promise) so the cached provider cannot be restored after disposal.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At engine/decofile/fetcher.ts, line 230:

<comment>`resolvedProvider` can be repopulated after `dispose()` if the promise resolves later, leaving a stale disposed provider cached. Guard the `.then` assignment with a disposed flag (or clear in the dispose promise) so the cached provider cannot be restored after disposal.</comment>

<file context>
@@ -225,26 +225,46 @@ export const fromEndpoint = (endpoint: string): DecofileProvider => {
       );
+
+  let resolvedProvider: DecofileProvider | null = null;
+  decofileProviderPromise.then((r) => {
+    resolvedProvider = r;
+  });
</file context>
Fix with Cubic

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant