Skip to content

Commit 3032b8f

Browse files
committed
Improve unload plugins
1 parent c0a7360 commit 3032b8f

File tree

5 files changed

+227
-24
lines changed

5 files changed

+227
-24
lines changed

src/main/java/io/antmedia/console/AdminApplication.java

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ public BroadcastInfo(String name, int watcherCount) {
104104
* path traversal (`..`, `/`, `\`) and any non-alphanumeric character that could be
105105
* abused to escape {@code {AMS_HOME}/plugins/}. */
106106
private static final java.util.regex.Pattern PLUGIN_NAME_PATTERN =
107-
java.util.regex.Pattern.compile("[a-zA-Z0-9_\\-]+");
107+
java.util.regex.Pattern.compile("[a-zA-Z0-9_.\\-]+");
108108

109109
@Override
110110
public boolean appStart(IScope app) {
@@ -113,12 +113,11 @@ public boolean appStart(IScope app) {
113113
vertx = (Vertx) scope.getContext().getBean("vertxCore");
114114
warDeployer = (WarDeployer) app.getContext().getBean("warDeployer");
115115

116-
// Plugin hot-load engine — registered as a singleton in red5-common.xml.
117116
try {
118117
pluginDeployer = (PluginDeployer) app.getContext().getBean("pluginDeployer");
118+
pluginDeployer.scanInstalledPlugins();
119119
} catch (Exception e) {
120-
log.warn("PluginDeployer bean not found; plugin REST install endpoints will be inactive: {}",
121-
e.getMessage());
120+
log.warn("PluginDeployer bean not found: {}", e.getMessage());
122121
}
123122

124123
if(isCluster) {

src/main/java/io/antmedia/console/rest/CommonRestService.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1256,7 +1256,7 @@ public List<io.antmedia.plugin.api.PluginRecord> getPlugins() {
12561256

12571257
public Result deployPlugin(String pluginName, InputStream inputStream) {
12581258
if (!AdminApplication.isValidPluginName(pluginName)) {
1259-
return new Result(false, "Plugin name must match [a-zA-Z0-9_-]+");
1259+
return new Result(false, "Plugin name must match [a-zA-Z0-9_.-]+");
12601260
}
12611261
if (inputStream == null) {
12621262
return new Result(false, "No plugin ZIP uploaded");
@@ -1288,7 +1288,7 @@ public Result installPluginFromUrl(String pluginId, String downloadUrl) {
12881288

12891289
public Result undeployPlugin(String pluginName) {
12901290
if (!AdminApplication.isValidPluginName(pluginName)) {
1291-
return new Result(false, "Plugin name must match [a-zA-Z0-9_-]+");
1291+
return new Result(false, "Plugin name must match [a-zA-Z0-9_.-]+");
12921292
}
12931293
AdminApplication adminApp = getApplication();
12941294
if (adminApp == null) {

src/main/java/io/antmedia/plugin/api/PluginRecord.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ public class PluginRecord {
1111
private PluginState state;
1212
private String lastError;
1313
private String pluginId;
14+
private String jarPath;
1415

1516
public String getName() { return name; }
1617
public void setName(String name) { this.name = name; }
@@ -38,4 +39,7 @@ public class PluginRecord {
3839

3940
public String getPluginId() { return pluginId; }
4041
public void setPluginId(String pluginId) { this.pluginId = pluginId; }
42+
43+
public String getJarPath() { return jarPath; }
44+
public void setJarPath(String jarPath) { this.jarPath = jarPath; }
4145
}

src/main/java/org/red5/server/plugin/PluginDeployer.java

Lines changed: 212 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -170,36 +170,51 @@ public Result loadPluginFromZip(File zipFile, File pluginsDir) {
170170
* the jar and the metadata subdirectory.
171171
*/
172172
public Result unloadPluginFromZip(String pluginName, File pluginsDir) {
173-
PluginRecord record = pluginRecords.get(pluginName);
173+
PluginRecord record = findPluginRecord(pluginName);
174174
String pluginId = record != null ? record.getPluginId() : slugify(pluginName);
175175

176-
Result unloadResult = unloadPlugin(pluginId);
176+
// Try to unload beans — will fail silently if plugin was loaded by Spring
177+
// component scan (after restart) rather than by PluginDeployer
178+
unloadPlugin(pluginId);
177179

178180
if (record != null) {
179181
String pid = record.getPluginId();
180182
File canonicalDir = new File(pluginsDir, pid);
181183
File uninstallSh = new File(canonicalDir, "uninstall.sh");
182-
File flatJar = new File(pluginsDir, pid + ".jar");
183184

185+
// Run uninstall.sh if present — handles WEB-INF/lib cleanup for
186+
// restart-required plugins
184187
if (uninstallSh.exists()) {
188+
File jarFile = record.getJarPath() != null
189+
? new File(record.getJarPath())
190+
: new File(pluginsDir, pid + ".jar");
185191
int exitCode = runInstallScript(uninstallSh, canonicalDir, record.getName(),
186-
record.getVersion(), flatJar, pid);
192+
record.getVersion(), jarFile, pid);
187193
if (exitCode != 0) {
188194
log.warn("uninstall.sh for {} exited with code {}", pluginName, exitCode);
189195
}
190196
}
191197

198+
// Delete the jar wherever it is — plugins/ or WEB-INF/lib/
199+
if (record.getJarPath() != null) {
200+
File jarFile = new File(record.getJarPath());
201+
if (jarFile.exists() && !jarFile.delete()) {
202+
log.warn("Failed to delete plugin jar: {}", jarFile.getAbsolutePath());
203+
}
204+
}
205+
206+
// Also try the flat jar in plugins/ in case jarPath wasn't set
207+
File flatJar = new File(pluginsDir, pid + ".jar");
192208
if (flatJar.exists() && !flatJar.delete()) {
193209
log.warn("Failed to delete plugin jar: {}", flatJar.getAbsolutePath());
194210
}
211+
195212
deleteDirectory(canonicalDir);
196-
record.setState(PluginState.UNINSTALLED);
213+
record.setState(PluginState.INSTALLED_PENDING_RESTART);
214+
record.setLastError("Uninstalled — restart server to fully clean up");
197215
}
198216

199-
pluginRecords.remove(pluginName);
200-
return new Result(true, unloadResult.isSuccess()
201-
? "Plugin removed: " + pluginName
202-
: "Plugin removed (was not active): " + pluginName);
217+
return new Result(true, "Plugin removed. Restart server to fully clean up.");
203218
}
204219

205220
/**
@@ -323,11 +338,50 @@ protected boolean isCandidateComponent(
323338
}
324339

325340
/**
326-
* Destroys singleton beans for a plugin in each webapp context.
341+
* Destroys plugin beans, removes stream listeners, and reloads Jersey
342+
* without the plugin's REST classes. Works for both hot-loaded plugins
343+
* (tracked in springPluginBeanNames) and startup-loaded plugins (found
344+
* by scanning Spring context bean names).
327345
*/
328346
Result unloadPlugin(String pluginId) {
347+
// Find bean names — either from our tracking map (hot-loaded) or by
348+
// scanning the record's jar to find what @Component classes it has
329349
List<String> beanNames = springPluginBeanNames.get(pluginId);
330-
if (beanNames == null) {
350+
351+
// For startup-loaded plugins, scan the jar to find bean names
352+
PluginRecord record = findPluginRecord(pluginId);
353+
List<Class<?>> restClassesToRemove = new ArrayList<>();
354+
355+
if (beanNames == null && record != null && record.getJarPath() != null) {
356+
beanNames = new ArrayList<>();
357+
try {
358+
File jarFile = new File(record.getJarPath());
359+
if (jarFile.exists()) {
360+
try (JarFile jar = new JarFile(jarFile)) {
361+
java.util.Enumeration<java.util.jar.JarEntry> entries = jar.entries();
362+
while (entries.hasMoreElements()) {
363+
java.util.jar.JarEntry entry = entries.nextElement();
364+
String name = entry.getName();
365+
if (!name.endsWith(".class") || name.contains("$")) continue;
366+
String className = name.replace('/', '.').replace(".class", "");
367+
try {
368+
Class<?> cls = Class.forName(className, false, ClassLoader.getSystemClassLoader());
369+
if (cls.isAnnotationPresent(Component.class)) {
370+
beanNames.add(resolveBeanName(cls));
371+
if (cls.isAnnotationPresent(Path.class)) {
372+
restClassesToRemove.add(cls);
373+
}
374+
}
375+
} catch (ClassNotFoundException | NoClassDefFoundError ignored) {}
376+
}
377+
}
378+
}
379+
} catch (Exception e) {
380+
log.warn("Could not scan jar for bean names: {}", e.getMessage());
381+
}
382+
}
383+
384+
if (beanNames == null || beanNames.isEmpty()) {
331385
return new Result(false, "Plugin not found: " + pluginId);
332386
}
333387

@@ -341,14 +395,30 @@ Result unloadPlugin(String pluginId) {
341395

342396
try {
343397
AutowireCapableBeanFactory bf = springCtx.getAutowireCapableBeanFactory();
344-
if (bf instanceof SingletonBeanRegistry) {
345-
for (String beanName : beanNames) {
346-
try {
347-
((org.springframework.beans.factory.support.DefaultSingletonBeanRegistry) bf)
348-
.destroySingleton(beanName);
349-
} catch (Exception e) {
350-
log.warn("Failed to destroy bean {} in {}: {}", beanName, ctxPath, e.getMessage());
398+
if (!(bf instanceof org.springframework.beans.factory.support.DefaultSingletonBeanRegistry)) continue;
399+
400+
org.springframework.beans.factory.support.DefaultSingletonBeanRegistry registry =
401+
(org.springframework.beans.factory.support.DefaultSingletonBeanRegistry) bf;
402+
403+
for (String beanName : beanNames) {
404+
try {
405+
// Remove stream listener if the bean is one
406+
Object bean = ((org.springframework.beans.factory.BeanFactory) bf).getBean(beanName);
407+
if (bean instanceof io.antmedia.plugin.api.IStreamListener) {
408+
try {
409+
io.antmedia.muxer.IAntMediaStreamHandler app =
410+
(io.antmedia.muxer.IAntMediaStreamHandler) springCtx.getBean(
411+
io.antmedia.AntMediaApplicationAdapter.BEAN_NAME);
412+
app.removeStreamListener((io.antmedia.plugin.api.IStreamListener) bean);
413+
log.info("Removed stream listener '{}' from context '{}'", beanName, ctxPath);
414+
} catch (Exception e) {
415+
log.debug("Could not remove stream listener: {}", e.getMessage());
416+
}
351417
}
418+
registry.destroySingleton(beanName);
419+
log.info("Destroyed bean '{}' in context '{}'", beanName, ctxPath);
420+
} catch (Exception e) {
421+
log.warn("Failed to destroy bean {} in {}: {}", beanName, ctxPath, e.getMessage());
352422
}
353423
}
354424
} catch (Exception e) {
@@ -357,10 +427,53 @@ Result unloadPlugin(String pluginId) {
357427
}
358428

359429
springPluginBeanNames.remove(pluginId);
430+
431+
// Reload Jersey without the plugin's REST classes
432+
if (!restClassesToRemove.isEmpty()) {
433+
reloadJerseyWithout(restClassesToRemove);
434+
}
435+
360436
log.info("Unloaded plugin: {}", pluginId);
361437
return new Result(true);
362438
}
363439

440+
private void reloadJerseyWithout(List<Class<?>> classesToRemove) {
441+
for (IApplicationContext appCtx : getApplicationContexts().values()) {
442+
if (!(appCtx instanceof TomcatApplicationContext)) continue;
443+
TomcatApplicationContext tomcatCtx = (TomcatApplicationContext) appCtx;
444+
org.apache.catalina.Context catalinaCtx = tomcatCtx.getContext();
445+
String ctxPath = catalinaCtx.getPath();
446+
if (ctxPath == null || ctxPath.isEmpty()) continue;
447+
448+
try {
449+
if (!(catalinaCtx instanceof StandardContext)) continue;
450+
Container wrapper = ((StandardContext) catalinaCtx).findChild("jersey-serlvet");
451+
if (!(wrapper instanceof Wrapper)) continue;
452+
Servlet servlet = ((Wrapper) wrapper).getServlet();
453+
if (!(servlet instanceof ServletContainer)) continue;
454+
455+
ServletContainer jerseyContainer = (ServletContainer) servlet;
456+
ResourceConfig oldConfig = jerseyContainer.getConfiguration();
457+
458+
ResourceConfig newConfig = new ResourceConfig();
459+
// Copy all classes except the ones being removed
460+
for (Class<?> cls : oldConfig.getClasses()) {
461+
if (!classesToRemove.contains(cls)) {
462+
newConfig.register(cls);
463+
}
464+
}
465+
newConfig.registerInstances(oldConfig.getSingletons());
466+
newConfig.registerResources(oldConfig.getResources());
467+
newConfig.addProperties(oldConfig.getProperties());
468+
469+
jerseyContainer.reload(newConfig);
470+
log.info("Reloaded Jersey in '{}' without {} removed classes", ctxPath, classesToRemove.size());
471+
} catch (Exception e) {
472+
log.error("Failed to reload Jersey in '{}': {}", ctxPath, e.getMessage(), e);
473+
}
474+
}
475+
}
476+
364477
/**
365478
* Creates a new ResourceConfig from the existing Jersey config plus the new classes,
366479
* then reloads the Jersey servlet container in each streaming webapp. This gives
@@ -494,10 +607,91 @@ public PluginRecord getPluginRecord(String pluginName) {
494607
return pluginRecords.get(pluginName);
495608
}
496609

610+
/**
611+
* Finds a plugin record by exact name, pluginId, or slug match.
612+
* Needed because REST calls use the URL slug (e.g. "clip-creator")
613+
* while pluginRecords is keyed by the manifest name (e.g. "Clip Creator Plugin").
614+
*/
615+
PluginRecord findPluginRecord(String query) {
616+
if (query == null) return null;
617+
618+
// Exact match by manifest name
619+
PluginRecord record = pluginRecords.get(query);
620+
if (record != null) return record;
621+
622+
// Match by pluginId (what the UI sends for uninstall)
623+
for (PluginRecord r : pluginRecords.values()) {
624+
if (query.equals(r.getPluginId())) {
625+
return r;
626+
}
627+
}
628+
return null;
629+
}
630+
497631
public List<PluginRecord> getAllPluginRecords() {
498632
return new ArrayList<>(pluginRecords.values());
499633
}
500634

635+
/**
636+
* Scans plugins/ directory and each webapp's WEB-INF/lib/ for jars with
637+
* AMS-Plugin-Name manifest entries. Builds PluginRecord for each and
638+
* populates the in-memory pluginRecords map. Called once at startup.
639+
*/
640+
public void scanInstalledPlugins() {
641+
String amsHome = System.getProperty("red5.root", "/usr/local/antmedia");
642+
643+
// Scan plugins/*.jar
644+
File pluginsDir = new File(amsHome, "plugins");
645+
if (pluginsDir.exists()) {
646+
scanDirectoryForPluginJars(pluginsDir);
647+
}
648+
649+
// Scan each webapp's WEB-INF/lib/
650+
File webappsDir = new File(amsHome, "webapps");
651+
if (webappsDir.exists()) {
652+
File[] apps = webappsDir.listFiles();
653+
if (apps != null) {
654+
for (File app : apps) {
655+
if (!app.isDirectory() || "root".equals(app.getName())) continue;
656+
File libDir = new File(app, "WEB-INF/lib");
657+
if (libDir.exists()) {
658+
scanDirectoryForPluginJars(libDir);
659+
}
660+
}
661+
}
662+
}
663+
664+
log.info("Startup scan found {} installed plugins", pluginRecords.size());
665+
}
666+
667+
private void scanDirectoryForPluginJars(File dir) {
668+
File[] jars = dir.listFiles((d, name) -> name.endsWith(".jar"));
669+
if (jars == null) return;
670+
671+
for (File jar : jars) {
672+
try {
673+
Attributes attrs = readManifestAttributes(jar);
674+
if (attrs == null) continue;
675+
676+
String pluginName = attrs.getValue(MANIFEST_PLUGIN_NAME);
677+
if (pluginName == null || pluginName.isEmpty()) continue;
678+
679+
// Skip if we already have a record for this plugin
680+
if (pluginRecords.containsKey(pluginName)) continue;
681+
682+
PluginRecord record = buildPluginRecord(attrs);
683+
record.setState(PluginState.ACTIVE);
684+
record.setJarPath(jar.getAbsolutePath());
685+
pluginRecords.put(pluginName, record);
686+
687+
log.info("Found installed plugin: {} v{} in {}", pluginName,
688+
record.getVersion(), jar.getAbsolutePath());
689+
} catch (Exception e) {
690+
log.debug("Skipping jar {}: {}", jar.getName(), e.getMessage());
691+
}
692+
}
693+
}
694+
501695
protected boolean isSystemClassLoaderServerClassLoader() {
502696
return ClassLoader.getSystemClassLoader() instanceof ServerClassLoader;
503697
}

src/test/java/org/red5/server/plugin/PluginDeployerTest.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,12 @@ public void testUnloadPlugin_success() throws Exception {
183183
Result result = spy.unloadPlugin("toUnload");
184184
assertTrue(result.isSuccess());
185185
assertFalse(spy.getPluginNames().contains("toUnload"));
186+
187+
// Verify destroySingleton was called on the bean factory
188+
org.springframework.beans.factory.support.DefaultListableBeanFactory bf =
189+
(org.springframework.beans.factory.support.DefaultListableBeanFactory)
190+
ctx.getSpringContext().getAutowireCapableBeanFactory();
191+
verify(bf).destroySingleton("plugin.minimal-component");
186192
}
187193

188194

0 commit comments

Comments
 (0)