Skip to content

Plugin Management Mechanism

This document is based on manager.go, loader.go, package.go, registry.go, hot_reload.go, health.go, scheduler.go, hash.go, service.go, and plugin.go under internal/jsplugin/, as well as internal/handlers/jsplugin.go and jsplugin_registry.go.

Table of Contents

  1. Plugin Lifecycle State Machine
  2. Plugin Loading Flow
  3. PackageManager: Install/Update/Delete
  4. Remote Registry
  5. Hot Reload
  6. Health Check and Self-Healing
  7. Scheduler
  8. Hash Verification System

1. Plugin Lifecycle State Machine

Section sources: manager.go (Manager lifecycle methods), service.go (ServiceStatus), health.go (HealthChecker state transitions), plugin.go (JSPluginStatus)

A plugin has two layers of state: the DB-persisted state (JSPluginStatus) and the runtime service state (ServiceStatus).

1.1 DB-Persisted State (JSPluginStatus)

                           ┌─────────────────────────┐
     InstallFromUpload     │                         │
     ─────────────────────>│       inactive           │
                           │   (新安装,未启用)       │
                           └─────────┬───────────────┘
                                     │ EnablePlugin
                                     v
                           ┌─────────────────────────┐
               ┌──────────>│                         │<─────────┐
  RecoverPlugin│           │        active            │          │自愈成功
  EnablePlugin │           │   (已启用,可加载)       │          │(runRecoveryAttempts)
               │           └──┬──────────────┬───────┘          │
               │              │              │                  │
               │   DisablePlugin      连续 maxFailures          │
               │              │       次健康检查失败            │
               │              v              v                  │
               │   ┌─────────────┐  ┌─────────────────┐        │
               │   │  inactive   │  │     error        │────────┘
               │   │ (用户禁用)  │  │ (自动标记异常)   │
               │   └─────────────┘  └─────────────────┘
               │                           │
               └───────────────────────────┘

Three DB states:

  • active: The plugin is enabled. It is loaded when the Manager starts, and lazily loaded on demand when a request arrives.
  • inactive: The user has explicitly disabled it, or it was newly installed but not yet enabled. It will not be loaded. Incoming requests return 403.
  • error: Automatically marked after the HealthChecker fails several consecutive health checks. The self-healing mechanism attempts recovery with exponential backoff. Incoming requests return 503.

1.2 Runtime Service State (ServiceStatus)

  stopped ──Load()──> ready ──HandleMessage()──> running ──完成──> ready
                        │                                          │
                        │<─────────────────────────────────────────┘

                  热更新开始

                        v
                     frozen ──卸载+重载──> ready

                    加载失败
                        v
                     stopped

Four runtime states:

  • ready: Fully loaded and able to receive and process messages.
  • running: Currently processing an HTTP request or an inter-plugin message.
  • frozen: Hot reload in progress; new messages are paused.
  • stopped: Stopped (the JS environment has been destroyed); must be re-loaded via Load before it can be used again.

1.3 Lazy Loading (EnsureLoaded)

When a request reaches a plugin whose DB status is active but which is not loaded at runtime (having been evicted for being idle), EnsureLoaded triggers LoadPlugin on demand. It uses singleflight.Group to deduplicate concurrency by entryPath, avoiding multiple requests simultaneously triggering redundant hash verification and scheduler registration.


2. Plugin Loading Flow

Section sources: manager.go (Start, LoadPlugin, loadPlugins), service.go (Load, Init), loader.go (readEntryFromZip, extractStaticFromZip)

2.1 Full Load at Startup

Execution order of Manager.Start:

Start(ctx)
  ├─ 创建 HealthChecker + HotReloader
  ├─ packager.SyncPluginsFromDirectory()      // 从磁盘同步插件记录
  │     ├─ 扫描 pluginsDir 中的 .jsplugin.zip
  │     ├─ 新 ZIP → InstallFromUpload
  │     ├─ 已有但 hash 不同 → Update
  │     ├─ DB 有但 ZIP 缺失 → Uninstall(清理孤儿)
  │     └─ 返回完整插件列表(避免再查 DB 引起 SQLITE_BUSY)
  ├─ loadPlugins(synced)                       // 只加载 status=active 的
  ├─ RefreshPublicPaths()                      // 刷新无需 JWT 的路径前缀缓存
  ├─ logPluginStaticURLs()                     // 打印插件静态页面 URL
  ├─ healthChecker.Start()                     // 启动健康检查 goroutine
  └─ go hotReloader.WatchForChanges()          // 启动热更新文件监控

