@vzn/vx 0.0.72 → 0.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.72",
3
+ "version": "0.0.73",
4
4
  "description": "An open, extensible monorepo task runner.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,10 +17,10 @@
17
17
  "node": ">=18"
18
18
  },
19
19
  "optionalDependencies": {
20
- "@vzn/vx-linux-x64": "0.0.72",
21
- "@vzn/vx-linux-arm64": "0.0.72",
22
- "@vzn/vx-darwin-x64": "0.0.72",
23
- "@vzn/vx-darwin-arm64": "0.0.72"
20
+ "@vzn/vx-linux-x64": "0.0.73",
21
+ "@vzn/vx-linux-arm64": "0.0.73",
22
+ "@vzn/vx-darwin-x64": "0.0.73",
23
+ "@vzn/vx-darwin-arm64": "0.0.73"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -62,6 +62,7 @@ import {
62
62
  type DeclaredOutputs,
63
63
  type IngestMeta,
64
64
  type InvocationRecord,
65
+ isIndexFull,
65
66
  type OutputDirRow,
66
67
  type OutputFileRow,
67
68
  type PruneOptions,
@@ -1483,7 +1484,12 @@ export class Cache implements CacheLayer {
1483
1484
  // node's writeFile, not Bun.write: the latter copies the buffer first
1484
1485
  // (measured on 150 MiB: +151 MiB and 33 ms against +0 and 21 ms).
1485
1486
  const endWrite = span('save: write temp')
1486
- await writeFile(tmpPath, compressed)
1487
+ // A write that fails part-way (a full disk) leaves the temp behind,
1488
+ // and a fresh one is an orphan only an hour on (A-14).
1489
+ await writeFile(tmpPath, compressed).catch(async (err: unknown) => {
1490
+ await unlink(tmpPath).catch(() => undefined)
1491
+ throw err
1492
+ })
1487
1493
  endWrite()
1488
1494
  } else {
1489
1495
  // `save` or `ingest` already streamed the artifact into its temp.
@@ -1625,6 +1631,16 @@ export class Cache implements CacheLayer {
1625
1631
  const hashes = [...this.touched]
1626
1632
  this.touched.clear()
1627
1633
  const now = Date.now()
1634
+ // LRU bookkeeping: on a full disk the bumps are dropped, never the prune
1635
+ // or the stats that asked for them (A-14).
1636
+ try {
1637
+ this.writeAccessed(hashes, now)
1638
+ } catch (err) {
1639
+ if (!isIndexFull(err)) throw err
1640
+ }
1641
+ }
1642
+
1643
+ private writeAccessed(hashes: readonly string[], now: number): void {
1628
1644
  // Chunked: SQLite's bound-parameter ceiling is 32k on modern
1629
1645
  // builds, but 900 keeps us safe on any build at negligible cost.
1630
1646
  for (let i = 0; i < hashes.length; i += 900) {
@@ -1824,15 +1840,25 @@ export class Cache implements CacheLayer {
1824
1840
  if (victims.size > 0 || stale.length > 0) {
1825
1841
  const hashes = [...victims]
1826
1842
  const rows = [...hashes, ...stale]
1827
- this.db.transaction(() => {
1843
+ // Artifacts first: a row whose artifact is gone reads as a miss, so the
1844
+ // order is safe, and on a full disk the delete's own journal needs the
1845
+ // space the unlinks free. Rows-first failed there before any file went,
1846
+ // and the one verb meant to free a full disk freed nothing (A-14).
1847
+ await Promise.all(hashes.map((h) => rm(this.tarPath(h), { force: true })))
1848
+ const deleteRows = this.db.transaction(() => {
1828
1849
  for (let i = 0; i < rows.length; i += 900) {
1829
1850
  const chunk = rows.slice(i, i + 900)
1830
1851
  this.db
1831
1852
  .prepare(`DELETE FROM entries WHERE hash IN (${chunk.map(() => '?').join(',')})`)
1832
1853
  .run(...(chunk as readonly SQLQueryBindings[]))
1833
1854
  }
1834
- })()
1835
- await Promise.all(hashes.map((h) => rm(this.tarPath(h), { force: true })))
1855
+ })
1856
+ try {
1857
+ deleteRows()
1858
+ } catch (err) {
1859
+ if (!isIndexFull(err)) throw err
1860
+ deleteRows()
1861
+ }
1836
1862
  }
1837
1863
 
1838
1864
  const orphans = await this.reapOrphans()
@@ -1853,9 +1879,14 @@ export class Cache implements CacheLayer {
1853
1879
  private async reapOrphans(
1854
1880
  now: number = Date.now(),
1855
1881
  ): Promise<{ orphans: number; orphanBytes: number }> {
1856
- this.db
1857
- .prepare('INSERT OR REPLACE INTO schema_meta(key, value) VALUES (?, ?)')
1858
- .run(SWEPT_AT, String(now))
1882
+ try {
1883
+ this.db
1884
+ .prepare('INSERT OR REPLACE INTO schema_meta(key, value) VALUES (?, ?)')
1885
+ .run(SWEPT_AT, String(now))
1886
+ } catch (err) {
1887
+ // The sweep's clock: on a full disk the sweep runs again next time.
1888
+ if (!isIndexFull(err)) throw err
1889
+ }
1859
1890
  let orphans = 0
1860
1891
  let orphanBytes = 0
1861
1892
  await Promise.all(
@@ -10,7 +10,7 @@ import type { Database } from 'bun:sqlite'
10
10
  import { lstatSync, readlinkSync } from 'node:fs'
11
11
  import path from 'node:path'
12
12
  import { fileIdentity, repoFacts } from './git-inputs.js'
13
- import { FILE_HASH_RACY_MS, racyWindowMs } from './layer.js'
13
+ import { FILE_HASH_RACY_MS, isIndexFull, racyWindowMs } from './layer.js'
14
14
 
15
15
  const FILE_HASHES_SWEPT_AT = 'file_hashes_swept_at'
16
16
  const DAY_MS = 24 * 60 * 60 * 1000
@@ -163,7 +163,12 @@ export class FileHashStore {
163
163
  // than memoised; the warm path never meets it (keys are derived long
164
164
  // after the files were written).
165
165
  if (this.write && Date.now() - ctimeMs >= racyWindowMs(st.ctimeMs, FILE_HASH_RACY_MS)) {
166
- this.upsertFileHash.run(filePath, mtimeMs, size, ctimeMs, ino, ch, Date.now())
166
+ // A memo: on a full disk the digest stands and the row is skipped.
167
+ try {
168
+ this.upsertFileHash.run(filePath, mtimeMs, size, ctimeMs, ino, ch, Date.now())
169
+ } catch (err) {
170
+ if (!isIndexFull(err)) throw err
171
+ }
167
172
  }
168
173
  return fileIdentity(mode, ch)
169
174
  }
@@ -261,20 +266,30 @@ export class FileHashStore {
261
266
  misses.map((p) => this.hashFileFromDisk(p).catch(() => undefined)),
262
267
  )
263
268
  const now = Date.now()
264
- this.db.transaction(() => {
265
- for (let i = 0; i < misses.length; i++) {
266
- const digest = digests[i]
267
- if (digest === undefined) continue
268
- const p = misses[i]!
269
- const st = stats.get(p)!
270
- out.set(p, fileIdentity(st.mode, digest))
271
- // The same racy-clean rule as `hashFile`: a stat taken within the
272
- // window of the file's last change is not memoised.
273
- if (this.write && now - st.ctimeMs >= racyWindowMs(st.ctimeRaw, FILE_HASH_RACY_MS)) {
269
+ const rows: Array<[string, Stat, string]> = []
270
+ for (let i = 0; i < misses.length; i++) {
271
+ const digest = digests[i]
272
+ if (digest === undefined) continue
273
+ const p = misses[i]!
274
+ const st = stats.get(p)!
275
+ out.set(p, fileIdentity(st.mode, digest))
276
+ // The same racy-clean rule as `hashFile`: a stat taken within the
277
+ // window of the file's last change is not memoised.
278
+ if (this.write && now - st.ctimeMs >= racyWindowMs(st.ctimeRaw, FILE_HASH_RACY_MS)) {
279
+ rows.push([p, st, digest])
280
+ }
281
+ }
282
+ // Every digest is in `out` before the memo is written: on a full disk the
283
+ // rows are skipped and the answer stands.
284
+ try {
285
+ this.db.transaction(() => {
286
+ for (const [p, st, digest] of rows) {
274
287
  this.upsertFileHash.run(p, st.mtimeMs, st.size, st.ctimeMs, st.ino, digest, now)
275
288
  }
276
- }
277
- })()
289
+ })()
290
+ } catch (err) {
291
+ if (!isIndexFull(err)) throw err
292
+ }
278
293
  return out
279
294
  }
280
295
 
@@ -438,6 +438,16 @@ export interface IngestMeta {
438
438
  outputs?: DeclaredOutputs
439
439
  }
440
440
 
441
+ /**
442
+ * SQLite's answer when the disk under the index is full. It is a code on
443
+ * the `SQLiteError` (`SQLITE_FULL`), never an `ENOSPC` errno, so
444
+ * `isDiskFull` does not see it: a memo write, a snapshot flush or a prune's
445
+ * delete on a full disk reached the user as a stack (A-14).
446
+ */
447
+ export function isIndexFull(err: unknown): boolean {
448
+ return (err as { code?: unknown } | null)?.code === 'SQLITE_FULL'
449
+ }
450
+
441
451
  /**
442
452
  * The supplied artifact bytes don't decompress/parse as a vx artifact.
443
453
  * Thrown by `save`/`ingest` BEFORE anything reaches the final cache
@@ -16,6 +16,7 @@ import {
16
16
  OUTPUT_DIRS_CAP,
17
17
  OUTPUT_DIRS_RACY_MS,
18
18
  type OutputDirRow,
19
+ isIndexFull,
19
20
  racyWindowMs,
20
21
  type OutputFileRow,
21
22
  WORKSPACE_OUTPUT_PREFIX,
@@ -265,6 +266,16 @@ export class OutputIndex {
265
266
 
266
267
  /** Land every pending stamp and snapshot in one transaction each; a null snapshot clears its rows. */
267
268
  flushOutputDirs(): void {
269
+ // A memo either way: on a full disk the pending rows are dropped (the
270
+ // next hit walks, or restores), never a failed run (A-14).
271
+ try {
272
+ this.flushPending()
273
+ } catch (err) {
274
+ if (!isIndexFull(err)) throw err
275
+ }
276
+ }
277
+
278
+ private flushPending(): void {
268
279
  if (this.pendingStamps.size > 0) {
269
280
  const stamps = [...this.pendingStamps]
270
281
  this.pendingStamps.clear()
@@ -304,10 +304,27 @@ export async function upgradeCmd(args: readonly string[]): Promise<number> {
304
304
  // rather than this process guessing, and one that cannot is rolled back.
305
305
  let installed = ''
306
306
  await replaceBinary(dest, asset.url, asset.sha256, (bin) => {
307
- const proc = Bun.spawnSync({ cmd: [bin, '--version'], stdout: 'pipe', stderr: 'pipe' })
308
- installed = new TextDecoder().decode(proc.stdout).trim()
309
- return proc.exitCode === 0 && installed.startsWith('vx ')
307
+ installed = startedVersion(bin) ?? ''
308
+ return installed !== ''
310
309
  })
311
310
  process.stdout.write(`vx upgrade: installed ${installed}\n`)
312
311
  return 0
313
312
  }
313
+
314
+ /**
315
+ * `bin --version` when it answers as vx within `timeoutMs`, else null. The
316
+ * answer decides whether a replaced binary is rolled back, so it is
317
+ * bounded: a binary that hung on start held `vx upgrade` forever, new
318
+ * binary in place, old one never restored.
319
+ */
320
+ export function startedVersion(bin: string, timeoutMs = 10_000): string | null {
321
+ const proc = Bun.spawnSync({
322
+ cmd: [bin, '--version'],
323
+ stdin: 'ignore',
324
+ stdout: 'pipe',
325
+ stderr: 'pipe',
326
+ timeout: timeoutMs,
327
+ })
328
+ const out = new TextDecoder().decode(proc.stdout).trim()
329
+ return proc.exitCode === 0 && out.startsWith('vx ') ? out : null
330
+ }