Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions packages/opencode/src/acp/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,33 @@ export class ACPSessionManager {
this.sessions.set(sessionId, session)
return session
}

/**
* Remove a session from the manager to free memory.
* Should be called when a session is terminated or the connection closes.
*/
remove(sessionId: string): boolean {
const existed = this.sessions.has(sessionId)
if (existed) {
this.sessions.delete(sessionId)
log.info("removed_session", { sessionId })
}
return existed
}

/**
* Get the count of active sessions (useful for monitoring/debugging).
*/
size(): number {
return this.sessions.size
}

/**
* Clear all sessions. Used during cleanup/dispose.
*/
clear(): void {
const count = this.sessions.size
this.sessions.clear()
log.info("cleared_all_sessions", { count })
}
Comment on lines +102 to +129
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

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

The new cleanup methods (remove, size, clear) are not being called anywhere in the production code. While they provide the infrastructure for memory management, the PR doesn't include integration points where these methods would actually be invoked (e.g., when an ACP connection closes or a session is terminated). Consider adding calls to sessionManager.remove() in the appropriate lifecycle hooks, such as the cancel method or connection cleanup handlers.

Copilot uses AI. Check for mistakes.
}
21 changes: 21 additions & 0 deletions packages/opencode/src/file/time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,27 @@ export namespace FileTime {
return state().read[sessionID]?.[file]
}

/**
* Clear all read timestamps for a session to free memory.
* Should be called when a session ends.
*/
export function clearSession(sessionID: string): boolean {
const s = state()
if (sessionID in s.read) {
delete s.read[sessionID]
log.info("cleared session read times", { sessionID })
return true
}
return false
}
Comment on lines +34 to +46
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

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

The FileTime.clearSession() method is not being called anywhere in the production code. While the method provides the infrastructure for memory management, there are no integration points where it would actually be invoked when a session ends. Consider adding calls to FileTime.clearSession() in session cleanup handlers or when sessions are removed.

Copilot uses AI. Check for mistakes.

/**
* Get the count of tracked sessions (useful for monitoring/debugging).
*/
export function sessionCount(): number {
return Object.keys(state().read).length
}

export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
const current = state()
const currentLock = current.locks.get(filepath) ?? Promise.resolve()
Expand Down
33 changes: 27 additions & 6 deletions packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,27 @@ export namespace MCP {
}

// Store transports for OAuth servers to allow finishing auth
// Each entry includes a timestamp for TTL-based cleanup
type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport
const pendingOAuthTransports = new Map<string, TransportWithAuth>()
type PendingOAuthEntry = {
transport: TransportWithAuth
createdAt: number
}
const pendingOAuthTransports = new Map<string, PendingOAuthEntry>()

/** TTL for pending OAuth transports (10 minutes) */
const OAUTH_TRANSPORT_TTL = 10 * 60 * 1000

/** Clean up expired OAuth transports */
function cleanupExpiredOAuthTransports() {
const now = Date.now()
for (const [key, entry] of pendingOAuthTransports) {
if (now - entry.createdAt > OAUTH_TRANSPORT_TTL) {
log.info("cleaning up expired oauth transport", { key, age: now - entry.createdAt })
pendingOAuthTransports.delete(key)
}
}
}
Comment on lines +155 to +164
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

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

The timeout cleanup only runs when a timeout occurs, but expired transports can accumulate if OAuth flows are abandoned before timeout. Consider implementing periodic cleanup or cleanup on every insertion to handle abandoned flows more proactively. For example, running cleanup on a timer or on every state initialization would prevent accumulation of entries that never timeout.

Copilot uses AI. Check for mistakes.

