diff --git a/frontend/obsidian-plugin/package.json b/frontend/obsidian-plugin/package.json index f8d510eb..3cd52514 100644 --- a/frontend/obsidian-plugin/package.json +++ b/frontend/obsidian-plugin/package.json @@ -20,7 +20,7 @@ "fs-extra": "^11.3.2", "mini-css-extract-plugin": "^2.9.4", "obsidian": "1.11.0", - "reconcile-text": "^0.11.0", + "reconcile-text": "^0.12.1", "resolve-url-loader": "^5.0.0", "sass": "^1.96.0", "sass-loader": "^16.0.6", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5aa0bee9..35dec132 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -5954,9 +5954,9 @@ } }, "node_modules/reconcile-text": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/reconcile-text/-/reconcile-text-0.8.0.tgz", - "integrity": "sha512-evskVha3YgpP2ZelsFxP9t7CuKnwE7TrsH3FdrH2mfKbzjUWiNF7scHXsFbFS921lmFlAOB94DHNAWPvL34Mqg==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/reconcile-text/-/reconcile-text-0.12.1.tgz", + "integrity": "sha512-UtDykRp8k/hDhMMqcexkeCmGCFy/ScrKFYkIj2lpT7OYEN5eXL6BeUORgf9jPZ+sMf6/z5tZWSOC1Q87jNzhUQ==", "dev": true, "license": "MIT" }, @@ -7686,7 +7686,7 @@ "fs-extra": "^11.3.2", "mini-css-extract-plugin": "^2.9.4", "obsidian": "1.11.0", - "reconcile-text": "^0.11.0", + "reconcile-text": "^0.12.1", "resolve-url-loader": "^5.0.0", "sass": "^1.96.0", "sass-loader": "^16.0.6", @@ -7740,13 +7740,6 @@ "@codemirror/view": "6.38.6" } }, - "obsidian-plugin/node_modules/reconcile-text": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/reconcile-text/-/reconcile-text-0.11.0.tgz", - "integrity": "sha512-a3sy3obazoc1BMEHx6IQn8ESZKnakVWZuRLi7OSEB56E8evRtrXBMj7yuo10fMoG4JkcZC6tokOfzpwZAKX+PQ==", - "dev": true, - "license": "MIT" - }, "obsidian-plugin/node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -7775,7 +7768,7 @@ "@types/node": "^24.8.1", "byte-base64": "^1.1.0", "minimatch": "^10.0.1", - "reconcile-text": "^0.8.0", + "reconcile-text": "^0.12.1", "ts-loader": "^9.5.2", "tslib": "2.8.1", "tsx": "^4.20.6", diff --git a/frontend/sync-client/package.json b/frontend/sync-client/package.json index 19ddd759..45f26acf 100644 --- a/frontend/sync-client/package.json +++ b/frontend/sync-client/package.json @@ -17,7 +17,7 @@ "devDependencies": { "byte-base64": "^1.1.0", "minimatch": "^10.0.1", - "reconcile-text": "^0.8.0", + "reconcile-text": "^0.12.1", "uuid": "^13.0.0", "@types/node": "^24.8.1", "ts-loader": "^9.5.2", diff --git a/frontend/test-support/file-application.test.ts b/frontend/test-support/file-application.test.ts index 6b99147c..3d140620 100644 --- a/frontend/test-support/file-application.test.ts +++ b/frontend/test-support/file-application.test.ts @@ -89,6 +89,49 @@ test("content edited after planning is merged with the incoming snapshot", async ); }); +test("repeated large text replacements reuse WebAssembly memory", async () => { + // The replacement from fuzz-193329 exhausted reconcile-text 0.8.0's + // allocator over repeated merges, eventually stalling sync and trapping. + const original = ( + " مرحباéשלום\t日本語\ncaféalphaalphaनमस्ते🦊🌍café\n" + + "🌍مرحبامرحباनमस्तेשלום\talphaalphacaféمرحبا🌍\r\n日本語مرحبا" + ).repeat(128); + const replacement = await hashSnapshot({ + content: bytes( + ( + "alpha🌍éनमस्ते日本語שלום\uFEFFשלום\t\r\n" + + "\uFEFF🦊 🦊🌍नमस्ते\uFEFFéمرحبا\r\ncafé🌍é🌍" + ).repeat(128) + ) + }); + const f = await fixture({ "a.md": original }); + const expected = (await f.files.readSnapshot("a.md"))!; + const apply = async () => { + await f.disk.userWrite("a.md", bytes(original)); + await f.files.applyChanges(structuredClone(f.database.state), { + a: { expected, replacement } + }); + assert.deepEqual(f.disk.userFiles().get("a.md"), replacement.content); + assert.equal( + f.database.state.documents.a.observedHash, + replacement.hash + ); + }; + + await apply(); // Warm up the module and its allocator before measuring. + const warmedMemory = process.memoryUsage.rss(); + for (let repeat = 0; repeat < 4; repeat++) { + await apply(); + // Measure resident memory because V8's external counter can omit WASM + // memory after GC. The old allocator grew ~150 MiB on every call. + const growth = process.memoryUsage.rss() - warmedMemory; + assert( + growth < 64 * 1024 * 1024, + `Resident memory grew by ${growth} bytes` + ); + } +}); + test("deleting the source after planning does not resurrect it", async () => { const f = await fixture(); const next = structuredClone(f.database.state);