2.2 Single Plugin Load (LoadPlugin)

LoadPlugin(ctx, plugin)
  ├─ os.MkdirAll(pluginsDataDir)               // 确保数据目录存在
  ├─ NewJSService(plugin, scheduler, jsManager) // 创建服务实例
  ├─ NewBridgeHandler(service, ...)             // 创建桥接处理器
  ├─ service.Load(pluginsDir, dataDir)          // 核心加载
  │     ├─ 读取 ZIP 文件到内存
  │     ├─ Layer 1: ComputeCanonicalZipHash    // 规范化 ZIP hash
  │     ├─ 校验 zipHash(mtime 未变则判定篡改)
  │     ├─ readEntryFromZip (优先 .jsc > .js)  // 从 ZIP 读入口文件
  │     ├─ Layer 2: sha256Hex(entryCode)       // 入口文件 hash
  │     ├─ 校验 entryHash
  │     ├─ 尝试加载字节码缓存 (loadBytecodeCache)
  │     ├─ 创建 JS 环境 (CreateEnv / CreateEnvWithBytecode)
  │     ├─ 注册桥接回调 (SetBridgeCallback)
  │     ├─ extractStaticFromZip → static/      // 解压静态资源
  │     ├─ extractBinFromZip → bin/            // 解压可执行文件
  │     └─ 异步编译并缓存字节码 (saveBytecodeCache)
  ├─ repo.UpdateHashes(ctx, ...)                // 持久化 hash 到 DB
  ├─ scheduler.RegisterService(entryPath, svc)  // 在调度器注册
  ├─ service.Init()                             // 调用 JS onInit()
  │     ├─ ExecuteJS("onInit()", 10s 超时)
  │     └─ 启动 runTimerProcessor goroutine(500ms 周期处理 JS 定时器)
  └─ services.Store(entryPath, service)         // 存入内存 map

2.3 Entry File Loading Priority

readEntryFromZip builds a candidate list based on the manifest's main field: .jsc (precompiled bytecode) takes priority, falling back to .js (source). In bytecode mode, the bootstrap source is executed first and then the bytecode is loaded; in source mode, the two are concatenated and executed together, and after a successful load the bytecode is asynchronously compiled and cached for next time.


3. PackageManager: Install/Update/Delete

Section sources: package.go (all PackageManager methods)

PackageManager is responsible for the full lifecycle management of .jsplugin.zip packages, decoupled from Manager (Manager handles the runtime, PackageManager handles package management).

3.1 Installation Flow (InstallFromUpload)

InstallFromUpload(zipData)
  ├─ readPluginManifestFromZip           // 从 ZIP 解析 plugin.json
  ├─ ValidateManifest + ValidatePermissions
  ├─ 检查 entryPath 已存在 → 走 Update 路径(保留原 ID 与状态)
  ├─ readEntryFromZip + sha256Hex        // 计算 entry_hash
  ├─ ComputeCanonicalZipHash             // 计算规范化 zip_hash
  ├─ 静态校验:manifest 声明 hash == 实际内容 hash
  ├─ 保存 ZIP → extractStaticFromZip
  ├─ 构建 JSPlugin 对象(初始状态 = inactive)
  └─ repo.Create(失败时回滚删除 ZIP)

A newly installed plugin starts in the inactive state and must be manually enabled by the user. wasUpdate=true indicates the overwrite-update path was taken.

3.2 Update Flow (Update)

Update(pluginID, zipData)
  ├─ 获取已有插件 → 校验 entryPath 必须匹配
  ├─ 解析 + 校验新 manifest + hash
  ├─ 覆盖旧 ZIP 文件
  ├─ 清理旧 static/ → 重新解压
  └─ repo.Update 更新数据库记录(保留原 ID 和 status)

An update does not change the plugin's enabled state. If the plugin is running, the handler layer calls manager.ReloadPlugin after the update to trigger a hot reload.

3.3 Deletion Flow (Uninstall)

Uninstall(pluginID)
  ├─ repo.GetByID           // 获取插件信息
  ├─ os.Remove(zipFile)     // 删除 ZIP 文件
  ├─ os.RemoveAll(staticDir)// 删除 static 目录
  └─ repo.Delete            // 删除数据库记录

