pinokiod 8.0.71 → 8.0.72

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,6 +52,7 @@ 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
@@ -136,6 +137,7 @@ class AutomaticScans {
136
137
  }
137
138
  this.observedApps.clear()
138
139
  this.changedPaths.clear()
140
+ this.changedPathIndex.clear()
139
141
  if (watcherError) throw watcherError
140
142
  }
141
143
 
@@ -152,7 +154,8 @@ class AutomaticScans {
152
154
  if (!event || !["create", "update", "delete"].includes(event.type)) {
153
155
  continue
154
156
  }
155
- this.recordChangedPath(event.path)
157
+ if (event.type === "delete") this.removeChangedPath(event.path)
158
+ else this.recordChangedPath(event.path)
156
159
  }
157
160
  }
158
161
 
@@ -171,19 +174,95 @@ class AutomaticScans {
171
174
  paths = new Set()
172
175
  this.changedPaths.set(app, paths)
173
176
  }
177
+ let index = this.changedPathIndex.get(app)
178
+ if (!index) {
179
+ index = { exact: new Map(), descendants: new Map() }
180
+ this.changedPathIndex.set(app, index)
181
+ }
182
+ const key = pathKey(resolved)
183
+ if (index.exact.has(key)) return true
174
184
  paths.add(resolved)
185
+ index.exact.set(key, resolved)
186
+ this.addChangedPathToIndex(index.descendants, appRoot, resolved)
187
+ return true
188
+ }
189
+
190
+ changedPathParentKeys(appRoot, filePath) {
191
+ const rootKey = pathKey(appRoot)
192
+ const keys = []
193
+ let current = path.dirname(path.resolve(filePath))
194
+ while (true) {
195
+ const key = pathKey(current)
196
+ keys.push(key)
197
+ if (key === rootKey) return keys
198
+ current = path.dirname(current)
199
+ }
200
+ }
201
+
202
+ addChangedPathToIndex(index, appRoot, filePath) {
203
+ for (const key of this.changedPathParentKeys(appRoot, filePath)) {
204
+ let descendants = index.get(key)
205
+ if (!descendants) {
206
+ descendants = new Set()
207
+ index.set(key, descendants)
208
+ }
209
+ descendants.add(filePath)
210
+ }
211
+ }
212
+
213
+ removeChangedPathFromIndex(index, appRoot, filePath) {
214
+ index.exact.delete(pathKey(filePath))
215
+ for (const key of this.changedPathParentKeys(appRoot, filePath)) {
216
+ const descendants = index.descendants.get(key)
217
+ if (!descendants) continue
218
+ descendants.delete(filePath)
219
+ if (!descendants.size) index.descendants.delete(key)
220
+ }
221
+ }
222
+
223
+ removeChangedPath(filePath) {
224
+ if (this.disposed || !this.supported ||
225
+ typeof filePath !== "string" || !filePath) {
226
+ return false
227
+ }
228
+ const app = this.appForLaunchPath(filePath)
229
+ if (!app || !this.observedApps.has(app)) return false
230
+ const paths = this.changedPaths.get(app)
231
+ if (!paths) return false
232
+ const resolved = path.resolve(filePath)
233
+ const appRoot = path.resolve(this.vault.kernel.homedir, "api", app)
234
+ const index = this.changedPathIndex.get(app)
235
+ if (!index) return false
236
+ const key = pathKey(resolved)
237
+ const exact = index.exact.get(key)
238
+ const descendants = index.descendants.get(key)
239
+ if (!exact && !descendants) return false
240
+ const matches = new Set(descendants || [])
241
+ if (exact) matches.add(exact)
242
+ for (const candidate of matches) {
243
+ paths.delete(candidate)
244
+ this.removeChangedPathFromIndex(index, appRoot, candidate)
245
+ }
246
+ if (!paths.size) this.discardChangedPaths(app)
175
247
  return true
176
248
  }
177
249
 
178
250
  discardChangedPaths(app) {
179
251
  this.changedPaths.delete(app)
252
+ this.changedPathIndex.delete(app)
180
253
  }
181
254
 
182
255
  consumeChangedPaths(app, paths) {
183
256
  const collected = this.changedPaths.get(app)
184
257
  if (!collected) return
185
- for (const filePath of paths || []) collected.delete(filePath)
186
- if (!collected.size) this.changedPaths.delete(app)
258
+ const appRoot = path.resolve(this.vault.kernel.homedir, "api", app)
259
+ const index = this.changedPathIndex.get(app)
260
+ for (const filePath of paths || []) {
261
+ const resolved = path.resolve(filePath)
262
+ if (!collected.delete(resolved) || !index) continue
263
+ this.removeChangedPathFromIndex(index, appRoot, resolved)
264
+ }
265
+ if (!collected.size) this.discardChangedPaths(app)
187
266
  }
188
267
 
