From 1d2c8fdda250ebfe5025328d04b42d536eb95147 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 18 Jun 2026 13:34:58 -0400 Subject: [PATCH 1/5] fix(plugin-manager): make Plugins page update check parallel and non-blocking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Installed Plugins page renders the list quickly from local .plg data, but the per-plugin update check ran as a single serial request: ShowPlugins.php looped every plugin and, for each, spawned the plugin script to wget that plugin's pluginURL. The request returned all-or-nothing, so one slow or unreachable plugin URL left every row stuck spinning 'checking...' — the page appears hung. Take the network check off the critical path and parallelize it: - ShowPlugins.php: add a single-plugin mode (?one=) that restricts the glob to one plugin and returns just its vid-/sid- line. - Plugins.page: replace the batched loadlist() post-init sweep with a bounded-concurrency (6) fan-out, one ?one= request per installed plugin, each with a 20s AJAX timeout. Rows resolve independently; the 'Update All' count is aggregated client-side. - Fail-safe: on timeout/error a row resolves to 'cannot check' instead of spinning forever. The page is never gated on update checks. A slow/unreachable plugin now only affects its own row, bounded by one timeout, never the whole page. No new caching introduced. Ref: OS-457 Co-Authored-By: Claude Opus 4.8 --- .../dynamix.plugin.manager/Plugins.page | 45 ++++++++++++++++++- .../include/ShowPlugins.php | 8 ++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/emhttp/plugins/dynamix.plugin.manager/Plugins.page b/emhttp/plugins/dynamix.plugin.manager/Plugins.page index e4a30d1776..d03c3d16e4 100755 --- a/emhttp/plugins/dynamix.plugin.manager/Plugins.page +++ b/emhttp/plugins/dynamix.plugin.manager/Plugins.page @@ -108,10 +108,53 @@ function initlist() { $('#plugin_table').tablesorter({sortList:[[4,0],[1,0]],sortAppend:[[1,0]],headers:{0:{sorter:false},5:{sorter:false}},textAttribute:'data'}); $('.desc_readmore').readmore({maxHeight:80,moreLink:"",lessLink:""}); $('div.spinner.fixed').hide('slow'); - loadlist(); + checkPlugins(); } }); } +// Update check, parallel + non-blocking. Instead of one serial sweep that +// blocks on the slowest/unreachable plugin, fire one bounded-concurrency +// request per installed plugin. Each row resolves independently; a slow or +// failed check only affects its own row (never the page), and times out into a +// definite "cannot check" state instead of spinning forever. +var pluginUpdates = 0; +function checkPlugin(plg) { + var name = plg.replace(/\.plg$/,''); + return $.ajax({url:'/plugins/dynamix.plugin.manager/include/ShowPlugins.php',data:{one:name},timeout:20000}) + .done(function(data){ + data = (data||'').split('\0'); + updateInfo(data[0]); + pluginUpdates += (parseInt(data[1],10) || 0); + }) + .fail(function(){ + var id = name.replace(/\./g,'-'); + $('#sid-'+id).attr('data','4').html(" "); + }); +} +function checkPlugins() { + var queue = []; + $('input.remove').each(function(){var d=$(this).attr('data'); if (d) queue.push(d);}); + pluginUpdates = 0; + $('#updateall').hide(); + var total = queue.length, done = 0, idx = 0, active = 0, MAX = 6; + if (!total) { $('#checkall').find('input').prop('disabled',false); return; } + function pump() { + while (active < MAX && idx < total) { + active++; + checkPlugin(queue[idx++]).always(function(){ + active--; done++; + $('#plugin_table').trigger('update'); + if (done == total) { + if (pluginUpdates > 1) $('#updateall').show(); else $('#updateall').hide(); + $('#checkall').find('input').prop('disabled',false); + } else { + pump(); + } + }); + } + } + pump(); +} function loadlist(id,check) { if (id) timers.plugins = setTimeout(function(){$('div.spinner.fixed').show('slow');},500); $.get('/plugins/dynamix.plugin.manager/include/ShowPlugins.php',{audit:id,check:check||},function(data) { diff --git a/emhttp/plugins/dynamix.plugin.manager/include/ShowPlugins.php b/emhttp/plugins/dynamix.plugin.manager/include/ShowPlugins.php index c0830e5467..1430e124cc 100644 --- a/emhttp/plugins/dynamix.plugin.manager/include/ShowPlugins.php +++ b/emhttp/plugins/dynamix.plugin.manager/include/ShowPlugins.php @@ -25,6 +25,7 @@ $check = unscript(_var($_GET,'check')); $cmd = unscript(_var($_GET,'cmd')); $init = unscript(_var($_GET,'init')); +$one = unscript(_var($_GET,'one')); // single-plugin update check (fired per row, in parallel) $empty = true; $install = false; $updates = 0; @@ -56,6 +57,13 @@ } } +// Restrict to a single installed plugin so its (network) update check can run +// in parallel with the others, instead of the legacy serial sweep that blocks +// the page on the slowest/unreachable plugin. Goes through the normal update +// check path below (no $audit, $check stays falsy) and returns just this +// plugin's vid-/sid- line. +if ($one) $plugins = "/var/log/plugins/".basename($one,'.plg').".plg"; + delete_file($alerts); foreach (glob($plugins,GLOB_NOSORT) as $plugin_link) { //only consider symlinks From b3a1b4bb880c2ea0f99f99042fcb535c6c0b4edb Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 18 Jun 2026 15:24:45 -0400 Subject: [PATCH 2/5] fix(plugin-manager): dedupe per-plugin update checks class 'remove' is on both the checkbox and the button of each plugin row, so the fan-out queued every plugin twice. Dedupe by data attribute so each plugin is checked once. Co-Authored-By: Claude Opus 4.8 --- emhttp/plugins/dynamix.plugin.manager/Plugins.page | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/emhttp/plugins/dynamix.plugin.manager/Plugins.page b/emhttp/plugins/dynamix.plugin.manager/Plugins.page index d03c3d16e4..b31509d0dc 100755 --- a/emhttp/plugins/dynamix.plugin.manager/Plugins.page +++ b/emhttp/plugins/dynamix.plugin.manager/Plugins.page @@ -132,8 +132,10 @@ function checkPlugin(plg) { }); } function checkPlugins() { - var queue = []; - $('input.remove').each(function(){var d=$(this).attr('data'); if (d) queue.push(d);}); + var queue = [], seen = {}; + // class 'remove' is on both the checkbox and the button for each plugin, so + // dedupe by data attribute to avoid checking every plugin twice + $('input.remove').each(function(){var d=$(this).attr('data'); if (d && !seen[d]) {seen[d]=1; queue.push(d);}}); pluginUpdates = 0; $('#updateall').hide(); var total = queue.length, done = 0, idx = 0, active = 0, MAX = 6; From 367758db464df5a43e8ae143900ab3fab6bd8af6 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Mon, 22 Jun 2026 16:39:25 -0400 Subject: [PATCH 3/5] fix(plugin-manager): keep plugin order stable during async update checks The Plugins page tablesorter sorts on the Status column first (sortList [[4,0],[1,0]]). Each per-plugin check rewrites that cell's data rank, and the post-check trigger('update') re-applied the sort, so rows jumped around as results landed one by one. Use trigger('updateCache') instead: it refreshes the parsed cache (so a later header-click sort still uses current status) without re-sorting, so rows fill in place and the list no longer reorders while checking. Co-Authored-By: Claude Opus 4.8 --- emhttp/plugins/dynamix.plugin.manager/Plugins.page | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/emhttp/plugins/dynamix.plugin.manager/Plugins.page b/emhttp/plugins/dynamix.plugin.manager/Plugins.page index b31509d0dc..47fdc12d96 100755 --- a/emhttp/plugins/dynamix.plugin.manager/Plugins.page +++ b/emhttp/plugins/dynamix.plugin.manager/Plugins.page @@ -145,7 +145,12 @@ function checkPlugins() { active++; checkPlugin(queue[idx++]).always(function(){ active--; done++; - $('#plugin_table').trigger('update'); + // Refresh the sort cache in place (do NOT 'update', which re-applies + // sortList and re-sorts by Status). Each check changes a row's Status + // rank, so 'update' would make rows jump around as results land. Rows + // stay in their initial order; a later header click still sorts on the + // current data because the cache is kept fresh. + $('#plugin_table').trigger('updateCache'); if (done == total) { if (pluginUpdates > 1) $('#updateall').show(); else $('#updateall').hide(); $('#checkall').find('input').prop('disabled',false); From ffb286fb54f7d2cb9f5cd174f0a41a83e19b3adc Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 9 Jul 2026 10:49:07 -0400 Subject: [PATCH 4/5] fix(plugin-manager): isolate plugin validation downloads --- emhttp/plugins/dynamix.plugin.manager/scripts/plugin | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/emhttp/plugins/dynamix.plugin.manager/scripts/plugin b/emhttp/plugins/dynamix.plugin.manager/scripts/plugin index add045ddde..a9a726be5b 100755 --- a/emhttp/plugins/dynamix.plugin.manager/scripts/plugin +++ b/emhttp/plugins/dynamix.plugin.manager/scripts/plugin @@ -263,6 +263,7 @@ function download($url, $name, &$error, $write=true) { $error = "$plg download failure: ".error_desc($perror); return false; } elseif (filesize($name) == 0) { + $error = "$plg download failure: zero-length file"; if ($write) write("plugin: download failure: zero-length file\r","\n"); return false; } else { @@ -330,7 +331,11 @@ function plugin($method, $plugin_file, &$error) { // validate plugin download without installation if ($method == 'validate') { - $name = '/tmp/validate-plugin.tmp'; + $name = tempnam('/tmp', 'validate-plugin-'); + if ($name === false) { + $error = "unable to create validation temporary file"; + return false; + } foreach ($xml->FILE as $file) if ($file->URL) { if (!$file->SHA256 and !$file->MD5) continue; if ( (download($file->URL, $name, $error, false) === false) && (download(filter_url($file->URL), $name, $error, false) === false) ) { From 5851f141d6eedf4847fbb613f2819c214fdba167 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 9 Jul 2026 11:07:32 -0400 Subject: [PATCH 5/5] fix(plugin-manager): route manual checks through row updates --- .../plugins/dynamix.plugin.manager/Plugins.page | 17 ++++++++++++++--- .../dynamix.plugin.manager/scripts/plugin | 4 +++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/emhttp/plugins/dynamix.plugin.manager/Plugins.page b/emhttp/plugins/dynamix.plugin.manager/Plugins.page index 47fdc12d96..d5ade9a962 100755 --- a/emhttp/plugins/dynamix.plugin.manager/Plugins.page +++ b/emhttp/plugins/dynamix.plugin.manager/Plugins.page @@ -131,13 +131,24 @@ function checkPlugin(plg) { $('#sid-'+id).attr('data','4').html(" "); }); } -function checkPlugins() { +function checkPlugins(reset) { var queue = [], seen = {}; // class 'remove' is on both the checkbox and the button for each plugin, so // dedupe by data attribute to avoid checking every plugin twice - $('input.remove').each(function(){var d=$(this).attr('data'); if (d && !seen[d]) {seen[d]=1; queue.push(d);}}); + $('input.remove').each(function(){ + var d=$(this).attr('data'); + if (d && !seen[d]) { + seen[d]=1; + queue.push(d); + if (reset) { + var id = d.replace(/\.plg$/,'').replace(/\./g,'-'); + $('#sid-'+id).attr('data','0').html(" ..."); + } + } + }); pluginUpdates = 0; $('#updateall').hide(); + $('#checkall').find('input').prop('disabled',true); var total = queue.length, done = 0, idx = 0, active = 0, MAX = 6; if (!total) { $('#checkall').find('input').prop('disabled',false); return; } function pump() { @@ -195,7 +206,7 @@ function loadlist(id,check) { } $(function() { initlist(); - $('.tabs-container').append(""); + $('.tabs-container').append(""); $('.tabs-container').append(""); $('.tabs-container').append(""); }); diff --git a/emhttp/plugins/dynamix.plugin.manager/scripts/plugin b/emhttp/plugins/dynamix.plugin.manager/scripts/plugin index a9a726be5b..56d767265a 100755 --- a/emhttp/plugins/dynamix.plugin.manager/scripts/plugin +++ b/emhttp/plugins/dynamix.plugin.manager/scripts/plugin @@ -262,7 +262,9 @@ function download($url, $name, &$error, $write=true) { if (($perror = pclose($file)) != 0) { $error = "$plg download failure: ".error_desc($perror); return false; - } elseif (filesize($name) == 0) { + } + clearstatcache(true, $name); + if (filesize($name) == 0) { $error = "$plg download failure: zero-length file"; if ($write) write("plugin: download failure: zero-length file\r","\n"); return false;