pinokiod 8.0.69 → 8.0.71

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/kernel/index.js CHANGED
@@ -1062,15 +1062,15 @@ class Kernel {
1062
1062
  kill() {
1063
1063
  process.kill(process.pid, "SIGTERM")
1064
1064
  }
1065
- async disposeVaultAutomaticScans() {
1065
+ async disposeVault() {
1066
1066
  const vault = this.vault
1067
- if (!vault || !vault.automaticScans) return false
1067
+ if (!vault) return false
1068
1068
  if (vault.ready) await Promise.resolve(vault.ready).catch(() => {})
1069
1069
  try {
1070
- await vault.automaticScans.dispose()
1070
+ await vault.dispose()
1071
1071
  return true
1072
1072
  } catch (error) {
1073
- console.warn("Vault automatic cleanup error:",
1073
+ console.warn("Vault cleanup error:",
1074
1074
  error && error.message ? error.message : error)
1075
1075
  return false
1076
1076
  }
@@ -1092,7 +1092,7 @@ class Kernel {
1092
1092
  })
1093
1093
 
1094
1094
  let home = this.store.get("home") || process.env.PINOKIO_HOME
1095
- await this.disposeVaultAutomaticScans()
1095
+ await this.disposeVault()
1096
1096
  this.vault = null
1097
1097
  this.homedir = home
1098
1098
 
package/kernel/shell.js CHANGED
@@ -290,6 +290,7 @@ class Shell {
290
290
  }
291
291
  }
292
292
 
293
+ setDefaultEnvValue(this.env, "UV_HTTP_TIMEOUT", "60")
293
294
  setDefaultEnvValue(this.env, "HF_HUB_DISABLE_UPDATE_CHECK", "1")
294
295
  setDefaultEnvValue(this.env, "HF_TOKEN_PATH", this.kernel.envs?.HF_TOKEN_PATH || path.resolve(this.kernel.homedir, "cache", "HF_AUTH", "token"))
295
296
 
@@ -2,9 +2,12 @@ const fs = require("fs")
2
2
  const path = require("path")
3
3
  const {
4
4
  SIZE_THRESHOLD,
5
- isCandidateFileSize
5
+ isCandidateFileSize,
6
+ SHA256_RE,
7
+ ENTRY_BATCH_SIZE
6
8
  } = require("./constants")
7
9
  const { statMany } = require("./walker")
10
+ const { sameSnapshot } = require("./snapshot")
8
11
  const {
9
12
  cancelledError,
10
13
  isPathError
@@ -24,6 +27,11 @@ const inside = (root, candidate) => {
24
27
  )
25
28
  }
26
29
 
30
+ const pathKey = (value) => {
31
+ const resolved = path.resolve(value)
32
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved
33
+ }
34
+
27
35
  class AutomaticScans {
28
36
  constructor(vault) {
29
37
  this.vault = vault
@@ -745,6 +753,7 @@ class AutomaticScans {
745
753
  }
746
754
  this.active.cancelled = true
747
755
  this.active.reason = reason
756
+ if (this.active.controller) this.active.controller.abort()
748
757
  this.log("check-cancel-requested", { app, reason })
749
758
  return true
750
759
  }
@@ -755,6 +764,90 @@ class AutomaticScans {
755
764
  }
756
765
  }
757
766
 
767
+ automaticHashKey(entry) {
768
+ return [
769
+ this.automaticIdentityKey(entry),
770
+ entry.size,
771
+ entry.mtime,
772
+ entry.ctime
773
+ ].join("\0")
774
+ }
775
+
776
+ automaticIdentityKey(entry) {
777
+ return entry.ino !== 0
778
+ ? `inode:${entry.dev}:${entry.ino}`
779
+ : `path:${pathKey(entry.path)}`
780
+ }
781
+
782
+ logVerificationSkip(app, entry, reason, counts, error = null) {
783
+ if (error) counts.path_errors += 1
784
+ else counts.unstable_hashes += 1
785
+ this.log("verification-skipped", {
786
+ app,
787
+ path: entry.path,
788
+ kind: entry.kind,
789
+ reason,
790
+ code: error && error.code,
791
+ message: error && error.message
792
+ })
793
+ }
794
+
795
+ async verifiedAutomaticHash(active, entry, memory, verifiedHashes, counts) {
796
+ this.checkpoint(active)
797
+ let current
798
+ try {
799
+ current = await fs.promises.lstat(entry.path)
800
+ } catch (error) {
801
+ if (!isPathError(error)) throw error
802
+ this.logVerificationSkip(active.app, entry, "unreadable", counts, error)
803
+ return null
804
+ }
805
+ this.checkpoint(active)
806
+ if (!current.isFile() || current.isSymbolicLink() ||
807
+ !sameSnapshot(entry, current)) {
808
+ this.logVerificationSkip(
809
+ active.app, entry, "snapshot-changed", counts)
810
+ return null
811
+ }
812
+ const key = this.automaticHashKey(entry)
813
+ const reusable = SHA256_RE.test(entry.hash || "")
814
+ ? entry.hash
815
+ : memory.get(key)
816
+ if (reusable) {
817
+ counts.hash_reuses += 1
818
+ memory.set(key, reusable)
819
+ verifiedHashes.set(pathKey(entry.path), Object.assign({}, entry, {
820
+ hash: reusable
821
+ }))
822
+ return reusable
823
+ }
824
+ let verified
825
+ try {
826
+ verified = await this.vault.scanner.hashStable(entry, {
827
+ signal: active.controller && active.controller.signal
828
+ })
829
+ } catch (error) {
830
+ if (error && error.code === "EVAULTCANCELLED") throw error
831
+ if (!isPathError(error)) throw error
832
+ counts.hash_failures += 1
833
+ this.logVerificationSkip(active.app, entry, "hash-failed", counts, error)
834
+ return null
835
+ }
836
+ this.checkpoint(active)
837
+ if (!verified.stable || !SHA256_RE.test(verified.result.hash || "")) {
838
+ this.logVerificationSkip(
839
+ active.app, entry, "changed-during-hash", counts)
840
+ return null
841
+ }
842
+ counts.hashed += 1
843
+ counts.hash_bytes += Math.max(0, Number(verified.result.size) || 0)
844
+ memory.set(key, verified.result.hash)
845
+ verifiedHashes.set(pathKey(entry.path), Object.assign({}, entry, {
846
+ hash: verified.result.hash
847
+ }))
848
+ return verified.result.hash
849
+ }
850
+
758
851
  async runPrecheck(active) {
759
852
  const app = active.app
760
853
  const root = path.resolve(this.vault.kernel.homedir, "api", app)
@@ -768,18 +861,24 @@ class AutomaticScans {
768
861
  files: 0,
769
862
  bytes: 0,
770
863
  candidates: 0,
771
- path_errors: 0
864
+ path_errors: 0,
865
+ hashed: 0,
866
+ hash_bytes: 0,
867
+ hash_reuses: 0,
868
+ hash_failures: 0,
869
+ unstable_hashes: 0
772
870
  }
773
871
  const threshold = await this.candidateThreshold()
774
872
  const paths = [...new Set(active.paths || [])].filter((filePath) =>
775
873
  typeof filePath === "string" && inside(root, filePath))
874
+ const verifiedHashes = new Map()
776
875
  await this.vault.registry.beginAutomaticPrecheck(app)
777
876
  this.log("check-started", {
778
877
  app,
779
878
  root,
780
879
  threshold_bytes: threshold,
781
880
  changed_paths: paths.length,
782
- policy: "metadata-only"
881
+ policy: "metadata-prefilter-sha256"
783
882
  })
784
883
  try {
785
884
  this.checkpoint(active)
@@ -817,8 +916,14 @@ class AutomaticScans {
817
916
  candidates.push({
818
917
  path: paths[index],
819
918
  size: stat.size,
919
+ mtime: stat.mtimeMs,
920
+ ctime: stat.ctimeMs,
820
921
  dev: stat.dev,
821
- ino: stat.ino
922
+ ino: stat.ino,
923
+ nlink: stat.nlink,
924
+ mode: stat.mode,
925
+ uid: stat.uid,
926
+ gid: stat.gid
822
927
  })
823
928
  }
824
929
  counts.candidates = candidates.length
@@ -831,15 +936,123 @@ class AutomaticScans {
831
936
  error.code = "ENOENT"
832
937
  throw error
833
938
  }
834
- const result = await this.vault.registry.automaticPrecheckResult(app)
939
+ const verifiedMatches = []
940
+ let group = null
941
+ const preparePeers = () => {
942
+ if (!group || group.unmatched) return
943
+ group.unmatched = new Map()
944
+ group.unmatchedIdentities = new Map()
945
+ for (const [hash, value] of group.candidatesByHash) {
946
+ if (value.identities.size > 1) {
947
+ for (const candidate of value.candidates) {
948
+ group.matched.add(pathKey(candidate.path))
949
+ }
950
+ } else {
951
+ const identity = value.identities.values().next().value
952
+ group.unmatched.set(hash, {
953
+ identity,
954
+ candidates: value.candidates
955
+ })
956
+ group.unmatchedIdentities.set(
957
+ identity,
958
+ (group.unmatchedIdentities.get(identity) || 0) + 1)
959
+ }
960
+ }
961
+ }
962
+ const finishGroup = () => {
963
+ if (!group) return
964
+ preparePeers()
965
+ for (const [hash, value] of group.candidatesByHash) {
966
+ for (const candidate of value.candidates) {
967
+ if (!group.matched.has(pathKey(candidate.path))) continue
968
+ verifiedMatches.push({ path: candidate.path, hash })
969
+ }
970
+ }
971
+ group = null
972
+ }
973
+ let cursor = null
974
+ do {
975
+ const page = await this.vault.registry.automaticPrecheckEntries(
976
+ app, cursor, ENTRY_BATCH_SIZE)
977
+ this.checkpoint(active)
978
+ for (const entry of page.entries) {
979
+ const key = `${entry.dev}\0${entry.size}`
980
+ if (!group || group.key !== key) {
981
+ finishGroup()
982
+ group = {
983
+ key,
984
+ candidatesByHash: new Map(),
985
+ matched: new Set(),
986
+ unmatched: null,
987
+ unmatchedIdentities: null,
988
+ memory: new Map()
989
+ }
990
+ }
991
+ if (entry.kind === "changed") {
992
+ const candidate = entry
993
+ this.checkpoint(active)
994
+ const hash = await this.verifiedAutomaticHash(
995
+ active, candidate, group.memory, verifiedHashes, counts)
996
+ if (!hash) continue
997
+ let value = group.candidatesByHash.get(hash)
998
+ if (!value) {
999
+ value = { candidates: [], identities: new Set() }
1000
+ group.candidatesByHash.set(hash, value)
1001
+ }
1002
+ value.candidates.push(candidate)
1003
+ value.identities.add(this.automaticIdentityKey(candidate))
1004
+ continue
1005
+ }
1006
+ preparePeers()
1007
+ if (!group.unmatched.size) continue
1008
+ this.checkpoint(active)
1009
+ const identity = this.automaticIdentityKey(entry)
1010
+ if (group.unmatchedIdentities.size === 1 &&
1011
+ group.unmatchedIdentities.has(identity)) continue
1012
+ const hash = await this.verifiedAutomaticHash(
1013
+ active, entry, group.memory, verifiedHashes, counts)
1014
+ const wanted = hash && group.unmatched.get(hash)
1015
+ if (!wanted || wanted.identity === identity) {
1016
+ continue
1017
+ }
1018
+ for (const candidate of wanted.candidates) {
1019
+ group.matched.add(pathKey(candidate.path))
1020
+ }
1021
+ group.unmatched.delete(hash)
1022
+ const remaining = group.unmatchedIdentities.get(wanted.identity) - 1
1023
+ if (remaining) {
1024
+ group.unmatchedIdentities.set(wanted.identity, remaining)
1025
+ } else {
1026
+ group.unmatchedIdentities.delete(wanted.identity)
1027
+ }
1028
+ }
1029
+ if (verifiedHashes.size >= ENTRY_BATCH_SIZE) {
1030
+ await this.vault.registry.rememberHashCache(
1031
+ [...verifiedHashes.values()])
1032
+ verifiedHashes.clear()
1033
+ this.checkpoint(active)
1034
+ }
1035
+ cursor = page.next_cursor
1036
+ } while (cursor)
1037
+ finishGroup()
1038
+ this.checkpoint(active)
1039
+ const result = await this.vault.registry.automaticPrecheckResult(
1040
+ app, verifiedMatches)
835
1041
  this.checkpoint(active)
836
1042
  return Object.assign({}, counts, {
837
1043
  signature: result.signature,
838
- possible_files: Math.max(0, Number(result.files) || 0),
1044
+ verified_files: Math.max(0, Number(result.files) || 0),
839
1045
  duration_ms: Date.now() - startedAt
840
1046
  })
841
1047
  } finally {
842
- await this.vault.registry.abortAutomaticPrecheck(app)
1048
+ try {
1049
+ if (verifiedHashes.size) {
1050
+ await this.vault.registry.rememberHashCache(
1051
+ [...verifiedHashes.values()])
1052
+ }
1053
+ } finally {
1054
+ await this.vault.registry.abortAutomaticPrecheck(app)
1055
+ }
843
1056
  }
844
1057
  }
845
1058
 
@@ -894,6 +1107,7 @@ class AutomaticScans {
894
1107
  paths: [...new Set(entry.paths || [])],
895
1108
  cancelled: false,
896
1109
  reason: null,
1110
+ controller: new AbortController(),
897
1111
  promise: null
898
1112
  }
899
1113
  this.active = active
@@ -933,7 +1147,12 @@ class AutomaticScans {
933
1147
  files: result && result.files,
934
1148
  bytes: result && result.bytes,
935
1149
  candidates: result && result.candidates,
936
- possible_files: result && result.possible_files,
1150
+ verified_files: result && result.verified_files,
1151
+ hashed: result && result.hashed,
1152
+ hash_bytes: result && result.hash_bytes,
1153
+ hash_reuses: result && result.hash_reuses,
1154
+ hash_failures: result && result.hash_failures,
1155
+ unstable_hashes: result && result.unstable_hashes,
937
1156
  path_errors: result && result.path_errors,
938
1157
  error: error && error.message
939
1158
  })
@@ -1037,8 +1256,8 @@ class AutomaticScans {
1037
1256
  }
1038
1257
 
1039
1258
  async publishResultNow(app, result = {}) {
1040
- const possibleFiles = Math.max(0, Number(result.possible_files) || 0)
1041
- if (possibleFiles > 0 && result.signature) {
1259
+ const verifiedFiles = Math.max(0, Number(result.verified_files) || 0)
1260
+ if (verifiedFiles > 0 && result.signature) {
1042
1261
  const acknowledged = (this.settings.get(app) || {})
1043
1262
  .acknowledged_signature
1044
1263
  const options = { signature: result.signature }
@@ -1059,9 +1278,9 @@ class AutomaticScans {
1059
1278
  previous: null,
1060
1279
  hidden: acknowledged === result.signature
1061
1280
  })
1062
- this.log("possible-matches", {
1281
+ this.log("verified-matches", {
1063
1282
  app,
1064
- possible_files: possibleFiles,
1283
+ verified_files: verifiedFiles,
1065
1284
  acknowledged: acknowledged === result.signature
1066
1285
  })
1067
1286
  return "result"
@@ -1076,7 +1295,7 @@ class AutomaticScans {
1076
1295
  await this.vault.registry.setAutomaticAppScanState(app, null)
1077
1296
  }
1078
1297
  this.entries.delete(app)
1079
- this.log("no-possible-matches", { app })
1298
+ this.log("no-verified-matches", { app })
1080
1299
  return "empty"
1081
1300
  }
1082
1301
 
@@ -1246,17 +1465,17 @@ class AutomaticScans {
1246
1465
 
1247
1466
  async acknowledge(app, signature) {
1248
1467
  if (this.disposed) {
1249
- return { error: "That possible-match result is no longer available." }
1468
+ return { error: "That automatic duplicate result is no longer available." }
1250
1469
  }
1251
1470
  return this.withAppTransition(app, async () => {
1252
1471
  await this.hydrate()
1253
1472
  if (this.disposed) {
1254
- return { error: "That possible-match result is no longer available." }
1473
+ return { error: "That automatic duplicate result is no longer available." }
1255
1474
  }
1256
1475
  const current = this.entries.get(app)
1257
1476
  const entry = this.publicResult(current)
1258
1477
  if (!entry) {
1259
- return { error: "That possible-match result is no longer available." }
1478
+ return { error: "That automatic duplicate result is no longer available." }
1260
1479
  }
1261
1480
  if (typeof signature !== "string" || signature !== entry.signature) {
1262
1481
  return { stale: true, app }
@@ -7,14 +7,27 @@ const fs = require('fs')
7
7
  // same sha256 digest, one file at a time.
8
8
  const HASH_READ_SIZE = 1024 * 1024
9
9
  const HASH_PROGRESS_INTERVAL_MS = 1000
10
+ const jobs = new Map()
10
11
 
11
- parentPort.on('message', ({ id, filePath }) => {
12
+ parentPort.on('message', ({ id, filePath, cancel }) => {
13
+ if (cancel) {
14
+ const job = jobs.get(id)
15
+ if (!job) return
16
+ const error = new Error('Hashing cancelled.')
17
+ error.code = 'EVAULTCANCELLED'
18
+ job.stream.destroy(error)
19
+ return
20
+ }
12
21
  const hash = crypto.createHash('sha256')
13
22
  let size = 0
14
23
  let lastProgressAt = 0
15
24
  let complete = false
16
25
  const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0)
17
- const stream = fs.createReadStream(filePath, { flags, highWaterMark: HASH_READ_SIZE })
26
+ const stream = fs.createReadStream(filePath, {
27
+ flags,
28
+ highWaterMark: HASH_READ_SIZE
29
+ })
30
+ jobs.set(id, { stream })
18
31
  stream.on('data', (chunk) => {
19
32
  size += chunk.length
20
33
  hash.update(chunk)
@@ -27,11 +40,13 @@ parentPort.on('message', ({ id, filePath }) => {
27
40
  stream.on('error', (error) => {
28
41
  if (complete) return
29
42
  complete = true
43
+ jobs.delete(id)
30
44
  parentPort.postMessage({ id, error: error.message, code: error.code })
31
45
  })
32
46
  stream.on('end', () => {
33
47
  if (complete) return
34
48
  complete = true
49
+ jobs.delete(id)
35
50
  parentPort.postMessage({ id, hash: hash.digest('hex'), size })
36
51
  })
37
52
  })
@@ -9,6 +9,7 @@ const Sweeper = require("./sweeper")
9
9
  const FolderFinder = require("./folder_finder")
10
10
  const AutomaticScans = require("./automatic_scans")
11
11
  const { fileSnapshot, sameSnapshot, sameContentState } = require("./snapshot")
12
+ const { cancelledError } = require("./operation_errors")
12
13
  const {
13
14
  SIZE_THRESHOLD,
14
15
  CANDIDATE_SIZE_OPTIONS,
@@ -230,6 +231,7 @@ class Vault {
230
231
  this._anchorStoresByDevice = new Map()
231
232
  this.operationTail = Promise.resolve()
232
233
  this.registryInitializationPromise = null
234
+ this.registryRestartPromise = null
233
235
  this.initializationPromise = null
234
236
  this.scanPromise = null
235
237
  this.scanCompletionPromise = null
@@ -246,6 +248,42 @@ class Vault {
246
248
  this.automaticScans = new AutomaticScans(this)
247
249
  }
248
250
 
251
+ async dispose() {
252
+ const failures = []
253
+ const settle = async (promise) => {
254
+ if (!promise) return
255
+ try {
256
+ await promise
257
+ } catch (error) {
258
+ failures.push(error)
259
+ }
260
+ }
261
+
262
+ await settle(this.automaticScans.dispose())
263
+ if (this.scanPromise) this.cancelScan()
264
+ if (this.folderDiscoveryPromise) this.cancelFolderDiscovery()
265
+ if (this.fileActionProgress) this.cancelFileAction()
266
+ await settle(this.operationTail)
267
+ await settle(this.scanCompletionPromise)
268
+ await settle(this.folderDiscoveryCommitPromise)
269
+ await settle(this.registryRestartPromise)
270
+
271
+ if (this.worker) {
272
+ const worker = this.worker
273
+ this.failHashWorker(worker, cancelledError("Vault disposed."))
274
+ await settle(worker.terminate())
275
+ }
276
+ if (this.registry) {
277
+ const registry = this.registry
278
+ this.registry = null
279
+ await settle(registry.close())
280
+ }
281
+ this.initialized = false
282
+ this.sweeper = null
283
+ this.folderFinder = null
284
+ if (failures.length) throw failures[0]
285
+ }
286
+
249
287
  get root() {
250
288
  return path.resolve(this.kernel.homedir, "vault")
251
289
  }
@@ -614,6 +652,9 @@ class Vault {
614
652
  }
615
653
 
616
654
  ensureRegistryInitialized() {
655
+ if (this.registryRestartPromise) {
656
+ return this.registryRestartPromise.then(() => ({ enabled: true }))
657
+ }
617
658
  if (this.registry) return Promise.resolve({ enabled: true })
618
659
  if (!this.registryInitializationPromise) {
619
660
  this.registryInitializationPromise = this.initializeRegistryStorage()
@@ -640,6 +681,7 @@ class Vault {
640
681
 
641
682
  async ensureInitialized() {
642
683
  if (!this.enabled) return { enabled: false }
684
+ await this.ensureRegistryInitialized()
643
685
  if (this.initialized) return { enabled: true, mode: this.mode }
644
686
  if (!this.initializationPromise) {
645
687
  this.initializationPromise = this.initializeStorage().finally(() => {
@@ -651,6 +693,7 @@ class Vault {
651
693
 
652
694
  async openWorkspace() {
653
695
  if (!this.initialized) return this.ensureInitialized()
696
+ await this.ensureRegistryInitialized()
654
697
  await this.refreshAnchorStores()
655
698
  await this.refreshSources()
656
699
  return { enabled: true, mode: this.mode }
@@ -936,7 +979,7 @@ class Vault {
936
979
  }
937
980
  for (const [id, job] of [...this.workerJobs]) {
938
981
  if (job.worker !== worker) continue
939
- clearTimeout(job.inactivityTimer)
982
+ job.cleanup()
940
983
  this.workerJobs.delete(id)
941
984
  job.reject(error)
942
985
  }
@@ -944,6 +987,10 @@ class Vault {
944
987
  }
945
988
 
946
989
  async hashFile(filePath, options = {}) {
990
+ const signal = options.signal
991
+ if (signal && signal.aborted) {
992
+ throw cancelledError("Hashing cancelled.")
993
+ }
947
994
  if (this.workerIdleTimer) {
948
995
  clearTimeout(this.workerIdleTimer)
949
996
  this.workerIdleTimer = null
@@ -962,7 +1009,7 @@ class Vault {
962
1009
  job.reportProgress(bytesRead)
963
1010
  return
964
1011
  }
965
- clearTimeout(job.inactivityTimer)
1012
+ job.cleanup()
966
1013
  this.workerJobs.delete(id)
967
1014
  if (error) {
968
1015
  const failure = new Error(error)
@@ -1016,7 +1063,22 @@ class Vault {
1016
1063
  } catch (error) {}
1017
1064
  },
1018
1065
  inactivityTimer: null,
1019
- resetInactivity: null
1066
+ resetInactivity: null,
1067
+ cancel: null,
1068
+ cleanup: null
1069
+ }
1070
+ job.cancel = () => {
1071
+ if (!this.workerJobs.has(id)) return
1072
+ try {
1073
+ worker.postMessage({ id, cancel: true })
1074
+ } catch (error) {
1075
+ const failure = cancelledError("Hashing cancelled.")
1076
+ this.failHashWorker(worker, failure, true)
1077
+ }
1078
+ }
1079
+ job.cleanup = () => {
1080
+ clearTimeout(job.inactivityTimer)
1081
+ if (signal) signal.removeEventListener("abort", job.cancel)
1020
1082
  }
1021
1083
  job.resetInactivity = () => {
1022
1084
  clearTimeout(job.inactivityTimer)
@@ -1031,9 +1093,11 @@ class Vault {
1031
1093
  if (job.inactivityTimer.unref) job.inactivityTimer.unref()
1032
1094
  }
1033
1095
  this.workerJobs.set(id, job)
1096
+ if (signal) signal.addEventListener("abort", job.cancel, { once: true })
1034
1097
  job.resetInactivity()
1035
1098
  try {
1036
1099
  worker.postMessage({ id, filePath })
1100
+ if (signal && signal.aborted) job.cancel()
1037
1101
  } catch (error) {
1038
1102
  const failure = new Error(error && error.message
1039
1103
  ? error.message
@@ -1696,6 +1760,9 @@ class Vault {
1696
1760
  this.scanPromise = tracked
1697
1761
  const completion = tracked.then(async () => {
1698
1762
  try {
1763
+ if (this.registryRestartPromise) {
1764
+ await this.registryRestartPromise
1765
+ }
1699
1766
  await this.automaticScans.scanFinished({
1700
1767
  scopeId,
1701
1768
  result: scanResult,
@@ -1719,17 +1786,47 @@ class Vault {
1719
1786
  if (!this.scanPromise || !this.sweeper) {
1720
1787
  return { cancel_requested: false }
1721
1788
  }
1789
+ // Publication is one atomic commit. Once it starts, let it finish so a
1790
+ // committed result can never be reported as cancelled.
1791
+ if (this.sweeper.state.active &&
1792
+ this.sweeper.state.phase === "publishing") {
1793
+ return { cancel_requested: false }
1794
+ }
1722
1795
  this.scanCancelRequested = true
1796
+ const error = new Error("Scan cancelled.")
1797
+ error.code = "EVAULTCANCELLED"
1798
+ const cancelRequested = this.sweeper.state.active
1799
+ ? this.sweeper.cancel()
1800
+ : true
1723
1801
  if (this.sweeper.state.active && this.sweeper.currentHash &&
1724
1802
  this.worker) {
1725
- const error = new Error("Scan cancelled.")
1726
- error.code = "EVAULTCANCELLED"
1727
1803
  this.failHashWorker(this.worker, error, true)
1728
1804
  }
1805
+ if (this.sweeper.state.active && this.registry &&
1806
+ !this.registryRestartPromise) {
1807
+ const registry = this.registry
1808
+ const restart = registry.restart(error).catch((restartError) => {
1809
+ if (this.registry === registry) {
1810
+ this.registry = null
1811
+ this.initialized = false
1812
+ this.sweeper = null
1813
+ }
1814
+ this.scanError = restartError && restartError.message
1815
+ ? restartError.message
1816
+ : String(restartError)
1817
+ throw restartError
1818
+ })
1819
+ const tracked = restart.finally(() => {
1820
+ if (this.registryRestartPromise === tracked) {
1821
+ this.registryRestartPromise = null
1822
+ }
1823
+ })
1824
+ this.registryRestartPromise = tracked
1825
+ // The action returns immediately; subsequent work awaits this promise.
1826
+ tracked.catch(() => {})
1827
+ }
1729
1828
  return {
1730
- cancel_requested: this.sweeper.state.active
1731
- ? this.sweeper.cancel()
1732
- : true
1829
+ cancel_requested: cancelRequested
1733
1830
  }
1734
1831
  }
1735
1832