File tree Expand file tree Collapse file tree
control-plane/workspace-server Expand file tree Collapse file tree Original file line number Diff line number Diff line change @@ -5,6 +5,7 @@ import { Flag } from "../../flag/flag"
55import { Workspace } from "../../control-plane/workspace"
66import { Project } from "../../project/project"
77import { Installation } from "../../installation"
8+ import { Instance } from "../../project/instance"
89
910export const ServeCommand = cmd ( {
1011 command : "serve" ,
@@ -18,7 +19,13 @@ export const ServeCommand = cmd({
1819 const server = Server . listen ( opts )
1920 console . log ( `opencode server listening on http://${ server . hostname } :${ server . port } ` )
2021
21- await new Promise ( ( ) => { } )
22+ // Wait for termination signal instead of blocking forever
23+ await new Promise < void > ( ( resolve ) => {
24+ const shutdown = ( ) => resolve ( )
25+ process . on ( "SIGTERM" , shutdown )
26+ process . on ( "SIGINT" , shutdown )
27+ } )
28+ await Instance . disposeAll ( )
2229 await server . stop ( )
2330 } ,
2431} )
Original file line number Diff line number Diff line change @@ -7,10 +7,25 @@ export function WorkspaceServerRoutes() {
77 c . header ( "X-Accel-Buffering" , "no" )
88 c . header ( "X-Content-Type-Options" , "nosniff" )
99 return streamSSE ( c , async ( stream ) => {
10+ let done = false
11+ let resolveStream : ( ( ) => void ) | undefined
12+
13+ const cleanup = ( ) => {
14+ if ( done ) return
15+ done = true
16+ clearInterval ( heartbeat )
17+ GlobalBus . off ( "event" , handler )
18+ resolveStream ?.( )
19+ }
20+
1021 const send = async ( event : unknown ) => {
11- await stream . writeSSE ( {
12- data : JSON . stringify ( event ) ,
13- } )
22+ try {
23+ await stream . writeSSE ( {
24+ data : JSON . stringify ( event ) ,
25+ } )
26+ } catch {
27+ cleanup ( )
28+ }
1429 }
1530 const handler = async ( event : { directory ?: string ; payload : unknown } ) => {
1631 await send ( event . payload )
@@ -22,11 +37,8 @@ export function WorkspaceServerRoutes() {
2237 } , 10_000 )
2338
2439 await new Promise < void > ( ( resolve ) => {
25- stream . onAbort ( ( ) => {
26- clearInterval ( heartbeat )
27- GlobalBus . off ( "event" , handler )
28- resolve ( )
29- } )
40+ resolveStream = resolve
41+ stream . onAbort ( cleanup )
3042 } )
3143 } )
3244 } )
Original file line number Diff line number Diff line change @@ -101,9 +101,13 @@ export namespace Format {
101101 return result
102102 }
103103
104+ let unsubFormatted : ( ( ) => void ) | undefined
105+
104106 export function init ( ) {
105107 log . info ( "init" )
106- Bus . subscribe ( File . Event . Edited , async ( payload ) => {
108+ // Unsubscribe previous subscription to prevent stacking on re-init
109+ unsubFormatted ?.( )
110+ unsubFormatted = Bus . subscribe ( File . Event . Edited , async ( payload ) => {
107111 const file = payload . properties . file
108112 log . info ( "formatting" , { file } )
109113 const ext = path . extname ( file )
Original file line number Diff line number Diff line change @@ -210,6 +210,10 @@ try {
210210 }
211211 process . exitCode = 1
212212} finally {
213+ // Dispose all instances (LSP, MCP, PTY child processes) to prevent zombies.
214+ // Race with a 5-second timeout so we don't hang on unresponsive subprocesses.
215+ const { Instance } = await import ( "./project/instance" )
216+ await Promise . race ( [ Instance . disposeAll ( ) , new Promise ( ( r ) => setTimeout ( r , 5000 ) ) ] ) . catch ( ( ) => { } )
213217 // Some subprocesses don't react properly to SIGTERM and similar signals.
214218 // Most notably, some docker-container-based MCP servers don't handle such signals unless
215219 // run using `docker run --init`.
Original file line number Diff line number Diff line change @@ -156,6 +156,7 @@ export namespace LSPClient {
156156 } )
157157 }
158158
159+ const MAX_OPEN_FILES = 1000
159160 const files : {
160161 [ path : string ] : number
161162 } = { }
@@ -224,6 +225,12 @@ export namespace LSPClient {
224225 } ,
225226 } )
226227 files [ input . path ] = 0
228+ // Evict oldest file if we exceed the limit
229+ const keys = Object . keys ( files )
230+ if ( keys . length > MAX_OPEN_FILES ) {
231+ const oldest = keys [ 0 ]
232+ delete files [ oldest ]
233+ }
227234 return
228235 } ,
229236 } ,
@@ -263,6 +270,7 @@ export namespace LSPClient {
263270 l . info ( "shutting down" )
264271 diagnostics . clear ( )
265272 diagnosticOrder . length = 0
273+ for ( const key of Object . keys ( files ) ) delete files [ key ]
266274 connection . end ( )
267275 connection . dispose ( )
268276 input . server . process . kill ( )
Original file line number Diff line number Diff line change @@ -141,6 +141,9 @@ export namespace LSP {
141141 } ,
142142 async ( state ) => {
143143 await Promise . all ( state . clients . map ( ( client ) => client . shutdown ( ) ) )
144+ state . clients . length = 0
145+ state . broken . clear ( )
146+ state . spawning . clear ( )
144147 } ,
145148 )
146149
Original file line number Diff line number Diff line change @@ -420,7 +420,9 @@ export namespace MCP {
420420 duration : 8000 ,
421421 } ) . catch ( ( e ) => log . debug ( "failed to show toast" , { error : e } ) )
422422 } else {
423- // Store transport for later finishAuth call
423+ // Close any existing pending transport before storing the new one
424+ const existing = pendingOAuthTransports . get ( key )
425+ if ( existing ) existing . close ?.( ) . catch ( ( ) => { } )
424426 pendingOAuthTransports . set ( key , transport )
425427 status = { status : "needs_auth" as const }
426428 // Show toast for needs_auth
@@ -942,6 +944,8 @@ export namespace MCP {
942944 export async function removeAuth ( mcpName : string ) : Promise < void > {
943945 await McpAuth . remove ( mcpName )
944946 McpOAuthCallback . cancelPending ( mcpName )
947+ const transport = pendingOAuthTransports . get ( mcpName )
948+ if ( transport ) transport . close ?.( ) . catch ( ( ) => { } )
945949 pendingOAuthTransports . delete ( mcpName )
946950 await McpAuth . clearOAuthState ( mcpName )
947951 log . info ( "removed oauth credentials" , { mcpName } )
Original file line number Diff line number Diff line change @@ -278,6 +278,21 @@ export namespace PermissionNext {
278278 }
279279 }
280280
281+ export async function clearSession ( sessionID : string ) {
282+ const s = await state ( )
283+ for ( const [ id , pending ] of Object . entries ( s . pending ) ) {
284+ if ( pending . info . sessionID === sessionID ) {
285+ delete s . pending [ id ]
286+ Bus . publish ( Event . Replied , {
287+ sessionID : pending . info . sessionID ,
288+ requestID : pending . info . id ,
289+ reply : "reject" ,
290+ } )
291+ pending . reject ( new RejectedError ( ) )
292+ }
293+ }
294+ }
295+
281296 export async function list ( ) {
282297 const s = await state ( )
283298 return Array . from ( s . pending . values ( ) , ( x ) => x . info )
Original file line number Diff line number Diff line change @@ -130,14 +130,18 @@ export namespace Plugin {
130130 return state ( ) . then ( ( x ) => x . hooks )
131131 }
132132
133+ let unsub : ( ( ) => void ) | undefined
134+
133135 export async function init ( ) {
134136 const hooks = await state ( ) . then ( ( x ) => x . hooks )
135137 const config = await Config . get ( )
136138 for ( const hook of hooks ) {
137139 // @ts -expect-error this is because we haven't moved plugin to sdk v2
138140 await hook . config ?.( config )
139141 }
140- Bus . subscribeAll ( async ( input ) => {
142+ // Unsubscribe previous wildcard subscriber to prevent stacking on re-init
143+ unsub ?.( )
144+ unsub = Bus . subscribeAll ( async ( input ) => {
141145 const hooks = await state ( ) . then ( ( x ) => x . hooks )
142146 for ( const hook of hooks ) {
143147 hook [ "event" ] ?.( {
Original file line number Diff line number Diff line change @@ -161,6 +161,20 @@ export namespace Question {
161161 }
162162 }
163163
164+ export async function clearSession ( sessionID : string ) {
165+ const s = await state ( )
166+ for ( const [ id , pending ] of Object . entries ( s . pending ) ) {
167+ if ( pending . info . sessionID === sessionID ) {
168+ delete s . pending [ id ]
169+ Bus . publish ( Event . Rejected , {
170+ sessionID : pending . info . sessionID ,
171+ requestID : pending . info . id ,
172+ } )
173+ pending . reject ( new RejectedError ( ) )
174+ }
175+ }
176+ }
177+
164178 export async function list ( ) {
165179 return state ( ) . then ( ( x ) => Array . from ( x . pending . values ( ) , ( x ) => x . info ) )
166180 }
You can’t perform that action at this time.
0 commit comments