DependencyControl Updater — Design & Architecture¶
This document describes how DependencyControl installs and updates scripts and modules: the components involved, how an update is triggered, and the full decision flow of a single update task. It targets contributors working on the updater, not end users (see the README for user-facing behavior).
Scope¶
The "updater" is the subsystem that, given a version record, finds a trustworthy source that satisfies a required version, downloads it, deploys its files, and reloads the affected module. It also resolves a script's required modules (installing missing or outdated ones) and runs periodic background update checks.
Component map¶
| Module | Role |
|---|---|
Updater |
Per-host orchestrator. Owns the queue of update tasks, the cross-process lock, and the official trust lists. Exposes the require (dependency) and scheduleUpdate (background) entry points. |
UpdateTask |
The worker for one record. Owns the whole resolve → trust-gate → download → deploy → reload flow for a single package. |
UpdateFeed |
Wraps one loaded, schema-checked feed and answers queries: getScript (a package by namespace/channel), getProviders (modules that provides a name). A dumb consumer of a FileCache handed to it by FeedLoader. |
FeedLoader |
Shared feed-access layer: opens the one feed FileCache and hands out UpdateFeed instances via load(url, opts). Trust-free, so it sits below FeedTrust/FeedInventory. |
FeedTrust |
Owns the trust model (updater.feedTrust): the merged trusted/blocked sets, the object block-list, and the trust mutators. |
FeedInventory |
Gathers (offline) and crawls (bounded, trust-gated) the feed graph, tracking each feed's provenance. |
Host |
Classifies a hostname/IP as private/loopback/link-local for the SSRF guard, resolving via a mockable resolver. |
ModuleLoader |
Loads modules from disk (private then global copies), orchestrates loadModules for a record's requirements, and manages dummy refs for circular dependencies. |
Lock |
Cross-process advisory lock (backed by an OS file lock) that serializes updates and survives a crashed holder. |
SemanticVersioning |
Version encode/decode/compare and npm-style range matching (parseRange, satisfiesRange, rangesIntersect, getRangeMaxVersion). |
Record (DependencyControl) |
The version record: identity, version, config, requirements, provides aliases, and the persisted currentSource. |
ConfigView |
Per-package and global config persistence. |
flowchart LR
subgraph Triggers
Macro["Macro run hook<br/>(Record.registerMacro)"]
Reqs["Record.requireModules<br/>(at load time)"]
Toolbox["Toolbox<br/>(scheduled & manual)"]
end
subgraph Orchestration
Updater
end
subgraph Worker
UpdateTask
end
subgraph Support
UpdateFeed
ModuleLoader
Lock
SemVer["SemanticVersioning"]
Record
Config["ConfigView"]
end
Reqs --> ModuleLoader
Macro --> Updater
Toolbox --> Updater
ModuleLoader -->|require missing/outdated| Updater
Updater -->|addTask + run| UpdateTask
UpdateTask --> UpdateFeed
UpdateTask -->|load reqs, reload| ModuleLoader
UpdateTask --> Lock
UpdateTask --> SemVer
UpdateTask --> Record
UpdateTask --> Config
Updater --> Lock
Entry points¶
Every update originates from one of three places, each carrying a different UpdateReason that later gates whether the user may be prompted (see Reasons and prompt thresholds).
- Dependency resolution — when a record is constructed,
Record.requireModulescallsModuleLoader.loadModules, which loads each requirement (private copy, then global). A requirement that is missing or fails its version constraint is forwarded toUpdater.require, defaulting to reasonDependencyResolution. This path is blocking: it waits on the updater lock because the caller needs the module now. - Macro run hook —
Record.registerMacrowraps the macro callback so that running the macro first callsUpdater.scheduleUpdatefor the script, then releases the lock, then runs the macro. This is a non-blocking background check (reasonAutoUpdate). - Toolbox — the DependencyControl Toolbox runs centralized scheduled checks for every installed record at startup (reason
AutoUpdate) and performs explicit user-initiated installs/updates (reasonUserRequested).
Core concepts¶
Records, feeds, candidates¶
A record describes one package (a macro or a module). A feed is a JSON registry that lists packages and, per release, the files and provides aliases. During resolution, every package a feed can supply for the requirement becomes a candidate (CandidatePackageSource: {updateRecord, feedUrl, isDirect, trustBand, providesVersion?}). A candidate is direct (isDirect) when the feed offers the package by its own name, or indirect when it comes from a provider — a different module that declares it provides the required name (e.g. dkjson provides json).
Trust bands¶
Candidates are ranked by trust band first, version second, so a feed can't win merely by advertising a higher version. Lower band = preferred.
| Band | Name | Meaning |
|---|---|---|
| 1 | DeclaredDirect |
The package's own/declared feed (trusted), by name. Also the band a remembered provider is boosted to. |
| 2 | TrustedDirect |
Another trusted feed, by name. |
| 3 | TrustedProvider |
A trusted feed, via a provider. |
| 4 | UntrustedDirect |
An untrusted feed, by name. |
| 5 | UntrustedProvider |
An untrusted feed, via a provider. |
Trust sets come from getTrustedFeeds/getBlockedFeeds: trusted = DepCtrl's official feeds (getOfficialTrustedFeeds, cached) ∪ user extraFeeds ∪ trustedFeeds; blocked = official block list (getOfficialBlockedFeeds) ∪ user blockedFeeds. extraFeeds and trustedFeeds both grant trust, but only extraFeeds are discovery roots and tier-2 search candidates: trustedFeeds are trust-only — they raise a feed's trust when it's reached another way, but are never themselves searched (tier 2) or crawled. isUserTrusted reports membership in either user list, distinct from getOfficialTrustedFeeds. Each block entry is a {url, matchMode, reason?} object matched case-insensitively as a URL prefix or an exact URL; getBlockedFeeds normalizes them to BlockedFeedEntry. A blocked feed is dropped before banding and overrides everything, including userFeed.
Reasons, the context ladder, and prompt thresholds¶
The UpdateReason values double as the rungs of the update-context ladder, ordered by autonomy: user-requested < dependency-resolution < auto-update. Settings that gate behavior by context hold an UpdateContextCeiling — one of those rungs, or off (below all of them) — meaning "up to and including this context". The shared predicate is UpdateTask.isWithinContextCeiling(reason, ceiling), a rank comparison on the ladder.
Two behaviors are gated this way: whether an update context may run at all (updates.mode, default auto-update = everything; checked by Updater.__isEnabledFor in addTask/scheduleUpdate) and whether a task may prompt (shouldPrompt(threshold) tests the task's reason against the relevant threshold).
The two prompt kinds have separate thresholds: feedTrustPromptThreshold (default auto-update — decides update success, so it may prompt in every context) and packageChoicePromptThreshold (default user-requested — a minor preference, so only for user-initiated actions). off disables a prompt kind entirely.
The trust prompt (promptTrustFeed) returns a FeedTrustDecision: once proceeds for this install only, always records the feed via addTrustedFeed, never records it via addBlockedFeed (then fails -19); a cancelled prompt is nil (fails -16). The source chooser (promptSelectPackageSource) likewise returns the picked candidate plus the SourceChoiceStickiness to persist.
Source stickiness¶
After a source satisfies a package, the choice is remembered per-package in currentSource ({feedSource, feedUrl?, channel, provider?, stickiness}) so the chooser isn't re-shown needlessly. feedSource (SourceFeedKind: self-declared/user-feed/provider/other) records where the source came from; the URL is derived at resolution for every kind but other, so a remembered choice follows feed migration. stickiness (SourceChoiceStickiness) records how sticky the choice is — see the state diagram.
Status codes¶
The run/require result is a (code, detail) pair. Sign is the convention: >= 0 means success or a benign skip; < 0 is an error whose message comes from getUpdaterErrorMsg.
| Code | Meaning |
|---|---|
1 |
Installed/updated successfully (detail: version). |
0 |
Already up-to-date; nothing to do. |
2 |
Already updated earlier this session (or by another updater). |
3 |
Optional dependency skipped (any reason). |
-5 |
Another update is already running (detail: holder). |
-6 |
No suitable package found in any reachable feed. |
-7 |
No internet connection. |
-9 |
Entry point lives in Aegisub's ?data dir — managed elsewhere. |
-10 |
This task is already running. |
-15 |
Requirements couldn't be satisfied. |
-16 |
Only an untrusted feed could satisfy, and trust wasn't granted. |
-17 |
A pinned source is no longer available. |
-18 |
Aborted by the user at the source chooser. |
-19 |
The only feed that could satisfy was blocked by the user at the trust prompt. |
-30 / -33 / -35 / -50 |
File-stage errors (temp dir, path escape, bad SHA-1, move failed). |
-55…-58 |
Installed, but the module couldn't be located/loaded/recorded afterward. |
-140 / -245 |
Download-manager component errors (codes <= -100). |
(new() may also return -1 updater disabled / -2 invalid namespace; scheduleUpdate has its own pre-check returns: -1 disabled, -3 virtual/unmanaged, 0 interval not elapsed, -9 protected path.)
The update process¶
UpdateTask.run(waitLock) is the heart of the system. The top-level flow runs guard checks, resolves a source, gates trust, persists the choice, and dispatches the install.
flowchart TD
Start(["UpdateTask.run(waitLock)"]) --> Running{another run<br/>in progress?}
Running -- yes --> E10["return -10"]
Running -- no --> Protected{entry point<br/>in ?data dir?}
Protected -- yes --> E9["return -9"]
Protected -- no --> AlreadyUpd{already updated<br/>& meets target?}
AlreadyUpd -- yes --> R2["return 2"]
AlreadyUpd -- no --> Net{internet<br/>connected?}
Net -- no --> E7["return -7"]
Net -- yes --> Lock{acquire<br/>updater lock}
Lock -- "fail" --> E5["return -5 (holder)"]
Lock -- "ok (refresh tasks if we waited)" --> Resolve[["Resolve source<br/>(see Resolution flow)"]]
Resolve --> Outcome{resolution<br/>outcome}
Outcome -- "pinned gone / aborted" --> RErr["return -17 / -18<br/>(optional: 3)"]
Outcome -- "none, but installed up-to-date" --> R0["return 0"]
Outcome -- "none at all" --> NoPkg{optional?}
NoPkg -- yes --> R3a["return 3"]
NoPkg -- no --> E6["return -6"]
Outcome -- "candidate selected" --> Trust{band >=<br/>UntrustedDirect?}
Trust -- yes --> TrustPrompt["promptTrustFeed<br/>(if reason meets feedTrustPromptThreshold)"]
TrustPrompt --> TrustOK{trusted<br/>once/always?}
TrustOK -- "no (cancel: -16 / never: block feed, -19)" --> TrustFail["return -16 / -19<br/>(optional: 3)"]
TrustOK -- "yes (always -> trust feed)" --> Persist
Trust -- no --> Persist["persistSource(selected, stickiness)"]
Persist --> UpToDate{installed already<br/>satisfies version?}
UpToDate -- yes --> R0b["return 0"]
UpToDate -- no --> Direct{direct<br/>candidate?}
Direct -- yes --> Perform[["performUpdate<br/>(download, deploy, reload)"]]
Direct -- no --> InstallProv[["installProvider<br/>(require the provider)"]]
Perform --> Done(["return (code, detail)"])
InstallProv --> Done
Resolution flow¶
Resolution turns the feed landscape into a single chosen candidate. It first honors a sticky choice (pinned/retain), otherwise runs a lazy, trust-ordered cascade, then optionally lets the user pick.
flowchart TD
R(["Resolve"]) --> Read["read currentSource:<br/>remembered, stickiness, stickyProvider"]
Read --> Sticky{stickiness ==<br/>pinned or retain?}
Sticky -- yes --> LoadRem["gather remembered feed early<br/>(constrained to userFeed if set)<br/>matchRememberedCandidate"]
LoadRem --> Reuse{remembered candidate<br/>still eligible?}
Reuse -- yes --> UseReuse["selected = remembered<br/>(skip chooser)"]
Reuse -- "no, pinned" --> PinMiss["pinned gone<br/>-> -17 (optional: 3)"]
Reuse -- "no, retain" --> Cascade
Sticky -- no --> Cascade["Lazy feed cascade<br/>(see Cascade)"]
Cascade --> Have{candidate<br/>selected?}
Have -- no --> NoneOut["none -> run() handles<br/>up-to-date / no-package"]
Have -- yes --> SkipPrompt{reuse, or<br/>stickiness == auto?}
SkipPrompt -- yes --> Out(["selected"])
SkipPrompt -- no --> ChoiceLogic
ChoiceLogic{stickiness} -->|retain miss| RetMiss{interactive?<br/>(packageChoicePromptThreshold)}
RetMiss -- yes --> RePrompt["promptSelectPackageSource<br/>(flagged: choice unavailable)"]
RetMiss -- no --> Downgrade["stickiness = once<br/>keep algorithm pick"]
ChoiceLogic -->|once / unset| RealChoice{>1 source &<br/>interactive?}
RealChoice -- yes --> Prompt["promptSelectPackageSource<br/>(preselect remembered/winner)"]
RealChoice -- no --> Out
RePrompt --> AbortQ
Prompt --> AbortQ{aborted?}
AbortQ -- yes --> AbortOut["-> -18 (optional: 3)"]
AbortQ -- no --> Out
Downgrade --> Out
UseReuse --> Out
packageChoiceOfferAllSources (global, default false) widens the chooser: when false it only fires on an exact band+version tie (tied); when true it offers every eligible source (eligible, sorted best-first), including lower-ranked and untrusted ones (flagged). Picking an untrusted source still passes through promptTrustFeed in run.
Cascade and ranking¶
Feeds are fetched lazily, closest-first, and selectCandidate is re-run after each tier so a satisfying, sufficiently-trusted source short-circuits the rest.
flowchart TD
C(["Cascade"]) --> UF{userFeed set?}
UF -- yes --> UFOnly["gather {userFeed} (exclusive, trusted unless blocked)"]
UFOnly --> Sel0["selectCandidate"]
Sel0 --> CEnd(["candidate or nil"])
UF -- no --> T1["tier 1: declared feed"]
T1 --> Sel1["selectCandidate"]
Sel1 --> Q1{DeclaredDirect<br/>winner?}
Q1 -- yes --> CEnd
Q1 -- no --> T2["tier 2: extraFeeds,<br/>trusted addFeeds, official registry*"]
T2 --> Sel2["selectCandidate"]
Sel2 --> Q2{band <=<br/>TrustedProvider?}
Q2 -- yes --> CEnd
Q2 -- no --> T3["tier 3: untrusted addFeeds"]
T3 --> Sel3["selectCandidate"]
Sel3 --> CEnd
* The official registry crawl is skipped for optional dependencies (a nice-to-have shouldn't trigger a registry-wide fetch).
For each gathered candidate, getCandidateRankVersion decides eligibility and the ranking version:
- Direct — eligible if its release version meets the target; ranked by that version.
- Provider — eligible if its declared alias range (or
*when it declares none) can still supply a version meeting the target (rangesIntersect(range, ">=target")); ranked by the highest version that range covers (getRangeMaxVersion). The provider's own release version doesn't affect its rank as a provider. - Either way, the candidate must support the current platform and list files to install.
selectCandidate then sorts eligible candidates by band → ranking version (desc) → declared-feed tie-break → namespace, returning the winner, the tied top set, and the full eligible list. A remembered provider (currentSource.provider.namespace) found on a trusted feed is boosted to DeclaredDirect during gathering, so a version bump updates it in place instead of switching providers.
Dependency resolution and recursion¶
Installing a package can require installing its dependencies, so performUpdate re-enters the loader, which can re-enter the updater. Dummy refs registered before requirements are loaded let circular dependencies resolve without infinite recursion.
sequenceDiagram
participant ML as ModuleLoader.loadModules
participant U as Updater
participant T as UpdateTask
participant F as UpdateFeed
participant PU as performUpdate
ML->>U: require(record, version, …, DependencyResolution)
U->>T: addTask + run(waitLock=true)
T->>T: guard checks, acquire lock
T->>F: gather feeds, getScript / getProviders
F-->>T: candidates
T->>T: selectCandidate + trust gate + persistSource
alt direct
T->>PU: performUpdate(record)
else indirect
T->>U: installProvider → require(provider)
end
PU->>PU: createDummyRef (virtual/unmanaged)
PU->>ML: loadModules(requirements) %% recursion
ML-->>PU: requirements satisfied
PU->>PU: re-check (circular dep may have completed)
PU->>F: download files (verify SHA-1, skip unchanged)
PU->>PU: move files, reload module, refresh record, changelog
PU-->>T: (1, version)
T-->>U: (code, detail)
U-->>ML: ref / (nil, code, detail)
Concurrency and locking¶
A single global, cross-process lock (UPDATER_LOCK_NAMESPACE/run, Lock.Scope.Global) serializes all updates so two scripts or processes don't deploy at once. run acquires it after the cheap guard checks; performUpdate and the download loop renewLock to keep the lease alive during long downloads; the macro hook and requireModules release it when done. The lease is backed by an OS advisory file lock that the system frees if the holder crashes, so a crash can't permanently wedge updates. If a task had to wait for the lock, another updater may have finished the same work in the meantime, so on acquisition each queued module task runs refreshRecord to pick up that result instead of re-installing. Updater.isRunning() reports the current holder without taking the lock.
Source stickiness state¶
currentSource.stickiness starts unset and is set by the source chooser; thereafter it governs whether the chooser reappears.
stateDiagram-v2
[*] --> unset
unset --> once: chooser → "Just This Once"
unset --> retain: chooser → "Remember"
unset --> pinned: chooser → "Pin/Lock"
unset --> auto: chooser → "Auto-Pick"
once --> once: prompt again (choice remains)
once --> retain: re-chosen
once --> pinned: re-chosen
once --> auto: re-chosen
retain --> retain: remembered pick still eligible (reuse, no prompt)
retain --> once: pick gone, non-interactive (downgrade)
retain --> retain: pick gone, interactive → re-chosen
pinned --> pinned: reuse when eligible; abort/skip when gone (never re-prompts)
auto --> auto: never prompts; always takes the ranked pick
unset— no preference; resolve normally and prompt only if interactive.once— always prompt when a choice remains, preselecting the remembered pick.retain— reuse the remembered pick whenever it's still eligible; if gone, re-prompt interactively or downgrade tooncenon-interactively.pinned— always reuse the pick; if it's gone, abort a required dependency (-17) or skip an optional one, and never re-prompt.auto— never prompt; always take the ranked pick, refreshing the remembered pick for information.
Feed discovery, provenance, and trust management¶
Beyond resolving a single record, DependencyControl maintains a picture of which feeds exist, where each came from, and whether it's trusted — surfaced through the Toolbox's Manage Feeds macro and shared with the install browser. FeedTrust owns the trust model (updater.feedTrust), FeedInventory gathers and crawls the feed graph, and FeedLoader is the shared feed-access layer beneath both: loading a feed is trust-free, so it sits below FeedTrust/FeedInventory and avoids a cycle.
Provenance¶
A feed URL's provenance is a set — one feed can arrive through several sources at once, and its removability derives from which of those are user-controlled. The sources are DepCtrl's own feed (official), feeds in its knownFeeds (official-trusted), extraFeeds (user; discovery roots and trusted), trustedFeeds (user; trust-only), feeds declared by installed packages (config.c.macros/modules[ns].feed), feeds advertised in installed packages' requiredModules[].feed (FeedInventory's DependencyAdvertised provenance), and the block list. Removing a feed drops one user-controlled contribution; when it's still reachable via another source, that is surfaced rather than implying the feed is gone.
Trust management¶
FeedTrust's mutators (addTrustedFeed/untrust, block/unblock, addExtraFeed/removeExtraFeed) dedup on every add path and invalidate the cached merged sets. The five resolver bands collapse, for a user, to three states: trusted (in official ∪ extraFeeds ∪ trustedFeeds), blocked (matched by the merged block list; overrides trust), and untrusted (the absence of trust — there is no stored "untrusted" list). Two rules constrain management:
- An official or package-advertised feed can't be untrusted — untrust only touches the user lists, so the only way to stop trusting such a feed is to block it.
- The bootstrap feed (
DEPCTRL_FEED_URL) can never be blocked — that would collapse the trust bootstrap (every feed becomes untrusted), so any block matching it is refused.
Before an action would cut off a package's update source — blocking or removing a feed an installed package's currentSource resolves to (re-derived per SourceFeedKind, not string-matched) — the user is warned. Block entries use the {url, matchMode, reason?} object form (see Trust bands); getBlockingEntry(url) returns the matching entry so the reason can be shown, and because merged blocks are prefix-based only an exact block is individually removable.
Discovery and its security model¶
FeedInventory is config-first: both gather (offline, config feeds only) and crawl (transitive) always include the user's feeds, so a dead extraFeed or block entry stays manageable even when unreachable (crawl records a per-feed fetched flag for an "unreachable" marker). crawl walks the feed graph breadth-first from the config-derived roots, following each feed's knownFeeds.
Discovery may recurse into untrusted feeds' knownFeeds, on by default (feeds.fetchUntrustedFeeds = always; never/prompt for the cautious). The risk it manages is SSRF-shaped, not classic SSRF: an attacker controlling a feed's knownFeeds chooses URLs the client then fetches, but the requester is the user's own workstation (no privileged vantage), the fetch is blind (the body is parsed as a feed and discarded), and it's GET-only. Enumeration grants no install capability either — a feed reached this way is still untrusted, and installing from it still needs an explicit trust decision. The residual is fetch fan-out plus low-value blind GETs. Two mitigations, both at the layer that also protects the resolver, make the default acceptable:
- Private-host blocking (
Host).Hostclassifies a hostname or IP literal as private/loopback/link-local/reserved (isPrivate!), resolving through a mockable resolver (the FFIgetaddrinfoinhelpers/resolve-hostby default) and catching encoded literals likehttp://2130706433/.Downloader.addDownloadrefuses private hosts under an opt-in per-instanceblockPrivateHosts(the engine stays neutral for other consumers); the updater enables it on its own feed/package downloaders fromupdates.blockPrivateHosts. - Bounded crawl. Trusted feeds are explored freely (a finite, non-adversarial set); only untrusted expansion is budgeted, and each root gets its own subtree budget so one malicious subtree can't starve others, with a per-feed cap on untrusted children and a secondary depth cap (
feeds.crawlLimits: per-root / per-feed / depth). Beyond-bounds feeds are recorded as "advertised, not fetched", andcrawlreturns(feeds, stats)whosestats.truncationsreports each limit hit (which limit, its value, the offending feed and its route, a bounded sample of dropped URLs) so incomplete coverage is visible. The install browser discovers through this same crawl (crawlWithPrompt), retiring the former ungated, unboundedgetKnownFeedsrecursion.
Backend gaps (open). Private-host blocking is robust on libcurl — validate the resolved IP per connection and redirect hop (CURLOPT_PREREQFUNCTION/CURLINFO_PRIMARY_IP) — but best-effort on WinINet, which re-resolves DNS internally with no connect-IP pin, leaving a residual DNS-rebinding (TOCTOU) gap only a raw-socket client would close. Neither backend yet caps response size (maxFeedSize) or fetch time (feedFetchTimeout). These are the remaining discovery-hardening items (tracked in PLAN.md).
Notes for maintainers¶
- The whole resolution path runs only after DependencyControl itself is loaded; a raw
require "json"before DepCtrl loads won't resolve through this mechanism (accepted boundary). getOfficialTrustedFeeds/getOfficialBlockedFeeds(backed by the cachedloadOfficialFeedTrustloader) are best-effort: if DepCtrl's own feed can't be loaded, only the DepCtrl feed URL is trusted, so third-party feeds are distrusted until it's reachable. A persistent cross-session trust cache would harden this.