Fountain Codes and Browser Lies: Building a Unidirectional Optical File Transfer System
Hook
V8 and JavaScriptCore's Math.log() implementations differ just enough to silently destroy rateless erasure codes. When your fountain code receiver can't decode anything and you get zero error messages, it's probably IEEE-754 approximation drift.
Context
Transferring files between airgapped systems remains surprisingly relevant in 2024. Security-critical environments, IoT devices without network stacks, malware analysis sandboxes, and even consumer scenarios like moving data off a phone with broken wireless radios all need physical-layer transfer mechanisms. The obvious approaches—sequential QR codes or chunked transfers—share a fatal weakness: dropped frames are catastrophic. Miss frame 47 of 200 and you either wait for the entire loop to cycle again or implement complex retry logic that requires a backchannel.
Fountain codes, specifically Luby Transform codes, eliminate this entire problem class. Instead of sending blocks 1, 2, 3... in sequence, the sender generates an infinite stream of frames where each encodes a random XOR combination of source blocks. The receiver accumulates frames until it has enough redundancy to solve the linear system via belief propagation. Order doesn't matter. Duplicates are harmless. You can start receiving mid-stream. The decimen-optical-transfer repository implements this using browser-native APIs, QR codes as the physical encoding layer, and discovers a minefield of platform bugs along the way.
Technical Insight
The core innovation is mapping fountain code theory to the constraints of browser video capture and QR encoding. Each QR frame carries a 20-byte header containing session ID, sequence number, block count, total file length, and SHA-256 hash, followed by the XOR payload. The sequence number seeds a PRNG that selects which source blocks to combine, following a robust-soliton degree distribution:
// fountain.ts - Degree selection using robust soliton
function chooseDegree(K: number, seed: number): number {
const c = 0.03;
const delta = 0.5;
const R = c * Math.log(K / delta) * Math.sqrt(K);
// THIS BREAKS: platform Math.log differences cause desync
// const p = robustSoliton(K, R, delta);
// FIXED: custom deterministic implementation
const p = robustSolitonDeterministic(K, R, delta);
const rng = seedRandom(seed);
return sampleDistribution(p, rng);
}
The problem? JavaScript's Math.log() isn't specified to bit-level precision. V8 and JavaScriptCore use different approximation algorithms, producing results that differ in the least significant bits. When those values drive pseudorandom distribution sampling, sender and receiver generate incompatible block selection schedules. The file accumulates frames forever without decoding, silently.
The fix is a custom IEEE-754 deterministic logarithm using polynomial approximation with hardcoded coefficients, ensuring bit-identical output across engines. This is the kind of edge case you'd never anticipate—JavaScript is deterministic for integer math but subtly nondeterministic for transcendental functions when crossing engine boundaries.
On the receiver side, the camera capture pipeline exposes equally brutal platform inconsistencies. iOS Safari claims to support 60fps via frameRate: {ideal: 60} but silently delivers 30fps. You must use {exact: 60} and handle the rejection:
// receiver.ts - iOS frameRate constraint workaround
async function startCapture() {
try {
stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment',
frameRate: { exact: 60 }, // ideal: 60 LIES on iOS
width: { ideal: 1920 },
height: { ideal: 1080 }
}
});
// ALWAYS validate - constraints are aspirational
const settings = stream.getVideoTracks()[0].getSettings();
console.log(`Actual FPS: ${settings.frameRate}`);
} catch (e) {
// Fall back to 30fps if 60fps rejected
stream = await navigator.mediaDevices.getUserMedia({
video: { frameRate: { exact: 30 } }
});
}
}
The frame processing uses requestVideoFrameCallback to extract frames at display cadence, feeding them to zxing-cpp compiled to WebAssembly running in Web Workers. But there's a zombie callback bug: RVFC callbacks survive MediaStream destruction. Stop and restart capture without defensive generation counting and you leak worker threads:
let captureGeneration = 0;
function processFrame(now: DOMHighResTimeStamp, metadata: VideoFrameMetadata) {
const generation = captureGeneration;
// Extract frame, dispatch to worker...
qrWorker.postMessage({ imageData, generation });
if (isCapturing) {
videoElement.requestVideoFrameCallback(processFrame);
}
}
// In worker message handler
worker.onmessage = (e) => {
if (e.data.generation !== captureGeneration) {
return; // Ignore zombie callbacks from previous streams
}
// ... process decoded QR
};
The fountain decoder implements belief propagation peeling: when a frame contains only one unsolved block (degree-1), solve it immediately and XOR it out of all other frames that reference it, potentially creating new degree-1 frames. This cascade typically triggers late in the process—you might collect frames up to 80% of the required count with only a handful of blocks solved, then suddenly cascade to completion. The implication for UI design is critical: progress bars must track frames collected, not blocks decoded, or users see 95% of the transfer time stuck at "0% complete" followed by instant success.
Gotcha
The throughput ceiling is brutally low. Under optimal conditions—ProMotion display at 120Hz, devices propped to eliminate hand tremor, perfect lighting—you might hit 186 KB/s. Typical real-world transfers sit around 128 KB/s. The 512KB demo file takes 4-5 seconds; a 10MB file would take over a minute. The repository's test payloads cap at 2MB because anything larger enters "go make coffee" territory. This isn't a limitation of the fountain code implementation—it's the QR encoding density hitting physical limits of camera resolution and refresh rates.
HTTPS requirement kills casual demos. getUserMedia is platform-banned on insecure origins, so even local development requires running Vite's HTTPS dev server with self-signed certificates. Every receiving device must manually accept a security exception before the camera activates. In a production deployment scenario, this means TLS certificate infrastructure even for a fully offline airgapped transfer tool, which is almost philosophical in its irony. Safari's lack of BarcodeDetector API support forces the zxing-cpp WebAssembly dependency—2.1MB of compiled C++ where Chromium gets native decoding for free. The performance gap is measurable: Chrome decodes frames 30-40% faster than Safari despite Safari having better camera APIs in other respects.
Verdict
Use if: you're building airgapped transfer systems where bidirectional communication is impossible (actual security airgaps, not just network-disabled devices), you need to understand fountain codes beyond textbook theory and see them applied to real physical channels, or you're debugging browser media APIs and need a reference for the undocumented quirks (the iOS frameRate lie and requestVideoFrameCallback zombies are real showstoppers elsewhere). This codebase is excellent educational material for rateless erasure coding and proof that fountain codes elegantly solve the dropped-frame problem that destroys sequential approaches. Skip if: you need actual high-throughput file transfer—at 128 KB/s, physical USB wins by 100x; if you can't tolerate HTTPS ceremony for local development; if your airgap scenario allows bidirectional communication (just use WebRTC or plain HTTP multipart); or if you've already discovered libcimbar, which achieves dramatically higher bitrates by abandoning QR for purpose-built color-grid encoding. This is a proof-of-concept that proves its concept beautifully but remains fundamentally a learning tool, not a production system.