pinokiod 8.0.72 → 8.0.74

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.
@@ -52,11 +52,11 @@ class AutomaticScans {
52
52
  this.manualDepth = 0
53
53
  this.observedApps = new Set()
54
54
  this.changedPaths = new Map()
55
- this.changedPathIndex = new Map()
56
55
  this.pendingStops = new Map()
57
56
  this.lifecycleWork = new Set()
58
57
  this.stopSettleMs = STOP_SETTLE_MS
59
58
  this.drainQueued = false
59
+ this.drainRequested = false
60
60
  this.waitingFor = null
61
61
  this.listeners = new Set()
62
62
  this.appTransitions = new Map()
@@ -137,7 +137,6 @@ class AutomaticScans {
137
137
  }
138
138
  this.observedApps.clear()
139
139
  this.changedPaths.clear()
140
- this.changedPathIndex.clear()
141
140
  if (watcherError) throw watcherError
142
141
  }
143
142
 
@@ -150,125 +149,135 @@ class AutomaticScans {
150
149
  })
151
150
  return
152
151
  }
153
- for (const event of events || []) {
152
+ const watcherEvents = Array.isArray(events) ? events : []
153
+ if (!watcherEvents.some((event) => event && event.type === "delete")) {
154
+ for (const event of watcherEvents) {
155
+ if (event && ["create", "update"].includes(event.type)) {
156
+ this.recordChangedPath(event.path)
157
+ }
158
+ }
159
+ return
160
+ }
161
+ const batches = new Map()
162
+ for (let index = 0; index < watcherEvents.length; index++) {
163
+ const event = watcherEvents[index]
154
164
  if (!event || !["create", "update", "delete"].includes(event.type)) {
155
165
  continue
156
166
  }
157
- if (event.type === "delete") this.removeChangedPath(event.path)
158
- else this.recordChangedPath(event.path)
167
+ const target = this.changedPathTarget(
168
+ event.path, event.type === "delete")
169
+ if (!target) continue
170
+ let batch = batches.get(target.app)
171
+ if (!batch) {
172
+ batch = {
173
+ root: target.root,
174
+ updates: new Map(),
175
+ deletes: new Map()
176
+ }
177
+ batches.set(target.app, batch)
178
+ }
179
+ const key = pathKey(target.path)
180
+ if (event.type === "delete") batch.deletes.set(key, index)
181
+ else batch.updates.set(key, { index, path: target.path })
182
+ }
183
+ for (const [app, batch] of batches) {
184
+ this.applyChangedPathBatch(app, batch)
159
185
  }
160
186
  }
161
187
 
