@@ -11,12 +11,26 @@ const PLUGIN_STATES = Object.freeze({
1111 INITIALIZED : 'initialized' ,
1212 ACTIVE : 'active' ,
1313 INACTIVE : 'inactive' ,
14+ FAILED : 'failed' ,
1415} ) ;
1516
1617function normalizePluginId ( pluginId ) {
1718 return String ( pluginId || '' ) . trim ( ) ;
1819}
1920
21+ function toErrorDetails ( error ) {
22+ if ( error instanceof Error ) {
23+ return {
24+ name : String ( error . name || 'Error' ) ,
25+ message : String ( error . message || 'Unknown plugin error' ) ,
26+ } ;
27+ }
28+ return {
29+ name : 'Error' ,
30+ message : String ( error || 'Unknown plugin error' ) ,
31+ } ;
32+ }
33+
2034function deepFreezeValue ( value , seen = new Set ( ) ) {
2135 if ( ! value || typeof value !== 'object' ) {
2236 return value ;
@@ -179,10 +193,63 @@ export default function createPhase19OverlayPluginRegistry({
179193 return ! currentOwner || currentOwner === normalizedPluginId ;
180194 }
181195
196+ function getRecoveryTargetState ( record ) {
197+ if ( ! record ) {
198+ return PLUGIN_STATES . REGISTERED ;
199+ }
200+ if ( record . stateBeforeFailure === PLUGIN_STATES . ACTIVE ) {
201+ return PLUGIN_STATES . INACTIVE ;
202+ }
203+ if (
204+ record . stateBeforeFailure === PLUGIN_STATES . INITIALIZED
205+ || record . stateBeforeFailure === PLUGIN_STATES . INACTIVE
206+ || record . stateBeforeFailure === PLUGIN_STATES . REGISTERED
207+ ) {
208+ return record . stateBeforeFailure ;
209+ }
210+ return PLUGIN_STATES . REGISTERED ;
211+ }
212+
213+ function isolatePluginFailure ( record , phase , error , context = { } , options = { } ) {
214+ if ( ! record ) {
215+ return false ;
216+ }
217+ const shouldUnregisterExtension = options . unregisterExtension !== false ;
218+ const shouldQuarantine = options . quarantine !== false ;
219+ const { name, message } = toErrorDetails ( error ) ;
220+ const failureSnapshot = Object . freeze ( {
221+ phase : String ( phase || 'unknown' ) ,
222+ name,
223+ message,
224+ pluginId : record . plugin . id ,
225+ extensionId : record . extension . id ,
226+ timestampIso : new Date ( ) . toISOString ( ) ,
227+ contextReason : String ( context ?. reason || '' ) ,
228+ } ) ;
229+ if ( shouldUnregisterExtension ) {
230+ try {
231+ expansionFramework . unregisterExtension ( record . extension . id ) ;
232+ } catch {
233+ // Failure isolation must never throw.
234+ }
235+ }
236+ record . failureCount = ( Number ( record . failureCount ) || 0 ) + 1 ;
237+ record . lastFailure = failureSnapshot ;
238+ record . failureHistory . push ( failureSnapshot ) ;
239+ if ( record . failureHistory . length > 10 ) {
240+ record . failureHistory . shift ( ) ;
241+ }
242+ if ( shouldQuarantine ) {
243+ record . stateBeforeFailure = record . state ;
244+ record . state = PLUGIN_STATES . FAILED ;
245+ }
246+ return true ;
247+ }
248+
182249 function runLifecycleHook ( record , phase , context = { } ) {
183250 const hook = record ?. plugin ?. [ phase ] ;
184251 if ( typeof hook !== 'function' ) {
185- return true ;
252+ return { ok : true } ;
186253 }
187254 try {
188255 const previousHookPluginId = activeHookPluginId ;
@@ -197,12 +264,12 @@ export default function createPhase19OverlayPluginRegistry({
197264 } ) ;
198265 try {
199266 hook ( lifecycleContext ) ;
200- return true ;
267+ return { ok : true } ;
201268 } finally {
202269 activeHookPluginId = previousHookPluginId ;
203270 }
204- } catch {
205- return false ;
271+ } catch ( error ) {
272+ return { ok : false , error : toErrorDetails ( error ) } ;
206273 }
207274 }
208275
@@ -221,7 +288,12 @@ export default function createPhase19OverlayPluginRegistry({
221288 if ( record . state !== PLUGIN_STATES . REGISTERED ) {
222289 return false ;
223290 }
224- if ( ! runLifecycleHook ( record , 'init' , context ) ) {
291+ const initResult = runLifecycleHook ( record , 'init' , context ) ;
292+ if ( ! initResult . ok ) {
293+ isolatePluginFailure ( record , 'init' , initResult . error , context , {
294+ unregisterExtension : true ,
295+ quarantine : true ,
296+ } ) ;
225297 return false ;
226298 }
227299 record . state = PLUGIN_STATES . INITIALIZED ;
@@ -241,6 +313,9 @@ export default function createPhase19OverlayPluginRegistry({
241313 if ( record . state === PLUGIN_STATES . ACTIVE ) {
242314 return false ;
243315 }
316+ if ( record . state === PLUGIN_STATES . FAILED ) {
317+ return false ;
318+ }
244319 if ( record . state === PLUGIN_STATES . REGISTERED ) {
245320 if ( ! initPlugin ( pluginId , context ) ) {
246321 return false ;
@@ -255,11 +330,19 @@ export default function createPhase19OverlayPluginRegistry({
255330
256331 const registered = expansionFramework . registerExtension ( record . extension ) ;
257332 if ( ! registered || ! registered . id ) {
333+ isolatePluginFailure ( record , 'activate-register' , new Error ( 'extension registration failed' ) , context , {
334+ unregisterExtension : true ,
335+ quarantine : true ,
336+ } ) ;
258337 return false ;
259338 }
260339
261- if ( ! runLifecycleHook ( record , 'activate' , context ) ) {
262- expansionFramework . unregisterExtension ( record . extension . id ) ;
340+ const activateResult = runLifecycleHook ( record , 'activate' , context ) ;
341+ if ( ! activateResult . ok ) {
342+ isolatePluginFailure ( record , 'activate' , activateResult . error , context , {
343+ unregisterExtension : true ,
344+ quarantine : true ,
345+ } ) ;
263346 return false ;
264347 }
265348
@@ -280,7 +363,12 @@ export default function createPhase19OverlayPluginRegistry({
280363 if ( record . state !== PLUGIN_STATES . ACTIVE ) {
281364 return false ;
282365 }
283- if ( ! runLifecycleHook ( record , 'deactivate' , context ) ) {
366+ const deactivateResult = runLifecycleHook ( record , 'deactivate' , context ) ;
367+ if ( ! deactivateResult . ok ) {
368+ isolatePluginFailure ( record , 'deactivate' , deactivateResult . error , context , {
369+ unregisterExtension : true ,
370+ quarantine : true ,
371+ } ) ;
284372 return false ;
285373 }
286374
@@ -305,7 +393,12 @@ export default function createPhase19OverlayPluginRegistry({
305393 return false ;
306394 }
307395 }
308- if ( ! runLifecycleHook ( record , 'destroy' , context ) ) {
396+ const destroyResult = runLifecycleHook ( record , 'destroy' , context ) ;
397+ if ( ! destroyResult . ok ) {
398+ isolatePluginFailure ( record , 'destroy' , destroyResult . error , context , {
399+ unregisterExtension : true ,
400+ quarantine : true ,
401+ } ) ;
309402 return false ;
310403 }
311404
@@ -351,14 +444,16 @@ export default function createPhase19OverlayPluginRegistry({
351444 plugin : normalizedPlugin ,
352445 extension : resolvedExtension ,
353446 state : PLUGIN_STATES . REGISTERED ,
447+ stateBeforeFailure : '' ,
448+ failureCount : 0 ,
449+ lastFailure : null ,
450+ failureHistory : [ ] ,
354451 } ;
355452 pluginRecordMap . set ( pluginId , record ) ;
356453 extensionOwnerMap . set ( resolvedExtension . id , pluginId ) ;
357454
358455 if ( autoActivate ) {
359456 if ( ! activatePlugin ( pluginId , context ) ) {
360- extensionOwnerMap . delete ( resolvedExtension . id ) ;
361- pluginRecordMap . delete ( pluginId ) ;
362457 throw new Error ( `Overlay plugin "${ pluginId } " failed lifecycle activation.` ) ;
363458 }
364459 }
@@ -373,6 +468,34 @@ export default function createPhase19OverlayPluginRegistry({
373468 return destroyPlugin ( pluginId , context ) ;
374469 }
375470
471+ function recoverPlugin ( pluginId , options = { } ) {
472+ if ( ! canMutate ( pluginId ) ) {
473+ return false ;
474+ }
475+ const context = options ?. context || { } ;
476+ const activate = options ?. activate === true ;
477+ const record = getPluginRecord ( pluginId ) ;
478+ if ( ! record || record . state !== PLUGIN_STATES . FAILED ) {
479+ return false ;
480+ }
481+ return withPluginTransition ( record . plugin . id , ( ) => {
482+ const recoveryState = getRecoveryTargetState ( record ) ;
483+ try {
484+ expansionFramework . unregisterExtension ( record . extension . id ) ;
485+ } catch {
486+ // Recovery should continue even if extension was already removed.
487+ }
488+ record . state = recoveryState ;
489+ record . stateBeforeFailure = '' ;
490+ record . lastFailure = null ;
491+ record . failureHistory = [ ] ;
492+ if ( ! activate ) {
493+ return true ;
494+ }
495+ return activatePlugin ( pluginId , { ...context , reason : context ?. reason || 'recover-activate' } ) ;
496+ } ) ;
497+ }
498+
376499 function getPlugin ( pluginId ) {
377500 const record = getPluginRecord ( pluginId ) ;
378501 return record ? record . plugin : null ;
@@ -388,6 +511,41 @@ export default function createPhase19OverlayPluginRegistry({
388511 return record ?. extension ?. id || '' ;
389512 }
390513
514+ function getPluginFailure ( pluginId ) {
515+ const record = getPluginRecord ( pluginId ) ;
516+ if ( ! record || ! record . lastFailure ) {
517+ return null ;
518+ }
519+ return record . lastFailure ;
520+ }
521+
522+ function listPluginFailures ( ) {
523+ const failures = [ ] ;
524+ for ( const [ pluginId , record ] of pluginRecordMap . entries ( ) ) {
525+ if ( ! record . lastFailure ) {
526+ continue ;
527+ }
528+ failures . push ( {
529+ pluginId,
530+ extensionId : record . extension . id ,
531+ state : record . state ,
532+ failureCount : record . failureCount ,
533+ lastFailure : record . lastFailure ,
534+ } ) ;
535+ }
536+ return Object . freeze ( failures ) ;
537+ }
538+
539+ function clearPluginFailure ( pluginId ) {
540+ const record = getPluginRecord ( pluginId ) ;
541+ if ( ! record || ! record . lastFailure ) {
542+ return false ;
543+ }
544+ record . lastFailure = null ;
545+ record . failureHistory = [ ] ;
546+ return true ;
547+ }
548+
391549 function listPlugins ( ) {
392550 const entries = [ ] ;
393551 for ( const [ pluginId , record ] of pluginRecordMap . entries ( ) ) {
@@ -396,6 +554,7 @@ export default function createPhase19OverlayPluginRegistry({
396554 version : record . plugin . version ,
397555 extensionId : record . extension . id ,
398556 state : record . state ,
557+ failureCount : record . failureCount ,
399558 } ) ;
400559 }
401560 return Object . freeze ( entries ) ;
@@ -407,10 +566,14 @@ export default function createPhase19OverlayPluginRegistry({
407566 activatePlugin,
408567 deactivatePlugin,
409568 destroyPlugin,
569+ recoverPlugin,
410570 unregisterPlugin,
411571 getPlugin,
412572 getPluginState,
413573 getPluginExtensionId,
574+ getPluginFailure,
575+ listPluginFailures,
576+ clearPluginFailure,
414577 listPlugins,
415578 states : PLUGIN_STATES ,
416579 getFramework ( ) {
0 commit comments