@mengruo/dsh-vision-toolkit 0.0.1 → 0.1.0

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.
Files changed (51) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +17 -42
  3. package/README.zh.md +16 -41
  4. package/assets/logo_eapi_dark.png +0 -0
  5. package/docs/aihubmix-gemini-vision.i18n.yaml +2 -2
  6. package/docs/aihubmix-gemini-vision.md +2 -2
  7. package/docs/aihubmix-gemini-vision.zh.md +2 -2
  8. package/lib/client.js +246 -68
  9. package/lib/client.js.map +1 -1
  10. package/lib/config.js +143 -24
  11. package/lib/config.js.map +1 -1
  12. package/lib/evidence-cache.js +14 -0
  13. package/lib/evidence-cache.js.map +1 -1
  14. package/lib/image-input-variants.js +39 -27
  15. package/lib/image-input-variants.js.map +1 -1
  16. package/lib/runtime-install.js +204 -19
  17. package/lib/runtime-install.js.map +1 -1
  18. package/lib/runtime.js +310 -154
  19. package/lib/runtime.js.map +1 -1
  20. package/lib/tools.js +1 -0
  21. package/lib/tools.js.map +1 -1
  22. package/lib/types/client/index.d.ts +45 -3
  23. package/lib/types/client/index.d.ts.map +1 -1
  24. package/lib/types/config.d.ts +53 -0
  25. package/lib/types/config.d.ts.map +1 -1
  26. package/lib/types/evidence-cache.d.ts.map +1 -1
  27. package/lib/types/image-input-variants.d.ts +1 -6
  28. package/lib/types/image-input-variants.d.ts.map +1 -1
  29. package/lib/types/runtime-install.d.ts +21 -0
  30. package/lib/types/runtime-install.d.ts.map +1 -1
  31. package/lib/types/runtime.d.ts +45 -10
  32. package/lib/types/runtime.d.ts.map +1 -1
  33. package/lib/types/tools.d.ts.map +1 -1
  34. package/lib/types/upstream.d.ts +3 -0
  35. package/lib/types/upstream.d.ts.map +1 -1
  36. package/lib/types/web.d.ts +8 -0
  37. package/lib/types/web.d.ts.map +1 -1
  38. package/lib/upstream.js +11 -6
  39. package/lib/upstream.js.map +1 -1
  40. package/lib/web.js +50 -7
  41. package/lib/web.js.map +1 -1
  42. package/package.json +1 -1
  43. package/src/client/index.tsx +331 -88
  44. package/src/config.ts +210 -28
  45. package/src/evidence-cache.ts +14 -0
  46. package/src/image-input-variants.ts +39 -27
  47. package/src/runtime-install.ts +231 -20
  48. package/src/runtime.ts +333 -180
  49. package/src/tools.ts +1 -0
  50. package/src/upstream.ts +15 -8
  51. package/src/web.ts +67 -8
@@ -22,9 +22,9 @@ import {
22
22
  utimes,
23
23
  writeFile,
24
24
  } from 'node:fs/promises'
25
- import { createWriteStream } from 'node:fs'
25
+ import { createWriteStream, type Dirent } from 'node:fs'
26
26
  import { homedir } from 'node:os'
27
- import { dirname, join, resolve } from 'node:path'
27
+ import { basename, dirname, join, resolve } from 'node:path'
28
28
  import { Transform } from 'node:stream'
29
29
  import { pipeline } from 'node:stream/promises'
30
30
  import { fileURLToPath } from 'node:url'
@@ -88,6 +88,9 @@ const PYPI_MIRROR_BASE_URL = 'https://mirrors.cloud.tencent.com/pypi/simple'
88
88
  const PROBE_TIMEOUT_MS = 30_000
89
89
  const LOCK_STALE_MS = 15 * 60 * 1000
90
90
  const LOCK_HEARTBEAT_MS = 5_000
91
+ const WINDOWS_FILE_RETRY_ATTEMPTS = 5
92
+ const WINDOWS_FILE_RETRY_DELAY_MS = 250
93
+ const LEGACY_RUNTIME_GC_STALE_MS = 24 * 60 * 60 * 1000
91
94
 
92
95
  /** Absolute root of the packaged upstream snapshot. */