Before calling Uninstall, the handler layer first unloads the running service via manager.UnloadPlugin, then refreshes the publicPaths cache.

3.4 Directory Sync (SyncPluginsFromDirectory)

Called at startup to keep disk and database consistent:

ScenarioBehavior
Newly discovered ZIP (no DB record)Automatic InstallFromUpload
Existing record but zipHash mismatchPerform Update (recompute the canonical hash)
zipHash matches but manifest metadata changedsyncManifestMetadata compensating update (icon/name/description/homepage/updateURL)
DB record exists but ZIP file is missingDelete the orphan record (Uninstall)

The zipHash algorithm excludes plugin.json itself, so modifying only manifest metadata (such as adding an icon field) does not change the zipHash; the syncManifestMetadata method is needed to detect and sync these separately.

3.5 Remote Update Check and Download

CheckUpdate fetches the remote plugin.json via the plugin's updateURL and compares version numbers to determine whether an update is available. After confirming an update, DownloadUpdate downloads the new ZIP and calls Update to install it. Both support GitHub acceleration proxy prefixes.

Batch update (handleBatchUpdate) iterates over all plugins, checking and updating each one; a single failure does not affect the others. It supports force=true to skip the version check and force a reinstall.


4. Remote Registry

Section sources: registry.go (RegistryService), handlers/jsplugin_registry.go (registry API + subscription source settings)

4.1 Registry Structure

The registry uses a JSON format and supports nested includes for multi-level composition:

json
{
  "name": "Songloft 官方插件",
  "includes": ["https://example.com/community-registry.json"],
  "plugins": [
    "https://example.com/plugin-a/plugin.json",
    "https://example.com/plugin-b/plugin.json"
  ]
}

Each URL in the plugins array points to a standalone plugin.json containing the plugin metadata and download_url.

4.2 Recursive Fetch and Merge (FetchAndMerge)

FetchAndMerge(registryURL)
  ├─ fetchRecursive()             // 递归拉取(最大深度 20)
  │     ├─ 去重(visited map,按 canonical URL)
  │     ├─ 收集所有 plugin.json URL
  │     └─ 递归处理 includes
  ├─ resolveAll()                 // 并发解析 plugin.json(并发度 8)
  │     └─ resolvePluginJSON()
  │           ├─ 解析 manifest → RegistryEntry
  │           ├─ 拼接 icon URL(相对路径 → 绝对路径)
  │           └─ 兼容旧版:download_url 为空时链式拉取 updateUrl
  └─ 按 entryPath 去重(高版本优先)

Security limits:

  • Maximum recursion depth: 20 levels
  • Maximum plugin count: 500 (truncated with a warning if exceeded)
  • Response body cap: 2 MB
  • Per-fetch timeout: 15 seconds
  • Concurrent plugin.json resolution: 8

4.3 Version Comparison

compareVersion supports both semver (1.2.3) and date formats (2026.6.2), comparing dot-separated numeric segments one by one. Missing segments are zero-padded.

4.4 Subscription Source Management

Users can configure multiple registry subscription sources, managed through the /api/v1/settings/plugin-registries endpoint. Each source includes a URL, a name, and an enabled flag. The official Songloft plugin registry is built in by default.

Installing a plugin from the registry goes through the /api/v1/jsplugins/registry/install endpoint: after downloading the ZIP it follows the InstallFromUpload flow (automatically taking the update path if the entryPath already exists). The download limit is 50 MB, and GitHub acceleration proxying is supported.


5. Hot Reload

Section sources: hot_reload.go (HotReloader), manager.go (ReloadPlugin)

5.1 File Monitoring (WatchForChanges)

HotReloader uses polling (every 30 seconds) to monitor changes to the mtime of plugin ZIP files:

WatchForChanges(ctx)
  └─ 每 30s ticker:
       checkForChanges()
         ├─ 遍历所有运行中的服务
         ├─ os.Stat(zipPath) 获取当前 mtime
         └─ mtime 与 plugin.FileModTime 不一致
              └─ 触发 ReloadPlugin(pluginID)

5.2 Hot Reload Flow (ReloadPlugin)