162
- recordChangedPath(filePath) {
188
+ changedPathTarget(filePath, allowRoot = false) {
163
189
  if (this.disposed || !this.supported ||
164
190
  typeof filePath !== "string" || !filePath) {
165
- return false
191
+ return null
166
192
  }
167
193
  const app = this.appForLaunchPath(filePath)
168
- if (!app || !this.observedApps.has(app)) return false
169
- const appRoot = path.resolve(this.vault.kernel.homedir, "api", app)
194
+ if (!app || !this.observedApps.has(app)) return null
195
+ const root = path.resolve(this.vault.kernel.homedir, "api", app)
170
196
  const resolved = path.resolve(filePath)
171
- if (resolved === appRoot || !inside(appRoot, resolved)) return false
197
+ if ((!allowRoot && resolved === root) || !inside(root, resolved)) return null
198
+ return { app, root, path: resolved }
199
+ }
200
+
201
+ recordChangedPath(filePath) {
202
+ const target = this.changedPathTarget(filePath)
203
+ if (!target) return false
204
+ const { app, path: resolved } = target
172
205
  let paths = this.changedPaths.get(app)
173
206
  if (!paths) {
174
207
  paths = new Set()
175
208
  this.changedPaths.set(app, paths)
176
209
  }
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
184
210
  paths.add(resolved)
185
- index.exact.set(key, resolved)
186
- this.addChangedPathToIndex(index.descendants, appRoot, resolved)
187
211
  return true
188
212
  }
189
213
 
190
- changedPathParentKeys(appRoot, filePath) {
191
- const rootKey = pathKey(appRoot)
192
- const keys = []
193
- let current = path.dirname(path.resolve(filePath))
214
+ lastDeleteIndex(root, filePath, deletes) {
215
+ const rootKey = pathKey(root)
216
+ let current = path.resolve(filePath)
217
+ let latest = -1
194
218
  while (true) {
195
219
  const key = pathKey(current)
196
- keys.push(key)
197
- if (key === rootKey) return keys
220
+ const index = deletes.get(key)
221
+ if (index !== undefined && index > latest) latest = index
222
+ if (key === rootKey) return latest
198
223
  current = path.dirname(current)
199
224
  }
200
225
  }
201
226
 
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)
227
+ applyChangedPathBatch(app, { root, updates, deletes }) {
228
+ const paths = this.changedPaths.get(app)
229
+ if (!deletes.size) {
230
+ const collected = paths || new Set()
231
+ for (const update of updates.values()) collected.add(update.path)
232
+ if (collected.size) this.changedPaths.set(app, collected)
233
+ return
210
234
  }
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)
235
+ const retained = new Set()
236
+ if (paths) {
237
+ for (const candidate of paths) {
238
+ const key = pathKey(candidate)
239
+ const update = updates.get(key)
240
+ if (this.lastDeleteIndex(root, candidate, deletes) >
241
+ (update ? update.index : -1) || retained.has(key)) {
242
+ paths.delete(candidate)
243
+ } else {
244
+ retained.add(key)
245
+ }
246
+ }
220
247
  }
221
- }
222
-
223
- removeChangedPath(filePath) {
224
- if (this.disposed || !this.supported ||
225
- typeof filePath !== "string" || !filePath) {
226
- return false
248
+ let collected = paths
249
+ for (const [key, update] of updates) {
250
+ if (update.index <= this.lastDeleteIndex(root, update.path, deletes) ||
251
+ retained.has(key)) continue
252
+ if (!collected) collected = new Set()
253
+ collected.add(update.path)
254
+ retained.add(key)
227
255
  }
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)
247
- return true
256
+ if (collected && collected.size) this.changedPaths.set(app, collected)
257
+ else this.changedPaths.delete(app)
248
258
  }
249
259
 
250
260
  discardChangedPaths(app) {
251
261
  this.changedPaths.delete(app)
252
- this.changedPathIndex.delete(app)
253
262
  }
254
263
 
255
264
  consumeChangedPaths(app, paths) {
256
265
  const collected = this.changedPaths.get(app)
257
266
  if (!collected) return
258
- const appRoot = path.resolve(this.vault.kernel.homedir, "api", app)
259
- const index = this.changedPathIndex.get(app)
260
267
  for (const filePath of paths || []) {
261
- const resolved = path.resolve(filePath)
262
- if (!collected.delete(resolved) || !index) continue
263
- this.removeChangedPathFromIndex(index, appRoot, resolved)
268
+ collected.delete(path.resolve(filePath))
264
269
  }
265
270
  if (!collected.size) this.discardChangedPaths(app)
266
271
  }
267
272
 
