OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer commits / 91b75fc

0.0.46: describe a file where the overlay actually has it
Files and Avatars fetch the SAME content hash from the SAME overlay and differ only in where they cache locally — blobs/<oid> vs avatars/<oid>. But get() ignored the path fetchFile RETURNS and re-read its own expected path. On a LOCAL hit the overlay returns where it already holds the bytes, which for an avatar is the other store directory — so the file was found and then looked for somewhere it was not. MEASURED on ashatron: file-get for its own avatar answered {found:false} on the machine with the bytes sitting in avatars/<oid>. Every consumer downstream then faithfully rendered no picture, which made an ap-bridge blob route look broken when it was reporting exactly what favela-peer told it. Both stores now describe r.destPath rather than assuming their own. favela-peer 340/340 tests, 808/808 asserts (was 337/802) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
91b75fcd837770f8888ff5a816d78457fd0a213a
by plankatron <fleeky@gmail.com> · 2026-09-08T22:09:40.000Z

Changed files (4) +74 -9

lib/avatars.js +10 -4
……@@ -106,11 +106,11 @@ export class Avatars {
106106 if (typeof oid !== 'string' || !/^[0-9a-f]{8,128}$/i.test(oid)) return null
107107 const p = this._path(oid)
108108
109 const describe = () => {
109 const describe = (at = p) => {
110110 try {
111 const bytes = fs.readFileSync(p)
111 const bytes = fs.readFileSync(at)
112112 const mimeType = sniff(bytes)
113 return mimeType ? { path: p, mimeType, size: bytes.length } : null
113 return mimeType ? { path: at, mimeType, size: bytes.length } : null
114114 } catch {
115115 return null
116116 }
……@@ -123,7 +123,13 @@ export class Avatars {
123123 fs.mkdirSync(this.dir, { recursive: true })
124124 const r = await this.overlay.fetchFile(oid, { destPath: p })
125125 if (!r) return null
126 return describe()
126 // ⛔ DESCRIBE WHERE IT ACTUALLY IS. On a LOCAL hit the overlay returns the path it already
127 // holds the bytes at — which may be the OTHER store's directory, since Files and Avatars
128 // fetch the same content hash from the same overlay and differ only in where they cache.
129 // Re-reading our own expected path meant a file the overlay had was reported missing: on
130 // ashatron, `file-get` for its own avatar answered {found:false} while the bytes sat in
131 // avatars/<oid>, and every consumer downstream faithfully rendered no picture.
132 return describe(r.destPath || p)
127133 } catch {
128134 return null
129135 }
lib/files.js +10 -4
……@@ -164,15 +164,15 @@ export class Files {
164164 if (typeof oid !== 'string' || !/^[0-9a-f]{8,128}$/i.test(oid)) return null
165165 const p = this._path(oid)
166166
167 const describe = () => {
167 const describe = (at = p) => {
168168 try {
169 const fd = fs.openSync(p, 'r')
169 const fd = fs.openSync(at, 'r')
170170 const head = b4a.alloc(4096)
171171 let n = 0
172172 try { n = fs.readSync(fd, head, 0, head.length, 0) } finally { fs.closeSync(fd) }
173173 const mimeType = sniff(head.subarray(0, n))
174174 if (!mimeType) return null
175 return { path: p, mimeType, size: fs.statSync(p).size }
175 return { path: at, mimeType, size: fs.statSync(at).size }
176176 } catch {
177177 return null
178178 }
……@@ -185,7 +185,13 @@ export class Files {
185185 fs.mkdirSync(this.dir, { recursive: true })
186186 const r = await this.overlay.fetchFile(oid, { destPath: p })
187187 if (!r) return null
188 return describe()
188 // ⛔ DESCRIBE WHERE IT ACTUALLY IS. On a LOCAL hit the overlay returns the path it already
189 // holds the bytes at — which may be the OTHER store's directory, since Files and Avatars
190 // fetch the same content hash from the same overlay and differ only in where they cache.
191 // Re-reading our own expected path meant a file the overlay had was reported missing: on
192 // ashatron, `file-get` for its own avatar answered {found:false} while the bytes sat in
193 // avatars/<oid>, and every consumer downstream faithfully rendered no picture.
194 return describe(r.destPath || p)
189195 } catch {
190196 return null
191197 }
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-peer",
3 "version": "0.0.45",
3 "version": "0.0.46",
44 "description": "The favela daemon — feeds and documents of markdown records over Hypercore. No atproto, no ActivityPub.",
55 "type": "module",
66 "scripts": {
test/files-crossstore.test.js +53 -0
……@@ -0,0 +1,53 @@
1// ⛔ THE BUG. Files and Avatars fetch from the SAME overlay by the SAME content hash, and differ
2// only in where they cache locally — blobs/<oid> vs avatars/<oid>. But get() ignores the path
3// fetchFile RETURNS and re-reads its own expected path. So when the overlay already holds the bytes
4// under the other store's directory, the file is found and then looked for somewhere it is not.
5//
6// Measured on ashatron: its own avatar sat at avatars/<oid>, and file-get for that oid answered
7// {found:false} on the machine holding it. Which made the bridge's per-user blob fetcher look
8// broken when it was faithfully reporting what favela-peer told it.
9
10import test from 'brittle'
11import fs from '#node:fs'
12import os from '#node:os'
13import { join } from '#node:path'
14import { Files } from '../lib/files.js'
15
16const PNG = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex')
17const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'xstore-'))
18
19test('★★ a file the overlay holds under ANOTHER path is still found', async (t) => {
20 const storage = tmp()
21 const oid = 'bb02a15a7f755740174f4ae6b9b4a9724cd53d4159878844db79e3ce9f94b580'
22
23 // The bytes exist, cached by the AVATARS store — a different directory from the one Files uses.
24 const elsewhere = join(storage, 'avatars')
25 fs.mkdirSync(elsewhere, { recursive: true })
26 fs.writeFileSync(join(elsewhere, oid), PNG)
27
28 const files = new Files(storage, {
29 // What hyper-overlay does on a local hit: returns where it ALREADY is, not where you asked.
30 async fetchFile (hash, opts) { return { destPath: join(elsewhere, hash), local: true, size: PNG.length } }
31 })
32
33 const got = await files.get(oid)
34 t.ok(got, 'found, rather than reported missing on the machine holding it')
35 t.is(got.mimeType, 'image/png', 'and typed from the bytes')
36 t.is(got.path, join(elsewhere, oid), 'at the path the overlay actually has it')
37})
38
39test('★ a fetch that lands in this store\'s own directory still works', async (t) => {
40 const storage = tmp()
41 const oid = 'c'.repeat(64)
42 const files = new Files(storage, {
43 async fetchFile (hash, opts) { fs.writeFileSync(opts.destPath, PNG); return { destPath: opts.destPath, local: false } }
44 })
45 const got = await files.get(oid)
46 t.ok(got)
47 t.is(got.mimeType, 'image/png')
48})
49
50test('⛔ genuinely absent is still null', async (t) => {
51 const files = new Files(tmp(), { async fetchFile () { return null } })
52 t.is(await files.get('d'.repeat(64)), null)
53})