dsh-usage-badge 0.1.5 → 0.1.7
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/README.md +2 -2
- package/docs/design-notes.md +55 -8
- package/lib/cache-store.js +113 -14
- package/lib/client.js +160 -12
- package/lib/index.js +210 -39
- package/lib/sync.js +292 -57
- package/lib/webdav.js +67 -15
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -22,7 +22,8 @@ import { join } from 'node:path'
|
|
|
22
22
|
|
|
23
23
|
import { createCacheStore } from './cache-store.js'
|
|
24
24
|
import { createAggregator, mergeRecords, resolveDshHome } from './fold.js'
|
|
25
|
-
import { SyncEngine, acquireLock, readSyncDocument, releaseLock, safeDeviceId } from './sync.js'
|
|
25
|
+
import { SyncEngine, acquireLock, lockIsStale, readLock, readSyncDocument, releaseLock, safeDeviceId } from './sync.js'
|
|
26
|
+
import { normalizeWebdavUrl } from './webdav.js'
|
|
26
27
|
import { describeCalendar, loadBundledTable, resolveHolidayRules } from './holidays.js'
|
|
27
28
|
import { OFFICIAL_SOURCES, applyOfficialPricing, fetchOfficialPricing } from './official-pricing.js'
|
|
28
29
|
import {
|
|
@@ -89,6 +90,16 @@ export const PRICING_PUSH_DELAY_MS = 3000
|
|
|
89
90
|
/** How long after startup the first automatic sync runs, when sync is enabled. */
|
|
90
91
|
export const STARTUP_SYNC_DELAY_MS = 8000
|
|
91
92
|
|
|
93
|
+
/**
|
|
94
|
+
* How long a sync on the way out may take before it is told to stop.
|
|
95
|
+
*
|
|
96
|
+
* The application's whole teardown gets 5 s (`PROCESS_SHUTDOWN_TIMEOUT_MS`), and this plugin is one of
|
|
97
|
+
* several things being disposed — so it takes most of that and leaves the rest alone. Long enough for a
|
|
98
|
+
* real sync against a reachable server (a few hundred milliseconds for a month of changes), short enough
|
|
99
|
+
* that quitting never feels stuck.
|
|
100
|
+
*/
|
|
101
|
+
export const EXIT_SYNC_BUDGET_MS = 3500
|
|
102
|
+
|
|
92
103
|
/**
|
|
93
104
|
* How long the panel waits for a status read before it is told the answer is not ready.
|
|
94
105
|
*
|
|
@@ -454,6 +465,23 @@ function readPricing(paths) {
|
|
|
454
465
|
}
|
|
455
466
|
|
|
456
467
|
/** Read a request body, bounded. An oversized one is a 413, not a server error. */
|
|
468
|
+
/**
|
|
469
|
+
* Read a JSON request body, reporting a malformed one as the caller's mistake.
|
|
470
|
+
*
|
|
471
|
+
* A bare `JSON.parse` throws a `SyntaxError` with no status, which the route wrapper can only answer with a
|
|
472
|
+
* 500 — and a 500 for a body the client got wrong sends whoever is debugging it looking at the host.
|
|
473
|
+
*/
|
|
474
|
+
async function readJsonBody(req) {
|
|
475
|
+
const text = (await readBody(req)) || '{}'
|
|
476
|
+
try {
|
|
477
|
+
return JSON.parse(text)
|
|
478
|
+
} catch (error) {
|
|
479
|
+
const bad = new Error(`the request body is not JSON: ${error?.message ?? error}`)
|
|
480
|
+
bad.statusCode = 400
|
|
481
|
+
throw bad
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
457
485
|
function readBody(req) {
|
|
458
486
|
return new Promise((resolvePromise, rejectPromise) => {
|
|
459
487
|
const chunks = []
|
|
@@ -648,6 +676,7 @@ export function apply(ctx) {
|
|
|
648
676
|
* forgotten key away from putting a credential in an HTTP response.
|
|
649
677
|
*/
|
|
650
678
|
function safeSyncConfig(config = {}) {
|
|
679
|
+
const ha1 = String(config.ha1 ?? '')
|
|
651
680
|
return {
|
|
652
681
|
enabled: Boolean(config.enabled),
|
|
653
682
|
url: String(config.url ?? ''),
|
|
@@ -658,8 +687,15 @@ export function apply(ctx) {
|
|
|
658
687
|
deviceLabel: String(config.deviceLabel ?? ''),
|
|
659
688
|
windowYears: Number(config.windowYears) || 0,
|
|
660
689
|
instanceId: String(config.instanceId ?? ''),
|
|
661
|
-
|
|
662
|
-
|
|
690
|
+
// Defaults are `true`, so an existing `sync.json` that predates these fields reads as "both on" —
|
|
691
|
+
// which is what the design promises: sync, once switched on, keeps itself current.
|
|
692
|
+
onStart: config.onStart !== false,
|
|
693
|
+
onExit: config.onExit !== false,
|
|
694
|
+
hasCredential: Boolean(ha1),
|
|
695
|
+
// Only a real HA1 counts. `password` and `passwordHash` are fields an older build could leave behind,
|
|
696
|
+
// and `connect()` cannot use either — claiming a credential exists would enable 立即同步 and then
|
|
697
|
+
// fail with "no WebDAV credential is configured" the moment it was pressed.
|
|
698
|
+
credentialPrefix: ha1.length >= 16 ? `${ha1.slice(0, 6)}…` : '',
|
|
663
699
|
}
|
|
664
700
|
}
|
|
665
701
|
|
|
@@ -692,28 +728,64 @@ export function apply(ctx) {
|
|
|
692
728
|
}
|
|
693
729
|
}
|
|
694
730
|
|
|
731
|
+
/**
|
|
732
|
+
* Every piece of work that talks to the server goes through one queue.
|
|
733
|
+
*
|
|
734
|
+
* The owner lock keeps two **processes** apart; inside one process it cannot, because the host holds it for
|
|
735
|
+
* its whole life. Without this, 立即同步 and the automatic price push run as two independent chains: a run's
|
|
736
|
+
* `PROPFIND` and a push's `GET pricing.json` would already be interleaving on the wire (measured), and both
|
|
737
|
+
* mutate the same `engine.state` and rewrite `sync.json`, so whichever finishes last decides what the panel
|
|
738
|
+
* says about the other.
|
|
739
|
+
*/
|
|
740
|
+
function enqueue(instance, work) {
|
|
741
|
+
const next = (instance.chain ?? Promise.resolve()).then(work, work)
|
|
742
|
+
// The chain must survive a rejection — neither breaking on it nor leaving it unhandled.
|
|
743
|
+
instance.chain = next.then(
|
|
744
|
+
() => {},
|
|
745
|
+
() => {},
|
|
746
|
+
)
|
|
747
|
+
return next
|
|
748
|
+
}
|
|
749
|
+
|
|
695
750
|
async function syncStatus() {
|
|
696
|
-
// Reading the status must not create anything
|
|
697
|
-
//
|
|
751
|
+
// Reading the status must not create anything: someone opening the tab to see what it says has not
|
|
752
|
+
// decided to use sync yet. So the system (and with it the lock and `sync.json`) is only created by an
|
|
753
|
+
// action that actually decides something — save, test, run — and this branch answers from the files on
|
|
754
|
+
// disk plus a **read** of the lock.
|
|
698
755
|
if (!system) {
|
|
699
756
|
const document = readSyncDocument(dataDir, log)
|
|
757
|
+
const state = document.state ?? {}
|
|
758
|
+
const pulled = {}
|
|
759
|
+
for (const context of Object.keys(state.held ?? {})) {
|
|
760
|
+
const device = context.split('/')[0]
|
|
761
|
+
pulled[device] = (pulled[device] ?? 0) + 1
|
|
762
|
+
}
|
|
763
|
+
// Whose lock is it? Answering "may this instance write" takes no writing: `readLock` looks, and this
|
|
764
|
+
// mirrors exactly what `acquireLock` would decide — **any** live lock blocks, including one this same
|
|
765
|
+
// pid holds, because that is the answer `acquireLock` gives and the panel must not promise a write the
|
|
766
|
+
// engine would then refuse. A stale lock is not a block: it would be taken over.
|
|
767
|
+
const owner = readLock(dataDir)
|
|
768
|
+
const blocking = owner && !lockIsStale(owner) ? owner : null
|
|
700
769
|
return {
|
|
701
|
-
config: safeSyncConfig(
|
|
702
|
-
writable:
|
|
703
|
-
lock: null,
|
|
770
|
+
config: safeSyncConfig(document),
|
|
771
|
+
writable: !blocking,
|
|
772
|
+
lock: blocking ? { reason: `this data directory is open by another DSH instance (pid ${blocking.pid} on ${blocking.hostname || 'this host'})`, owner: blocking, pid: process.pid } : null,
|
|
704
773
|
running: false,
|
|
705
774
|
last: {
|
|
706
|
-
result:
|
|
707
|
-
message:
|
|
708
|
-
at: Number(
|
|
775
|
+
result: state.lastResult || 'never',
|
|
776
|
+
message: state.message ?? '',
|
|
777
|
+
at: Number(state.lastSyncAt) || 0,
|
|
709
778
|
},
|
|
710
|
-
collision:
|
|
711
|
-
|
|
779
|
+
collision: state.collision ?? null,
|
|
780
|
+
// From the persisted state, so a home that has published before still shows what it published —
|
|
781
|
+
// none of this needs the lock.
|
|
782
|
+
remoteDevices: pulled,
|
|
712
783
|
// `null` rather than `{}`: nothing has asked the server yet, and an empty map would read as "there
|
|
713
784
|
// are no other devices".
|
|
714
785
|
remoteKnown: false,
|
|
715
786
|
remoteError: null,
|
|
716
|
-
|
|
787
|
+
remoteDevicesOnServer: null,
|
|
788
|
+
published: state.published ?? {},
|
|
717
789
|
conflictFiles: conflictFiles(),
|
|
718
790
|
paths: { sync: join(dataDir, 'sync.json'), pricing: pricingPath },
|
|
719
791
|
}
|
|
@@ -757,7 +829,7 @@ export function apply(ctx) {
|
|
|
757
829
|
function runSync() {
|
|
758
830
|
const instance = syncSystem()
|
|
759
831
|
if (!instance.running) {
|
|
760
|
-
instance.running = (async () => {
|
|
832
|
+
instance.running = enqueue(instance, async () => {
|
|
761
833
|
if (!instance.engine.store) {
|
|
762
834
|
// The cache is read-only here (another instance holds the lock, a permission problem, a newer
|
|
763
835
|
// schema). The sync would publish a history this process cannot even fold, so it declines
|
|
@@ -794,7 +866,7 @@ export function apply(ctx) {
|
|
|
794
866
|
return { result: 'failed', message: String(error?.message ?? error), at: 0 }
|
|
795
867
|
}
|
|
796
868
|
// Nothing to release: the lock belongs to the system and lives as long as this process does.
|
|
797
|
-
})
|
|
869
|
+
})
|
|
798
870
|
.then((result) => {
|
|
799
871
|
instance.last = { result: result.result, message: result.message, at: result.at ?? 0 }
|
|
800
872
|
return result
|
|
@@ -863,11 +935,9 @@ export function apply(ctx) {
|
|
|
863
935
|
pricingPushTimer = null
|
|
864
936
|
const instance = syncSystem()
|
|
865
937
|
if (!instance.lock?.acquired || !instance.engine.store) return
|
|
866
|
-
//
|
|
867
|
-
// interleaving PUTs
|
|
868
|
-
|
|
869
|
-
instance.pushing = (instance.pushing ?? Promise.resolve())
|
|
870
|
-
.then(() => instance.engine.pushPricing({ lock: instance.lock, instanceId: instance.instanceId }))
|
|
938
|
+
// Onto the **same** queue as a full run, so an edit landing mid-sync waits for it instead of
|
|
939
|
+
// interleaving its PUTs and its `sync.json` write with the run's.
|
|
940
|
+
instance.pushing = enqueue(instance, () => instance.engine.pushPricing({ lock: instance.lock, instanceId: instance.instanceId }))
|
|
871
941
|
.then((result) => {
|
|
872
942
|
log(`pricing pushed after an edit: ${result.result} — ${result.status}`)
|
|
873
943
|
instance.last = { result: result.result, message: `prices: ${result.status}`, at: result.result === 'ok' ? Number(instance.engine.state.lastSyncAt) || 0 : instance.last?.at ?? 0 }
|
|
@@ -895,6 +965,10 @@ export function apply(ctx) {
|
|
|
895
965
|
function scheduleStartupSync(delayMs = delayFor('DSH_USAGE_BADGE_STARTUP_SYNC_MS', STARTUP_SYNC_DELAY_MS)) {
|
|
896
966
|
const configured = readSyncDocument(dataDir, log)
|
|
897
967
|
if (!configured.enabled || !configured.url || !configured.ha1) return
|
|
968
|
+
if (configured.onStart === false) {
|
|
969
|
+
log('startup sync is switched off')
|
|
970
|
+
return
|
|
971
|
+
}
|
|
898
972
|
const timer = setTimeout(() => {
|
|
899
973
|
startupSyncTimer = null
|
|
900
974
|
const instance = syncSystem()
|
|
@@ -909,6 +983,56 @@ export function apply(ctx) {
|
|
|
909
983
|
}
|
|
910
984
|
let startupSyncTimer = null
|
|
911
985
|
|
|
986
|
+
/**
|
|
987
|
+
* A last sync on the way out, awaited — but only for as long as there is time.
|
|
988
|
+
*
|
|
989
|
+
* DSH gives the whole application teardown **5 seconds** (`PROCESS_SHUTDOWN_TIMEOUT_MS` in the runtime)
|
|
990
|
+
* before it exits the host process outright, and the desktop then waits up to 10 more for the child to go.
|
|
991
|
+
* So the chain that reaches this function is: desktop asks the host to shut down → the host awaits its own
|
|
992
|
+
* disposal → cordis disposes this plugin's effects, awaiting what they return. Returning a promise here is
|
|
993
|
+
* what makes the sync happen at all instead of being cut off mid-request.
|
|
994
|
+
*
|
|
995
|
+
* The budget is what keeps that promise from becoming a slow quit: past it the run is told to stop at its
|
|
996
|
+
* next step boundary (nothing half-written — uploads go through a temporary name and the manifest is
|
|
997
|
+
* written last) and the disposal returns. A sync that did not finish is reported as such, not pretended.
|
|
998
|
+
*/
|
|
999
|
+
async function syncOnExit() {
|
|
1000
|
+
// Read the configuration from disk when this session never touched sync: a home configured in an earlier
|
|
1001
|
+
// session, quit without opening the panel, still has to sync. Creating the system here is also what takes
|
|
1002
|
+
// the lock — and the store is still open at this point (the disposer closes it afterwards).
|
|
1003
|
+
const document = system?.engine?.config ?? readSyncDocument(dataDir, log)
|
|
1004
|
+
if (!document.enabled || !document.url || !document.ha1) return
|
|
1005
|
+
if (document.onExit === false) {
|
|
1006
|
+
log('exit sync is switched off')
|
|
1007
|
+
return
|
|
1008
|
+
}
|
|
1009
|
+
const instance = syncSystem()
|
|
1010
|
+
if (!instance.engine.store) return
|
|
1011
|
+
if (!instance.lock?.acquired) {
|
|
1012
|
+
log(`exit sync skipped: ${instance.lock?.reason ?? 'no lock'}`)
|
|
1013
|
+
return
|
|
1014
|
+
}
|
|
1015
|
+
const budget = delayFor('DSH_USAGE_BADGE_EXIT_SYNC_MS', EXIT_SYNC_BUDGET_MS)
|
|
1016
|
+
if (budget <= 0) {
|
|
1017
|
+
// A zero budget means "there is no time for this" — the switch a test or a debugging session uses.
|
|
1018
|
+
log('exit sync skipped: no time budget')
|
|
1019
|
+
return
|
|
1020
|
+
}
|
|
1021
|
+
let timer = null
|
|
1022
|
+
const deadline = new Promise((resolve) => {
|
|
1023
|
+
timer = setTimeout(() => resolve('deadline'), budget)
|
|
1024
|
+
timer.unref?.()
|
|
1025
|
+
})
|
|
1026
|
+
const outcome = await Promise.race([runSync().then((result) => result ?? 'done', () => 'failed'), deadline])
|
|
1027
|
+
clearTimeout(timer)
|
|
1028
|
+
if (outcome === 'deadline') {
|
|
1029
|
+
instance.engine.abort()
|
|
1030
|
+
log(`exit sync did not finish within ${budget}ms; it stops at its next step boundary`)
|
|
1031
|
+
return
|
|
1032
|
+
}
|
|
1033
|
+
log(`exit sync: ${instance.last?.result ?? outcome} — ${instance.last?.message ?? ''}`)
|
|
1034
|
+
}
|
|
1035
|
+
|
|
912
1036
|
async function warm() {
|
|
913
1037
|
try {
|
|
914
1038
|
const status = await refreshOnce()
|
|
@@ -1018,7 +1142,7 @@ export function apply(ctx) {
|
|
|
1018
1142
|
sendJson(res, 403, { error: 'config writes are accepted from loopback clients only' })
|
|
1019
1143
|
return
|
|
1020
1144
|
}
|
|
1021
|
-
const patch =
|
|
1145
|
+
const patch = await readJsonBody(req)
|
|
1022
1146
|
commitPricing(mergeConfig(readRawPricing(pricingPaths).raw, patch))
|
|
1023
1147
|
log('pricing config updated')
|
|
1024
1148
|
sendJson(res, 200, { ok: true, effective: pricing, calendar: describeCalendar(rules) })
|
|
@@ -1039,7 +1163,7 @@ export function apply(ctx) {
|
|
|
1039
1163
|
sendJson(res, 403, { error: 'config writes are accepted from loopback clients only' })
|
|
1040
1164
|
return
|
|
1041
1165
|
}
|
|
1042
|
-
const body =
|
|
1166
|
+
const body = await readJsonBody(req)
|
|
1043
1167
|
// Apply the copy the user was looking at, reusing the recent fetch rather
|
|
1044
1168
|
// than pulling a possibly different page under them.
|
|
1045
1169
|
const parsed = await officialPricing(body.source ?? 'zh-cn')
|
|
@@ -1072,14 +1196,15 @@ export function apply(ctx) {
|
|
|
1072
1196
|
// plus a `hasCredential` flag, so the state of "is one configured" is visible without the secret
|
|
1073
1197
|
// leaving the host half.
|
|
1074
1198
|
if (route === '/sync' && req.method === 'GET') {
|
|
1075
|
-
// Loopback only, like every other sync route
|
|
1076
|
-
//
|
|
1077
|
-
//
|
|
1199
|
+
// Loopback only, like every other sync route: the answer names the server and the account.
|
|
1200
|
+
//
|
|
1201
|
+
// **And it creates nothing.** Opening the tab is looking, not deciding — so this no longer takes the
|
|
1202
|
+
// lock or writes `sync.json`; the system comes into being when something is saved, tested or run.
|
|
1203
|
+
// `writable` is still answered honestly, by reading the lock file rather than writing one.
|
|
1078
1204
|
if (!isLoopback(req)) {
|
|
1079
1205
|
sendJson(res, 403, { error: 'the sync status is visible to loopback clients only' })
|
|
1080
1206
|
return
|
|
1081
1207
|
}
|
|
1082
|
-
syncSystem()
|
|
1083
1208
|
let body
|
|
1084
1209
|
try {
|
|
1085
1210
|
body = await withTimeout(syncStatus(), STATUS_TIMEOUT_MS, 'reading the sync status')
|
|
@@ -1097,11 +1222,16 @@ export function apply(ctx) {
|
|
|
1097
1222
|
return
|
|
1098
1223
|
}
|
|
1099
1224
|
const instance = syncSystem()
|
|
1100
|
-
const patch =
|
|
1225
|
+
const patch = await readJsonBody(req)
|
|
1101
1226
|
const config = {}
|
|
1102
|
-
for (const field of ['enabled', 'url', 'username', '
|
|
1227
|
+
for (const field of ['enabled', 'url', 'username', 'deviceLabel', 'deviceId', 'realm']) {
|
|
1103
1228
|
if (patch[field] !== undefined) config[field] = patch[field]
|
|
1104
1229
|
}
|
|
1230
|
+
// The switches, coerced: a panel sends booleans, but a hand-written request may send 0/1, and a
|
|
1231
|
+
// stored `"false"` string would otherwise read as *on* (it is not `false`).
|
|
1232
|
+
for (const field of ['allowInsecureTls', 'onStart', 'onExit']) {
|
|
1233
|
+
if (patch[field] !== undefined) config[field] = patch[field] !== false && patch[field] !== 'false' && patch[field] !== 0
|
|
1234
|
+
}
|
|
1105
1235
|
if (patch.windowYears !== undefined) config.windowYears = Math.max(0, Math.min(50, Number(patch.windowYears) || 0))
|
|
1106
1236
|
if (typeof patch.deviceId === 'string' && patch.deviceId !== '') {
|
|
1107
1237
|
try {
|
|
@@ -1132,12 +1262,19 @@ export function apply(ctx) {
|
|
|
1132
1262
|
config.ha1 = patch.ha1.toLowerCase()
|
|
1133
1263
|
}
|
|
1134
1264
|
if (typeof config.url === 'string' && config.url !== '') {
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1265
|
+
// The **same** judgement the client makes, from the same function. A copy here is what let the
|
|
1266
|
+
// route accept a query string, embedded credentials and `ftp:` — addresses the client refuses —
|
|
1267
|
+
// so the panel stored one, showed it, and the sync could not run at all. Two copies of a rule
|
|
1268
|
+
// drift; one copy cannot.
|
|
1269
|
+
const judged = normalizeWebdavUrl(config.url)
|
|
1270
|
+
if (!judged.ok) {
|
|
1271
|
+
sendJson(res, 400, { error: judged.reason })
|
|
1139
1272
|
return
|
|
1140
1273
|
}
|
|
1274
|
+
// The trailing slash is **added** rather than demanded: someone typing `host:port` means the
|
|
1275
|
+
// collection at that address. The panel stores what this returns, so the field ends up showing
|
|
1276
|
+
// the address that will actually be used instead of the one that was typed.
|
|
1277
|
+
config.url = judged.url
|
|
1141
1278
|
}
|
|
1142
1279
|
instance.engine.save({ config })
|
|
1143
1280
|
log(`sync configuration updated (${instance.engine.config.enabled ? 'on' : 'off'}, device ${instance.engine.config.deviceId})`)
|
|
@@ -1151,8 +1288,13 @@ export function apply(ctx) {
|
|
|
1151
1288
|
sendJson(res, 403, { error: 'sync writes are accepted from loopback clients only' })
|
|
1152
1289
|
return
|
|
1153
1290
|
}
|
|
1291
|
+
// The client is kept across the catch so a **failure** can still report the realm the server
|
|
1292
|
+
// announced. That realm is the missing piece when someone's server does not use `WebDAV`: the
|
|
1293
|
+
// password was hashed against the default, so nothing can work until it is re-hashed against the
|
|
1294
|
+
// real one — and without this the panel had no way to learn it.
|
|
1295
|
+
let client = null
|
|
1154
1296
|
try {
|
|
1155
|
-
|
|
1297
|
+
client = syncSystem().engine.connect()
|
|
1156
1298
|
const options = await client.options()
|
|
1157
1299
|
const devices = await syncSystem().engine.discover(client)
|
|
1158
1300
|
sendJson(res, 200, {
|
|
@@ -1164,7 +1306,7 @@ export function apply(ctx) {
|
|
|
1164
1306
|
warning: client.insecureWarning ?? null,
|
|
1165
1307
|
})
|
|
1166
1308
|
} catch (error) {
|
|
1167
|
-
sendJson(res, 200, { ok: false, error: String(error?.message ?? error) })
|
|
1309
|
+
sendJson(res, 200, { ok: false, error: String(error?.message ?? error), realm: client?.challenge?.realm ?? null })
|
|
1168
1310
|
}
|
|
1169
1311
|
return
|
|
1170
1312
|
}
|
|
@@ -1198,23 +1340,52 @@ export function apply(ctx) {
|
|
|
1198
1340
|
// asynchronous, and a value read after it would be whatever the environment holds by then — which a
|
|
1199
1341
|
// test that sets an override for exactly this mount would already have taken away.
|
|
1200
1342
|
const startupDelay = delayFor('DSH_USAGE_BADGE_STARTUP_SYNC_MS', STARTUP_SYNC_DELAY_MS)
|
|
1343
|
+
// Unloading before the first fold finishes used to schedule the first sync *after* the unload: the
|
|
1344
|
+
// callback ran anyway, took the lock back, and synced through a store that had already been closed
|
|
1345
|
+
// (`statement has been finalized`), leaving a lock file behind for a host that was gone.
|
|
1346
|
+
let disposed = false
|
|
1201
1347
|
void warm().then(() => {
|
|
1348
|
+
if (disposed) return
|
|
1202
1349
|
scheduleStartupSync(startupDelay)
|
|
1203
1350
|
// Fire and forget: this only fills in what the server holds, for the panel's next read.
|
|
1204
1351
|
void refreshRemoteDevices()
|
|
1205
1352
|
})
|
|
1206
|
-
|
|
1353
|
+
// The disposer is **async on purpose**, and everything below depends on the order:
|
|
1354
|
+
//
|
|
1355
|
+
// 1. stop automatic work that would fire into a process that is going away;
|
|
1356
|
+
// 2. sync once, while the store is still open (a sync reads it) and while we still own the lock;
|
|
1357
|
+
// 3. hand the lock back and close the store.
|
|
1358
|
+
//
|
|
1359
|
+
// Returning a promise is what makes step 2 real: DSH awaits the host's teardown, cordis awaits what an
|
|
1360
|
+
// effect's disposer returns, and the runtime force-exits only after 5 s. So this is not "fire and hope";
|
|
1361
|
+
// it is synchronised with the shutdown — and bounded, so a slow server cannot hold up quitting.
|
|
1362
|
+
return async () => {
|
|
1363
|
+
disposed = true
|
|
1207
1364
|
// Any pending automatic work is dropped on unload: a push that fires while the host is going away
|
|
1208
1365
|
// would write state for a process that no longer exists.
|
|
1209
1366
|
if (pricingPushTimer) clearTimeout(pricingPushTimer)
|
|
1210
1367
|
if (startupSyncTimer) clearTimeout(startupSyncTimer)
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1368
|
+
if (system?.running) log('unloading while a sync is in flight; it stops at its next step boundary')
|
|
1369
|
+
try {
|
|
1370
|
+
await syncOnExit()
|
|
1371
|
+
} catch (error) {
|
|
1372
|
+
log(`exit sync failed: ${error?.message ?? error}`)
|
|
1373
|
+
}
|
|
1374
|
+
// **And the lock is handed back.** Not releasing it leaves `host.lock` naming this dead process, and a
|
|
1375
|
+
// re-mount inside the same process — a hot reload, or a test mounting the same home twice — is then
|
|
1376
|
+
// refused by this plugin's own lock, for a pid that is very much alive: its own.
|
|
1377
|
+
//
|
|
1378
|
+
// `engine.release()` alone is not enough: a run in flight was handed the host's lock object, and the
|
|
1379
|
+
// engine only releases one it acquired itself (`ownsLock`), so unloading mid-run would leave the file
|
|
1380
|
+
// behind. `releaseLock` checks the pid, so this can only ever remove our own.
|
|
1214
1381
|
if (system) {
|
|
1215
1382
|
system.syncAbort = true
|
|
1383
|
+
// Stops a run at its next step boundary. Requests already on the wire finish; the phases that would
|
|
1384
|
+
// touch the store do not start.
|
|
1385
|
+
system.engine.abort()
|
|
1216
1386
|
system.engine.release()
|
|
1217
1387
|
}
|
|
1388
|
+
releaseLock(dataDir)
|
|
1218
1389
|
aggregator.close()
|
|
1219
1390
|
}
|
|
1220
1391
|
}, 'usage-badge: warm the fold, schedule the first sync, and close the cache on unload')
|