268
273
  async candidateThreshold() {
269
- const scan = this.vault.registry
270
- ? await this.vault.registry.scanFor()
274
+ const cached = this.vault.lastScanCache &&
275
+ typeof this.vault.lastScanCache.get === "function"
276
+ ? this.vault.lastScanCache.get("")
271
277
  : null
278
+ const scan = cached || (this.vault.registry
279
+ ? await this.vault.registry.scanFor()
280
+ : null)
272
281
  const threshold = scan ? Number(scan.candidate_min_bytes) : NaN
273
282
  return Number.isFinite(threshold) && threshold >= 0
274
283
  ? threshold
@@ -805,24 +814,38 @@ class AutomaticScans {
805
814
  }
806
815
 
807
816
  schedule() {
808
- if (this.disposed || this.drainQueued) return
817
+ if (this.disposed) return
818
+ if (this.drainQueued) {
819
+ this.drainRequested = true
820
+ return
821
+ }
809
822
  this.drainQueued = true
810
823
  queueMicrotask(() => {
811
- this.drainQueued = false
812
- const pending = this.drain()
824
+ const pending = (async () => {
825
+ do {
826
+ this.drainRequested = false
827
+ await this.drain()
828
+ } while (!this.disposed && this.drainRequested)
829
+ })()
813
830
  this.lifecycleWork.add(pending)
814
831
  pending.then(
815
- () => this.lifecycleWork.delete(pending),
816
- () => this.lifecycleWork.delete(pending)
832
+ () => {
833
+ this.lifecycleWork.delete(pending)
834
+ this.drainQueued = false
835
+ if (this.drainRequested) this.schedule()
836
+ },
837
+ (error) => {
838
+ this.lifecycleWork.delete(pending)
839
+ this.drainQueued = false
840
+ this.log("error", {
841
+ stage: "queue",
842
+ message: error && error.message ? error.message : String(error)
843
+ })
844
+ console.warn("Automatic Disk Saver check failed:",
845
+ error && error.message ? error.message : error)
846
+ if (this.drainRequested) this.schedule()
847
+ }
817
848
  )
818
- pending.catch((error) => {
819
- this.log("error", {
820
- stage: "queue",
821
- message: error && error.message ? error.message : String(error)
822
- })
823
- console.warn("Automatic Disk Saver check failed:",
824
- error && error.message ? error.message : error)
825
- })
826
849
  })
827
850
  }
828
851
 
@@ -912,10 +935,11 @@ class AutomaticScans {
912
935
  if (reusable) {
913
936
  counts.hash_reuses += 1
914
937
  memory.set(key, reusable)
915
- verifiedHashes.set(pathKey(entry.path), Object.assign({}, currentEntry, {
938
+ const verifiedEntry = Object.assign({}, currentEntry, {
916
939
  hash: reusable
917
- }))
918
- return reusable
940
+ })
941
+ verifiedHashes.set(pathKey(entry.path), verifiedEntry)
942
+ return verifiedEntry
919
943
  }
920
944
  let verified
921
945
  try {
@@ -942,10 +966,11 @@ class AutomaticScans {
942
966
  counts.hashed += 1
943
967
  counts.hash_bytes += Math.max(0, Number(verified.result.size) || 0)
944
968
  memory.set(key, verified.result.hash)
945
- verifiedHashes.set(pathKey(entry.path), Object.assign({}, currentEntry, {
969
+ const verifiedEntry = Object.assign({}, currentEntry, {
946
970
  hash: verified.result.hash
947
- }))
948
- return verified.result.hash
971
+ })
972
+ verifiedHashes.set(pathKey(entry.path), verifiedEntry)
973
+ return verifiedEntry
949
974
  }
950
975
 
951
976
  async runPrecheck(active) {
@@ -972,7 +997,6 @@ class AutomaticScans {
972
997
  const paths = [...new Set(active.paths || [])].filter((filePath) =>
973
998
  typeof filePath === "string" && inside(root, filePath))
974
999
  const verifiedHashes = new Map()
975
- await this.vault.registry.beginAutomaticPrecheck(app)
976
1000
  this.log("check-started", {
977
1001
  app,
978
1002
  root,
@@ -980,6 +1004,7 @@ class AutomaticScans {
980
1004
  changed_paths: paths.length,
981
1005
  policy: "metadata-prefilter-sha256"
982
1006
  })
1007
+ await this.vault.registry.beginAutomaticPrecheck(app)
983
1008
  try {
984
1009
  this.checkpoint(active)
985
1010
  const metadataOptions = {
@@ -1042,7 +1067,6 @@ class AutomaticScans {
1042
1067
  const preparePeers = () => {
1043
1068
  if (!group || group.unmatched) return
1044
1069
  group.unmatched = new Map()
1045
- group.unmatchedIdentities = new Map()
1046
1070
  for (const [hash, value] of group.candidatesByHash) {
1047
1071
  if (value.identities.size > 1) {
1048
1072
  for (const candidate of value.candidates) {
@@ -1054,9 +1078,6 @@ class AutomaticScans {
1054
1078
  identity,
1055
1079
  candidates: value.candidates
1056
1080
  })
1057
- group.unmatchedIdentities.set(
1058
- identity,
1059
- (group.unmatchedIdentities.get(identity) || 0) + 1)
1060
1081
  }
1061
1082
  }
1062
1083
  }
@@ -1085,47 +1106,39 @@ class AutomaticScans {
1085
1106
  candidatesByHash: new Map(),
1086
1107
  matched: new Set(),
1087
1108
  unmatched: null,
1088
- unmatchedIdentities: null,
1089
1109
  memory: new Map()
1090
1110
  }
1091
1111
  }
1092
1112
  if (entry.kind === "changed") {
1093
1113
  const candidate = entry
1094
1114
  this.checkpoint(active)
1095
- const hash = await this.verifiedAutomaticHash(
1115
+ const verified = await this.verifiedAutomaticHash(
1096
1116
  active, candidate, group.memory, verifiedHashes, counts)
1097
- if (!hash) continue
1117
+ if (!verified) continue
1118
+ const hash = verified.hash
1098
1119
  let value = group.candidatesByHash.get(hash)
1099
1120
  if (!value) {
1100
1121
  value = { candidates: [], identities: new Set() }
1101
1122
  group.candidatesByHash.set(hash, value)
1102
1123
  }
1103
- value.candidates.push(candidate)
1104
- value.identities.add(this.automaticIdentityKey(candidate))
1124
+ value.candidates.push(verified)
1125
+ value.identities.add(this.automaticIdentityKey(verified))
1105
1126
  continue
1106
1127
  }
1107
1128
  preparePeers()
1108
1129
  if (!group.unmatched.size) continue
1109
1130
  this.checkpoint(active)
1110
- const identity = this.automaticIdentityKey(entry)
1111
- if (group.unmatchedIdentities.size === 1 &&
1112
- group.unmatchedIdentities.has(identity)) continue
1113
- const hash = await this.verifiedAutomaticHash(
1131
+ const verified = await this.verifiedAutomaticHash(
1114
1132
  active, entry, group.memory, verifiedHashes, counts)
1115
- const wanted = hash && group.unmatched.get(hash)
1133
+ const wanted = verified && group.unmatched.get(verified.hash)
1134
+ const identity = verified && this.automaticIdentityKey(verified)
1116
1135
  if (!wanted || wanted.identity === identity) {
1117
1136
  continue
1118
1137
  }
1119
1138
  for (const candidate of wanted.candidates) {
1120
1139
  group.matched.add(pathKey(candidate.path))
1121
1140
  }
1122
- group.unmatched.delete(hash)
1123
- const remaining = group.unmatchedIdentities.get(wanted.identity) - 1
1124
- if (remaining) {
1125
- group.unmatchedIdentities.set(wanted.identity, remaining)
1126
- } else {
1127
- group.unmatchedIdentities.delete(wanted.identity)
1128
- }
1141
+ group.unmatched.delete(verified.hash)
1129
1142
  }
1130
1143
  if (verifiedHashes.size >= ENTRY_BATCH_SIZE) {
1131
1144
  await this.vault.registry.rememberHashCache(
@@ -1164,7 +1177,19 @@ class AutomaticScans {
1164
1177
  item.state === "checking")
1165
1178
  if (!entry) return
1166
1179
  await this.refreshGlobalScanReady()
1167
- if (this.disposed || !this.globalScanReady) return
1180
+ if (this.disposed) return
1181
+ if (!this.globalScanReady) {
1182
+ if (this.entries.get(entry.app) === entry) {
1183
+ this.restorePrevious(entry.app)
1184
+ this.discardChangedPaths(entry.app)
1185
+ this.log("check-skipped", {
1186
+ app: entry.app,
1187
+ reason: "global-scan-required"
1188
+ })
1189
+ }
1190
+ this.schedule()
1191
+ return
1192
+ }
1168
1193
  if (this.manualDepth > 0 || this.currentBusyPromise() ||
1169
1194
  this.vault.fileActionProgress) {
1170
1195
  this.waitForBusyWork()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "8.0.72",
3
+ "version": "8.0.74",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -167,7 +167,8 @@
167
167
  tab.classList.remove("app-vault-result-attention")
168
168
  }
169
169
  })
170
- tab.addEventListener("click", () => {
170
+ tab.addEventListener("click", (event) => {
171
+ if (!event.isTrusted) return
171
172
  const signature = status.dataset.resultSignature || ""
172
173
  if (!signature) return
173
174
  requestAutomaticScanFocus(signature)
@@ -221,7 +221,7 @@ test("the app workspace presents the automatic-result coachmark without starting
221
221
  assert.doesNotMatch(present, /innerHTML|post\(|btn-scan\.click/)
222
222
  })
223
223
 
224
- test("selecting a badged row acknowledges it without delaying navigation", async () => {
224
+ test("user-selecting a badged row acknowledges it without delaying navigation", async () => {
225
225
  const script = await fs.promises.readFile(
226
226
  path.join(root, "server", "public", "app-vault-mode.js"), "utf8")
227
227
  const parent = new JSDOM("", { url: "http://localhost/" })
@@ -256,14 +256,19 @@ test("selecting a badged row acknowledges it without delaying navigation", async
256
256
  }
257
257
  }
258
258
 
259
- dom.window.eval(script)
260
259
  const tab = dom.window.document.getElementById("save-space-tab")
261
- let preventedByHandler = null
262
- tab.addEventListener("click", (event) => {
263
- preventedByHandler = event.defaultPrevented
264
- event.preventDefault()
260
+ let clickHandler
261
+ const addEventListener = tab.addEventListener.bind(tab)
262
+ tab.addEventListener = (type, listener, options) => {
263
+ if (type === "click") clickHandler = listener
264
+ addEventListener(type, listener, options)
265
+ }
266
+ dom.window.eval(script)
267
+ let preventedByHandler = false
268
+ clickHandler({
269
+ isTrusted: true,
270
+ preventDefault: () => { preventedByHandler = true }
265
271
  })
266
- tab.click()
267
272
 
268
273
  await waitFor(() => requests.length === 1)
269
274
  assert.equal(preventedByHandler, false)
@@ -289,6 +294,49 @@ test("selecting a badged row acknowledges it without delaying navigation", async
289
294
  parent.window.close()
290
295
  })
291
296
 
297
+ test("programmatic tab clicks do not acknowledge a result", async () => {
298
+ const script = await fs.promises.readFile(
299
+ path.join(root, "server", "public", "app-vault-mode.js"), "utf8")
300
+ const signature = "e".repeat(64)
301
+ const parent = new JSDOM("", { url: "http://localhost/" })
302
+ const dom = new JSDOM(`<a id="save-space-tab" href="/vault/app/ComfyUI">
303
+ <span data-app-vault-result-badge></span>
304
+ <span data-app-vault-mode data-app="ComfyUI" data-mode="automatic"
305
+ data-ready="true" data-result-signature="${signature}">
306
+ <span data-app-vault-mode-label>Auto</span>
307
+ </span>
308
+ </a>`, {
309
+ runScripts: "outside-only",
310
+ url: "http://localhost/v/ComfyUI"
311
+ })
312
+ Object.defineProperty(dom.window, "parent", {
313
+ configurable: true,
314
+ value: parent.window
315
+ })
316
+ parent.window.postMessage = () => {}
317
+ const requests = []
318
+ dom.window.fetch = async (url, options = {}) => {
319
+ requests.push({ url, options })
320
+ return { ok: true, json: async () => ({}) }
321
+ }
322
+
323
+ dom.window.eval(script)
324
+ const tab = dom.window.document.getElementById("save-space-tab")
325
+ tab.addEventListener("click", (event) => event.preventDefault())
326
+ tab.click()
327
+
328
+ assert.equal(requests.length, 0)
329
+ assert.equal(tab.querySelector("[data-app-vault-result-badge]").hidden,
330
+ false)
331
+ assert.equal(dom.window.document.querySelector("[data-app-vault-mode]")
332
+ .dataset.resultSignature, signature)
333
+ assert.equal(dom.window.sessionStorage.getItem(
334
+ "pinokio:vault:auto-scan-focus:ComfyUI"), null)
335
+
336
+ dom.window.close()
337
+ parent.window.close()
338
+ })
339
+
292
340
  test("a retained Disk Saver handoff targets the visible existing frame", async () => {
293
341
  const template = await fs.promises.readFile(
294
342
  path.join(root, "server", "views", "app.ejs"), "utf8")
@@ -336,8 +384,15 @@ test("a retained Disk Saver handoff targets the visible existing frame", async (
336
384
  json: async () => ({ acknowledged: true, app: "ComfyUI" })
337
385
  })
338
386
 
387
+ const tab = dom.window.document.getElementById("save-space-tab")
388
+ let clickHandler
389
+ const addEventListener = tab.addEventListener.bind(tab)
390
+ tab.addEventListener = (type, listener, options) => {
391
+ if (type === "click") clickHandler = listener
392
+ addEventListener(type, listener, options)
393
+ }
339
394
  dom.window.eval(script)
340
- dom.window.document.getElementById("save-space-tab").click()
395
+ clickHandler({ isTrusted: true })
341
396
  await waitFor(() => visibleMessages.length === 1)
342
397
 
343
398
  assert.deepEqual(hiddenMessages, [])
@@ -265,6 +265,46 @@ describe("automatic app checks", () => {
265
265
  await close(vault)
266
266
  })
267
267
 
268
+ test("a watcher batch retains a path recreated after deletion", async () => {
269
+ const home = await makeHome()
270
+ const app = "delete-create-app"
271
+ const root = path.join(home, "api", app)
272
+ const temporary = path.join(root, "temporary")
273
+ const recreated = path.join(temporary, "model.bin")
274
+ const automatic = makeAutomatic({
275
+ enabled: true,
276
+ kernel: { homedir: home, platform: "darwin" }
277
+ })
278
+ automatic.observedApps.add(app)
279
+
280
+ automatic.handleWatcherEvents(null, [
281
+ { type: "delete", path: temporary },
282
+ { type: "create", path: recreated }
283
+ ])
284
+
285
+ assert.deepEqual([...automatic.changedPaths.get(app)], [recreated])
286
+ })
287
+
288
+ test("a watcher batch removes a path deleted after creation", async () => {
289
+ const home = await makeHome()
290
+ const app = "create-delete-app"
291
+ const root = path.join(home, "api", app)
292
+ const temporary = path.join(root, "temporary")
293
+ const removed = path.join(temporary, "model.bin")
294
+ const automatic = makeAutomatic({
295
+ enabled: true,
296
+ kernel: { homedir: home, platform: "darwin" }
297
+ })
298
+ automatic.observedApps.add(app)
299
+
300
+ automatic.handleWatcherEvents(null, [
301
+ { type: "create", path: removed },
302
+ { type: "delete", path: temporary }
303
+ ])
304
+
305
+ assert.equal(automatic.changedPaths.has(app), false)
306
+ })
307
+
268
308
  test("disposing automatic checks releases the watcher and pending work", async () => {
269
309
  const home = await makeHome()
270
310
  const appRoot = path.join(home, "api", "disposed-app")
@@ -405,6 +445,71 @@ describe("automatic app checks", () => {
405
445
  assert.equal(checksStarted, 0)
406
446
  })
407
447
 
448
+ test("scheduler coalesces wakeups while dispatch is in progress", async () => {
449
+ let releaseReadiness
450
+ let readinessCalls = 0
451
+ let dispatches = 0
452
+ const automatic = makeAutomatic({
453
+ enabled: true,
454
+ registry: {},
455
+ runExclusive: () => {
456
+ dispatches += 1
457
+ return new Promise(() => {})
458
+ }
459
+ })
460
+ automatic.entries.set("demo", {
461
+ app: "demo",
462
+ state: "checking",
463
+ paths: []
464
+ })
465
+ automatic.globalScanReady = true
466
+ automatic.refreshGlobalScanReady = async () => {
467
+ readinessCalls += 1
468
+ if (readinessCalls === 1) {
469
+ await new Promise((resolve) => { releaseReadiness = resolve })
470
+ }
471
+ automatic.globalScanReady = true
472
+ }
473
+ automatic.appRootIsAvailable = async () => true
474
+ automatic.appIsRunning = () => false
475
+
476
+ automatic.schedule()
477
+ await waitFor(() => typeof releaseReadiness === "function",
478
+ "scheduler readiness pause")
479
+ automatic.schedule()
480
+ releaseReadiness()
481
+ await waitFor(() => dispatches > 0, "automatic dispatch")
482
+ await new Promise((resolve) => setImmediate(resolve))
483
+
484
+ assert.equal(dispatches, 1)
485
+ assert.equal(readinessCalls, 1)
486
+ })
487
+
488
+ test("queued checks are cleared if global readiness disappears", async () => {
489
+ const automatic = makeAutomatic({
490
+ enabled: true,
491
+ registry: {}
492
+ })
493
+ automatic.entries.set("demo", {
494
+ app: "demo",
495
+ state: "checking",
496
+ paths: ["/pinokio/api/demo/model.bin"]
497
+ })
498
+ automatic.changedPaths.set("demo", new Set([
499
+ "/pinokio/api/demo/model.bin"
500
+ ]))
501
+ automatic.refreshGlobalScanReady = async () => {
502
+ automatic.globalScanReady = false
503
+ }
504
+ automatic.log = () => {}
505
+
506
+ automatic.schedule()
507
+ await waitFor(() => !automatic.drainQueued, "automatic queue cleanup")
508
+
509
+ assert.equal(automatic.entries.has("demo"), false)
510
+ assert.equal(automatic.changedPaths.has("demo"), false)
511
+ })
512
+
408
513
  test("Linux starts no watcher and ignores automatic lifecycle work", async () => {
409
514
  const home = await makeHome()
410
515
  const appRoot = path.join(home, "api", "linux-app")
@@ -1363,6 +1468,80 @@ describe("automatic app checks", () => {
1363
1468
  await close(vault)
1364
1469
  })
1365
1470
 
1471
+ test("files that become hardlinks after staging are not duplicates", async () => {
1472
+ const home = await makeHome()
1473
+ const app = "became-hardlinks-app"
1474
+ const appRoot = path.join(home, "api", app)
1475
+ const first = path.join(appRoot, "first.bin")
1476
+ const second = path.join(appRoot, "second.bin")
1477
+ await fs.promises.mkdir(appRoot)
1478
+ await fs.promises.writeFile(first, "same-content")
1479
+ await fs.promises.writeFile(second, "same-content")
1480
+ const before = await Promise.all([
1481
+ fs.promises.lstat(first),
1482
+ fs.promises.lstat(second)
1483
+ ])
1484
+ assert.notEqual(before[0].ino, before[1].ino)
1485
+ const vault = await makeVault(home)
1486
+ vault.automaticScans.candidateThreshold = async () => 1
1487
+ const stage = vault.registry.stageAutomaticPrecheckFiles.bind(
1488
+ vault.registry)
1489
+ vault.registry.stageAutomaticPrecheckFiles = async (...args) => {
1490
+ await stage(...args)
1491
+ await fs.promises.unlink(second)
1492
+ await fs.promises.link(first, second)
1493
+ }
1494
+
1495
+ vault.automaticScans.queueApp(app, [first, second])
1496
+ await waitFor(() => !vault.automaticScans.active &&
1497
+ !vault.automaticScans.entries.has(app))
1498
+
1499
+ const after = await Promise.all([
1500
+ fs.promises.lstat(first),
1501
+ fs.promises.lstat(second)
1502
+ ])
1503
+ assert.equal(after[0].ino, after[1].ino)
1504
+ assert.deepEqual(vault.automaticScans.snapshot().rows, [])
1505
+ await close(vault)
1506
+ })
1507
+
1508
+ test("hardlinks that become independent after staging are duplicates", async () => {
1509
+ const home = await makeHome()
1510
+ const app = "split-hardlinks-app"
1511
+ const appRoot = path.join(home, "api", app)
1512
+ const first = path.join(appRoot, "first.bin")
1513
+ const second = path.join(appRoot, "second.bin")
1514
+ await fs.promises.mkdir(appRoot)
1515
+ await fs.promises.writeFile(first, "same-content")
1516
+ await fs.promises.link(first, second)
1517
+ const before = await Promise.all([
1518
+ fs.promises.lstat(first),
1519
+ fs.promises.lstat(second)
1520
+ ])
1521
+ assert.equal(before[0].ino, before[1].ino)
1522
+ const vault = await makeVault(home)
1523
+ vault.automaticScans.candidateThreshold = async () => 1
1524
+ const stage = vault.registry.stageAutomaticPrecheckFiles.bind(
1525
+ vault.registry)
1526
+ vault.registry.stageAutomaticPrecheckFiles = async (...args) => {
1527
+ await stage(...args)
1528
+ await fs.promises.unlink(second)
1529
+ await fs.promises.writeFile(second, "same-content")
1530
+ }
1531
+
1532
+ vault.automaticScans.queueApp(app, [first, second])
1533
+ await waitFor(() => !vault.automaticScans.active &&
1534
+ vault.automaticScans.entries.get(app)?.state === "result")
1535
+
1536
+ const after = await Promise.all([
1537
+ fs.promises.lstat(first),
1538
+ fs.promises.lstat(second)
1539
+ ])
1540
+ assert.notEqual(after[0].ino, after[1].ino)
1541
+ assert.equal(vault.automaticScans.snapshot().rows[0].state, "result")
1542
+ await close(vault)
1543
+ })
1544
+
1366
1545
  test("stale file rows and known identical inodes are not peers", async () => {
1367
1546
  const home = await makeHome()
1368
1547
  const appRoot = path.join(home, "api", "no-peer-app")