93
96
  export function bundledUpstreamRoot(): string {
@@ -103,6 +106,207 @@ function sha256(bytes: string | Buffer): string {
103
106
  return createHash('sha256').update(bytes).digest('hex')
104
107
  }
105
108
 
109
+ /**
110
+ * Windows Defender/antivirus real-time scanning briefly locks freshly written
111
+ * Python DLLs, so recursive removal and directory replacement can fail with
112
+ * EBUSY/EPERM immediately after installation. Retry those transient Windows
113
+ * errors before surfacing them; non-Windows platforms pass through unchanged.
114
+ */
115
+ export async function withWindowsTransientRetry<T>(operation: () => Promise<T>): Promise<T> {
116
+ let lastError: unknown
117
+ for (let attempt = 1; attempt <= WINDOWS_FILE_RETRY_ATTEMPTS; attempt += 1) {
118
+ try {
119
+ return await operation()
120
+ } catch (error) {
121
+ lastError = error
122
+ const code = error instanceof Error ? (error as NodeJS.ErrnoException).code : undefined
123
+ const transient = process.platform === 'win32' && (code === 'EBUSY' || code === 'EPERM' || code === 'EACCES')
124
+ if (!transient) break
125
+ if (attempt < WINDOWS_FILE_RETRY_ATTEMPTS) {
126
+ await new Promise(resolveWait => setTimeout(resolveWait, WINDOWS_FILE_RETRY_DELAY_MS * attempt))
127
+ }
128
+ }
129
+ }
130
+ throw lastError
131
+ }
132
+
133
+ /**
134
+ * Best-effort removal used after the primary runtime path has already
135
+ * succeeded or failed. Transient Windows locks must not turn a usable runtime
136
+ * into an error, but leaving the directory behind should still be audible.
137
+ */
138
+ export async function ignoreCleanupFailure(ctx: Context, label: string, path: string): Promise<void> {
139
+ try {
140
+ await withWindowsTransientRetry(() => rm(path, { recursive: true, force: true }))
141
+ } catch (error) {
142
+ ctx.logger.warn(
143
+ 'dsh-vision-toolkit: %s cleanup failed: %s',
144
+ label,
145
+ error instanceof Error ? error.message : String(error),
146
+ )
147
+ }
148
+ }
149
+
150
+ type RuntimeGarbageKind = 'managed runtime staging' | 'managed runtime quarantine' | 'bundled Python staging'
151
+
152
+ interface RuntimeGarbageCandidate {
153
+ kind: RuntimeGarbageKind
154
+ lockName?: string
155
+ lockToken?: string
156
+ minimumAgeMs?: number
157
+ createdAtMs?: number
158
+ observationMarker?: string
159
+ }
160
+
161
+ function runtimeGarbageLockToken(lockBase: string): string {
162
+ return sha256(lockBase).slice(0, 12)
163
+ }
164
+
165
+ function managedRuntimeGarbage(name: string): RuntimeGarbageCandidate | undefined {
166
+ if (name.startsWith('.prepare-')) {
167
+ const current = /^\.prepare-([a-f0-9]{12})-[^/]{6}$/.exec(name)
168
+ return { kind: 'managed runtime staging', ...(current === null ? {} : { lockToken: current[1] }) }
169
+ }
170
+ const replaced = name.indexOf('.replaced-')
171
+ if (replaced > 0) {
172
+ const stamped = /^(\d{13})-/.exec(name.slice(replaced + '.replaced-'.length))
173
+ return {
174
+ kind: 'managed runtime quarantine',
175
+ lockName: `${name.slice(0, replaced)}.lock`,
176
+ minimumAgeMs: LEGACY_RUNTIME_GC_STALE_MS,
177
+ ...(stamped === null
178
+ ? { observationMarker: '.dsh-vision-toolkit-gc-observed' }
179
+ : { createdAtMs: Number(stamped[1]) }),
180
+ }
181
+ }
182
+ return undefined
183
+ }
184
+
185
+ function bundledPythonGarbage(name: string): RuntimeGarbageCandidate | undefined {
186
+ if (!name.startsWith('.python-bootstrap-')) return undefined
187
+ const current = /^\.python-bootstrap-([a-f0-9]{12})-[^/]{6}$/.exec(name)
188
+ return { kind: 'bundled Python staging', ...(current === null ? {} : { lockToken: current[1] }) }
189
+ }
190
+
191
+ async function runtimeLockIsActive(lockPath: string, now: number): Promise<boolean> {
192
+ try {
193
+ const info = await stat(lockPath)
194
+ return now - info.mtimeMs <= LOCK_STALE_MS
195
+ } catch (error) {
196
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
197
+ return true
198
+ }
199
+ }
200
+
201
+ async function garbageCollectDirectory(
202
+ ctx: Context,
203
+ parent: string,
204
+ classify: (name: string) => RuntimeGarbageCandidate | undefined,
205
+ now: number,
206
+ ): Promise<void> {
207
+ let entries: Dirent[]
208
+ try {
209
+ entries = await readdir(parent, { withFileTypes: true })
210
+ } catch (error) {
211
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return
212
+ ctx.logger.warn(
213
+ 'dsh-vision-toolkit: runtime garbage collection scan failed for %s: %s',
214
+ parent,
215
+ error instanceof Error ? error.message : String(error),
216
+ )
217
+ return
218
+ }
219
+ let activeLockTokens: Set<string> | undefined
220
+ const getActiveLockTokens = async (): Promise<Set<string>> => {
221
+ if (activeLockTokens !== undefined) return activeLockTokens
222
+ activeLockTokens = new Set<string>()
223
+ for (const lock of entries) {
224
+ if (
225
+ lock.isDirectory()
226
+ && lock.name.endsWith('.lock')
227
+ && await runtimeLockIsActive(join(parent, lock.name), now)
228
+ ) {
229
+ activeLockTokens.add(runtimeGarbageLockToken(lock.name.slice(0, -'.lock'.length)))
230
+ }
231
+ }
232
+ return activeLockTokens
233
+ }
234
+ let legacyCollectionSafe: boolean | undefined
235
+ for (const entry of entries) {
236
+ if (!entry.isDirectory()) continue
237
+ const candidate = classify(entry.name)
238
+ if (candidate === undefined) continue
239
+ const path = join(parent, entry.name)
240
+ if (candidate.lockName !== undefined) {
241
+ if (await runtimeLockIsActive(join(parent, candidate.lockName), now)) continue
242
+ } else if (candidate.lockToken !== undefined) {
243
+ if ((await getActiveLockTokens()).has(candidate.lockToken)) continue
244
+ } else {
245
+ if (legacyCollectionSafe === undefined) {
246
+ legacyCollectionSafe = (await getActiveLockTokens()).size === 0
247
+ }
248
+ if (!legacyCollectionSafe) continue
249
+ try {
250
+ const info = await stat(path)
251
+ if (now - info.mtimeMs <= LEGACY_RUNTIME_GC_STALE_MS) continue
252
+ } catch (error) {
253
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue
254
+ ctx.logger.warn(
255
+ 'dsh-vision-toolkit: runtime garbage collection inspection failed for %s: %s',
256
+ path,
257
+ error instanceof Error ? error.message : String(error),
258
+ )
259
+ continue
260
+ }
261
+ }
262
+ if (candidate.minimumAgeMs !== undefined) {
263
+ let createdAt = candidate.createdAtMs
264
+ if (createdAt === undefined && candidate.observationMarker !== undefined) {
265
+ const marker = join(path, candidate.observationMarker)
266
+ try {
267
+ createdAt = (await stat(marker)).mtimeMs
268
+ } catch (error) {
269
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
270
+ await writeFile(marker, `${now}\n`, { flag: 'wx' }).catch(() => {})
271
+ }
272
+ continue
273
+ }
274
+ }
275
+ if (createdAt === undefined) {
276
+ try {
277
+ createdAt = (await stat(path)).mtimeMs
278
+ } catch (error) {
279
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue
280
+ ctx.logger.warn(
281
+ 'dsh-vision-toolkit: runtime garbage collection inspection failed for %s: %s',
282
+ path,
283
+ error instanceof Error ? error.message : String(error),
284
+ )
285
+ continue
286
+ }
287
+ }
288
+ if (now - createdAt <= candidate.minimumAgeMs) continue
289
+ }
290
+ await ignoreCleanupFailure(ctx, `stale ${candidate.kind}`, path)
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Opportunistically remove abandoned runtime staging and quarantine trees.
296
+ * Current names encode their owning runtime lock, so live preparation is
297
+ * skipped. Quarantines retain a 24-hour recovery window; legacy names use the
298
+ * same grace period and are collected only when no runtime lock is active
299
+ * because they cannot be associated with a specific lock.
300
+ */
301
+ export async function garbageCollectRuntimeCache(
302
+ ctx: Context,
303
+ stateRoot: string,
304
+ now: number = Date.now(),
305
+ ): Promise<void> {
306
+ await garbageCollectDirectory(ctx, join(stateRoot, 'python'), managedRuntimeGarbage, now)
307
+ await garbageCollectDirectory(ctx, join(stateRoot, 'python-bootstrap'), bundledPythonGarbage, now)
308
+ }
309
+
106
310
  export function isolatedPythonEnvironment(home: string): NodeJS.ProcessEnv {
107
311
  return {
108
312
  HOME: home,
@@ -488,7 +692,7 @@ async function withDirectoryLock<T>(lockPath: string, fn: () => Promise<T>): Pro
488
692
  await writeFile(join(lockPath, 'owner'), `${owner}\n`, { flag: 'wx' })
489
693
  } catch (error) {
490
694
  if (acquired) {
491
- await rm(lockPath, { recursive: true, force: true }).catch(() => {})
695
+ await withWindowsTransientRetry(() => rm(lockPath, { recursive: true, force: true })).catch(() => {})
492
696
  throw error
493
697
  }
494
698
  if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
@@ -497,7 +701,7 @@ async function withDirectoryLock<T>(lockPath: string, fn: () => Promise<T>): Pro
497
701
  try {
498
702
  const info = await stat(lockPath)
499
703
  if (Date.now() - info.mtimeMs > LOCK_STALE_MS) {
500
- await rm(lockPath, { recursive: true, force: true })
704
+ await withWindowsTransientRetry(() => rm(lockPath, { recursive: true, force: true }))
501
705
  return withDirectoryLock(lockPath, fn)
502
706
  }
503
707
  } catch {
@@ -518,7 +722,7 @@ async function withDirectoryLock<T>(lockPath: string, fn: () => Promise<T>): Pro
518
722
  clearInterval(heartbeat)
519
723
  try {
520
724
  if ((await readFile(join(lockPath, 'owner'), 'utf8')).trim() === owner) {
521
- await rm(lockPath, { recursive: true, force: true })
725
+ await withWindowsTransientRetry(() => rm(lockPath, { recursive: true, force: true }))
522
726
  }
523
727
  } catch {
524
728
  // The lock was already removed or replaced.
@@ -552,8 +756,8 @@ export async function acquireBundledPython(
552
756
  if (ready !== undefined) return
553
757
  const parent = dirname(root)
554
758
  await mkdir(parent, { recursive: true })
555
- await rm(root, { recursive: true, force: true })
556
- const work = await mkdtemp(join(parent, '.python-bootstrap-'))
759
+ await withWindowsTransientRetry(() => rm(root, { recursive: true, force: true }))
760
+ const work = await mkdtemp(join(parent, `.python-bootstrap-${runtimeGarbageLockToken(basename(root))}-`))
557
761
  try {
558
762
  const archive = join(work, 'python.tar.gz')
559
763
  const extractDir = join(work, 'extract')
@@ -589,9 +793,9 @@ export async function acquireBundledPython(
589
793
  )
590
794
  }
591
795
  if (process.platform !== 'win32') await chmod(extractedInterpreter, 0o755)
592
- await rename(extractDir, root)
796
+ await withWindowsTransientRetry(() => rename(extractDir, root))
593
797
  } finally {
594
- await rm(work, { recursive: true, force: true })
798
+ await ignoreCleanupFailure(ctx, 'bundled Python staging', work)
595
799
  }
596
800
  })
597
801
  const metadata = await pythonMetadata(ctx, command, cwd)
@@ -632,6 +836,7 @@ export async function resolveBootstrapPython(
632
836
  try {
633
837
  const stateRoot = visionToolkitStateRoot()
634
838
  await mkdir(stateRoot, { recursive: true })
839
+ await garbageCollectRuntimeCache(ctx, stateRoot)
635
840
  const bundled = await acquireBundledPython(ctx, stateRoot, cwd, manifestOverride, requestImpl)
636
841
  return {
637
842
  command: bundled.command,
@@ -827,7 +1032,7 @@ async function waitForManagedRuntime(
827
1032
  try {
828
1033
  const info = await stat(lockPath)
829
1034
  if (Date.now() - info.mtimeMs > LOCK_STALE_MS) {
830
- await rm(lockPath, { recursive: true, force: true })
1035
+ await withWindowsTransientRetry(() => rm(lockPath, { recursive: true, force: true }))
831
1036
  return undefined
832
1037
  }
833
1038
  } catch {
@@ -844,7 +1049,7 @@ async function releaseManagedLock(lockPath: string, owner: string): Promise<void
844
1049
  } catch {
845
1050
  return
846
1051
  }
847
- await rm(lockPath, { recursive: true, force: true })
1052
+ await withWindowsTransientRetry(() => rm(lockPath, { recursive: true, force: true }))
848
1053
  }
849
1054
 
850
1055
  async function prepareManaged(
@@ -854,6 +1059,7 @@ async function prepareManaged(
854
1059
  ): Promise<PreparedUpstreamRuntime> {
855
1060
  const stateRoot = visionToolkitStateRoot()
856
1061
  await mkdir(stateRoot, { recursive: true })
1062
+ await garbageCollectRuntimeCache(ctx, stateRoot)
857
1063
  const cleanHome = join(stateRoot, 'home')
858
1064
  await mkdir(cleanHome, { recursive: true })
859
1065
  const bootstrap = await resolveBootstrapPython(ctx, config.runtime.python, cleanHome)
@@ -899,7 +1105,7 @@ async function prepareManaged(
899
1105
  await writeFile(join(lockPath, 'owner'), `${lockOwner}\n`, { flag: 'wx' })
900
1106
  } catch (error) {
901
1107
  if (lockAcquired) {
902
- await rm(lockPath, { recursive: true, force: true })
1108
+ await withWindowsTransientRetry(() => rm(lockPath, { recursive: true, force: true })).catch(() => {})
903
1109
  throw error
904
1110
  }
905
1111
  if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
@@ -923,7 +1129,7 @@ async function prepareManaged(
923
1129
  return prepareManaged(ctx, config, manifest)
924
1130
  }
925
1131
 
926
- const staging = await mkdtemp(join(parent, '.prepare-'))
1132
+ const staging = await mkdtemp(join(parent, `.prepare-${runtimeGarbageLockToken(runtimeId)}-`))
927
1133
  const installEnv: NodeJS.ProcessEnv = {
928
1134
  ...isolatedPythonEnvironment(cleanHome),
929
1135
  UV_CACHE_DIR: join(stateRoot, 'uv-cache'),
@@ -1013,20 +1219,20 @@ async function prepareManaged(
1013
1219
  manager,
1014
1220
  }
1015
1221
  await writeFile(join(staging, 'runtime.json'), `${JSON.stringify(marker, null, 2)}\n`)
1016
- const quarantine = `${finalRoot}.replaced-${randomUUID()}`
1222
+ const quarantine = `${finalRoot}.replaced-${Date.now()}-${randomUUID()}`
1017
1223
  let quarantined = false
1018
1224
  try {
1019
- await rename(finalRoot, quarantine)
1225
+ await withWindowsTransientRetry(() => rename(finalRoot, quarantine))
1020
1226
  quarantined = true
1021
1227
  } catch (error) {
1022
1228
  if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
1023
1229
  }
1024
1230
  try {
1025
- await rename(staging, finalRoot)
1231
+ await withWindowsTransientRetry(() => rename(staging, finalRoot))
1026
1232
  } catch (error) {
1027
1233
  if (quarantined) {
1028
1234
  try {
1029
- await rename(quarantine, finalRoot)
1235
+ await withWindowsTransientRetry(() => rename(quarantine, finalRoot))
1030
1236
  } catch (restoreError) {
1031
1237
  throw new VisionToolkitError(
1032
1238
  'runtime',
@@ -1037,13 +1243,18 @@ async function prepareManaged(
1037
1243
  }
1038
1244
  throw error
1039
1245
  }
1040
- await rm(quarantine, { recursive: true, force: true })
1246
+ await ignoreCleanupFailure(ctx, 'managed runtime quarantine', quarantine)
1041
1247
  const python: RuntimeCommand = { program: interpreter, prefix: [], display: interpreter }
1042
1248
  return { source: 'managed', root: BUNDLED_ROOT, python, cleanHome, pythonVersion: metadata.version, dependencies }
1043
1249
  } finally {
1044
1250
  clearInterval(heartbeat)
1045
- await rm(staging, { recursive: true, force: true })
1046
- await releaseManagedLock(lockPath, lockOwner)
1251
+ await ignoreCleanupFailure(ctx, 'managed runtime staging', staging)
1252
+ await releaseManagedLock(lockPath, lockOwner).catch(error => {
1253
+ ctx.logger.warn(
1254
+ 'dsh-vision-toolkit: managed runtime lock cleanup failed: %s',
1255
+ error instanceof Error ? error.message : String(error),
1256
+ )
1257
+ })
1047
1258
  }
1048
1259
  }
1049
1260