ReloadPlugin(ctx, pluginID)
  ├─ 获取插件信息
  ├─ 获取旧服务(如果存在)
  ├─ 冻结旧服务(status = frozen,停止接收新消息)
  ├─ 卸载旧插件(UnloadPlugin)
  │     ├─ scheduler.UnregisterService(等待队列消息处理完或 10s 超时)
  │     ├─ service.Stop()
  │     │     ├─ 停止定时器 goroutine
  │     │     ├─ 调用 onDeinit() 回调
  │     │     ├─ 清理桥接资源(终止后台进程)
  │     │     └─ 销毁 JS 环境(含子 env)
  │     └─ 从 services map 移除
  ├─ 清除字节码缓存(os.RemoveAll cacheDir)
  ├─ 重新加载插件(LoadPlugin)
  │     └─ 完整的 Load → Init 流程
  └─ RefreshPublicPaths()

5.3 Failure Rollback

If loading the new version fails, HotReloader attempts to LoadPlugin again with the original plugin information as a rollback. If the rollback also fails, the plugin is marked in the error state and handed off to the HealthChecker's self-healing mechanism.

5.4 Manual Hot Reload

In addition to being triggered automatically by file monitoring, API uploads (handleUpload/handleUpdate), registry installs (handleRegistryInstall), batch updates (handleBatchUpdate), and remote download updates (handleDownloadUpdate) all call manager.ReloadPlugin to trigger a hot reload after updating the ZIP, provided the plugin is in the active state.


6. Health Check and Self-Healing

Section sources: health.go (full HealthChecker implementation)

6.1 Check Mechanism

HealthChecker runs a round of health checks every 60 seconds. The execution order per round:

runChecks(ctx)
  ├─ runRecoveryAttempts()     // 先扫描 error 状态插件,尝试自愈
  ├─ runWakeupChecks()         // 唤醒因长定时器而休眠的插件
  └─ 遍历所有运行中的服务:
       ├─ checkIdle()          // 检查空闲状态
       │     ├─ lastActive 超过 idleTimeout(10min) → 卸载释放资源
       │     ├─ 有活跃 WebSocket → 不休眠
       │     ├─ 有运行中子进程 → 不休眠
       │     └─ 有近期定时器(3x idleTimeout 内) → 不休眠
       └─ checkHealth()        // 健康探针(直连 VM,绕开 scheduler)
             ├─ Healthy → 重置失败计数
             ├─ Busy → 计数,连续 5 次升级为 Unhealthy
             └─ Unhealthy → handleUnhealthy

6.2 Health Probe Design

The health check does not go through the scheduler queue (to avoid false positives caused by being blocked behind a long fetch); instead it TryLocks the JS VM's mutex directly via jsruntime.HealthProbe: if it acquires the lock it runs eval("1+1") to verify the VM is alive (Healthy); if it cannot acquire the lock the VM is busy (Busy, not counted as a failure); if the env has been destroyed it is judged Unhealthy.

6.3 Fault Escalation and Automatic Disabling

After maxFailures (default 3) consecutive Unhealthy results → the DB is marked error and the plugin is unloaded:

handleUnhealthy()
  ├─ failures[entryPath]++
  └─ failCount >= maxFailures:
       ├─ repo.UpdateStatus → error
       ├─ UnloadPlugin                  // 卸载但不删除文件
       └─ 初始化 recoveryAttempt       // 启动指数退避自愈

Busy backstop: maxBusyRounds (default 5, i.e. 5 minutes) consecutive Busy results are also escalated to Unhealthy handling, serving as a safety net for a genuine deadlock.

6.4 Exponential Backoff Self-Healing

Plugins in the error state are automatically recovered by runRecoveryAttempts following a backoff sequence:

TierDelay
Attempt 11 minute
Attempt 25 minutes
Attempt 315 minutes
Attempt 430 minutes
Attempt 5 and beyond60 minutes (ongoing)

Recovery flow: first push the DB status back to active → call LoadPlugin → on success, clear the recovery progress; on failure, roll the DB back to error and use the next backoff tier. Once the backoff table length is exceeded, logging is downgraded to Debug level to avoid log spam.

When the user actively calls EnablePlugin or manually invokes RecoverPlugin, the backoff counter is cleared (ClearRecovery), so the next error backoff restarts from 1 minute.

6.5 Idle Eviction and Timer Wakeup

A plugin with no activity for more than idleTimeout (default 10 minutes) is unloaded to free resources, but its DB status remains active (EnsureLoaded will reload it on the next request).

