-
Notifications
You must be signed in to change notification settings - Fork 6k
fix(core): add dispose functions to prevent subscription memory leaks #7914
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
8142552
3fb83f7
9bcdac9
520ca09
663546a
48a68e6
d1b5d6e
7b271ab
76f0ea6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -10,29 +10,44 @@ import type * as SDK from "@opencode-ai/sdk/v2" | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export namespace ShareNext { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const log = Log.create({ service: "share-next" }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Generation counter to invalidate in-flight operations from previous init cycles | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let generation = 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let disposed = false | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Store unsubscribe functions for cleanup | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const unsubscribers: Array<() => void> = [] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async function url() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export async function init() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Bus.subscribe(Session.Event.Updated, async (evt) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await sync(evt.properties.info.id, [ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Clean up any existing subscriptions before adding new ones | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| dispose() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| disposed = false | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Increment generation so in-flight operations from previous cycle are invalidated | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const gen = ++generation | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
18
to
+29
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const unsub1 = Bus.subscribe(Session.Event.Updated, async (evt) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (disposed || gen !== generation) return | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await sync(gen, evt.properties.info.id, [ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type: "session", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| data: evt.properties.info, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ]) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Bus.subscribe(MessageV2.Event.Updated, async (evt) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await sync(evt.properties.info.sessionID, [ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const unsub2 = Bus.subscribe(MessageV2.Event.Updated, async (evt) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (disposed || gen !== generation) return | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await sync(gen, evt.properties.info.sessionID, [ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type: "message", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| data: evt.properties.info, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ]) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (gen !== generation) return | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
hendem marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (gen !== generation) return | |
| if (disposed || gen !== generation) return |
Copilot
AI
Jan 12, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The same concurrency issue exists here as in Share.ts. The unsubscribers.splice(0) operation could race with init() adding new subscribers. Additionally, the queue clearing logic snapshots the queue after clearing it, which means timeouts being added during this operation could be missed.
| const toUnsubscribe = unsubscribers.splice(0) | |
| for (const unsub of toUnsubscribe) { | |
| try { | |
| unsub() | |
| } catch (error) { | |
| log.error("failed to unsubscribe", { error }) | |
| } | |
| } | |
| // Hardened: snapshot and clear atomically to avoid race during iteration | |
| const pending = Array.from(queue.values()) | |
| queue.clear() | |
| for (const entry of pending) { | |
| clearTimeout(entry.timeout) | |
| } | |
| // Drain the live unsubscribers array so handlers added during disposal | |
| // are also cleaned up before we return. | |
| while (unsubscribers.length > 0) { | |
| const unsub = unsubscribers.pop() | |
| if (!unsub) { | |
| continue | |
| } | |
| try { | |
| unsub() | |
| } catch (error) { | |
| log.error("failed to unsubscribe", { error }) | |
| } | |
| } | |
| // Clear all pending timeouts from the live queue and then empty it. | |
| for (const entry of queue.values()) { | |
| clearTimeout(entry.timeout) | |
| } | |
| queue.clear() |
Copilot
AI
Jan 12, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The test helper function _addToQueueForTesting creates a timeout with an empty callback that fires after 100ms. While the comment says it's a "no-op callback that won't cause issues if it fires," the timeout will still consume resources and fire during test execution. Consider using a much longer timeout (e.g., 10000ms) or clearTimeout immediately after adding to the queue if the goal is just to test disposal without the timeout actually firing.
| // Use short timeout for tests - this is a no-op callback that won't cause issues if it fires | |
| const timeout = setTimeout(() => {}, 100) | |
| queue.set(sessionID, { timeout, data: dataMap }) | |
| // Use timeout handle for testing dispose cleanup, but clear it immediately so it never fires | |
| const timeout = setTimeout(() => {}, 100) | |
| queue.set(sessionID, { timeout, data: dataMap }) | |
| clearTimeout(timeout) |
hendem marked this conversation as resolved.
Show resolved
Hide resolved
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,21 +9,35 @@ export namespace Share { | |
|
|
||
| let queue: Promise<void> = Promise.resolve() | ||
| const pending = new Map<string, any>() | ||
| // Generation counter to invalidate in-flight operations from previous init cycles | ||
| let generation = 0 | ||
| let disposed = false | ||
|
|
||
| export async function sync(key: string, content: any) { | ||
| // Store unsubscribe functions for cleanup | ||
| const unsubscribers: Array<() => void> = [] | ||
|
|
||
| export async function sync(gen: number, key: string, content: any) { | ||
| // Skip if disposed or wrong generation | ||
| if (disposed || gen !== generation) return | ||
| const [root, ...splits] = key.split("/") | ||
| if (root !== "session") return | ||
| const [sub, sessionID] = splits | ||
| if (sub === "share") return | ||
| const share = await Session.getShare(sessionID).catch(() => {}) | ||
| if (!share) return | ||
| // Re-check after async operation | ||
| if (disposed || gen !== generation) return | ||
| const { secret } = share | ||
| pending.set(key, content) | ||
| queue = queue | ||
| .then(async () => { | ||
| // Check at start of queued operation | ||
| if (disposed || gen !== generation) return | ||
| const content = pending.get(key) | ||
| if (content === undefined) return | ||
| pending.delete(key) | ||
| // Final check before network request | ||
| if (disposed || gen !== generation) return | ||
|
|
||
| return fetch(`${URL}/share_sync`, { | ||
| method: "POST", | ||
|
|
@@ -46,14 +60,25 @@ export namespace Share { | |
| } | ||
|
|
||
| export function init() { | ||
| Bus.subscribe(Session.Event.Updated, async (evt) => { | ||
| await sync("session/info/" + evt.properties.info.id, evt.properties.info) | ||
| // Clean up any existing subscriptions before adding new ones | ||
| dispose() | ||
| disposed = false | ||
hendem marked this conversation as resolved.
Show resolved
Hide resolved
Comment on lines
+63
to
+65
|
||
| // Increment generation so in-flight operations from previous cycle are invalidated | ||
| const gen = ++generation | ||
|
Comment on lines
+63
to
+67
|
||
|
|
||
| const unsub1 = Bus.subscribe(Session.Event.Updated, async (evt) => { | ||
| await sync(gen, "session/info/" + evt.properties.info.id, evt.properties.info) | ||
| }) | ||
| Bus.subscribe(MessageV2.Event.Updated, async (evt) => { | ||
| await sync("session/message/" + evt.properties.info.sessionID + "/" + evt.properties.info.id, evt.properties.info) | ||
| const unsub2 = Bus.subscribe(MessageV2.Event.Updated, async (evt) => { | ||
| await sync( | ||
| gen, | ||
| "session/message/" + evt.properties.info.sessionID + "/" + evt.properties.info.id, | ||
| evt.properties.info, | ||
| ) | ||
| }) | ||
| Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => { | ||
| const unsub3 = Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => { | ||
| await sync( | ||
| gen, | ||
| "session/part/" + | ||
| evt.properties.part.sessionID + | ||
| "/" + | ||
|
|
@@ -63,6 +88,22 @@ export namespace Share { | |
| evt.properties.part, | ||
| ) | ||
| }) | ||
| unsubscribers.push(unsub1, unsub2, unsub3) | ||
| } | ||
|
|
||
| export function dispose() { | ||
| disposed = true | ||
| const toUnsubscribe = unsubscribers.splice(0) | ||
|
||
| for (const unsub of toUnsubscribe) { | ||
| try { | ||
| unsub() | ||
| } catch (error) { | ||
| log.error("failed to unsubscribe", { error }) | ||
| } | ||
| } | ||
| pending.clear() | ||
| queue = Promise.resolve() | ||
hendem marked this conversation as resolved.
Show resolved
Hide resolved
hendem marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| log.info("disposed share subscriptions") | ||
| } | ||
|
|
||
| export const URL = | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.