dsh-usage-badge 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -15,11 +15,14 @@
15
15
  * @module dsh-usage-badge
16
16
  */
17
17
 
18
- import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
18
+ import { createHash, randomBytes } from 'node:crypto'
19
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
20
+ import { hostname } from 'node:os'
19
21
  import { join } from 'node:path'
20
22
 
21
23
  import { createCacheStore } from './cache-store.js'
22
24
  import { createAggregator, mergeRecords, resolveDshHome } from './fold.js'
25
+ import { SyncEngine, acquireLock, readSyncDocument, releaseLock, safeDeviceId } from './sync.js'
23
26
  import { describeCalendar, loadBundledTable, resolveHolidayRules } from './holidays.js'
24
27
  import { OFFICIAL_SOURCES, applyOfficialPricing, fetchOfficialPricing } from './official-pricing.js'
25
28
  import {
@@ -53,7 +56,7 @@ const ROUTE_PREFIX = '/usage-badge'
53
56
  * (an unknown patch key is accepted with a 200 and ignored). The panel compares this number
54
57
  * and says so instead. `test/client.render.mjs` asserts the two halves agree.
55
58
  */
56
- export const HOST_API_VERSION = 6
59
+ export const HOST_API_VERSION = 7
57
60
 
58
61
  /**
59
62
  * Directory under `$DSH_HOME/storages` holding every file this plugin owns, so its
@@ -75,6 +78,48 @@ const DAYS_KEPT = 400
75
78
  /** A snapshot younger than this is reused instead of rebuilt on every poll. */
76
79
  const SNAPSHOT_TTL_MS = 2000
77
80
 
81
+ /**
82
+ * How long after an edit the price table is published.
83
+ *
84
+ * Long enough that editing three rows in a row is one publish rather than three, short enough that the
85
+ * person is still on the page when it happens. Only ever reached when sync is on and configured.
86
+ */
87
+ export const PRICING_PUSH_DELAY_MS = 3000
88
+
89
+ /** How long after startup the first automatic sync runs, when sync is enabled. */
90
+ export const STARTUP_SYNC_DELAY_MS = 8000
91
+
92
+ /**
93
+ * How long the panel waits for a status read before it is told the answer is not ready.
94
+ *
95
+ * The status read may ask the server what devices it holds. That happens in the background, but this
96
+ * bound is what keeps a slow or black-holed server from making the whole dialog look frozen.
97
+ */
98
+ const STATUS_TIMEOUT_MS = 5000
99
+
100
+ /** Resolve `promise`, or reject once `ms` have passed, so a route can always answer. */
101
+ function withTimeout(promise, ms, what) {
102
+ return Promise.race([
103
+ promise,
104
+ new Promise((_, reject) => {
105
+ const timer = setTimeout(() => reject(new Error(`${what} took longer than ${Math.round(ms / 1000)}s`)), ms)
106
+ timer.unref?.()
107
+ }),
108
+ ])
109
+ }
110
+
111
+ /**
112
+ * The delay to actually use, overridable so a test does not have to wait out a real one.
113
+ *
114
+ * Reading it per call rather than at import time is what lets a test set the variable, mount the plugin,
115
+ * and still get the short delay — the host half is imported once per scenario with a cache-busting
116
+ * query, but a constant captured at module load would freeze whatever the first reader saw.
117
+ */
118
+ function delayFor(name, fallback) {
119
+ const configured = Number(process.env[name])
120
+ return Number.isFinite(configured) && configured >= 0 ? configured : fallback
121
+ }
122
+
78
123
  /**
79
124
  * How long a fetched price list is reused. Opening the dialog asks for a fresh
80
125
  * copy every time, and this window only coalesces the requests that arrive within
@@ -517,6 +562,57 @@ export function apply(ctx) {
517
562
  const aggregator = createAggregator({ sessionsRoot, store, log })
518
563
  aggregator.loadCache()
519
564
 
565
+ /**
566
+ * The sync system, created **on first use** rather than when the plugin mounts.
567
+ *
568
+ * Creating it eagerly would write `host.lock` and `sync.json` into the data directory of every
569
+ * install, including the many that never turn sync on — and would quietly change the directory a
570
+ * user is looking at. Mounting a plugin should not do that; asking it to sync should.
571
+ *
572
+ * The lock decides who may **write**: a second DSH instance pointed at the same home cannot share a
573
+ * `deviceId`, because two processes would each believe the same remote shard is theirs to overwrite.
574
+ * The instance that loses is not broken and does not refuse to start — it reports why and declines to
575
+ * sync, which is the same answer the design gives to a read-only cache.
576
+ */
577
+ let system = null
578
+ function syncSystem() {
579
+ if (system) return system
580
+ const document = readSyncDocument(dataDir, log)
581
+ const instanceId = document.instanceId || `${hostname()}-${process.pid}`
582
+ const lock = acquireLock(dataDir, { instanceId, log })
583
+ if (!lock.acquired) log(`usage-badge is read-only here: ${lock.reason}`)
584
+ const engine = new SyncEngine({
585
+ dataDir,
586
+ store: lock.acquired ? store : null,
587
+ log,
588
+ config: document,
589
+ state: document.state,
590
+ instanceId,
591
+ })
592
+ if (!document.deviceId) engine.save({ config: { deviceId: `${hostname()}-${randomBytes(3).toString('hex')}` } })
593
+ // **This** is where the lock becomes ours, not inside `run()`: the host takes it once, when the system
594
+ // comes into being, and hands the already-held lock to every run. Without this the engine reported
595
+ // `ownsLock: false`, so `release()` declined to remove a lock this very process was holding — and the
596
+ // next mount of the same directory was refused by this plugin's own lock, naming a live pid.
597
+ engine.ownsLock = lock.acquired
598
+ system = {
599
+ engine,
600
+ lock,
601
+ instanceId,
602
+ running: null,
603
+ pushing: null,
604
+ // The last run is **persisted** state, so a restart must show it rather than "从未": the panel's
605
+ // first question is "when did this last work", and the answer is in `sync.json` — the engine wrote
606
+ // it there precisely so that it would outlive the process.
607
+ last: {
608
+ result: document.state?.lastResult || (lock.acquired ? 'never' : 'locked'),
609
+ message: document.state?.message ?? (lock.acquired ? '' : lock.reason),
610
+ at: Number(document.state?.lastSyncAt) || 0,
611
+ },
612
+ }
613
+ return system
614
+ }
615
+
520
616
  const { path: pricingSourcePath } = readRawPricing(pricingPaths)
521
617
  if (pricingSourcePath === legacyPricingPath) {
522
618
  log(`reading the price table from the pre-isolation path ${legacyPricingPath}; the next write moves it to ${pricingPath}`)
@@ -543,6 +639,173 @@ export function apply(ctx) {
543
639
  return inFlight
544
640
  }
545
641
 
642
+ /**
643
+ * The sync state the panel reads: configuration, lock ownership, and the last run's outcome.
644
+ *
645
+ * The configuration is **rebuilt field by field** rather than spread. A spread would forward whatever a
646
+ * hand-edited `sync.json` happens to contain — and an older build of this plugin wrote exactly such a
647
+ * field, `password`, in plaintext. A blacklist that removes `ha1` and forwards everything else is one
648
+ * forgotten key away from putting a credential in an HTTP response.
649
+ */
650
+ function safeSyncConfig(config = {}) {
651
+ return {
652
+ enabled: Boolean(config.enabled),
653
+ url: String(config.url ?? ''),
654
+ username: String(config.username ?? ''),
655
+ realm: String(config.realm ?? ''),
656
+ allowInsecureTls: Boolean(config.allowInsecureTls),
657
+ deviceId: String(config.deviceId ?? ''),
658
+ deviceLabel: String(config.deviceLabel ?? ''),
659
+ windowYears: Number(config.windowYears) || 0,
660
+ instanceId: String(config.instanceId ?? ''),
661
+ hasCredential: Boolean(config.ha1 || config.passwordHash || config.password),
662
+ credentialPrefix: config.ha1 ? `${String(config.ha1).slice(0, 6)}…` : '',
663
+ }
664
+ }
665
+
666
+ function conflictFiles() {
667
+ try {
668
+ return readdirSync(dataDir).filter((name) => name.startsWith('.conflict-'))
669
+ } catch {
670
+ return []
671
+ }
672
+ }
673
+
674
+ /** The devices the **server** holds, remembered between polls. Never a network call. */
675
+ let knownRemote = null
676
+ /** How long that answer is reused: the panel polls every 2s while a run is in flight. */
677
+ const REMOTE_CACHE_MS = 30_000
678
+
679
+ async function refreshRemoteDevices() {
680
+ if (!system?.engine?.store || !system.engine.config.enabled) return
681
+ if (system.syncAbort) return
682
+ if (system.remoteInFlight) return
683
+ system.remoteInFlight = true
684
+ try {
685
+ const client = system.engine.connect()
686
+ knownRemote = { devices: await system.engine.discover(client), at: Date.now() }
687
+ } catch (error) {
688
+ // A site the panel can show as "unknown" is better than an exception on every poll.
689
+ knownRemote = { devices: null, at: Date.now(), error: String(error?.message ?? error) }
690
+ } finally {
691
+ system.remoteInFlight = false
692
+ }
693
+ }
694
+
695
+ async function syncStatus() {
696
+ // Reading the status must not create anything either, so an untouched install answers with the
697
+ // sensible defaults rather than acquiring a lock it has no intention of using.
698
+ if (!system) {
699
+ const document = readSyncDocument(dataDir, log)
700
+ return {
701
+ config: safeSyncConfig({ ...document, hasCredential: Boolean(document.ha1 || document.passwordHash || document.password) }),
702
+ writable: true,
703
+ lock: null,
704
+ running: false,
705
+ last: {
706
+ result: document.state?.lastResult || 'never',
707
+ message: document.state?.message ?? '',
708
+ at: Number(document.state?.lastSyncAt) || 0,
709
+ },
710
+ collision: document.state?.collision ?? null,
711
+ remoteDevices: {},
712
+ // `null` rather than `{}`: nothing has asked the server yet, and an empty map would read as "there
713
+ // are no other devices".
714
+ remoteKnown: false,
715
+ remoteError: null,
716
+ published: {},
717
+ conflictFiles: conflictFiles(),
718
+ paths: { sync: join(dataDir, 'sync.json'), pricing: pricingPath },
719
+ }
720
+ }
721
+ const { engine, lock, running, last } = system
722
+ const pulled = {}
723
+ for (const context of Object.keys(engine.state.held ?? {})) {
724
+ const device = context.split('/')[0]
725
+ pulled[device] = (pulled[device] ?? 0) + 1
726
+ }
727
+ // The count of shards this device has actually read, and the names the server holds, are two
728
+ // different questions. The panel shows both: "已同步到的月份" from `held`, and "服务器上的设备" from
729
+ // this list, so a device that published but was never pulled from still shows up. Refreshed at most
730
+ // every `REMOTE_CACHE_MS` — the panel polls during a run, and a `PROPFIND` per poll would be a lot of
731
+ // requests for a list that changes when someone else syncs.
732
+ if (!knownRemote || Date.now() - (knownRemote.at ?? 0) > REMOTE_CACHE_MS) void refreshRemoteDevices()
733
+ return {
734
+ config: safeSyncConfig(engine.config),
735
+ writable: Boolean(engine.store),
736
+ lock: lock.acquired ? null : { reason: lock.reason, owner: lock.owner ?? null, pid: process.pid },
737
+ running: Boolean(running),
738
+ last,
739
+ collision: engine.state.collision ?? null,
740
+ remoteDevices: pulled,
741
+ remoteKnown: Boolean(knownRemote?.devices),
742
+ remoteError: knownRemote?.error ?? null,
743
+ remoteDevicesOnServer: knownRemote?.devices ?? null,
744
+ published: engine.state.published ?? {},
745
+ conflictFiles: conflictFiles(),
746
+ paths: { sync: join(dataDir, 'sync.json'), pricing: pricingPath },
747
+ }
748
+ }
749
+
750
+ /**
751
+ * Run one sync, at most one at a time.
752
+ *
753
+ * The host owns the lock for as long as it lives, so an automatic pricing push that lands mid-run
754
+ * waits on the same chain rather than racing it — two writers on one data directory is the case the
755
+ * lock exists for, and a lock cannot serialise work inside a single process.
756
+ */
757
+ function runSync() {
758
+ const instance = syncSystem()
759
+ if (!instance.running) {
760
+ instance.running = (async () => {
761
+ if (!instance.engine.store) {
762
+ // The cache is read-only here (another instance holds the lock, a permission problem, a newer
763
+ // schema). The sync would publish a history this process cannot even fold, so it declines
764
+ // rather than doing half of it.
765
+ return { result: 'locked', message: instance.lock.reason ?? 'the cache is read-only here', at: 0 }
766
+ }
767
+ try {
768
+ await refreshOnce()
769
+ } catch (error) {
770
+ // Refreshing is what makes the store worth publishing; without it there is nothing to send. A
771
+ // rejection here used to escape the route and the startup timer as an unhandled rejection.
772
+ return { result: 'failed', message: `could not fold the logs: ${error?.message ?? error}`, at: 0 }
773
+ }
774
+ // The system already owns the lock from the moment it was created; taking it again in the same
775
+ // process would see this process's own lock file and refuse. It is re-acquired only when the
776
+ // system was created without one (a second instance that lost the race).
777
+ const attempt = instance.lock?.acquired
778
+ ? instance.lock
779
+ : acquireLock(dataDir, { instanceId: instance.instanceId, log })
780
+ if (!attempt.acquired) return { result: 'locked', message: attempt.reason, at: 0 }
781
+ try {
782
+ const result = await instance.engine.run({ lock: attempt })
783
+ // A shard may have carried a newer price table; the panel's next poll should see it.
784
+ pricing = readPricing(pricingPaths)
785
+ rules = resolveHolidayRules(pricing.holidays)
786
+ // The engine's own record of when this happened, not "now": `locked` and `skipped` deliberately
787
+ // leave `lastSyncAt` alone, and showing a fresh timestamp for them would claim a sync that did
788
+ // not happen.
789
+ return { ...result, at: Number(instance.engine.state.lastSyncAt) || 0 }
790
+ } catch (error) {
791
+ // `SyncEngine.run` catches its own failures; this is the belt to its braces, so a bug there
792
+ // cannot take the host half down.
793
+ log(`sync failed unexpectedly: ${error?.message ?? error}`)
794
+ return { result: 'failed', message: String(error?.message ?? error), at: 0 }
795
+ }
796
+ // Nothing to release: the lock belongs to the system and lives as long as this process does.
797
+ })()
798
+ .then((result) => {
799
+ instance.last = { result: result.result, message: result.message, at: result.at ?? 0 }
800
+ return result
801
+ })
802
+ .finally(() => {
803
+ instance.running = null
804
+ })
805
+ }
806
+ return instance.running
807
+ }
808
+
546
809
  async function getSnapshot() {
547
810
  const status = await refreshOnce()
548
811
  // Nothing changed and the previous payload is still fresh: reuse it rather
@@ -577,8 +840,75 @@ export function apply(ctx) {
577
840
  pricing = normalizePricing(next)
578
841
  rules = resolveHolidayRules(pricing.holidays)
579
842
  snapshot = null // force a rebuild on the next read
843
+ // The price table is the one hand-written thing this plugin owns, so an edit is worth publishing
844
+ // while the person is still looking at the page. It is fire-and-forget: a sync that cannot reach
845
+ // the server must never make a local edit fail, and the state it leaves behind is what the panel
846
+ // reports.
847
+ schedulePricingPush()
848
+ }
849
+
850
+ /**
851
+ * Publish just the price table, a moment after the last edit.
852
+ *
853
+ * Debounced because editing a row and then the one below it is one intention, not two publishes, and
854
+ * because the fetch it needs would otherwise happen on every keystroke-sized save. It only ever runs
855
+ * when sync was already configured and enabled: a local edit is not a reason to create `sync.json`.
856
+ */
857
+ let pricingPushTimer = null
858
+ function schedulePricingPush() {
859
+ const configured = system ? system.engine.config : readSyncDocument(dataDir, log)
860
+ if (!configured.enabled || !configured.url || !configured.ha1) return
861
+ if (pricingPushTimer) clearTimeout(pricingPushTimer)
862
+ pricingPushTimer = setTimeout(() => {
863
+ pricingPushTimer = null
864
+ const instance = syncSystem()
865
+ if (!instance.lock?.acquired || !instance.engine.store) return
866
+ // Chained onto the same queue as a full run, so an edit landing mid-sync waits rather than
867
+ // interleaving PUTs with it. The result is recorded like any other outcome: an edit whose publish
868
+ // failed is exactly the thing the person would otherwise never learn about.
869
+ instance.pushing = (instance.pushing ?? Promise.resolve())
870
+ .then(() => instance.engine.pushPricing({ lock: instance.lock, instanceId: instance.instanceId }))
871
+ .then((result) => {
872
+ log(`pricing pushed after an edit: ${result.result} — ${result.status}`)
873
+ instance.last = { result: result.result, message: `prices: ${result.status}`, at: result.result === 'ok' ? Number(instance.engine.state.lastSyncAt) || 0 : instance.last?.at ?? 0 }
874
+ return result
875
+ })
876
+ .catch((error) => {
877
+ log(`pricing push failed: ${error?.message ?? error}`)
878
+ instance.last = { result: 'failed', message: `prices: ${error?.message ?? error}`, at: instance.last?.at ?? 0 }
879
+ })
880
+ }, delayFor('DSH_USAGE_BADGE_PRICING_PUSH_MS', PRICING_PUSH_DELAY_MS))
881
+ pricingPushTimer.unref?.()
580
882
  }
581
883
 
884
+ /**
885
+ * The first automatic sync, a little after startup.
886
+ *
887
+ * Deliberately conditional: it only runs when sync is **already configured and enabled**, so a home
888
+ * that never turned sync on stays untouched (no `sync.json`, no lock, no network). The delay exists so
889
+ * the cold fold and the first panel paint come first — a sync on the way up would compete with the
890
+ * work that makes the badge show anything at all.
891
+ *
892
+ * It is also skipped when the instance is not the writer: on a second DSH instance the lock belongs to
893
+ * the first, and the honest answer is to do nothing rather than take the lock away from it.
894
+ */
895
+ function scheduleStartupSync(delayMs = delayFor('DSH_USAGE_BADGE_STARTUP_SYNC_MS', STARTUP_SYNC_DELAY_MS)) {
896
+ const configured = readSyncDocument(dataDir, log)
897
+ if (!configured.enabled || !configured.url || !configured.ha1) return
898
+ const timer = setTimeout(() => {
899
+ startupSyncTimer = null
900
+ const instance = syncSystem()
901
+ if (!instance.lock?.acquired) {
902
+ log(`startup sync skipped: ${instance.lock?.reason ?? 'no lock'}`)
903
+ return
904
+ }
905
+ void runSync().then((result) => log(`startup sync: ${result.result} — ${result.message}`))
906
+ }, delayMs)
907
+ timer.unref?.()
908
+ startupSyncTimer = timer
909
+ }
910
+ let startupSyncTimer = null
911
+
582
912
  async function warm() {
583
913
  try {
584
914
  const status = await refreshOnce()
@@ -736,6 +1066,119 @@ export function apply(ctx) {
736
1066
  return
737
1067
  }
738
1068
 
1069
+ // ── cloud sync ──────────────────────────────────────────────────────────
1070
+ //
1071
+ // The panel's third tab. The credential never travels back: `ha1` is reported as a short prefix
1072
+ // plus a `hasCredential` flag, so the state of "is one configured" is visible without the secret
1073
+ // leaving the host half.
1074
+ if (route === '/sync' && req.method === 'GET') {
1075
+ // Loopback only, like every other sync route. The answer names the server and the account, and
1076
+ // reading it *is* engaging with sync — this is where the system comes into being, and with it the
1077
+ // owner lock. A page that never opens this tab still touches nothing.
1078
+ if (!isLoopback(req)) {
1079
+ sendJson(res, 403, { error: 'the sync status is visible to loopback clients only' })
1080
+ return
1081
+ }
1082
+ syncSystem()
1083
+ let body
1084
+ try {
1085
+ body = await withTimeout(syncStatus(), STATUS_TIMEOUT_MS, 'reading the sync status')
1086
+ } catch (error) {
1087
+ sendJson(res, 200, { error: String(error?.message ?? error) })
1088
+ return
1089
+ }
1090
+ sendJson(res, 200, body)
1091
+ return
1092
+ }
1093
+
1094
+ if (route === '/sync' && (req.method === 'PUT' || req.method === 'POST')) {
1095
+ if (!isLoopback(req)) {
1096
+ sendJson(res, 403, { error: 'sync writes are accepted from loopback clients only' })
1097
+ return
1098
+ }
1099
+ const instance = syncSystem()
1100
+ const patch = JSON.parse((await readBody(req)) || '{}')
1101
+ const config = {}
1102
+ for (const field of ['enabled', 'url', 'username', 'allowInsecureTls', 'deviceLabel', 'deviceId', 'realm']) {
1103
+ if (patch[field] !== undefined) config[field] = patch[field]
1104
+ }
1105
+ if (patch.windowYears !== undefined) config.windowYears = Math.max(0, Math.min(50, Number(patch.windowYears) || 0))
1106
+ if (typeof patch.deviceId === 'string' && patch.deviceId !== '') {
1107
+ try {
1108
+ config.deviceId = safeDeviceId(patch.deviceId)
1109
+ } catch (error) {
1110
+ sendJson(res, 400, { error: error.message })
1111
+ return
1112
+ }
1113
+ }
1114
+ // Either a plaintext password (hashed here, immediately, and never stored) or an HA1 someone
1115
+ // pasted in — the same value the server keeps in its password file.
1116
+ //
1117
+ // The realm is part of the digest input, so it has to be right before the password can be hashed.
1118
+ // The panel does not offer it as a field: a person has no reason to care, and the servers this is
1119
+ // built for use `WebDAV`. So it defaults here rather than being demanded — requiring it made
1120
+ // "fill in address/user/password and press 保存" fail with a sentence about a term the UI never
1121
+ // showed. A connection test replaces the guess with the realm the server actually announced.
1122
+ if (typeof patch.password === 'string' && patch.password !== '') {
1123
+ const username = String(patch.username ?? instance.engine.config.username ?? '')
1124
+ const realm = String(patch.realm ?? instance.engine.config.realm ?? '').trim() || 'WebDAV'
1125
+ if (!username) {
1126
+ sendJson(res, 400, { error: 'a username is needed to hash the password' })
1127
+ return
1128
+ }
1129
+ config.ha1 = createHash('md5').update(`${username}:${realm}:${patch.password}`).digest('hex')
1130
+ config.realm = realm
1131
+ } else if (typeof patch.ha1 === 'string' && /^[0-9a-f]{32}$/i.test(patch.ha1)) {
1132
+ config.ha1 = patch.ha1.toLowerCase()
1133
+ }
1134
+ if (typeof config.url === 'string' && config.url !== '') {
1135
+ try {
1136
+ new URL(config.url)
1137
+ } catch {
1138
+ sendJson(res, 400, { error: `the WebDAV URL is not a URL: ${config.url}` })
1139
+ return
1140
+ }
1141
+ }
1142
+ instance.engine.save({ config })
1143
+ log(`sync configuration updated (${instance.engine.config.enabled ? 'on' : 'off'}, device ${instance.engine.config.deviceId})`)
1144
+ sendJson(res, 200, { ok: true, ...(await syncStatus()) })
1145
+ return
1146
+ }
1147
+
1148
+ // A connection check: it must not sync, and it must not write anything anywhere.
1149
+ if (route === '/sync/test' && req.method === 'POST') {
1150
+ if (!isLoopback(req)) {
1151
+ sendJson(res, 403, { error: 'sync writes are accepted from loopback clients only' })
1152
+ return
1153
+ }
1154
+ try {
1155
+ const client = syncSystem().engine.connect()
1156
+ const options = await client.options()
1157
+ const devices = await syncSystem().engine.discover(client)
1158
+ sendJson(res, 200, {
1159
+ ok: true,
1160
+ realm: options.realm,
1161
+ dav: options.dav,
1162
+ devices,
1163
+ insecure: Boolean(client.insecureWarning),
1164
+ warning: client.insecureWarning ?? null,
1165
+ })
1166
+ } catch (error) {
1167
+ sendJson(res, 200, { ok: false, error: String(error?.message ?? error) })
1168
+ }
1169
+ return
1170
+ }
1171
+
1172
+ if (route === '/sync/run' && req.method === 'POST') {
1173
+ if (!isLoopback(req)) {
1174
+ sendJson(res, 403, { error: 'sync writes are accepted from loopback clients only' })
1175
+ return
1176
+ }
1177
+ const result = await runSync()
1178
+ sendJson(res, 200, { ok: result.result === 'ok' || result.result === 'partial', ...result, ...(await syncStatus()) })
1179
+ return
1180
+ }
1181
+
739
1182
  sendJson(res, 404, { error: `no such usage-badge route: ${route}` })
740
1183
  } catch (error) {
741
1184
  // A caller's mistake is reported as one; only genuinely unexpected failures are 500s.
@@ -751,9 +1194,30 @@ export function apply(ctx) {
751
1194
  )
752
1195
 
753
1196
  ctx.effect(() => {
754
- void warm()
755
- return () => aggregator.close()
756
- }, 'usage-badge: warm the fold and close the cache on unload')
1197
+ // The delay is read **here**, synchronously, rather than inside the warm-up callback: warming is
1198
+ // asynchronous, and a value read after it would be whatever the environment holds by then — which a
1199
+ // test that sets an override for exactly this mount would already have taken away.
1200
+ const startupDelay = delayFor('DSH_USAGE_BADGE_STARTUP_SYNC_MS', STARTUP_SYNC_DELAY_MS)
1201
+ void warm().then(() => {
1202
+ scheduleStartupSync(startupDelay)
1203
+ // Fire and forget: this only fills in what the server holds, for the panel's next read.
1204
+ void refreshRemoteDevices()
1205
+ })
1206
+ return () => {
1207
+ // Any pending automatic work is dropped on unload: a push that fires while the host is going away
1208
+ // would write state for a process that no longer exists.
1209
+ if (pricingPushTimer) clearTimeout(pricingPushTimer)
1210
+ if (startupSyncTimer) clearTimeout(startupSyncTimer)
1211
+ // **And the lock is handed back.** Not releasing it leaves `host.lock` naming this dead process,
1212
+ // and a re-mount inside the same process — a hot reload, or a test mounting the same home twice —
1213
+ // is then refused by this plugin's own lock, for a pid that is very much alive: its own.
1214
+ if (system) {
1215
+ system.syncAbort = true
1216
+ system.engine.release()
1217
+ }
1218
+ aggregator.close()
1219
+ }
1220
+ }, 'usage-badge: warm the fold, schedule the first sync, and close the cache on unload')
757
1221
 
758
1222
  log(`mounted on ${ROUTE_PREFIX} (home: ${home}; data: ${dataDir})`)
759
1223
  }