From af079cf34867ec809de63a1d8108f2a58ba8c599 Mon Sep 17 00:00:00 2001 From: William Stein Date: Wed, 21 Oct 2026 07:20:59 +0000 Subject: [PATCH 1/2] dmp: bound diffs by @cocalc/diff-match-patch's deterministic work budget (diffWorkLimit 25M) instead of diffTimeout 0.2 The same two texts now give the same patch on any machine, however fast or loaded; 25M units are about 0.2 s on V8 on an AMD EPYC core (dmp README). Requires @cocalc/diff-match-patch ^1.1.0. --- package.json | 2 +- pnpm-lock.yaml | 10 ++++---- src/dmp-work-limit.test.ts | 50 ++++++++++++++++++++++++++++++++++++++ src/dmp.ts | 9 ++++++- 4 files changed, 64 insertions(+), 7 deletions(-) create mode 100644 src/dmp-work-limit.test.ts diff --git a/package.json b/package.json index b8f18ad..d9df968 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "bench:immer": "node --loader ts-node/esm --experimental-specifier-resolution=node examples/bench-immer-apply.ts" }, "dependencies": { - "@cocalc/diff-match-patch": "^1.0.0", + "@cocalc/diff-match-patch": "^1.1.0", "events": "^3.3.0", "fast-deep-equal": "^3.1.3", "fast-json-stable-stringify": "^2.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2bbc223..b7e3e8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,8 +24,8 @@ importers: .: dependencies: '@cocalc/diff-match-patch': - specifier: ^1.0.0 - version: 1.0.1 + specifier: ^1.1.0 + version: 1.1.0 events: specifier: ^3.3.0 version: 3.3.0 @@ -148,8 +148,8 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} - '@cocalc/diff-match-patch@1.0.1': - resolution: {integrity: sha512-sF55A4FvQLSA3GukGjlprMkAYvD89Q2HbqmAI1Pannqa1BePjhKkGIAM2DTMu+v/UHDjJJNgP7AxUQcMOc/gBg==} + '@cocalc/diff-match-patch@1.1.0': + resolution: {integrity: sha512-eRk0KlDGL6/NzJskY6xbRqGnHSuG7alAUQp+Qtd4vs65bLeIn4IMLJb/RT7PZozNL0KR5u3PxRIdDm7tzAGN4g==} '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} @@ -1244,7 +1244,7 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@cocalc/diff-match-patch@1.0.1': {} + '@cocalc/diff-match-patch@1.1.0': {} '@cspotcode/source-map-support@0.8.1': dependencies: diff --git a/src/dmp-work-limit.test.ts b/src/dmp-work-limit.test.ts new file mode 100644 index 0000000..bb1770e --- /dev/null +++ b/src/dmp-work-limit.test.ts @@ -0,0 +1,50 @@ +import { applyPatch, diffMain, DIFF_WORK_LIMIT, makePatch } from "./dmp"; + +// Texts whose diff is too costly to finish within the budget: unrelated +// random text, so the cutoff decides the result. +function random(n: number, seed: number): string { + let s = seed; + let out = ""; + for (let i = 0; i < n; i++) { + s = (s * 1103515245 + 12345) % 2 ** 31; + out += String.fromCharCode(97 + (s % 26)); + } + return out; +} + +describe("diffs are bounded by work, not the clock", () => { + const a = random(100_000, 1); + const b = random(100_000, 2); + const small = ["the quick brown fox", "the quick red fox jumps"]; + + afterEach(() => vi.restoreAllMocks()); + + it("uses diffWorkLimit (25M)", () => { + expect(DIFF_WORK_LIMIT).toBe(25_000_000); + }); + + it("never reads the clock: makePatch, diffMain and applyPatch", () => { + vi.spyOn(Date, "now").mockImplementation(() => { + throw Error("the clock was read"); + }); + const patch = makePatch(small[0], small[1]); + expect(applyPatch(patch, small[0])).toEqual([small[1], true]); + expect(diffMain(small[0], small[1]).length).toBeGreaterThan(0); + // ... nor for a diff cut off by the budget. + expect(applyPatch(makePatch(a, b), a)).toEqual([b, true]); + }); + + it("the same texts give the same patch whatever the clock says", () => { + const reference = JSON.stringify(makePatch(a, b)); + let t = 0; + for (const clock of [ + () => 0, // frozen + () => (t += 60_000), // racing: a minute per read + () => Date.UTC(2100, 0, 1), // far ahead + ]) { + vi.spyOn(Date, "now").mockImplementation(clock); + expect(JSON.stringify(makePatch(a, b))).toBe(reference); + vi.restoreAllMocks(); + } + }); +}); diff --git a/src/dmp.ts b/src/dmp.ts index 0ae0912..7f3a122 100644 --- a/src/dmp.ts +++ b/src/dmp.ts @@ -2,8 +2,15 @@ import { DiffMatchPatch, PatchObject } from "@cocalc/diff-match-patch"; export type CompressedPatch = [[-1 | 0 | 1, string][], number, number, number, number][]; +// A diff (and so a patch) is cut off by a deterministic work budget, never by +// the clock: the same two texts give the same patch on any machine, however +// fast or loaded (diffTimeout = 0.2 made a commit's patch, and its size, +// depend on the machine). 25M units take about 0.2 s on V8 on an AMD EPYC +// core (@cocalc/diff-match-patch README): measured there, not guaranteed. +export const DIFF_WORK_LIMIT = 25_000_000; + const dmp = new DiffMatchPatch(); -dmp.diffTimeout = 0.2; +dmp.diffWorkLimit = DIFF_WORK_LIMIT; export const diffMain = dmp.diff_main.bind(dmp); export const patchMake = dmp.patch_make.bind(dmp); From d32e70aaca2e9d1977314bc7430dd917a338a2df Mon Sep 17 00:00:00 2001 From: William Stein Date: Wed, 21 Oct 2026 20:38:11 +0000 Subject: [PATCH 2/2] pnpm: exempt our own @cocalc/diff-match-patch from minimumReleaseAge 1.1.0 was published by us via npm trusted publishing on 2026-10-21; the new-release guard protects against malicious third-party releases and does not apply to our own package. Approved by William (Oct 21). --- pnpm-workspace.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index aee29f4..afad43f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,11 @@ packages: - "." +# @cocalc/diff-match-patch is our own package (published by us via npm +# trusted publishing), so the new-release age guard does not apply to it. +minimumReleaseAgeExclude: + - "@cocalc/diff-match-patch" + overrides: "ajv@>=6.0.0 <6.14.0": ">=6.14.0 <7" "baseline-browser-mapping@>=2.0.0 <2.11.0": ">=2.11.0 <3"