@@ -28,6 +28,11 @@ import { log } from './logger.js';
2828import { PKG_VERSION } from './version.js' ;
2929import { DEFAULT_CONTEXT_ID } from './browser/profile.js' ;
3030import { recordExtensionVersion } from './update-check.js' ;
31+ import {
32+ buildCommandDispatchFailure ,
33+ buildExtensionDisconnectFailure ,
34+ getResponseCorsHeaders ,
35+ } from './daemon-utils.js' ;
3136
3237const PORT = parseInt ( process . env . OPENCLI_DAEMON_PORT ?? String ( DEFAULT_DAEMON_PORT ) , 10 ) ;
3338
@@ -44,10 +49,13 @@ type ExtensionProfileConnection = {
4449const extensionProfiles = new Map < string , ExtensionProfileConnection > ( ) ;
4550const pending = new Map < string , {
4651 contextId : string ;
52+ action : string ;
53+ dispatched : boolean ;
4754 resolve : ( data : unknown ) => void ;
4855 reject : ( error : Error ) => void ;
4956 timer : ReturnType < typeof setTimeout > ;
5057} > ( ) ;
58+ let commandResultUnknownCount = 0 ;
5159// Extension log ring buffer
5260interface LogEntry { level : string ; msg : string ; ts : number ; }
5361const LOG_BUFFER_SIZE = 200 ;
@@ -136,12 +144,16 @@ function unregisterExtensionConnection(ws: WebSocket): void {
136144 for ( const [ id , p ] of pending ) {
137145 if ( p . contextId !== contextId ) continue ;
138146 clearTimeout ( p . timer ) ;
139- p . reject ( new DaemonCommandFailure (
140- `Browser profile "${ contextId } " disconnected` ,
141- 'profile_disconnected' ,
142- 'Open that Chrome profile and make sure the OpenCLI extension is enabled, or choose another profile with opencli profile use <name>.' ,
143- 503 ,
144- ) ) ;
147+ const failure = buildExtensionDisconnectFailure ( {
148+ contextId,
149+ action : p . action ,
150+ dispatched : p . dispatched ,
151+ } ) ;
152+ if ( failure . countAsCommandResultUnknown ) {
153+ commandResultUnknownCount ++ ;
154+ log . warn ( `[daemon] Command result unknown after extension disconnect (id=${ id } , action=${ p . action } , context=${ contextId } )` ) ;
155+ }
156+ p . reject ( new DaemonCommandFailure ( failure . message , failure . errorCode , failure . errorHint , failure . status ) ) ;
145157 pending . delete ( id ) ;
146158 }
147159 }
@@ -176,15 +188,6 @@ function jsonResponse(
176188 res . end ( JSON . stringify ( data ) ) ;
177189}
178190
179- export function getResponseCorsHeaders ( pathname : string , origin ?: string ) : Record < string , string > | undefined {
180- if ( pathname !== '/ping' ) return undefined ;
181- if ( ! origin || ! origin . startsWith ( 'chrome-extension://' ) ) return undefined ;
182- return {
183- 'Access-Control-Allow-Origin' : origin ,
184- Vary : 'Origin' ,
185- } ;
186- }
187-
188191async function handleRequest ( req : IncomingMessage , res : ServerResponse ) : Promise < void > {
189192 // ─── Security: Origin & custom-header check ──────────────────────
190193 // Block browser-based CSRF: browsers always send an Origin header on
@@ -257,6 +260,7 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
257260 profileDisconnected : route . errorCode === 'profile_disconnected' ,
258261 profiles,
259262 pending : pending . size ,
263+ commandResultUnknown : commandResultUnknownCount ,
260264 memoryMB : Math . round ( mem . rss / 1024 / 1024 * 10 ) / 10 ,
261265 port : PORT ,
262266 } ) ;
@@ -321,8 +325,34 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
321325 pending . delete ( body . id ) ;
322326 reject ( new Error ( `Command timeout (${ timeoutMs / 1000 } s)` ) ) ;
323327 } , timeoutMs ) ;
324- pending . set ( body . id , { contextId : route . connection ! . contextId , resolve, reject, timer } ) ;
325- route . connection ! . ws . send ( JSON . stringify ( body ) ) ;
328+ const entry = {
329+ contextId : route . connection ! . contextId ,
330+ action : typeof body . action === 'string' ? body . action : 'unknown' ,
331+ dispatched : false ,
332+ resolve,
333+ reject,
334+ timer,
335+ } ;
336+ pending . set ( body . id , entry ) ;
337+ const failBeforeDispatch = ( err : unknown ) => {
338+ if ( pending . get ( body . id ) !== entry ) return ;
339+ const failure = buildCommandDispatchFailure ( entry . contextId ) ;
340+ clearTimeout ( timer ) ;
341+ pending . delete ( body . id ) ;
342+ reject ( new DaemonCommandFailure ( failure . message , failure . errorCode , failure . errorHint , failure . status ) ) ;
343+ log . warn ( `[daemon] Failed to dispatch command ${ body . id } : ${ err instanceof Error ? err . message : String ( err ) } ` ) ;
344+ } ;
345+ try {
346+ route . connection ! . ws . send ( JSON . stringify ( body ) , ( err ?: Error ) => {
347+ if ( err && ! entry . dispatched ) failBeforeDispatch ( err ) ;
348+ } ) ;
349+ // Once ws accepts the frame, the command may execute even if the
350+ // result is later lost; do not downgrade later disconnects to a
351+ // pre-dispatch failure just because no result/ack has arrived yet.
352+ entry . dispatched = true ;
353+ } catch ( err ) {
354+ failBeforeDispatch ( err ) ;
355+ }
326356 } ) ;
327357
328358 jsonResponse ( res , 200 , result ) ;
0 commit comments