Skip to content

Commit 805758b

Browse files
authored
feat: plugin hardening layer (#25)
* feat: add plugin hardening layer — soft resolution, crash quarantine, dependency analysis Implements the plugin hardening layer (3 features + foundation): - Soft contribution resolution: contributions targeting missing extension points are held in a pending map and replayed when the target EP appears, instead of failing the entire plugin load. - Session-local crash quarantine: QuarantineManager suppresses repeatedly crashing contributions at render time (threshold-based), with dev-mode inline fallback UI and manual re-enable via devtools. - Advisory dependency metadata: DependencyAnalyzer tracks declared plugin and extension-point dependencies, detects cycles via Tarjan's SCC algorithm, and emits advisory warnings (never blocks loading). - CrashDataStrategy: bounded crash record storage with ring-buffer eviction, crash count tracked independently of record cap, and subscriber notification for reactive UI updates. Also adds validation for the new `dependencies` export field, `extractDeclaredDependencies` normalization, and 439 passing tests across 17 test files. * fix: address second round of code review findings - recordContributionCrash/recordBoundaryCrash: notify unconditionally so crash counts in debug snapshot stay current - getPendingContributions: deep-copy arrays to prevent mutation of internal state - onQuarantine callback: try/catch per listener so one bad listener can't break iteration - handleLoadError: clean up pending contributions for failed plugins - loadAll batch path: persist declaredDependencies on PluginState, only replay EPs from plugins that reached ready, emit missing dependency advisories - replayContributionsForExtensionPoints second pass: narrow catch to MissingExtensionPointError and duplicate-registration only - DependencyAnalyzer.getGraph: include extension point edges - extractDeclaredDependencies: fix docstring, shallow-clone arrays - createTestDeps: accept configurable maxCrashRecordsPerContribution - Tests: add clear() regression test, EP edges test, crash count assertions for clearForPlugin/clearAll * fix: replace fragile string matching with typed DuplicateContributionError Add DuplicateContributionError class and wrap registry's plain Error at the deps boundary (both production and test). Update catch blocks in replayContributionsForExtensionPoints to use instanceof check. * fix: retry() dep tracking, composite replay dedupe key, dep advisories on all paths - retry() now extracts declaredDependencies, updates dependencyAnalyzer, and persists deps in plugin state (matching doLoad/doReload) - replayContributionsForExtensionPoints uses extensionPointId:contributionId composite key so same contributionId across different EPs is not suppressed - First replay pass catches DuplicateContributionError (discards stale duplicates instead of rethrowing) - doLoad, doReload, and retry all emit missing-dependency advisory warnings (previously only loadAll did)
1 parent fc1c405 commit 805758b

23 files changed

Lines changed: 1863 additions & 50 deletions

ui/features/plugins/adapters/createProductionDeps.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import { EXTENSION_REGISTRY } from '@/features/extensions/store';
22
import { ensureBuiltinExtensionPointsRegistered } from '@/features/extensions/registerBuiltinExtensionPoints';
33
import { EventsOn } from '@omniviewdev/runtime/runtime';
44
import { validatePluginExports } from '../core/validation';
5-
import { MissingExtensionPointError } from '../core/errors';
6-
import type { PluginServiceDeps } from '../core/types';
5+
import { MissingExtensionPointError, DuplicateContributionError } from '../core/errors';
6+
import { InMemoryCrashDataStrategy } from '../core/CrashDataService';
7+
import type { PluginServiceDeps, PluginServiceConfig } from '../core/types';
8+
import { DEFAULT_CONFIG } from '../core/types';
79
import { importPlugin } from './importPlugin';
810
import { clearPlugin } from './clearPlugin';
911
import { ensureDevSharedDeps } from './devSharedDeps';
@@ -26,8 +28,12 @@ const pluginServiceLogger = {
2628
* Wires the real extension registry, SystemJS/ESM import adapters,
2729
* Wails event system, and real validation pipeline.
2830
*/
29-
export function createProductionDeps(): PluginServiceDeps {
31+
export function createProductionDeps(config?: Partial<PluginServiceConfig>): PluginServiceDeps {
32+
const resolved = { ...DEFAULT_CONFIG, ...config };
33+
const crashData = new InMemoryCrashDataStrategy({ maxRecordsPerContribution: resolved.maxCrashRecordsPerContribution });
34+
3035
return {
36+
crashData,
3137
importPlugin,
3238
clearPlugin,
3339

@@ -59,7 +65,20 @@ export function createProductionDeps(): PluginServiceDeps {
5965
},
6066
);
6167
}
62-
store.register(contribution);
68+
try {
69+
store.register(contribution);
70+
} catch (err) {
71+
// Wrap the registry's plain Error into a typed error for structured handling
72+
if (err instanceof Error && err.message.includes('already exists')) {
73+
throw new DuplicateContributionError(err.message, {
74+
pluginId: contribution.plugin,
75+
extensionPointId,
76+
contributionId: contribution.id,
77+
cause: err,
78+
});
79+
}
80+
throw err;
81+
}
6382
},
6483

6584
removeContributions: (pluginId) => {

ui/features/plugins/components/ExtensionPointRenderer.tsx

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
'use no memo';
2-
import React from 'react';
2+
import React, { useContext, useSyncExternalStore } from 'react';
33
import { ErrorBoundary } from 'react-error-boundary';
44
import type { FallbackProps } from 'react-error-boundary';
55

@@ -11,6 +11,8 @@ import {
1111
logPluginBoundaryError,
1212
} from './PluginSurfaceBoundary';
1313
import type { DefaultExtensionFallbackProps } from './PluginSurfaceBoundary';
14+
import { QuarantinedContributionFallback } from './QuarantinedContributionFallback';
15+
import { PluginServiceContext } from '../react/context';
1416

1517
// ─── Props ──────────────────────────────────────────────────────────
1618

@@ -45,6 +47,8 @@ function ContributionWrapper({
4547
const pluginId = registration.plugin;
4648
const contributionId = registration.id;
4749

50+
const service = useContext(PluginServiceContext);
51+
4852
const handleError = React.useCallback(
4953
(error: Error, info: React.ErrorInfo) => {
5054
logPluginBoundaryError({
@@ -56,8 +60,19 @@ function ContributionWrapper({
5660
stack: error.stack ?? '',
5761
componentStack: info.componentStack ?? '',
5862
});
63+
// F3: Record crash and check quarantine threshold
64+
service?.recordContributionCrash({
65+
contributionId,
66+
pluginId,
67+
extensionPointId,
68+
boundary: 'ExtensionPointRenderer',
69+
errorMessage: error.message,
70+
stack: error.stack,
71+
componentStack: info.componentStack ?? undefined,
72+
timestamp: Date.now(),
73+
});
5974
},
60-
[pluginId, extensionPointId, contributionId],
75+
[pluginId, extensionPointId, contributionId, service],
6176
);
6277

6378
const renderFallback = React.useCallback(
@@ -104,22 +119,51 @@ export function ExtensionPointRenderer<TContext extends ExtensionRenderContext =
104119
const extensionPoint = useExtensionPoint<React.ComponentType<any>, TContext>(extensionPointId);
105120
const registrations = extensionPoint?.list(context) ?? [];
106121

122+
const service = useContext(PluginServiceContext);
123+
124+
// Subscribe to service snapshot so quarantine state changes trigger re-render
125+
useSyncExternalStore(
126+
service?.subscribe.bind(service) ?? (() => () => {}),
127+
service?.getSnapshot.bind(service) ?? (() => null),
128+
);
129+
130+
const isQuarantined = (contributionId: string) => service?.isQuarantined(contributionId) ?? false;
131+
const unquarantine = (contributionId: string) => service?.unquarantine(contributionId);
132+
107133
if (registrations.length === 0) {
108134
return null;
109135
}
110136

111137
return (
112138
<>
113-
{registrations.map((registration) => (
114-
<ContributionWrapper
115-
key={registration.id}
116-
registration={registration}
117-
extensionPointId={extensionPointId}
118-
fallback={fallback}
119-
context={context}
120-
componentProps={componentProps}
121-
/>
122-
))}
139+
{registrations.map((registration) => {
140+
if (isQuarantined(registration.id)) {
141+
if (import.meta.env.DEV) {
142+
return (
143+
<QuarantinedContributionFallback
144+
key={registration.id}
145+
pluginId={registration.plugin}
146+
extensionPointId={extensionPointId}
147+
contributionId={registration.id}
148+
crashCount={service?.getCrashCount(registration.id) ?? 0}
149+
onReEnable={() => unquarantine(registration.id)}
150+
/>
151+
);
152+
}
153+
return null;
154+
}
155+
156+
return (
157+
<ContributionWrapper
158+
key={registration.id}
159+
registration={registration}
160+
extensionPointId={extensionPointId}
161+
fallback={fallback}
162+
context={context}
163+
componentProps={componentProps}
164+
/>
165+
);
166+
})}
123167
</>
124168
);
125169
}

ui/features/plugins/components/PluginSurfaceBoundary.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import React from 'react';
1+
import React, { useContext } from 'react';
22
import { ErrorBoundary } from 'react-error-boundary';
33
import type { FallbackProps } from 'react-error-boundary';
44
import Box from '@mui/material/Box';
55
import Typography from '@mui/material/Typography';
6+
import { PluginServiceContext } from '../react/context';
67

78
// ─── Boundary Log Event ─────────────────────────────────────────────
89

@@ -167,6 +168,8 @@ export function PluginSurfaceBoundary({
167168
fallback: FallbackComponent = DefaultPluginFallback,
168169
resetKeys,
169170
}: PluginSurfaceBoundaryProps): React.ReactElement {
171+
const service = useContext(PluginServiceContext);
172+
170173
const handleError = React.useCallback(
171174
(error: Error, info: React.ErrorInfo) => {
172175
logPluginBoundaryError({
@@ -177,8 +180,21 @@ export function PluginSurfaceBoundary({
177180
stack: error.stack ?? '',
178181
componentStack: info.componentStack ?? '',
179182
});
183+
// F3: Record crash (no quarantine check — quarantine only applies to
184+
// extension contributions rendered via ExtensionPointRenderer)
185+
service?.recordBoundaryCrash({
186+
contributionId: `${pluginId}/${boundary}${resourceKey ? `/${resourceKey}` : ''}`,
187+
pluginId,
188+
extensionPointId: '',
189+
boundary,
190+
resourceKey,
191+
errorMessage: error.message,
192+
stack: error.stack,
193+
componentStack: info.componentStack ?? undefined,
194+
timestamp: Date.now(),
195+
});
180196
},
181-
[pluginId, boundary, resourceKey],
197+
[pluginId, boundary, resourceKey, service],
182198
);
183199

184200
const renderFallback = React.useCallback(
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import React from 'react';
2+
import Box from '@mui/material/Box';
3+
import Typography from '@mui/material/Typography';
4+
import Button from '@mui/material/Button';
5+
6+
export interface QuarantinedContributionFallbackProps {
7+
readonly pluginId: string;
8+
readonly extensionPointId: string;
9+
readonly contributionId: string;
10+
readonly crashCount: number;
11+
readonly onReEnable: () => void;
12+
}
13+
14+
/**
15+
* Dev-mode inline fallback for quarantined contributions.
16+
* Shows which contribution was suppressed and offers a re-enable button.
17+
* Only rendered in dev mode — production uses empty slots + notifications.
18+
*/
19+
export function QuarantinedContributionFallback({
20+
pluginId,
21+
extensionPointId,
22+
contributionId,
23+
crashCount,
24+
onReEnable,
25+
}: QuarantinedContributionFallbackProps): React.ReactElement {
26+
return (
27+
<Box
28+
role="alert"
29+
sx={{
30+
p: 1.5,
31+
border: '1px dashed',
32+
borderColor: 'warning.main',
33+
borderRadius: 1,
34+
bgcolor: 'warning.light',
35+
}}
36+
>
37+
<Typography variant="caption" color="warning.main" fontWeight="bold">
38+
Quarantined Extension
39+
</Typography>
40+
<Typography variant="caption" display="block" color="text.secondary">
41+
Plugin: {pluginId}
42+
</Typography>
43+
<Typography variant="caption" display="block" color="text.secondary">
44+
Extension Point: {extensionPointId}
45+
</Typography>
46+
<Typography variant="caption" display="block" color="text.secondary">
47+
Contribution: {contributionId}
48+
</Typography>
49+
<Typography variant="caption" display="block" color="text.secondary">
50+
Crashes: {crashCount}
51+
</Typography>
52+
<Button
53+
size="small"
54+
variant="outlined"
55+
color="warning"
56+
onClick={onReEnable}
57+
sx={{ mt: 1 }}
58+
>
59+
Re-enable
60+
</Button>
61+
</Box>
62+
);
63+
}

0 commit comments

Comments
 (0)