// Prompt cache types
type PromptInfo = Awaited<ReturnType<MCPClient["listPrompts"]>>["prompts"][number]
Expand Down Expand Up @@ -364,7 +383,8 @@ export namespace MCP {
}).catch((e) => log.debug("failed to show toast", { error: e }))
} else {
// Store transport for later finishAuth call
pendingOAuthTransports.set(key, transport)
cleanupExpiredOAuthTransports()
pendingOAuthTransports.set(key, { transport, createdAt: Date.now() })
status = { status: "needs_auth" as const }
// Show toast for needs_auth
Bus.publish(TuiEvent.ToastShow, {
Expand Down Expand Up @@ -739,7 +759,8 @@ export namespace MCP {
} catch (error) {
if (error instanceof UnauthorizedError && capturedUrl) {
// Store transport for finishAuth
pendingOAuthTransports.set(mcpName, transport)
cleanupExpiredOAuthTransports()
pendingOAuthTransports.set(mcpName, { transport, createdAt: Date.now() })
return { authorizationUrl: capturedUrl.toString() }
}
throw error
Expand Down Expand Up @@ -790,15 +811,15 @@ export namespace MCP {
* Complete OAuth authentication with the authorization code.
*/
export async function finishAuth(mcpName: string, authorizationCode: string): Promise<Status> {
const transport = pendingOAuthTransports.get(mcpName)
const entry = pendingOAuthTransports.get(mcpName)

if (!transport) {
if (!entry) {
throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
}

try {
// Call finishAuth on the transport
await transport.finishAuth(authorizationCode)
await entry.transport.finishAuth(authorizationCode)
Comment on lines 820 to +822
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

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

After calling finishAuth on the transport (line 822), the entry is deleted from pendingOAuthTransports at line 840 before the subsequent add() operation. If the add() call or any other operation fails after line 840, the transport is already removed and cannot be retried. Consider moving the deletion to after the entire operation succeeds to allow retry on partial failures.

Copilot uses AI. Check for mistakes.

// Clear the code verifier after successful auth
await McpAuth.clearCodeVerifier(mcpName)
Expand Down
60 changes: 50 additions & 10 deletions packages/opencode/src/util/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ export namespace Rpc {
[method: string]: (input: any) => any
}

/** Default timeout for RPC calls in milliseconds (30 seconds) */
const DEFAULT_TIMEOUT = 30_000

export function listen(rpc: Definition) {
onmessage = async (evt) => {
const parsed = JSON.parse(evt.data)
Expand All @@ -13,30 +16,67 @@ export namespace Rpc {
}
}

export function client<T extends Definition>(target: {
postMessage: (data: string) => void | null
onmessage: ((this: Worker, ev: MessageEvent<any>) => any) | null
}) {
const pending = new Map<number, (result: any) => void>()
type PendingRequest = {
resolve: (result: any) => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout>
}

export function client<T extends Definition>(
target: {
postMessage: (data: string) => void | null
onmessage: ((this: Worker, ev: MessageEvent<any>) => any) | null
},
options?: { timeout?: number },
) {
const pending = new Map<number, PendingRequest>()
const timeout = options?.timeout ?? DEFAULT_TIMEOUT
let id = 0

target.onmessage = async (evt) => {
const parsed = JSON.parse(evt.data)
if (parsed.type === "rpc.result") {
const resolve = pending.get(parsed.id)
if (resolve) {
resolve(parsed.result)
const request = pending.get(parsed.id)
if (request) {
clearTimeout(request.timeout)
pending.delete(parsed.id)
request.resolve(parsed.result)
}
}
}

return {
call<Method extends keyof T>(method: Method, input: Parameters<T[Method]>[0]): Promise<ReturnType<T[Method]>> {
const requestId = id++
return new Promise((resolve) => {
pending.set(requestId, resolve)
return new Promise((resolve, reject) => {
const timeoutHandle = setTimeout(() => {
const request = pending.get(requestId)
if (request) {
pending.delete(requestId)
reject(new Error(`RPC call '${String(method)}' timed out after ${timeout}ms`))
}
}, timeout)
Comment on lines +52 to +58
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

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

There's a potential race condition between the timeout callback (lines 52-58) and the response handler (lines 36-46). If a response arrives at exactly the same time as the timeout fires, both could execute simultaneously. While the current implementation uses Map.get() and Map.delete() which should handle this, consider checking if the request still exists after clearTimeout to make the race handling more explicit.

Copilot uses AI. Check for mistakes.

pending.set(requestId, {
resolve,
reject,
timeout: timeoutHandle,
})
target.postMessage(JSON.stringify({ type: "rpc.request", method, input, id: requestId }))
})
},
/** Get count of pending requests (for testing/monitoring) */
pendingCount(): number {
return pending.size
},
/** Clear all pending requests (for cleanup) */
dispose(): void {
for (const [requestId, request] of pending) {
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

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

Unused variable requestId.

Suggested change
for (const [requestId, request] of pending) {
for (const request of pending.values()) {

Copilot uses AI. Check for mistakes.
clearTimeout(request.timeout)
request.reject(new Error("RPC client disposed"))
}
pending.clear()
},
}
}
}
89 changes: 89 additions & 0 deletions packages/opencode/test/acp/session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, expect, test, beforeEach, afterEach } from "bun:test"
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

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

Unused imports afterEach, beforeEach.

Suggested change
import { describe, expect, test, beforeEach, afterEach } from "bun:test"
import { describe, expect, test } from "bun:test"

Copilot uses AI. Check for mistakes.
import { ACPSessionManager } from "../../src/acp/session"

describe("ACPSessionManager", () => {
// Create a mock SDK
const mockSdk = {
session: {
create: async () => ({ data: { id: "test-session-1" } }),
get: async () => ({ data: { id: "test-session-1", time: { created: new Date().toISOString() } } }),
},
} as any

describe("remove", () => {
test("removes existing session and returns true", async () => {
const manager = new ACPSessionManager(mockSdk)

// Create a session
await manager.create("/test/path", [], undefined)
expect(manager.size()).toBe(1)

// Remove it
const result = manager.remove("test-session-1")
expect(result).toBe(true)
expect(manager.size()).toBe(0)
})

test("returns false for non-existent session", () => {
const manager = new ACPSessionManager(mockSdk)

const result = manager.remove("non-existent")
expect(result).toBe(false)
})
})

describe("size", () => {
test("returns correct count of sessions", async () => {
const manager = new ACPSessionManager(mockSdk)
expect(manager.size()).toBe(0)

// Create sessions with different IDs
const sdk1 = {
session: { create: async () => ({ data: { id: "session-1" } }) },
} as any
const sdk2 = {
session: { create: async () => ({ data: { id: "session-2" } }) },
} as any
Comment on lines +44 to +46
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

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

Unused variable sdk2.

Suggested change
const sdk2 = {
session: { create: async () => ({ data: { id: "session-2" } }) },
} as any

Copilot uses AI. Check for mistakes.

const manager1 = new ACPSessionManager(sdk1)
await manager1.create("/path1", [], undefined)
expect(manager1.size()).toBe(1)

// Create another in same manager via load
const loadSdk = {
session: {
create: async () => ({ data: { id: "session-a" } }),
get: async () => ({ data: { id: "session-b", time: { created: new Date().toISOString() } } }),
},
} as any
const manager2 = new ACPSessionManager(loadSdk)
await manager2.create("/path", [], undefined)
await manager2.load("session-b", "/path", [], undefined)
expect(manager2.size()).toBe(2)
})
})

describe("clear", () => {
test("removes all sessions", async () => {
const sdk = {
session: {
create: async () => ({ data: { id: `session-${Math.random()}` } }),
get: async (params: any) => ({
data: { id: params.sessionID, time: { created: new Date().toISOString() } },
}),
},
} as any

const manager = new ACPSessionManager(sdk)

await manager.create("/path1", [], undefined)
await manager.create("/path2", [], undefined)
await manager.create("/path3", [], undefined)

expect(manager.size()).toBe(3)

manager.clear()
expect(manager.size()).toBe(0)
})
})
})
79 changes: 79 additions & 0 deletions packages/opencode/test/file/time.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, expect, test } from "bun:test"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"

describe("FileTime", () => {
describe("clearSession", () => {
test("clears read times for a session and returns true", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const { FileTime } = await import("../../src/file/time")

// Record some reads
FileTime.read("session-1", "/path/to/file1.ts")
FileTime.read("session-1", "/path/to/file2.ts")
FileTime.read("session-2", "/path/to/file3.ts")

expect(FileTime.sessionCount()).toBe(2)
expect(FileTime.get("session-1", "/path/to/file1.ts")).toBeDefined()
expect(FileTime.get("session-1", "/path/to/file2.ts")).toBeDefined()
expect(FileTime.get("session-2", "/path/to/file3.ts")).toBeDefined()

// Clear session-1
const result = FileTime.clearSession("session-1")
expect(result).toBe(true)

// Verify session-1 data is gone
expect(FileTime.get("session-1", "/path/to/file1.ts")).toBeUndefined()
expect(FileTime.get("session-1", "/path/to/file2.ts")).toBeUndefined()

// Verify session-2 data is still there
expect(FileTime.get("session-2", "/path/to/file3.ts")).toBeDefined()

expect(FileTime.sessionCount()).toBe(1)
},
})
})

test("returns false for non-existent session", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const { FileTime } = await import("../../src/file/time")

const result = FileTime.clearSession("non-existent-session")
expect(result).toBe(false)
},
})
})
})

describe("sessionCount", () => {
test("returns correct count of tracked sessions", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const { FileTime } = await import("../../src/file/time")

expect(FileTime.sessionCount()).toBe(0)

FileTime.read("session-a", "/file1.ts")
expect(FileTime.sessionCount()).toBe(1)

FileTime.read("session-b", "/file2.ts")
expect(FileTime.sessionCount()).toBe(2)

FileTime.read("session-a", "/file3.ts") // Same session
expect(FileTime.sessionCount()).toBe(2)

FileTime.clearSession("session-a")
expect(FileTime.sessionCount()).toBe(1)
},
})
})
})
})
Loading