pinokiod 8.0.71 → 8.0.73

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.
@@ -7,7 +7,7 @@ const {
7
7
  ENTRY_BATCH_SIZE
8
8
  } = require("./constants")
9
9
  const { statMany } = require("./walker")
10
- const { sameSnapshot } = require("./snapshot")
10
+ const { fileSnapshot, sameSnapshot } = require("./snapshot")
11
11
  const {
12
12
  cancelledError,
13
13
  isPathError
@@ -52,10 +52,12 @@ class AutomaticScans {
52
52
  this.manualDepth = 0
53
53
  this.observedApps = new Set()
54
54
  this.changedPaths = new Map()
55
+ this.changedPathIndex = new Map()
55
56
  this.pendingStops = new Map()
56
57
  this.lifecycleWork = new Set()
57
58
  this.stopSettleMs = STOP_SETTLE_MS
58
59
  this.drainQueued = false
60
+ this.drainRequested = false
59
61
  this.waitingFor = null
60
62
  this.listeners = new Set()
61
63
  this.appTransitions = new Map()
@@ -136,6 +138,7 @@ class AutomaticScans {
136
138
  }
137
139
  this.observedApps.clear()
138
140
  this.changedPaths.clear()
141
+ this.changedPathIndex.clear()
139
142
  if (watcherError) throw watcherError
140
143
  }
141
144
 
@@ -152,7 +155,8 @@ class AutomaticScans {
152
155
  if (!event || !["create", "update", "delete"].includes(event.type)) {
153
156
  continue
154
157
  }
155
- this.recordChangedPath(event.path)
158
+ if (event.type === "delete") this.removeChangedPath(event.path)
159
+ else this.recordChangedPath(event.path)
156
160
  }
157
161
  }
158
162
 
@@ -171,25 +175,105 @@ class AutomaticScans {
171
175
  paths = new Set()
172
176
  this.changedPaths.set(app, paths)
173
177
  }
178
+ let index = this.changedPathIndex.get(app)
179
+ if (!index) {
180
+ index = { exact: new Map(), descendants: new Map() }
181
+ this.changedPathIndex.set(app, index)
182
+ }
183
+ const key = pathKey(resolved)
184
+ if (index.exact.has(key)) return true
174
185
  paths.add(resolved)
186
+ index.exact.set(key, resolved)
187
+ this.addChangedPathToIndex(index.descendants, appRoot, resolved)
188
+ return true
189
+ }
190
+
191
+ changedPathParentKeys(appRoot, filePath) {
192
+ const rootKey = pathKey(appRoot)
193
+ const keys = []
194
+ let current = path.dirname(path.resolve(filePath))
195
+ while (true) {
196
+ const key = pathKey(current)
197
+ keys.push(key)
198
+ if (key === rootKey) return keys
199
+ current = path.dirname(current)
200
+ }
201
+ }
202
+
203
+ addChangedPathToIndex(index, appRoot, filePath) {
204
+ for (const key of this.changedPathParentKeys(appRoot, filePath)) {
205
+ let descendants = index.get(key)
206
+ if (!descendants) {
207
+ descendants = new Set()
208
+ index.set(key, descendants)
209
+ }
210
+ descendants.add(filePath)
211
+ }
212
+ }
213
+
214
+ removeChangedPathFromIndex(index, appRoot, filePath) {
215
+ index.exact.delete(pathKey(filePath))
216
+ for (const key of this.changedPathParentKeys(appRoot, filePath)) {
217
+ const descendants = index.descendants.get(key)
218
+ if (!descendants) continue
219
+ descendants.delete(filePath)
220
+ if (!descendants.size) index.descendants.delete(key)
221
+ }
222
+ }
223
+
224
+ removeChangedPath(filePath) {
225
+ if (this.disposed || !this.supported ||
226
+ typeof filePath !== "string" || !filePath) {
227
+ return false
228
+ }
229
+ const app = this.appForLaunchPath(filePath)
230
+ if (!app || !this.observedApps.has(app)) return false
231
+ const paths = this.changedPaths.get(app)
232
+ if (!paths) return false
233
+ const resolved = path.resolve(filePath)
234
+ const appRoot = path.resolve(this.vault.kernel.homedir, "api", app)
235
+ const index = this.changedPathIndex.get(app)
236
+ if (!index) return false
237
+ const key = pathKey(resolved)
238
+ const exact = index.exact.get(key)
239
+ const descendants = index.descendants.get(key)
240
+ if (!exact && !descendants) return false
241
+ const matches = new Set(descendants || [])
242
+ if (exact) matches.add(exact)
243
+ for (const candidate of matches) {
244
+ paths.delete(candidate)
245
+ this.removeChangedPathFromIndex(index, appRoot, candidate)
246
+ }
247
+ if (!paths.size) this.discardChangedPaths(app)
175
248
  return true
176
249
  }
