Skip to content

Commit f010cd3

Browse files
author
FTMahringer
committed
test(plugins): add lifecycle regression coverage
1 parent 25ecbbb commit f010cd3

10 files changed

Lines changed: 514 additions & 91 deletions

File tree

packages/core/src/main/java/dev/synapse/core/common/domain/Plugin.java

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ public class Plugin {
2727

2828
@Enumerated(EnumType.STRING)
2929
@Column(nullable = false)
30-
private PluginStatus status = PluginStatus.INSTALLED;
30+
private PluginStatus status = PluginStatus.installed;
3131

3232
@JdbcTypeCode(SqlTypes.JSON)
3333
@Column(nullable = false, columnDefinition = "jsonb")
@@ -38,7 +38,7 @@ public class Plugin {
3838

3939
@Enumerated(EnumType.STRING)
4040
@Column(name = "storage_tier", nullable = false)
41-
private StorageTier storageTier = StorageTier.SYSTEM;
41+
private StorageTier storageTier = StorageTier.system;
4242

4343
@Enumerated(EnumType.STRING)
4444
@Column(name = "loader_state", nullable = false)
@@ -85,21 +85,21 @@ protected void onCreate() {
8585
}
8686

8787
public enum PluginType {
88-
CHANNEL,
89-
MODEL,
90-
SKILL,
91-
MCP,
88+
channel,
89+
model,
90+
skill,
91+
mcp,
9292
}
9393

9494
public enum PluginStatus {
95-
INSTALLED,
96-
DISABLED,
97-
ERROR,
95+
installed,
96+
disabled,
97+
error,
9898
}
9999

100100
public enum StorageTier {
101-
SYSTEM,
102-
STAGING,
101+
system,
102+
staging,
103103
}
104104

105105
public enum LoaderState {
@@ -114,6 +114,7 @@ public enum TrustTier {
114114
COMMUNITY,
115115
}
116116

117+
117118
public String getId() {
118119
return id;
119120
}

packages/core/src/main/java/dev/synapse/plugins/ManifestValidator.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ public ValidationResult validate(PluginManifest manifest) {
3636
}
3737

3838
if (manifest.type() == null) {
39-
errors.add("type is required and must be one of: CHANNEL, MODEL, SKILL, MCP");
39+
errors.add("type is required and must be one of: channel, model, skill, mcp");
4040
}
4141

4242
if (blank(manifest.version())) {

packages/core/src/main/java/dev/synapse/plugins/PluginLifecycleService.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,9 @@ public Plugin install(Map<String, Object> rawManifest) {
7575
plugin.setName(manifest.name());
7676
plugin.setType(manifest.type());
7777
plugin.setVersion(manifest.version());
78-
plugin.setStatus(Plugin.PluginStatus.INSTALLED);
78+
plugin.setStatus(Plugin.PluginStatus.installed);
7979
plugin.setManifest(rawManifest);
80-
plugin.setStorageTier(Plugin.StorageTier.STAGING);
80+
plugin.setStorageTier(Plugin.StorageTier.staging);
8181
plugin.setLoaderState(Plugin.LoaderState.UNLOADED);
8282
plugin.setTrustTier(detectTrustTier(rawManifest));
8383

@@ -154,7 +154,7 @@ public Plugin install(Map<String, Object> rawManifest) {
154154
@CacheEvict(value = "plugin-metadata", allEntries = true)
155155
public Plugin enable(String id) {
156156
Plugin plugin = findById(id);
157-
plugin.setStatus(Plugin.PluginStatus.INSTALLED);
157+
plugin.setStatus(Plugin.PluginStatus.installed);
158158
Plugin saved = pluginRepository.save(plugin);
159159

160160
logService.log(
@@ -176,7 +176,7 @@ public Plugin enable(String id) {
176176
@CacheEvict(value = "plugin-metadata", allEntries = true)
177177
public Plugin disable(String id) {
178178
Plugin plugin = findById(id);
179-
plugin.setStatus(Plugin.PluginStatus.DISABLED);
179+
plugin.setStatus(Plugin.PluginStatus.disabled);
180180
Plugin saved = pluginRepository.save(plugin);
181181

182182
logService.log(

packages/core/src/main/java/dev/synapse/plugins/PluginManifest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ public static PluginManifest fromMap(Map<String, Object> map) {
2727
Plugin.PluginType type = null;
2828
if (typeStr != null) {
2929
try {
30-
type = Plugin.PluginType.valueOf(typeStr.toUpperCase());
30+
type = Plugin.PluginType.valueOf(typeStr.toLowerCase());
3131
} catch (IllegalArgumentException ignored) {}
3232
}
3333

packages/core/src/main/java/dev/synapse/plugins/loader/PluginLoaderService.java

Lines changed: 160 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@
1212
import java.lang.module.Configuration;
1313
import java.lang.module.ModuleDescriptor;
1414
import java.lang.module.ModuleFinder;
15+
import java.lang.reflect.Constructor;
1516
import java.net.MalformedURLException;
1617
import java.net.URL;
1718
import java.net.URLClassLoader;
1819
import java.nio.file.Files;
1920
import java.nio.file.Path;
2021
import java.nio.file.Paths;
22+
import java.nio.charset.StandardCharsets;
2123
import java.time.Instant;
2224
import java.util.*;
2325
import java.util.concurrent.ConcurrentHashMap;
@@ -78,6 +80,7 @@ public PluginLoaderService(
7880
*/
7981
public LoadedPlugin loadPlugin(Plugin dbPlugin) throws PluginLoadException {
8082
String pluginId = dbPlugin.getId();
83+
URLClassLoader classLoader = null;
8184

8285
// Prevent double-load
8386
if (loadedPlugins.containsKey(pluginId)) {
@@ -132,65 +135,21 @@ public LoadedPlugin loadPlugin(Plugin dbPlugin) throws PluginLoadException {
132135
);
133136
}
134137

135-
// Layer 1: Create URLClassLoader (parent = platform class loader)
136-
URLClassLoader classLoader = new URLClassLoader(
138+
// Use the application plugin API loader as parent so plugin implementations
139+
// share the same SynapsePlugin/Channel/ModelProvider types.
140+
ClassLoader parentClassLoader = SynapsePlugin.class.getClassLoader();
141+
classLoader = new URLClassLoader(
137142
new URL[] { jarUrl },
138-
ClassLoader.getPlatformClassLoader()
143+
parentClassLoader
139144
);
140145

141-
// Layer 2: Create JPMS ModuleLayer
142-
ModuleFinder pluginFinder = ModuleFinder.of(realJarPath);
143-
Set<ModuleDescriptor> descriptors = pluginFinder
144-
.findAll()
145-
.stream()
146-
.map(java.lang.module.ModuleReference::descriptor)
147-
.collect(Collectors.toSet());
148-
149-
if (descriptors.isEmpty()) {
150-
classLoader.close();
151-
throw new PluginLoadException(
152-
pluginId,
153-
"JAR contains no module descriptor (module-info.class). " +
154-
"Plugins must declare a JPMS module."
155-
);
156-
}
157-
158-
Set<String> moduleNames = descriptors
159-
.stream()
160-
.map(ModuleDescriptor::name)
161-
.collect(Collectors.toSet());
162-
163-
Configuration parentConfig = ModuleLayer.boot().configuration();
164-
Configuration pluginConfig = parentConfig.resolve(
165-
pluginFinder,
166-
ModuleFinder.of(),
167-
moduleNames
168-
);
169-
170-
ModuleLayer pluginLayer = ModuleLayer.defineModulesWithOneLoader(
171-
pluginConfig,
172-
List.of(ModuleLayer.boot()),
173-
classLoader
174-
).layer();
175-
176-
// Layer 3: Discover plugin implementation via ServiceLoader
177-
ServiceLoader<SynapsePlugin> loader = ServiceLoader.load(
146+
ModuleLayer pluginLayer = tryCreatePluginLayer(realJarPath, classLoader);
147+
SynapsePlugin instance = discoverPluginInstance(
148+
dbPlugin,
178149
pluginLayer,
179-
SynapsePlugin.class
150+
classLoader
180151
);
181152

182-
Optional<SynapsePlugin> instanceOpt = loader.findFirst();
183-
if (instanceOpt.isEmpty()) {
184-
classLoader.close();
185-
throw new PluginLoadException(
186-
pluginId,
187-
"No SynapsePlugin implementation found in JAR. " +
188-
"Ensure META-INF/services/dev.synapse.plugin.api.SynapsePlugin is present."
189-
);
190-
}
191-
192-
SynapsePlugin instance = instanceOpt.get();
193-
194153
// Validate id matches
195154
if (!pluginId.equals(instance.getId())) {
196155
classLoader.close();
@@ -289,15 +248,138 @@ public LoadedPlugin loadPlugin(Plugin dbPlugin) throws PluginLoadException {
289248
Plugin.LoaderState.ERROR,
290249
e.getMessage()
291250
);
251+
closeQuietly(classLoader);
292252
throw e;
293253
} catch (Exception e) {
294254
String msg =
295255
"Unexpected error during plugin load: " + e.getMessage();
296256
updateLoaderState(pluginId, Plugin.LoaderState.ERROR, msg);
257+
closeQuietly(classLoader);
297258
throw new PluginLoadException(pluginId, msg, e);
298259
}
299260
}
300261

262+
private ModuleLayer tryCreatePluginLayer(
263+
Path jarPath,
264+
URLClassLoader classLoader
265+
) throws Exception {
266+
ModuleFinder pluginFinder = ModuleFinder.of(jarPath);
267+
Set<ModuleDescriptor> descriptors = pluginFinder
268+
.findAll()
269+
.stream()
270+
.map(java.lang.module.ModuleReference::descriptor)
271+
.collect(Collectors.toSet());
272+
273+
if (descriptors.isEmpty() || !SynapsePlugin.class.getModule().isNamed()) {
274+
return null;
275+
}
276+
277+
Set<String> moduleNames = descriptors
278+
.stream()
279+
.map(ModuleDescriptor::name)
280+
.collect(Collectors.toSet());
281+
282+
Configuration parentConfig = ModuleLayer.boot().configuration();
283+
Configuration pluginConfig = parentConfig.resolve(
284+
pluginFinder,
285+
ModuleFinder.of(),
286+
moduleNames
287+
);
288+
289+
return ModuleLayer.defineModulesWithOneLoader(
290+
pluginConfig,
291+
List.of(ModuleLayer.boot()),
292+
classLoader
293+
).layer();
294+
}
295+
296+
private SynapsePlugin discoverPluginInstance(
297+
Plugin dbPlugin,
298+
ModuleLayer pluginLayer,
299+
URLClassLoader classLoader
300+
) throws Exception {
301+
if (pluginLayer != null) {
302+
ServiceLoader<SynapsePlugin> layerLoader = ServiceLoader.load(
303+
pluginLayer,
304+
SynapsePlugin.class
305+
);
306+
Optional<SynapsePlugin> instanceOpt = layerLoader.findFirst();
307+
if (instanceOpt.isPresent()) {
308+
return instanceOpt.get();
309+
}
310+
}
311+
312+
ServiceLoader<SynapsePlugin> classPathLoader = ServiceLoader.load(
313+
SynapsePlugin.class,
314+
classLoader
315+
);
316+
Optional<SynapsePlugin> serviceInstance = classPathLoader.findFirst();
317+
if (serviceInstance.isPresent()) {
318+
return serviceInstance.get();
319+
}
320+
321+
String entryPoint = extractEntryPoint(dbPlugin, classLoader);
322+
if (entryPoint == null || entryPoint.isBlank()) {
323+
throw new PluginLoadException(
324+
dbPlugin.getId(),
325+
"No SynapsePlugin implementation found in JAR and manifest entry_point is missing."
326+
);
327+
}
328+
329+
Class<?> pluginClass = Class.forName(entryPoint, true, classLoader);
330+
if (!SynapsePlugin.class.isAssignableFrom(pluginClass)) {
331+
throw new PluginLoadException(
332+
dbPlugin.getId(),
333+
"Manifest entry_point does not implement SynapsePlugin: " + entryPoint
334+
);
335+
}
336+
337+
@SuppressWarnings("unchecked")
338+
Class<? extends SynapsePlugin> typedClass =
339+
(Class<? extends SynapsePlugin>) pluginClass;
340+
Constructor<? extends SynapsePlugin> constructor =
341+
typedClass.getDeclaredConstructor();
342+
constructor.setAccessible(true);
343+
return constructor.newInstance();
344+
}
345+
346+
private String extractEntryPoint(Plugin dbPlugin, URLClassLoader classLoader) {
347+
Map<String, Object> manifest = dbPlugin.getManifest();
348+
if (manifest != null) {
349+
Object entryPoint = manifest.get("entry_point");
350+
if (entryPoint != null) {
351+
return entryPoint.toString();
352+
}
353+
}
354+
355+
URL manifestUrl = classLoader.getResource("manifest.yml");
356+
if (manifestUrl == null) {
357+
return null;
358+
}
359+
try (var input = manifestUrl.openStream()) {
360+
String content = new String(
361+
input.readAllBytes(),
362+
StandardCharsets.UTF_8
363+
);
364+
for (String line : content.split("\\R")) {
365+
String trimmed = line.trim();
366+
if (trimmed.startsWith("entry_point:")) {
367+
return trimmed.substring("entry_point:".length()).trim();
368+
}
369+
}
370+
} catch (Exception ignored) {}
371+
return null;
372+
}
373+
374+
private void closeQuietly(URLClassLoader classLoader) {
375+
if (classLoader == null) {
376+
return;
377+
}
378+
try {
379+
classLoader.close();
380+
} catch (Exception ignored) {}
381+
}
382+
301383
/**
302384
* Unloads a plugin by id. Calls onUnload(), closes ClassLoader, removes from memory.
303385
*
@@ -310,23 +392,32 @@ public boolean unloadPlugin(String pluginId) {
310392
return false;
311393
}
312394

313-
Plugin dbPlugin = pluginRepository.findById(pluginId).orElse(null);
314-
if (dbPlugin != null) {
315-
sandboxService.runLifecycleHookWithTimeout(
316-
pluginId,
317-
() -> {
318-
try {
319-
loaded.instance().onUnload();
320-
} catch (Exception e) {
321-
// Logged by sandbox service; do not block unload
322-
}
323-
},
324-
false,
325-
dbPlugin
326-
);
327-
} else {
328-
loaded.unload();
395+
try {
396+
Plugin dbPlugin = pluginRepository.findById(pluginId).orElse(null);
397+
if (dbPlugin != null) {
398+
sandboxService.runLifecycleHookWithTimeout(
399+
pluginId,
400+
() -> {
401+
try {
402+
loaded.instance().onUnload();
403+
} catch (Exception e) {
404+
// Logged by sandbox service; do not block unload
405+
}
406+
},
407+
false,
408+
dbPlugin
409+
);
410+
} else {
411+
try {
412+
loaded.instance().onUnload();
413+
} catch (Exception e) {
414+
// Best-effort unload without database metadata
415+
}
416+
}
417+
} finally {
418+
closeQuietly(loaded.classLoader());
329419
}
420+
330421
updateLoaderState(pluginId, Plugin.LoaderState.UNLOADED, null);
331422

332423
logService.log(

packages/core/src/main/java/dev/synapse/plugins/loader/PluginSandboxService.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ private void markPluginError(String pluginId, String message) {
224224
.ifPresent(p -> {
225225
p.setLoaderState(Plugin.LoaderState.ERROR);
226226
p.setErrorMessage(message);
227-
p.setStatus(Plugin.PluginStatus.ERROR);
227+
p.setStatus(Plugin.PluginStatus.error);
228228
pluginRepository.save(p);
229229
});
230230
}

0 commit comments

Comments
 (0)