Skip to content

Commit cfefdde

Browse files
fix: register exit-abort once process-wide, not per plugin instance
OpenCode Desktop loads one plugin instance per open project in a single Node process. The plugin factory registered process.once("exit") per instance to abort the auto-update AbortController, so past Node's default 10-listener cap it logged MaxListenersExceededWarning ("11 exit listeners added to [process]"). The abort is not useless (it kills a spawned npm install child on hard exit), so rather than drop it, route all controllers through a module-global registry (shared/exit-abort-registry.ts) backed by a SINGLE process.once("exit") listener that fans out to every registered controller. Disposed instances unregister their controller so the set doesn't grow across Desktop project open/close. Corrects an earlier mis-attribution (this warning was thought to be Effect/OpenCode internals; the stack points squarely at plugin() -> process.once at index.ts). Gate: plugin 2605/0 (+3 registry tests), tsc + biome clean. Co-authored-by: Alfonso [Magic Context] <288211368+alfonso-magic-context@users.noreply.github.com>
1 parent babdb30 commit cfefdde

3 files changed

Lines changed: 104 additions & 3 deletions

File tree

packages/plugin/src/index.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { registerRpcHandlers } from "./plugin/rpc-handlers";
3939
import { createToolRegistry } from "./plugin/tool-registry";
4040
import { type ConflictResult, detectConflicts } from "./shared/conflict-detector";
4141
import { getMagicContextStorageDir } from "./shared/data-path";
42+
import { registerExitAbort, unregisterExitAbort } from './shared/exit-abort-registry';
4243
import { setKeepSubagents } from "./shared/keep-subagents";
4344
import { log } from "./shared/logger";
4445
import { refreshModelLimitsFromApi } from "./shared/models-dev-cache";
@@ -69,9 +70,10 @@ const server: Plugin = async (ctx) => {
6970
// (historian/dreamer/sidekick/migration) instead of deleting on success.
7071
setKeepSubagents(pluginConfig.keep_subagents === true);
7172
const autoUpdateAbort = new AbortController();
72-
process.once("exit", () => {
73-
autoUpdateAbort.abort();
74-
});
73+
// Abort on process exit via the shared single-listener registry. Registering
74+
// a process.once("exit") here directly would add one listener PER plugin
75+
// instance, and OpenCode Desktop runs many in one process (Node warns past 10).
76+
registerExitAbort(autoUpdateAbort);
7577

7678
// Surface config validation warnings to user and log
7779
if (pluginConfig.configWarnings?.length) {
@@ -396,6 +398,9 @@ const server: Plugin = async (ctx) => {
396398
if (!isDisposedInstanceDirectory(ownInstanceDirectory, disposedDirectory)) return;
397399
try {
398400
autoUpdateAbort.abort();
401+
// Drop it from the exit-abort registry so disposed instances'
402+
// controllers aren't retained there for the process lifetime.
403+
unregisterExitAbort(autoUpdateAbort);
399404
} catch {
400405
// best-effort
401406
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { describe, expect, it } from "bun:test";
2+
import { registerExitAbort, unregisterExitAbort } from "./exit-abort-registry";
3+
4+
// Captured before any registration so we can isolate the ONE listener the
5+
// registry installs process-wide (it intentionally never removes it, mirroring
6+
// production, so the suite must not strip it either).
7+
const baseline = process.listenerCount("exit");
8+
9+
/** The registry's single 'exit' listener (the first one added past baseline). */
10+
function registryListener(): () => void {
11+
return process.listeners("exit").slice(baseline)[0] as () => void;
12+
}
13+
14+
describe("exit-abort-registry", () => {
15+
it("adds exactly ONE process exit listener no matter how many controllers register", () => {
16+
registerExitAbort(new AbortController());
17+
registerExitAbort(new AbortController());
18+
registerExitAbort(new AbortController());
19+
expect(process.listenerCount("exit") - baseline).toBe(1);
20+
});
21+
22+
it("aborts every registered controller when the exit listener fires", () => {
23+
const a = new AbortController();
24+
const b = new AbortController();
25+
registerExitAbort(a);
26+
registerExitAbort(b);
27+
28+
// Invoke the registry's listener directly (emitting 'exit' would end the
29+
// test process).
30+
registryListener()();
31+
32+
expect(a.signal.aborted).toBe(true);
33+
expect(b.signal.aborted).toBe(true);
34+
// Still exactly one listener after firing.
35+
expect(process.listenerCount("exit") - baseline).toBe(1);
36+
});
37+
38+
it("does not abort a controller that was unregistered before exit", () => {
39+
const keep = new AbortController();
40+
const drop = new AbortController();
41+
registerExitAbort(keep);
42+
registerExitAbort(drop);
43+
unregisterExitAbort(drop);
44+
45+
registryListener()();
46+
47+
expect(keep.signal.aborted).toBe(true);
48+
expect(drop.signal.aborted).toBe(false);
49+
});
50+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* Process-global registry of AbortControllers to abort on process exit, backed
3+
* by a SINGLE `process.once("exit")` listener no matter how many controllers
4+
* register.
5+
*
6+
* Why this exists: the plugin factory runs once per plugin instance, and
7+
* OpenCode Desktop loads many instances in one Node process (one per open
8+
* project). Registering a `process.once("exit")` per instance added one listener
9+
* each, so past Node's default 10-listener cap it logged a
10+
* `MaxListenersExceededWarning` ("11 exit listeners added to [process]"). One
11+
* module-global listener that fans out to every registered controller keeps the
12+
* count at one.
13+
*/
14+
15+
const controllers = new Set<AbortController>();
16+
let listenerRegistered = false;
17+
18+
function abortAll(): void {
19+
for (const controller of controllers) {
20+
try {
21+
controller.abort();
22+
} catch {
23+
// best-effort: the process is exiting anyway
24+
}
25+
}
26+
}
27+
28+
/**
29+
* Abort `controller` when the process exits. The underlying `process.once("exit")`
30+
* listener is installed on the first call only; subsequent calls just add to the
31+
* fan-out set.
32+
*/
33+
export function registerExitAbort(controller: AbortController): void {
34+
controllers.add(controller);
35+
if (listenerRegistered) return;
36+
listenerRegistered = true;
37+
process.once("exit", abortAll);
38+
}
39+
40+
/**
41+
* Stop tracking `controller` (e.g. when its plugin instance is disposed) so the
42+
* set doesn't grow without bound as Desktop opens and closes projects.
43+
*/
44+
export function unregisterExitAbort(controller: AbortController): void {
45+
controllers.delete(controller);
46+
}

0 commit comments

Comments
 (0)