Fix one-shot exit hangs by tearing down cached memory managers#40367
Fix one-shot exit hangs by tearing down cached memory managers#40367Julbarth wants to merge 5 commits into
Conversation
Greptile SummaryThis PR fixes one-shot CLI hangs by adding explicit teardown of globally-cached memory managers ( Key changes:
Confidence Score: 4/5
Last reviewed commit: 9b3dc23 |
| export async function closeAllMemoryIndexManagers(): Promise<void> { | ||
| const managers = Array.from(INDEX_CACHE.values()); | ||
| if (managers.length === 0) { | ||
| return; | ||
| } | ||
| for (const manager of managers) { | ||
| try { | ||
| await manager.close(); | ||
| } catch (err) { | ||
| log.warn(`failed to close memory index manager: ${String(err)}`); | ||
| } | ||
| } | ||
| INDEX_CACHE.clear(); | ||
| } |
There was a problem hiding this comment.
INDEX_CACHE_PENDING not cleared during teardown
closeAllMemoryIndexManagers() clears INDEX_CACHE but leaves INDEX_CACHE_PENDING intact. If a MemoryIndexManager.get() call is still in-flight when teardown runs, the pending promise will eventually resolve and re-populate INDEX_CACHE with a brand-new manager (including its FSWatcher) that was never closed — exactly the hang this PR aims to fix.
The race window is narrow for the targeted one-shot CLI use case (all work is typically finished before teardown), but it's worth plugging for correctness:
| export async function closeAllMemoryIndexManagers(): Promise<void> { | |
| const managers = Array.from(INDEX_CACHE.values()); | |
| if (managers.length === 0) { | |
| return; | |
| } | |
| for (const manager of managers) { | |
| try { | |
| await manager.close(); | |
| } catch (err) { | |
| log.warn(`failed to close memory index manager: ${String(err)}`); | |
| } | |
| } | |
| INDEX_CACHE.clear(); | |
| } | |
| export async function closeAllMemoryIndexManagers(): Promise<void> { | |
| const managers = Array.from(INDEX_CACHE.values()); | |
| INDEX_CACHE_PENDING.clear(); | |
| if (managers.length === 0) { | |
| return; | |
| } | |
| for (const manager of managers) { | |
| try { | |
| await manager.close(); | |
| } catch (err) { | |
| log.warn(`failed to close memory index manager: ${String(err)}`); | |
| } | |
| } | |
| INDEX_CACHE.clear(); | |
| } |
Clearing INDEX_CACHE_PENDING first prevents any concurrently-creating manager from re-inserting itself into the cache. Note the INDEX_CACHE.clear() is moved to after the close() loop so that manager.close() (which internally calls INDEX_CACHE.delete(this.cacheKey)) doesn't race with the bulk clear.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/memory/manager.ts
Line: 45-58
Comment:
**`INDEX_CACHE_PENDING` not cleared during teardown**
`closeAllMemoryIndexManagers()` clears `INDEX_CACHE` but leaves `INDEX_CACHE_PENDING` intact. If a `MemoryIndexManager.get()` call is still in-flight when teardown runs, the pending promise will eventually resolve and re-populate `INDEX_CACHE` with a brand-new manager (including its `FSWatcher`) that was never closed — exactly the hang this PR aims to fix.
The race window is narrow for the targeted one-shot CLI use case (all work is typically finished before teardown), but it's worth plugging for correctness:
```suggestion
export async function closeAllMemoryIndexManagers(): Promise<void> {
const managers = Array.from(INDEX_CACHE.values());
INDEX_CACHE_PENDING.clear();
if (managers.length === 0) {
return;
}
for (const manager of managers) {
try {
await manager.close();
} catch (err) {
log.warn(`failed to close memory index manager: ${String(err)}`);
}
}
INDEX_CACHE.clear();
}
```
Clearing `INDEX_CACHE_PENDING` first prevents any concurrently-creating manager from re-inserting itself into the cache. Note the `INDEX_CACHE.clear()` is moved to after the `close()` loop so that `manager.close()` (which internally calls `INDEX_CACHE.delete(this.cacheKey)`) doesn't race with the bulk clear.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9b3dc232af
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| @@ -2,6 +2,7 @@ import type { OpenClawConfig } from "../config/config.js"; | |||
| import { createSubsystemLogger } from "../logging/subsystem.js"; | |||
| import type { ResolvedQmdConfig } from "./backend-config.js"; | |||
| import { resolveMemoryBackendConfig } from "./backend-config.js"; | |||
| import { closeAllMemoryIndexManagers } from "./manager.js"; | |||
There was a problem hiding this comment.
Keep memory-manager teardown on the lazy runtime boundary
Importing closeAllMemoryIndexManagers directly from ./manager.js makes search-manager.ts eagerly load the same module that loadManagerRuntime() is trying to lazy-load, which defeats the runtime boundary and can pull in heavy memory/index dependencies on code paths that never use memory. This mixed static+dynamic loading pattern is exactly what the repo’s dynamic-import guardrail is intended to prevent, and it risks startup/perf regressions for routed CLI commands.
Useful? React with 👍 / 👎.
| if (managers.length === 0) { | ||
| return; |
There was a problem hiding this comment.
Drain pending index-manager creations during global teardown
This teardown exits early when INDEX_CACHE is empty, but it does not account for in-flight creations tracked in INDEX_CACHE_PENDING. If shutdown runs while a MemoryIndexManager.get() creation promise is still pending, that promise can still resolve after teardown and repopulate the cache with a live manager (including watcher handles), so the new one-shot exit fix can still miss the exact hang it is trying to prevent in cancellation/error timing windows.
Useful? React with 👍 / 👎.
9b3dc23 to
244159b
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fcaf4f069f
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| import { createSubsystemLogger } from "../logging/subsystem.js"; | ||
| import type { ResolvedQmdConfig } from "./backend-config.js"; | ||
| import { resolveMemoryBackendConfig } from "./backend-config.js"; | ||
| import { closeAllMemoryIndexManagers } from "./manager.js"; |
There was a problem hiding this comment.
Keep index-manager teardown behind lazy runtime boundary
The new top-level import { closeAllMemoryIndexManagers } from "./manager.js" eagerly loads manager.js whenever search-manager.ts is imported, which bypasses the existing loadManagerRuntime() lazy-loading boundary used for memory internals. Because runCli() now calls memory teardown in finally, this turns many non-memory CLI paths into eager memory-module loads and can introduce avoidable startup/perf regressions on commands that never touch memory.
Useful? React with 👍 / 👎.
| if (managers.length === 0) { | ||
| return; |
There was a problem hiding this comment.
Drain pending manager creations during global teardown
closeAllMemoryIndexManagers() only closes instances currently in INDEX_CACHE and returns immediately when that cache is empty, but it never handles in-flight creations in INDEX_CACHE_PENDING. If shutdown happens while MemoryIndexManager.get() is still resolving, the pending promise can complete after teardown and repopulate INDEX_CACHE with a live manager (including watcher/timer handles), so one-shot runs can still hang in this race window.
Useful? React with 👍 / 👎.
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
13 similar comments
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
5 similar comments
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch. |
|
Superseded by #40389 from a clean branch to remove unrelated changes. Closing this PR to keep review focused. |
Summary
Describe the problem and fix in 2–5 bullets:
message_end/agent_end) but the process may hang instead of exiting.runCli()finally.Change Type (select all)
Scope (select all touched areas)
Linked Issue/PR
User-visible / Behavior Changes
One-shot CLI commands now exit cleanly after completion in cases where cached memory manager watcher handles previously kept the process alive.
Security Impact (required)
No)No)No)No)No)Yes, explain risk + mitigation:Repro + Verification
Environment
Steps
mainbefore this fix with default memory enabled.Expected
Actual
FSWatcherhandles from cached memory managers.Evidence
Attach at least one:
Human Verification (required)
What you personally verified (not just CI), and how:
pnpm exec vitest run src/memory/search-manager.test.ts src/cli/run-main.exit.test.tsfinally(success/failure/interrupt paths).Review Conversations
If a bot review conversation is addressed by this PR, resolve that conversation yourself. Do not leave bot review conversation cleanup for maintainers.
Compatibility / Migration
Yes)No)No)Failure Recovery (if this breaks)
src/cli/run-main.ts,src/memory/manager.ts,src/memory/search-manager.ts, related test updates.Risks and Mitigations
List only real risks for this PR. Add/remove entries as needed. If none, write
None.finally) and is best-effort with error handling.