Timer-aware decisions:

  • No timer → sleep directly.
  • Next timer within 3 × idleTimeout (30 minutes) → stay active, do not sleep.
  • Next timer beyond 30 minutes → sleep, and record the wakeup time. runWakeupChecks reloads the plugin wakeupLead (default 2 minutes) before the timer deadline, compensating for the VM rebuild and onInit overhead.

7. Scheduler

Section sources: scheduler.go (full ServiceScheduler implementation)

7.1 Design Philosophy

ServiceScheduler borrows Skynet's Actor message-dispatch model: each plugin is an independent Actor (serviceEntry) with its own message queue and worker goroutine, guaranteeing that messages within the same plugin are processed serially.

7.2 Message Types

go
MsgHTTPRequest   // HTTP 路由请求
MsgTimerFire     // 定时器触发
MsgInterPlugin   // 插件间通信
MsgLifecycle     // 生命周期事件(init/deinit)
MsgHostCall      // 宿主函数调用结果
MsgHealthCheck   // 健康检查

7.3 Message Delivery Modes

  • Async send (Send): Delivers the message to the target queue and returns immediately without waiting for a response.
  • Synchronous call (Call): Delivers the message and waits for a response via RespChan, with a default timeout of 30 seconds and support for context cancellation. Internally it creates a callCtx with a timeout; after dispatch it selects between waiting on respChan or callCtx.Done.

7.4 Worker Processing

Each service has one worker goroutine that consumes its message queue serially. Before processing a message it checks whether msg.Ctx has been cancelled; a cancelled request (e.g. the user quickly skipping tracks) is skipped directly, avoiding subsequent requests getting stuck behind a serialized ExecuteJS.

7.5 Backpressure and Resource Protection

  • Message queue capacity: 256 by default
  • When the queue is full, dispatch returns ErrQueueFull (immediate rejection, non-blocking)
  • When unregistering a service: first mark it closed (reject new messages) → close the channel → wait for the worker to finish the remaining messages or forcibly cancel after a timeout

When closing the scheduler (Close): close all service entrances → wait for all workers to exit (global timeout 10 seconds) → forcibly cancel timed-out workers.


8. Hash Verification System

Section sources: hash.go (hash algorithms and verification functions), loader.go (bytecode cache hash), service.go (two-layer verification in Load)

8.1 Two-Layer Hash Verification

Two layers of integrity verification are performed when a plugin loads, ensuring the entire chain from build to runtime is tamper-proof:

Layer 1 - Canonical ZIP Hash (zipHash): The algorithm matches @songloft/plugin-builder -- enumerate all regular files in the ZIP other than plugin.json, sort them by filename in ascending Unicode order, write <path>\n<sha256(content)>\n for each file, then compute SHA256 over the final concatenated string. Excluding plugin.json avoids the circular dependency of writing the hash back into the manifest; the canonical algorithm is insensitive to file order and metadata within the ZIP, so repackaging on any machine yields identical results.

Layer 2 - Entry File Hash (entryHash): Computes SHA256 over the content of the entry file (main.js/main.jsc) inside the ZIP. Even if the overall ZIP hash passes, this is verified separately, providing defense in depth.

8.2 Verification Timing and Tamper Detection

TimingVerified contentTamper handling
InstallFromUploadmanifest-declared hash vs. actual computed valueReject the install, return ErrManifestHashMismatch
UpdateSame as aboveReject the update
Load (runtime)DB-stored hash vs. actual filemtime unchanged → judged tampering, reject load; mtime changed → legitimate update, adopt the new hash
SyncPluginsFromDirectoryCanonical zipHash comparisonHash mismatch → perform the Update flow

8.3 Manifest Hash Field Validation

ValidateHashField strictly validates the hash field:

  • Must not be empty (ErrManifestHashMissing)
  • Must be 64-character lowercase hex (ErrManifestHashInvalid)
  • Must match the actual content (ErrManifestHashMismatch)

8.4 Bytecode Cache Hash

The bytecode cache uses a two-line hash file (.jsc.sha256):

  • Line 1: the source entryHash (source changes → cache invalidated)
  • Line 2: the SHA256 of the .jsc file itself (detects tampering of the cache file)

Both lines must pass verification on load for the cache to be used; if either mismatches, the cache file is deleted and recompilation from source is forced.


Diagram sources: The state machine diagrams are drawn from the state-transition logic of EnablePlugin/DisablePlugin/LoadPlugin in manager.go and handleUnhealthy/runRecoveryAttempts in health.go. The flowcharts are extracted from the actual execution steps of each method.