189
268
  async candidateThreshold() {
@@ -799,37 +878,58 @@ class AutomaticScans {
799
878
  current = await fs.promises.lstat(entry.path)
800
879
  } catch (error) {
801
880
  if (!isPathError(error)) throw error
881
+ if (entry.kind === "changed" && isMissing(error)) {
882
+ counts.path_errors += 1
883
+ return null
884
+ }
802
885
  this.logVerificationSkip(active.app, entry, "unreadable", counts, error)
803
886
  return null
804
887
  }
805
888
  this.checkpoint(active)
806
889
  if (!current.isFile() || current.isSymbolicLink() ||
807
- !sameSnapshot(entry, current)) {
890
+ current.dev !== entry.dev || current.size !== entry.size) {
808
891
  this.logVerificationSkip(
809
892
  active.app, entry, "snapshot-changed", counts)
810
893
  return null
811
894
  }
812
- const key = this.automaticHashKey(entry)
813
- const reusable = SHA256_RE.test(entry.hash || "")
814
- ? entry.hash
815
- : memory.get(key)
895
+ const currentEntry = Object.assign({}, entry, fileSnapshot(current))
896
+ const key = this.automaticHashKey(currentEntry)
897
+ const cachedSnapshot = {
898
+ size: entry.cached_size,
899
+ mtime: entry.cached_mtime,
900
+ ctime: entry.cached_ctime,
901
+ dev: entry.cached_dev,
902
+ ino: entry.cached_ino
903
+ }
904
+ const reusable = memory.get(key) ||
905
+ (sameSnapshot(entry, current) && SHA256_RE.test(entry.hash || "")
906
+ ? entry.hash
907
+ : null) ||
908
+ (sameSnapshot(cachedSnapshot, current) &&
909
+ SHA256_RE.test(entry.cached_hash || "")
910
+ ? entry.cached_hash
911
+ : null)
816
912
  if (reusable) {
817
913
  counts.hash_reuses += 1
818
914
  memory.set(key, reusable)
819
- verifiedHashes.set(pathKey(entry.path), Object.assign({}, entry, {
915
+ verifiedHashes.set(pathKey(entry.path), Object.assign({}, currentEntry, {
820
916
  hash: reusable
821
917
  }))
822
918
  return reusable
823
919
  }
824
920
  let verified
825
921
  try {
826
- verified = await this.vault.scanner.hashStable(entry, {
922
+ verified = await this.vault.scanner.hashStable(currentEntry, {
827
923
  signal: active.controller && active.controller.signal
828
924
  })
829
925
  } catch (error) {
830
926
  if (error && error.code === "EVAULTCANCELLED") throw error
831
927
  if (!isPathError(error)) throw error
832
928
  counts.hash_failures += 1
929
+ if (entry.kind === "changed" && isMissing(error)) {
930
+ counts.path_errors += 1
931
+ return null
932
+ }
833
933
  this.logVerificationSkip(active.app, entry, "hash-failed", counts, error)
834
934
  return null
835
935
  }
@@ -842,7 +942,7 @@ class AutomaticScans {
842
942
  counts.hashed += 1
843
943
  counts.hash_bytes += Math.max(0, Number(verified.result.size) || 0)
844
944
  memory.set(key, verified.result.hash)
845
- verifiedHashes.set(pathKey(entry.path), Object.assign({}, entry, {
945
+ verifiedHashes.set(pathKey(entry.path), Object.assign({}, currentEntry, {
846
946
  hash: verified.result.hash
847
947
  }))
848
948
  return verified.result.hash
@@ -888,6 +988,7 @@ class AutomaticScans {
888
988
  onError: (error, filePath) => {
889
989
  if (!isPathError(error)) return false
890
990
  counts.path_errors += 1
991
+ if (isMissing(error)) return true
891
992
  this.log("path-skipped", {
892
993
  app,
893
994
  path: filePath,
@@ -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.72",
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")
@@ -1150,6 +1189,31 @@ describe("automatic app checks", () => {
1150
1189
  await close(vault)
1151
1190
  })
1152
1191
 
1192
+ test("transient missing watcher paths do not emit per-path logs", async () => {
1193
+ const home = await makeHome()
1194
+ const app = "temporary-path-app"
1195
+ const appRoot = path.join(home, "api", app)
1196
+ const missing = [
1197
+ path.join(appRoot, "temporary-1.bin"),
1198
+ path.join(appRoot, "temporary-2.bin")
1199
+ ]
1200
+ await fs.promises.mkdir(appRoot)
1201
+ const vault = await makeVault(home)
1202
+ const logs = []
1203
+ vault.automaticScans.log = (event, details) => {
1204
+ logs.push({ event, details })
1205
+ }
1206
+
1207
+ vault.automaticScans.queueApp(app, missing)
1208
+ await waitFor(() => !vault.automaticScans.active &&
1209
+ !vault.automaticScans.entries.has(app))
1210
+
1211
+ assert.equal(logs.some((record) =>
1212
+ ["path-skipped", "verification-skipped"].includes(record.event) &&
1213
+ missing.includes(record.details.path)), false)
1214
+ await close(vault)
1215
+ })
1216
+
1153
1217
  test("stable published hashes are reused without rereading contents", async () => {
1154
1218
  const home = await makeHome()
1155
1219
  const app = "published-hash-app"
@@ -1203,6 +1267,102 @@ describe("automatic app checks", () => {
1203
1267
  await close(vault)
1204
1268
  })
1205
1269
 
1270
+ test("a metadata-changed Windows peer is freshly hashed and cached", async () => {
1271
+ const home = await makeHome()
1272
+ const app = "windows-peer-app"
1273
+ const appRoot = path.join(home, "api", app)
1274
+ const candidate = path.join(appRoot, "candidate.bin")
1275
+ const peer = path.join(home, "published-peer.bin")
1276
+ await fs.promises.mkdir(appRoot)
1277
+ await fs.promises.writeFile(candidate, "same-content")
1278
+ await fs.promises.writeFile(peer, "same-content")
1279
+ const vault = await makeVault(home, {
1280
+ deferStorage: true,
1281
+ platform: "win32"
1282
+ })
1283
+ await vault.ensureRegistryInitialized()
1284
+ vault.automaticScans.candidateThreshold = async () => 1
1285
+ const peerStat = await fs.promises.lstat(peer)
1286
+ await vault.registry.upsertFile({
1287
+ path: peer,
1288
+ hash: crypto.createHash("sha256").update("old-content!").digest("hex"),
1289
+ size: peerStat.size,
1290
+ mtime: peerStat.mtimeMs - 1,
1291
+ ctime: peerStat.ctimeMs - 1,
1292
+ dev: peerStat.dev,
1293
+ ino: peerStat.ino,
1294
+ mode: peerStat.mode,
1295
+ uid: peerStat.uid,
1296
+ gid: peerStat.gid,
1297
+ source_id: "app:peer",
1298
+ app: "peer",
1299
+ status: "reference",
1300
+ unavailable_reason: null,
1301
+ updated_at: Date.now()
1302
+ })
1303
+ const hashedPaths = []
1304
+ const originalHashFile = vault.hashFile.bind(vault)
1305
+ vault.hashFile = async (filePath, options) => {
1306
+ hashedPaths.push(filePath)
1307
+ return originalHashFile(filePath, options)
1308
+ }
1309
+
1310
+ vault.automaticScans.queueApp(app, [candidate])
1311
+ await waitFor(() => !vault.automaticScans.active &&
1312
+ vault.automaticScans.entries.get(app)?.state === "result")
1313
+ assert.deepEqual(hashedPaths.sort(), [candidate, peer].sort())
1314
+
1315
+ await vault.registry.setAutomaticAppScanState(app, null)
1316
+ vault.automaticScans.entries.delete(app)
1317
+ vault.hashFile = async () => {
1318
+ throw new Error("Fresh automatic hashes must be reused.")
1319
+ }
1320
+ vault.automaticScans.queueApp(app, [candidate])
1321
+ await waitFor(() => !vault.automaticScans.active &&
1322
+ vault.automaticScans.entries.get(app)?.state === "result")
1323
+
1324
+ await close(vault)
1325
+ })
1326
+
1327
+ test("a stale peer hash cannot match different equal-size content", async () => {
1328
+ const home = await makeHome()
1329
+ const app = "stale-peer-hash-app"
1330
+ const appRoot = path.join(home, "api", app)
1331
+ const candidate = path.join(appRoot, "candidate.bin")
1332
+ const peer = path.join(home, "stale-peer.bin")
1333
+ await fs.promises.mkdir(appRoot)
1334
+ await fs.promises.writeFile(candidate, "same-content")
1335
+ await fs.promises.writeFile(peer, "other-bytes!")
1336
+ const vault = await makeVault(home, { deferStorage: true })
1337
+ await vault.ensureRegistryInitialized()
1338
+ vault.automaticScans.candidateThreshold = async () => 1
1339
+ const peerStat = await fs.promises.lstat(peer)
1340
+ await vault.registry.upsertFile({
1341
+ path: peer,
1342
+ hash: crypto.createHash("sha256").update("same-content").digest("hex"),
1343
+ size: peerStat.size,
1344
+ mtime: peerStat.mtimeMs - 1,
1345
+ ctime: peerStat.ctimeMs - 1,
1346
+ dev: peerStat.dev,
1347
+ ino: peerStat.ino,
1348
+ mode: peerStat.mode,
1349
+ uid: peerStat.uid,
1350
+ gid: peerStat.gid,
1351
+ source_id: "app:peer",
1352
+ app: "peer",
1353
+ status: "reference",
1354
+ unavailable_reason: null,
1355
+ updated_at: Date.now()
1356
+ })
1357
+
1358
+ vault.automaticScans.queueApp(app, [candidate])
1359
+ await waitFor(() => !vault.automaticScans.active &&
1360
+ !vault.automaticScans.entries.has(app))
1361
+
1362
+ assert.deepEqual(vault.automaticScans.snapshot().rows, [])
1363
+ await close(vault)
1364
+ })
1365
+
1206
1366
  test("stale file rows and known identical inodes are not peers", async () => {
1207
1367
  const home = await makeHome()
1208
1368
  const appRoot = path.join(home, "api", "no-peer-app")