Repository navigation
fix(components): scope the handleApplication lock per application - #2884
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request scopes the plugin lock in sequentiallyHandleApplication per application and plugin (${scope.appName}.${scope.pluginName}) instead of using the plugin name alone, preventing concurrent application loads from starving each other. A regression test is added to verify this behavior. Feedback was provided to improve the robustness of the test cleanup in the after hook by guarding against undefined variables if the before hook fails.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| @@ -691,13 +691,18 @@ function sequentiallyHandleApplication(scope: Scope, plugin: PluginModule) { | |||
| whenResolved(sequentiallyHandleApplication(scope, plugin)); | |||
There was a problem hiding this comment.
Suggestion (non-blocking): when the waiter's own setTimeout (line ~705) fires first and rejects, this promise has already settled. If the lock's wake callback still fires later, whenResolved(sequentiallyHandleApplication(scope, plugin)) calls resolve on an already-settled promise (a no-op per spec), but the argument is still evaluated — spawning a fresh, completely unreferenced retry promise for a scope whose caller already gave up. If that retry later rejects, it's an unhandled promise rejection (Node crashes by default). The PR body already flags this as a known, pre-existing gap ("a rejection from that re-run is unhandled"), not introduced by this diff. A minimal fix: const retry = sequentiallyHandleApplication(scope, plugin); retry.catch((e) => harperLogger.warn?.(...)); whenResolved(retry); — or adopt the wait-loop pattern already used in resources/recordLock.ts (acquireRecordKey), which retries by resolving an internal wait promise instead of recursively spawning a fresh unawaited chain.
There was a problem hiding this comment.
Good catch, and the analysis is right: the timed-out waiter's wake callback still evaluates a fresh sequentiallyHandleApplication retry after the outer promise has rejected, so a late unlock can leave an unhandled rejection. As you note it predates this change, and keying the lock per application only narrows the window (same app on two threads still contends) rather than closing it.
I'm keeping this PR scoped to the lock-key fix and tracking the waiter rejection separately in #2889, which captures both the minimal .catch() mitigation and the acquireRecordKey wait-loop pattern you pointed to as the cleaner option.
Lavinia, via Claude
|
Reviewed; no blockers found. |
cb1kenobi
left a comment
There was a problem hiding this comment.
The handleApplication lock is now keyed per application and plugin, so one hung load no longer times out every other app of that plugin type. Lock and unlock use the same NUL-separated id, and the timeout text still matches the ops grep prefix. Existing discussion already covers the pre-existing stale-waiter rejection and the test after-hook guards. No confirmed blocking issues on the changed lines.
—
Reviewed 112671c
cb1kenobi
left a comment
There was a problem hiding this comment.
The handleApplication lock is now keyed per application and plugin, so one hung load no longer times out every other app of that plugin type. Lock and unlock use the same NUL-separated id, and the timeout text still matches the ops grep prefix. Existing discussion already covers the pre-existing stale-waiter rejection and the test after-hook guards. No confirmed blocking issues remain on the changed lines.
—
Reviewed 112671c
| // Keyed per (application, plugin), not the plugin name alone: applications load concurrently, | ||
| // and a plugin-wide lock lets one application's hung handleApplication starve every other | ||
| // application's load of that plugin into the timeout below (#3184). NUL separator: appName | ||
| // can contain dots and slashes, so a printable separator could alias two distinct pairs. |
There was a problem hiding this comment.
Could this key encode the two names unambiguously? For example, application billing.eu with plugin rest and application billing with plugin eu.rest both produce billing.eu.rest. Application names come from component identities, and plugin names can be supplied through the built-in component mapping (components/Application.ts:5524-5533), so these distinct loads can still block each other and the second can hit the lock timeout. A tuple encoding such as JSON.stringify([scope.appName, scope.pluginName]) would preserve the intended per-application isolation.
| }; | ||
| }); | ||
|
|
||
| after(async () => { |
There was a problem hiding this comment.
Please close the two scopes before removing tempRoot. Each Scope installs an OptionsWatcher and deploy lifecycle listeners, which Scope.close() removes (components/Scope.ts:162-177, components/Scope.ts:292-302). Awaiting hogLoad settles the loader promise but does not close those scopes, so this suite leaves watchers and listeners in the shared Mocha process and removes files they still watch. The existing lifecycle test in unitTests/components/componentLoader.test.js:430-471 demonstrates this leak. Capture both scopes in the synthetic plugin and close them in after, including when the test fails partway through.
…, and keep the facts the shrinks dropped The backend-contract pointer now carries the three batch facts the type comments lack, the plane search note says an unfiltered query uses plane.search(), the measured HNSW claims keep the sizes they were measured at, the record-lock pointers name performCommit() and rescope(), the #2884 note states with evidence that today's plugins tolerate per-application interleaving, and the token-scope note keeps #2173's open question open. The deleted untested-abort note becomes a pointer at the test that now covers it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QkTp7Vfjhm5D4g4kK1vcWd Dispatch-Task: harper-prune-design-docs-2026q4
…as moved past (#2960) * Prune the design notes for 2026 Q4, and correct the claims the code has moved past resources/DESIGN.md and resources/indexes/DESIGN.md, the two files over 80% of their budget, get the full pass: measurement logs move to the PRs that hold them, the derived-index backend contract and the HNSW search-side calibration shrink to pointers at the type and comment that carry them, crate-owned native-plane internals point at @harperfast/hnsw's own design note, the user-facing path-routing rules point at the documentation repo, and two duplicated notes merge into their owners. Every other note gets the stale-reference sweep: renamed symbols, moved files, and claims the code now contradicts (the component load-lock key, the TLS key-rotation guard, the scheduler's worker gate, record-lock payloads and caps, the rocksdb-js#849 workaround condition). Docs only; no source changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QkTp7Vfjhm5D4g4kK1vcWd Dispatch-Task: harper-prune-design-docs-2026q4 * Address the pre-push review: name each fence and rescope site exactly, and keep the facts the shrinks dropped The backend-contract pointer now carries the three batch facts the type comments lack, the plane search note says an unfiltered query uses plane.search(), the measured HNSW claims keep the sizes they were measured at, the record-lock pointers name performCommit() and rescope(), the #2884 note states with evidence that today's plugins tolerate per-application interleaving, and the token-scope note keeps #2173's open question open. The deleted untested-abort note becomes a pointer at the test that now covers it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QkTp7Vfjhm5D4g4kK1vcWd Dispatch-Task: harper-prune-design-docs-2026q4 * Address the second review round: follow the declaration after a lock wait, and keep the macOS durability caveat The merged lock-scope text now says a call follows a mid-wait redeclaration, as rescope() and the "re-reads the declaration after the native wait" tests show; the HNSW plane note keeps the macOS msync caveat, since the crate's msync() has no F_FULLFSYNC pass; the measured ef sweep and cap-32 deficit keep their sizes and latency cost; and the successor-freshness note states that harper-pro's barrier fails closed while any home-map member is down. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QkTp7Vfjhm5D4g4kK1vcWd Dispatch-Task: harper-prune-design-docs-2026q4 * State exactly what a macOS power loss can cost the native HNSW plane The restored caveat said the crash contract "holds only up to that barrier", which reads as safety up to the last barrier — the state macOS can lose. Say instead that the cursor can land past graph writes the file lost, that nothing compares the plane watermark with the cursor on attach, and so those vectors stay missing until a rebuild. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QkTp7Vfjhm5D4g4kK1vcWd Dispatch-Task: harper-prune-design-docs-2026q4 --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
One application's hung plugin load could time out every other application's load of the same plugin type, because the component loader serialized
handleApplicationon a lock keyed by plugin name alone. This PR scopes that lock per (application, plugin) and adds a loader-level regression test that fails on the old key and passes on the new one. It is the lock half of #3184; the status-honesty half is #2883.Problem
sequentiallyHandleApplicationserializes pluginhandleApplicationcalls with aStatus.primaryStorelock keyed byscope.pluginName, which is the plugin type (jsResource,graphqlSchema, ...). Applications load concurrently (each serialized per app byserializeComponentLoad), so that lock is shared across applications: one application hanging inside itshandleApplicationholds thejsResourcelock, and every other application'sjsResourceload rejects withTimeout waiting for lock on jsResourceafter its own timeout plus the 5s acquisition grace. In production this cascaded into 500s while/statusstayed green.Fix
The lock key is now
${scope.appName}\0${scope.pluginName}intryLockand the matchingfinallyunlock. The NUL separator cannot appear in a path segment or config key, so two distinct (application, plugin) pairs can never alias to one key; the key is never displayed, so nothing user-visible changes. Cross-thread serialization of the same application's plugin load is preserved, which was the lock's stated purpose; only the cross-application coupling is removed. The timeout message keeps theTimeout waiting for lock onprefix that ops runbooks grep for and now also names the application.For the human reviewer
app.pluginstatus-name convention) becauseappNamecan contain dots and slashes, so any printable separator could alias two distinct pairs. Raised by the Codex review leg; both storage engines accept the key (regression test exercises the real store).sequentiallyHandleApplicationlate for the already-failed scope, and a rejection from that re-run is unhandled. Pre-existing; the per-application key narrows the window but does not remove it. Fixing it needs the store's lock-callback wake semantics (one waiter or all).Verification
All runs against the built dist on macOS, Node 24.
unitTests/components/componentLoaderLock.test.js: two real applications loaded throughloadComponent, both declaring the same plugin; the first hangs insidehandleApplicationholding its lock, the second must load within its own timeout budget.Timeout waiting for lock on lockIsolationProbe.unitTests/components/**/*test.*js) minusapplicationSpawn.test.js: 1885 passing, 3 pending, 5 failing. The identical 5 failures reproduce with this change reverted, so all are pre-existing environment issues on this machine (4x macOS/private/varvs/vartmpdir realpath mismatches inRuntimeModuleTracker.test.js, 1xOptionsWatcher.test.jsdeletion race).applicationSpawn.test.jswas excluded because it hangs the whole suite with no output here, the same behaviorwindowsGate.mjsdocuments for it.npm run buildemits only the pre-existingutility/interactivePrompts.tserrors from the renovate@inquirerbump (stale local node_modules; tsc still emits).Review coverage
author: claude; cross-model coverage: codex ok (3 rounds, resumed session) / gemini failed (agy not on PATH, no GEMINI_API_KEY) / domain adjudication failed (claude CLI not on PATH), adjudicated inline by the authoring session. Codex findings: separator aliasing (fixed in 9ad2e64, round 2 confirms closed), cross-worker test scope (acknowledged above), test-header verbosity (declined, matches repo regression-test precedent). Round 3 (the Windows cleanup fix in 112671c) surfaced nothing new.
Complexity: easy
Lavinia, via Claude
🤖 Generated with Claude Code
Review-Coverage: authored=claude; ran=codex; blocked=gemini; declined=cursor-grok,cursor-composer,domain; rounds=3 @ 112671c
Human-Review-Need: 3 @ 112671c