177
250
 
178
251
  discardChangedPaths(app) {
179
252
  this.changedPaths.delete(app)
253
+ this.changedPathIndex.delete(app)
180
254
  }
181
255
 
182
256
  consumeChangedPaths(app, paths) {
183
257
  const collected = this.changedPaths.get(app)
184
258
  if (!collected) return
185
- for (const filePath of paths || []) collected.delete(filePath)
186
- if (!collected.size) this.changedPaths.delete(app)
259
+ const appRoot = path.resolve(this.vault.kernel.homedir, "api", app)
260
+ const index = this.changedPathIndex.get(app)
261
+ for (const filePath of paths || []) {
262
+ const resolved = path.resolve(filePath)
263
+ if (!collected.delete(resolved) || !index) continue
264
+ this.removeChangedPathFromIndex(index, appRoot, resolved)
265
+ }
266
+ if (!collected.size) this.discardChangedPaths(app)
187
267
  }
188
268
 
189
269
  async candidateThreshold() {
190
- const scan = this.vault.registry
191
- ? await this.vault.registry.scanFor()
270
+ const cached = this.vault.lastScanCache &&
271
+ typeof this.vault.lastScanCache.get === "function"
272
+ ? this.vault.lastScanCache.get("")
192
273
  : null
274
+ const scan = cached || (this.vault.registry
275
+ ? await this.vault.registry.scanFor()
276
+ : null)
193
277
  const threshold = scan ? Number(scan.candidate_min_bytes) : NaN
194
278
  return Number.isFinite(threshold) && threshold >= 0
195
279
  ? threshold
@@ -726,24 +810,38 @@ class AutomaticScans {
726
810
  }
727
811
 
728
812
  schedule() {
729
- if (this.disposed || this.drainQueued) return
813
+ if (this.disposed) return
814
+ if (this.drainQueued) {
815
+ this.drainRequested = true
816
+ return
817
+ }
730
818
  this.drainQueued = true
731
819
  queueMicrotask(() => {
732
- this.drainQueued = false
733
- const pending = this.drain()
820
+ const pending = (async () => {
821
+ do {
822
+ this.drainRequested = false
823
+ await this.drain()
824
+ } while (!this.disposed && this.drainRequested)
825
+ })()
734
826
  this.lifecycleWork.add(pending)
735
827
  pending.then(
736
- () => this.lifecycleWork.delete(pending),
737
- () => this.lifecycleWork.delete(pending)
828
+ () => {
829
+ this.lifecycleWork.delete(pending)
830
+ this.drainQueued = false
831
+ if (this.drainRequested) this.schedule()
832
+ },
833
+ (error) => {
834
+ this.lifecycleWork.delete(pending)
835
+ this.drainQueued = false
836
+ this.log("error", {
837
+ stage: "queue",
838
+ message: error && error.message ? error.message : String(error)
839
+ })
840
+ console.warn("Automatic Disk Saver check failed:",
841
+ error && error.message ? error.message : error)
842
+ if (this.drainRequested) this.schedule()
843
+ }
738
844
  )
739
- pending.catch((error) => {
740
- this.log("error", {
741
- stage: "queue",
742
- message: error && error.message ? error.message : String(error)
743
- })
744
- console.warn("Automatic Disk Saver check failed:",
745
- error && error.message ? error.message : error)
746
- })
747
845
  })
748
846
  }
749
847
 
@@ -799,37 +897,58 @@ class AutomaticScans {
799
897
  current = await fs.promises.lstat(entry.path)
800
898
  } catch (error) {
801
899
  if (!isPathError(error)) throw error
900
+ if (entry.kind === "changed" && isMissing(error)) {
901
+ counts.path_errors += 1
902
+ return null
903
+ }
802
904
  this.logVerificationSkip(active.app, entry, "unreadable", counts, error)
803
905
  return null
804
906
  }
805
907
  this.checkpoint(active)
806
908
  if (!current.isFile() || current.isSymbolicLink() ||
807
- !sameSnapshot(entry, current)) {
909
+ current.dev !== entry.dev || current.size !== entry.size) {
808
910
  this.logVerificationSkip(
809
911
  active.app, entry, "snapshot-changed", counts)
810
912
  return null
811
913
  }
812
- const key = this.automaticHashKey(entry)
813
- const reusable = SHA256_RE.test(entry.hash || "")
814
- ? entry.hash
815
- : memory.get(key)
914
+ const currentEntry = Object.assign({}, entry, fileSnapshot(current))
915
+ const key = this.automaticHashKey(currentEntry)
916
+ const cachedSnapshot = {
917
+ size: entry.cached_size,
918
+ mtime: entry.cached_mtime,
919
+ ctime: entry.cached_ctime,
920
+ dev: entry.cached_dev,
921
+ ino: entry.cached_ino
922
+ }
923
+ const reusable = memory.get(key) ||
924
+ (sameSnapshot(entry, current) && SHA256_RE.test(entry.hash || "")
925
+ ? entry.hash
926
+ : null) ||
927
+ (sameSnapshot(cachedSnapshot, current) &&
928
+ SHA256_RE.test(entry.cached_hash || "")
929
+ ? entry.cached_hash
930
+ : null)
816
931
  if (reusable) {
817
932
  counts.hash_reuses += 1
818
933
  memory.set(key, reusable)
819
- verifiedHashes.set(pathKey(entry.path), Object.assign({}, entry, {
934
+ verifiedHashes.set(pathKey(entry.path), Object.assign({}, currentEntry, {
820
935
  hash: reusable
821
936
  }))
822
937
  return reusable
823
938
  }
824
939
  let verified
825
940
  try {
826
- verified = await this.vault.scanner.hashStable(entry, {
941
+ verified = await this.vault.scanner.hashStable(currentEntry, {
827
942
  signal: active.controller && active.controller.signal
828
943
  })
829
944
  } catch (error) {
830
945
  if (error && error.code === "EVAULTCANCELLED") throw error
831
946
  if (!isPathError(error)) throw error
832
947
  counts.hash_failures += 1
948
+ if (entry.kind === "changed" && isMissing(error)) {
949
+ counts.path_errors += 1
950
+ return null
951
+ }
833
952
  this.logVerificationSkip(active.app, entry, "hash-failed", counts, error)
834
953
  return null
835
954
  }
@@ -842,7 +961,7 @@ class AutomaticScans {
842
961
  counts.hashed += 1
843
962
  counts.hash_bytes += Math.max(0, Number(verified.result.size) || 0)
844
963
  memory.set(key, verified.result.hash)
845
- verifiedHashes.set(pathKey(entry.path), Object.assign({}, entry, {
964
+ verifiedHashes.set(pathKey(entry.path), Object.assign({}, currentEntry, {
846
965
  hash: verified.result.hash
847
966
  }))
848
967
  return verified.result.hash
@@ -872,7 +991,6 @@ class AutomaticScans {
872
991
  const paths = [...new Set(active.paths || [])].filter((filePath) =>
873
992
  typeof filePath === "string" && inside(root, filePath))
874
993
  const verifiedHashes = new Map()
875
- await this.vault.registry.beginAutomaticPrecheck(app)
876
994
  this.log("check-started", {
877
995
  app,
878
996
  root,
@@ -880,6 +998,7 @@ class AutomaticScans {
880
998
  changed_paths: paths.length,
881
999
  policy: "metadata-prefilter-sha256"
882
1000
  })
1001
+ await this.vault.registry.beginAutomaticPrecheck(app)
883
1002
  try {
884
1003
  this.checkpoint(active)
885
1004
  const metadataOptions = {
@@ -888,6 +1007,7 @@ class AutomaticScans {
888
1007
  onError: (error, filePath) => {
889
1008
  if (!isPathError(error)) return false
890
1009
  counts.path_errors += 1
1010
+ if (isMissing(error)) return true
891
1011
  this.log("path-skipped", {
892
1012
  app,
893
1013
  path: filePath,
@@ -1063,7 +1183,19 @@ class AutomaticScans {
1063
1183
  item.state === "checking")
1064
1184
  if (!entry) return
1065
1185
  await this.refreshGlobalScanReady()
1066
- if (this.disposed || !this.globalScanReady) return
1186
+ if (this.disposed) return
1187
+ if (!this.globalScanReady) {
1188
+ if (this.entries.get(entry.app) === entry) {
1189
+ this.restorePrevious(entry.app)
1190
+ this.discardChangedPaths(entry.app)
1191
+ this.log("check-skipped", {
1192
+ app: entry.app,
1193
+ reason: "global-scan-required"
1194
+ })
1195
+ }
1196
+ this.schedule()
1197
+ return
1198
+ }
1067
1199
  if (this.manualDepth > 0 || this.currentBusyPromise() ||
1068
1200
  this.vault.fileActionProgress) {
1069
1201
  this.waitForBusyWork()
@@ -2867,23 +2867,30 @@ class RegistryCore {
2867
2867
  rows = this.database.prepare(`
2868
2868
  SELECT
2869
2869
  'anchor' AS kind,
2870
- path,
2871
- size,
2872
- mtime,
2873
- ctime,
2874
- dev,
2875
- ino,
2876
- nlink,
2877
- mode,
2878
- uid,
2879
- gid,
2880
- hash
2881
- FROM anchors
2882
- WHERE dev = @dev
2883
- AND size = @size
2884
- AND path > @path
2885
- AND verified_at IS NOT NULL
2886
- ORDER BY path
2870
+ anchor.path,
2871
+ anchor.size,
2872
+ anchor.mtime,
2873
+ anchor.ctime,
2874
+ anchor.dev,
2875
+ anchor.ino,
2876
+ anchor.nlink,
2877
+ anchor.mode,
2878
+ anchor.uid,
2879
+ anchor.gid,
2880
+ anchor.hash,
2881
+ cached.hash AS cached_hash,
2882
+ cached.size AS cached_size,
2883
+ cached.mtime AS cached_mtime,
2884
+ cached.ctime AS cached_ctime,
2885
+ cached.dev AS cached_dev,
2886
+ cached.ino AS cached_ino
2887
+ FROM anchors anchor
2888
+ LEFT JOIN hash_cache cached ON cached.path = anchor.path
2889
+ WHERE anchor.dev = @dev
2890
+ AND anchor.size = @size
2891
+ AND anchor.path > @path
2892
+ AND anchor.verified_at IS NOT NULL
2893
+ ORDER BY anchor.path
2887
2894
  LIMIT @limit
2888
2895
  `).all({ dev, size, path: afterPath, limit: pageSize })
2889
2896
  } else {
@@ -2900,15 +2907,15 @@ class RegistryCore {
2900
2907
  peer.mode,
2901
2908
  peer.uid,
2902
2909
  peer.gid,
2903
- COALESCE(peer.hash, cached.hash) AS hash
2910
+ peer.hash,
2911
+ cached.hash AS cached_hash,
2912
+ cached.size AS cached_size,
2913
+ cached.mtime AS cached_mtime,
2914
+ cached.ctime AS cached_ctime,
2915
+ cached.dev AS cached_dev,
2916
+ cached.ino AS cached_ino
2904
2917
  FROM files peer
2905
- LEFT JOIN hash_cache cached
2906
- ON cached.path = peer.path
2907
- AND cached.size = peer.size
2908
- AND cached.mtime = peer.mtime
2909
- AND cached.ctime = peer.ctime
2910
- AND cached.dev = peer.dev
2911
- AND cached.ino = peer.ino
2918
+ LEFT JOIN hash_cache cached ON cached.path = peer.path
2912
2919
  WHERE peer.dev = @dev
2913
2920
  AND peer.size = @size
2914
2921
  AND peer.path > @path
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "8.0.71",
3
+ "version": "8.0.73",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -226,6 +226,45 @@ describe("automatic app checks", () => {
226
226
  await close(vault)
227
227
  })
228
228
 
229
+ test("Windows watcher deletes remove retained paths and descendants", async () => {
230
+ const home = await makeHome()
231
+ const appRoot = path.join(home, "api", "windows-app")
232
+ const scriptPath = path.join(appRoot, "start.js")
233
+ const first = path.join(appRoot, "first.bin")
234
+ const nestedRoot = path.join(appRoot, "temporary")
235
+ const nested = path.join(nestedRoot, "nested.bin")
236
+ await fs.promises.mkdir(appRoot)
237
+ let callback = null
238
+ const vault = await makeVault(home, {
239
+ deferStorage: true,
240
+ platform: "win32",
241
+ automaticWatcher: {
242
+ subscribe: async (_root, listener) => {
243
+ callback = listener
244
+ return { unsubscribe: async () => {} }
245
+ }
246
+ }
247
+ })
248
+
249
+ assert.equal(await vault.automaticScans.startWatcher(), true)
250
+ vault.automaticScans.handleStarted(scriptPath)
251
+ callback(null, [
252
+ { type: "create", path: first },
253
+ { type: "update", path: nestedRoot },
254
+ { type: "update", path: nested }
255
+ ])
256
+ assert.deepEqual([...vault.automaticScans.changedPaths.get(
257
+ "windows-app")].sort(), [first, nestedRoot, nested].sort())
258
+
259
+ callback(null, [{ type: "delete", path: nestedRoot }])
260
+ assert.deepEqual([...vault.automaticScans.changedPaths.get(
261
+ "windows-app")], [first])
262
+ callback(null, [{ type: "delete", path: first }])
263
+ assert.equal(vault.automaticScans.changedPaths.has("windows-app"), false)
264
+ assert.equal(vault.registry, null)
265
+ await close(vault)
266
+ })
267
+
229
268
  test("disposing automatic checks releases the watcher and pending work", async () => {
230
269
  const home = await makeHome()
231
270
  const appRoot = path.join(home, "api", "disposed-app")
@@ -366,6 +405,71 @@ describe("automatic app checks", () => {
366
405
  assert.equal(checksStarted, 0)
367
406
  })
368
407
 
408
+ test("scheduler coalesces wakeups while dispatch is in progress", async () => {
409
+ let releaseReadiness
410
+ let readinessCalls = 0
411
+ let dispatches = 0
412
+ const automatic = makeAutomatic({
413
+ enabled: true,
414
+ registry: {},
415
+ runExclusive: () => {
416
+ dispatches += 1
417
+ return new Promise(() => {})
418
+ }
419
+ })
420
+ automatic.entries.set("demo", {
421
+ app: "demo",
422
+ state: "checking",
423
+ paths: []
424
+ })
425
+ automatic.globalScanReady = true
426
+ automatic.refreshGlobalScanReady = async () => {
427
+ readinessCalls += 1
428
+ if (readinessCalls === 1) {
429
+ await new Promise((resolve) => { releaseReadiness = resolve })
430
+ }
431
+ automatic.globalScanReady = true
432
+ }
433
+ automatic.appRootIsAvailable = async () => true
434
+ automatic.appIsRunning = () => false
435
+
436
+ automatic.schedule()
437
+ await waitFor(() => typeof releaseReadiness === "function",
438
+ "scheduler readiness pause")
439
+ automatic.schedule()
440
+ releaseReadiness()
441
+ await waitFor(() => dispatches > 0, "automatic dispatch")
442
+ await new Promise((resolve) => setImmediate(resolve))
443
+
444
+ assert.equal(dispatches, 1)
445
+ assert.equal(readinessCalls, 1)
446
+ })
447
+
448
+ test("queued checks are cleared if global readiness disappears", async () => {
449
+ const automatic = makeAutomatic({
450
+ enabled: true,
451
+ registry: {}
452
+ })
453
+ automatic.entries.set("demo", {
454
+ app: "demo",
455
+ state: "checking",
456
+ paths: ["/pinokio/api/demo/model.bin"]
457
+ })
458
+ automatic.changedPaths.set("demo", new Set([
459
+ "/pinokio/api/demo/model.bin"
460
+ ]))
461
+ automatic.refreshGlobalScanReady = async () => {
462
+ automatic.globalScanReady = false
463
+ }
464
+ automatic.log = () => {}
465
+
466
+ automatic.schedule()
467
+ await waitFor(() => !automatic.drainQueued, "automatic queue cleanup")
468
+
469
+ assert.equal(automatic.entries.has("demo"), false)
470
+ assert.equal(automatic.changedPaths.has("demo"), false)
471
+ })
472
+
369
473
  test("Linux starts no watcher and ignores automatic lifecycle work", async () => {
370
474
  const home = await makeHome()
371
475
  const appRoot = path.join(home, "api", "linux-app")
@@ -1150,6 +1254,31 @@ describe("automatic app checks", () => {
1150
1254
  await close(vault)
1151
1255
  })
1152
1256
 
1257
+ test("transient missing watcher paths do not emit per-path logs", async () => {
1258
+ const home = await makeHome()
1259
+ const app = "temporary-path-app"
1260
+ const appRoot = path.join(home, "api", app)
1261
+ const missing = [
1262
+ path.join(appRoot, "temporary-1.bin"),
1263
+ path.join(appRoot, "temporary-2.bin")
1264
+ ]
1265
+ await fs.promises.mkdir(appRoot)
1266
+ const vault = await makeVault(home)
1267
+ const logs = []
1268
+ vault.automaticScans.log = (event, details) => {
1269
+ logs.push({ event, details })
1270
+ }
1271
+
1272
+ vault.automaticScans.queueApp(app, missing)
1273
+ await waitFor(() => !vault.automaticScans.active &&
1274
+ !vault.automaticScans.entries.has(app))
1275
+
1276
+ assert.equal(logs.some((record) =>
1277
+ ["path-skipped", "verification-skipped"].includes(record.event) &&
1278
+ missing.includes(record.details.path)), false)
1279
+ await close(vault)
1280
+ })
1281
+
1153
1282
  test("stable published hashes are reused without rereading contents", async () => {
1154
1283
  const home = await makeHome()
1155
1284
  const app = "published-hash-app"
@@ -1203,6 +1332,102 @@ describe("automatic app checks", () => {
1203
1332
  await close(vault)
1204
1333
  })
1205
1334
 
1335
+ test("a metadata-changed Windows peer is freshly hashed and cached", async () => {
1336
+ const home = await makeHome()
1337
+ const app = "windows-peer-app"
1338
+ const appRoot = path.join(home, "api", app)
1339
+ const candidate = path.join(appRoot, "candidate.bin")
1340
+ const peer = path.join(home, "published-peer.bin")
1341
+ await fs.promises.mkdir(appRoot)
1342
+ await fs.promises.writeFile(candidate, "same-content")
1343
+ await fs.promises.writeFile(peer, "same-content")
1344
+ const vault = await makeVault(home, {
1345
+ deferStorage: true,
1346
+ platform: "win32"
1347
+ })
1348
+ await vault.ensureRegistryInitialized()
1349
+ vault.automaticScans.candidateThreshold = async () => 1
1350
+ const peerStat = await fs.promises.lstat(peer)
1351
+ await vault.registry.upsertFile({
1352
+ path: peer,
1353
+ hash: crypto.createHash("sha256").update("old-content!").digest("hex"),
1354
+ size: peerStat.size,
1355
+ mtime: peerStat.mtimeMs - 1,
1356
+ ctime: peerStat.ctimeMs - 1,
1357
+ dev: peerStat.dev,
1358
+ ino: peerStat.ino,
1359
+ mode: peerStat.mode,
1360
+ uid: peerStat.uid,
1361
+ gid: peerStat.gid,
1362
+ source_id: "app:peer",
1363
+ app: "peer",
1364
+ status: "reference",
1365
+ unavailable_reason: null,
1366
+ updated_at: Date.now()
1367
+ })
1368
+ const hashedPaths = []
1369
+ const originalHashFile = vault.hashFile.bind(vault)
1370
+ vault.hashFile = async (filePath, options) => {
1371
+ hashedPaths.push(filePath)
1372
+ return originalHashFile(filePath, options)
1373
+ }
1374
+
1375
+ vault.automaticScans.queueApp(app, [candidate])
1376
+ await waitFor(() => !vault.automaticScans.active &&
1377
+ vault.automaticScans.entries.get(app)?.state === "result")
1378
+ assert.deepEqual(hashedPaths.sort(), [candidate, peer].sort())
1379
+
1380
+ await vault.registry.setAutomaticAppScanState(app, null)
1381
+ vault.automaticScans.entries.delete(app)
1382
+ vault.hashFile = async () => {
1383
+ throw new Error("Fresh automatic hashes must be reused.")
1384
+ }
1385
+ vault.automaticScans.queueApp(app, [candidate])
1386
+ await waitFor(() => !vault.automaticScans.active &&
1387
+ vault.automaticScans.entries.get(app)?.state === "result")
1388
+
1389
+ await close(vault)
1390
+ })
1391
+
1392
+ test("a stale peer hash cannot match different equal-size content", async () => {
1393
+ const home = await makeHome()
1394
+ const app = "stale-peer-hash-app"
1395
+ const appRoot = path.join(home, "api", app)
1396
+ const candidate = path.join(appRoot, "candidate.bin")
1397
+ const peer = path.join(home, "stale-peer.bin")
1398
+ await fs.promises.mkdir(appRoot)
1399
+ await fs.promises.writeFile(candidate, "same-content")
1400
+ await fs.promises.writeFile(peer, "other-bytes!")
1401
+ const vault = await makeVault(home, { deferStorage: true })
1402
+ await vault.ensureRegistryInitialized()
1403
+ vault.automaticScans.candidateThreshold = async () => 1
1404
+ const peerStat = await fs.promises.lstat(peer)
1405
+ await vault.registry.upsertFile({
1406
+ path: peer,
1407
+ hash: crypto.createHash("sha256").update("same-content").digest("hex"),
1408
+ size: peerStat.size,
1409
+ mtime: peerStat.mtimeMs - 1,
1410
+ ctime: peerStat.ctimeMs - 1,
1411
+ dev: peerStat.dev,
1412
+ ino: peerStat.ino,
1413
+ mode: peerStat.mode,
1414
+ uid: peerStat.uid,
1415
+ gid: peerStat.gid,
1416
+ source_id: "app:peer",
1417
+ app: "peer",
1418
+ status: "reference",
1419
+ unavailable_reason: null,
1420
+ updated_at: Date.now()
1421
+ })
1422
+
1423
+ vault.automaticScans.queueApp(app, [candidate])
1424
+ await waitFor(() => !vault.automaticScans.active &&
1425
+ !vault.automaticScans.entries.has(app))
1426
+
1427
+ assert.deepEqual(vault.automaticScans.snapshot().rows, [])
1428
+ await close(vault)
1429
+ })
1430
+
1206
1431
  test("stale file rows and known identical inodes are not peers", async () => {
1207
1432
  const home = await makeHome()
1208
1433
  const appRoot = path.join(home, "api", "no-peer-app")