Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Port mutexExecutor from Upstream
Rūdolfs Ošiņš committed 3 years ago
commit 85dc1c3552558b6bafd5df3dbe8750a3b6381cb7
parent d8d049518b2d7f5761e3dd4d384d4d20646d2281
5 files changed +342 -5
modified package-lock.json
@@ -10,6 +10,7 @@
      "dependencies": {
        "@radicle/gray-matter": "4.1.0",
        "@wooorm/starry-night": "^2.0.0",
+
        "baconjs": "^3.0.17",
        "bs58": "^5.0.0",
        "buffer": "^6.0.3",
        "dompurify": "^3.0.2",
@@ -20,6 +21,7 @@
        "marked": "^5.0.1",
        "md5": "^2.3.0",
        "plausible-tracker": "^0.3.8",
+
        "sinon": "^15.0.4",
        "svelte": "^3.58.0",
        "twemoji": "^14.0.2",
        "zod": "^3.21.2"
@@ -35,6 +37,7 @@
        "@types/marked": "^4.3.0",
        "@types/md5": "^2.3.2",
        "@types/node": "^18.16.2",
+
        "@types/sinon": "^10.0.14",
        "@types/sinonjs__fake-timers": "^8.1.2",
        "@typescript-eslint/eslint-plugin": "^5.59.2",
        "chalk": "^5.2.0",
@@ -596,7 +599,6 @@
      "version": "2.0.0",
      "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz",
      "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==",
-
      "dev": true,
      "dependencies": {
        "type-detect": "4.0.8"
      }
@@ -605,11 +607,25 @@
      "version": "10.0.2",
      "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.0.2.tgz",
      "integrity": "sha512-SwUDyjWnah1AaNl7kxsa7cfLhlTYoiyhDAIgyh+El30YvXs/o7OLXpYH88Zdhyx9JExKrmHDJ+10bwIcY80Jmw==",
-
      "dev": true,
      "dependencies": {
        "@sinonjs/commons": "^2.0.0"
      }
    },
+
    "node_modules/@sinonjs/samsam": {
+
      "version": "8.0.0",
+
      "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.0.tgz",
+
      "integrity": "sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew==",
+
      "dependencies": {
+
        "@sinonjs/commons": "^2.0.0",
+
        "lodash.get": "^4.4.2",
+
        "type-detect": "^4.0.8"
+
      }
+
    },
+
    "node_modules/@sinonjs/text-encoding": {
+
      "version": "0.7.2",
+
      "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz",
+
      "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ=="
+
    },
    "node_modules/@sveltejs/vite-plugin-svelte": {
      "version": "2.2.0",
      "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-2.2.0.tgz",
@@ -722,6 +738,15 @@
      "integrity": "sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==",
      "dev": true
    },
+
    "node_modules/@types/sinon": {
+
      "version": "10.0.14",
+
      "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-10.0.14.tgz",
+
      "integrity": "sha512-mn72up6cjaMyMuaPaa/AwKf6WtsSRysQC7wxFkCm1XcOKXPM1z+5Y4H5wjIVBz4gdAkjvZxVVfjA6ba1nHr5WQ==",
+
      "dev": true,
+
      "dependencies": {
+
        "@types/sinonjs__fake-timers": "*"
+
      }
+
    },
    "node_modules/@types/sinonjs__fake-timers": {
      "version": "8.1.2",
      "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.2.tgz",
@@ -1142,6 +1167,11 @@
        "node": "*"
      }
    },
+
    "node_modules/baconjs": {
+
      "version": "3.0.17",
+
      "resolved": "https://registry.npmjs.org/baconjs/-/baconjs-3.0.17.tgz",
+
      "integrity": "sha512-XwhawE4gmO+NhMKZYmUSidk8ZjDhx5pfp9k/s5HM25S+k/YKajsakFOxbmUU1L9zbh1SarPbCobBuHhCMPFbCA=="
+
    },
    "node_modules/balanced-match": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -1533,6 +1563,14 @@
        "node": ">=8"
      }
    },
+
    "node_modules/diff": {
+
      "version": "5.1.0",
+
      "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz",
+
      "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==",
+
      "engines": {
+
        "node": ">=0.3.1"
+
      }
+
    },
    "node_modules/dir-glob": {
      "version": "3.0.1",
      "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@@ -2119,7 +2157,6 @@
      "version": "4.0.0",
      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
-
      "dev": true,
      "engines": {
        "node": ">=8"
      }
@@ -2425,6 +2462,11 @@
        "node": ">=8"
      }
    },
+
    "node_modules/isarray": {
+
      "version": "0.0.1",
+
      "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
+
      "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ=="
+
    },
    "node_modules/isexe": {
      "version": "2.0.0",
      "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -2490,6 +2532,11 @@
        "graceful-fs": "^4.1.6"
      }
    },
+
    "node_modules/just-extend": {
+
      "version": "4.2.1",
+
      "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz",
+
      "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg=="
+
    },
    "node_modules/katex": {
      "version": "0.16.7",
      "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.7.tgz",
@@ -2567,6 +2614,11 @@
      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
    },
+
    "node_modules/lodash.get": {
+
      "version": "4.4.2",
+
      "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
+
      "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ=="
+
    },
    "node_modules/lodash.merge": {
      "version": "4.6.2",
      "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -2760,6 +2812,18 @@
      "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==",
      "dev": true
    },
+
    "node_modules/nise": {
+
      "version": "5.1.4",
+
      "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.4.tgz",
+
      "integrity": "sha512-8+Ib8rRJ4L0o3kfmyVCL7gzrohyDe0cMFTBa2d364yIrEGMEoetznKJx899YxjybU6bL9SQkYPSBBs1gyYs8Xg==",
+
      "dependencies": {
+
        "@sinonjs/commons": "^2.0.0",
+
        "@sinonjs/fake-timers": "^10.0.2",
+
        "@sinonjs/text-encoding": "^0.7.1",
+
        "just-extend": "^4.0.2",
+
        "path-to-regexp": "^1.7.0"
+
      }
+
    },
    "node_modules/normalize-path": {
      "version": "3.0.0",
      "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -2869,6 +2933,14 @@
        "node": ">=8"
      }
    },
+
    "node_modules/path-to-regexp": {
+
      "version": "1.8.0",
+
      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz",
+
      "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==",
+
      "dependencies": {
+
        "isarray": "0.0.1"
+
      }
+
    },
    "node_modules/path-type": {
      "version": "4.0.0",
      "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
@@ -3243,6 +3315,31 @@
      "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
      "dev": true
    },
+
    "node_modules/sinon": {
+
      "version": "15.0.4",
+
      "resolved": "https://registry.npmjs.org/sinon/-/sinon-15.0.4.tgz",
+
      "integrity": "sha512-uzmfN6zx3GQaria1kwgWGeKiXSSbShBbue6Dcj0SI8fiCNFbiUDqKl57WFlY5lyhxZVUKmXvzgG2pilRQCBwWg==",
+
      "dependencies": {
+
        "@sinonjs/commons": "^3.0.0",
+
        "@sinonjs/fake-timers": "^10.0.2",
+
        "@sinonjs/samsam": "^8.0.0",
+
        "diff": "^5.1.0",
+
        "nise": "^5.1.4",
+
        "supports-color": "^7.2.0"
+
      },
+
      "funding": {
+
        "type": "opencollective",
+
        "url": "https://opencollective.com/sinon"
+
      }
+
    },
+
    "node_modules/sinon/node_modules/@sinonjs/commons": {
+
      "version": "3.0.0",
+
      "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz",
+
      "integrity": "sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA==",
+
      "dependencies": {
+
        "type-detect": "4.0.8"
+
      }
+
    },
    "node_modules/slash": {
      "version": "3.0.0",
      "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
@@ -3370,7 +3467,6 @@
      "version": "7.2.0",
      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
-
      "dev": true,
      "dependencies": {
        "has-flag": "^4.0.0"
      },
@@ -3598,7 +3694,6 @@
      "version": "4.0.8",
      "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
      "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==",
-
      "dev": true,
      "engines": {
        "node": ">=4"
      }
modified package.json
@@ -29,6 +29,7 @@
    "@types/marked": "^4.3.0",
    "@types/md5": "^2.3.2",
    "@types/node": "^18.16.2",
+
    "@types/sinon": "^10.0.14",
    "@types/sinonjs__fake-timers": "^8.1.2",
    "@typescript-eslint/eslint-plugin": "^5.59.2",
    "chalk": "^5.2.0",
@@ -45,6 +46,7 @@
  "dependencies": {
    "@radicle/gray-matter": "4.1.0",
    "@wooorm/starry-night": "^2.0.0",
+
    "baconjs": "^3.0.17",
    "bs58": "^5.0.0",
    "buffer": "^6.0.3",
    "dompurify": "^3.0.2",
@@ -55,6 +57,7 @@
    "marked": "^5.0.1",
    "md5": "^2.3.0",
    "plausible-tracker": "^0.3.8",
+
    "sinon": "^15.0.4",
    "svelte": "^3.58.0",
    "twemoji": "^14.0.2",
    "zod": "^3.21.2"
added src/lib/mutexExecutor.ts
@@ -0,0 +1,112 @@
+
// Copyright © 2021 The Radicle Upstream Contributors
+
//
+
// This file is part of radicle-upstream, distributed under the GPLv3
+
// with Radicle Linking Exception. For full terms see the included
+
// LICENSE file.
+

+
import * as Bacon from "baconjs";
+

+
// A task executor that runs only one task concurrently. If a new task
+
// is run, any previously running task is aborted and the promise
+
// returned from `run()` will return undefined.
+
//
+
//     import * as mutexExecutor from "ui/src/mutexExecutor"
+
//     const executor = mutexExecutor.create()
+
//     const first = await executor.run(async () => {
+
//       await sleep(1000)
+
//       return "first"
+
//     })
+
//     const second = await executor.run(async () => "second")
+
//
+
// In the example above the promise `first` will resolve to `undefined`
+
// while the promise `second` will resolve to "second".
+
//
+
// If the first tasks throws after the second task has run the
+
// behavior is the same.
+
//
+
//     const first = await executor.run(async () => {
+
//       await sleep(1000)
+
//       throw new Error()
+
//     })
+
//
+
// The task call back receives an AbortSignal as a parameter. The abort
+
// event is emitted when another task is run.
+
export function create(): MutexExecutor {
+
  return new MutexExecutor();
+
}
+

+
// A worker that asynchronously process one item at a time and provides
+
// the result as an event stream.
+
//
+
//     import * as mutexExecutor from "ui/src/mutexExecutor"
+
//     const worker = mutexExecutor.createWorker(async (value) => {
+
//       await sleep(1000)
+
//       return value
+
//     })
+
//
+
//     const firstPromise = worker.output.firstToPromise()
+
//     worker.push("first)
+
//     assert.equal(await firstPromise, "first")
+
//
+
// When an item is submitted to the worker while the previous items is
+
// still being processed, the result of the first item will not be
+
// emitted to `worker.output`. Instead, only the last item will be
+
// emitted.
+
export function createWorker<In, Out>(
+
  fn: (x: In, abortSignal: AbortSignal) => Promise<Out>,
+
): MutexWorker<In, Out> {
+
  return new MutexWorker(fn);
+
}
+

+
class MutexExecutor {
+
  private runningTaskId = 0;
+
  private abortController: AbortController | null = null;
+

+
  public async run<T>(
+
    f: (abortSignal: AbortSignal) => Promise<T>,
+
  ): Promise<T | undefined> {
+
    this.runningTaskId += 1;
+
    const taskId = this.runningTaskId;
+

+
    if (this.abortController) {
+
      this.abortController.abort();
+
    }
+
    this.abortController = new AbortController();
+
    return f(this.abortController.signal).then(
+
      data => {
+
        if (this.runningTaskId === taskId) {
+
          return data;
+
        } else {
+
          return undefined;
+
        }
+
      },
+
      err => {
+
        if (this.runningTaskId === taskId) {
+
          throw err;
+
        } else {
+
          return undefined;
+
        }
+
      },
+
    );
+
  }
+
}
+

+
class MutexWorker<In, Out> {
+
  private outputBus = new Bacon.Bus<Out>();
+
  private executor = new MutexExecutor();
+

+
  public output: Bacon.EventStream<Out>;
+

+
  public constructor(
+
    private fn: (x: In, abortSignal: AbortSignal) => Promise<Out>,
+
  ) {
+
    this.output = this.outputBus.toEventStream();
+
  }
+

+
  public async submit(x: In): Promise<void> {
+
    const output = await this.executor.run(abort => this.fn(x, abort));
+
    if (output !== undefined) {
+
      this.outputBus.push(output);
+
    }
+
  }
+
}
added src/lib/sleep.ts
@@ -0,0 +1,5 @@
+
export function sleep(timeMs: number): Promise<void> {
+
  return new Promise(resolve => {
+
    setTimeout(resolve, timeMs);
+
  });
+
}
added tests/unit/mutexExecutor.test.ts
@@ -0,0 +1,122 @@
+
/* eslint-disable @typescript-eslint/no-floating-promises */
+
import * as sinon from "sinon";
+
import { describe, expect, test } from "vitest";
+

+
import * as mutexExecutor from "@app/lib/mutexExecutor";
+
import { sleep } from "@app/lib/sleep";
+

+
describe("executor", () => {
+
  test("cancels running task", async () => {
+
    const e = mutexExecutor.create();
+

+
    const first = e.run(async () => {
+
      await sleep(10);
+
      return "first";
+
    });
+
    const second = e.run(async () => {
+
      return "second";
+
    });
+

+
    expect(await first).toBe(undefined);
+
    expect(await second).toBe("second");
+

+
    const third = e.run(async () => {
+
      await sleep(10);
+
      return "third";
+
    });
+
    const fourth = e.run(async () => {
+
      return "fourth";
+
    });
+

+
    expect(await third).toBe(undefined);
+
    expect(await fourth).toBe("fourth");
+
  });
+

+
  test("cancels multiple tasks", async () => {
+
    const e = mutexExecutor.create();
+

+
    const canceled1 = e.run(async () => {
+
      await sleep(10);
+
      return true;
+
    });
+
    const canceled2 = e.run(async () => {
+
      await sleep(10);
+
      return true;
+
    });
+
    const canceled3 = e.run(async () => {
+
      await sleep(10);
+
      return true;
+
    });
+
    const last = e.run(async () => {
+
      return true;
+
    });
+

+
    expect(await canceled1).toBe(undefined);
+
    expect(await canceled2).toBe(undefined);
+
    expect(await canceled3).toBe(undefined);
+
    expect(await last).toBe(true);
+
  });
+

+
  test("triggers abort signal event", async () => {
+
    const e = mutexExecutor.create();
+
    const abortListener = sinon.spy();
+

+
    e.run(async abort => {
+
      abort.addEventListener("abort", abortListener);
+
      await sleep(10);
+
      return "first";
+
    });
+
    expect(abortListener.called).toBe(false);
+
    // eslint-disable-next-line @typescript-eslint/no-empty-function
+
    e.run(async () => {});
+
    expect(abortListener.called).toBe(true);
+
  });
+

+
  test("don’t throw error on aborted task", async () => {
+
    const e = mutexExecutor.create();
+

+
    const first = e.run(async () => {
+
      await sleep(10);
+
      throw new Error();
+
    });
+
    const second = e.run(async () => {
+
      return "second";
+
    });
+

+
    expect(await first).toBe(undefined);
+
    expect(await second).toBe("second");
+
  });
+
});
+

+
describe("worker", () => {
+
  test("sequential work", async () => {
+
    const w = mutexExecutor.createWorker(async (value: number) => {
+
      await sleep(10);
+
      return value;
+
    });
+

+
    const outputs: number[] = [];
+
    w.output.onValue(value => outputs.push(value));
+

+
    await w.submit(1);
+
    await w.submit(2);
+
    await w.submit(3);
+

+
    expect(outputs).toEqual([1, 2, 3]);
+
  });
+

+
  test("overlapping work cancels", async () => {
+
    const w = mutexExecutor.createWorker(async (value: number) => {
+
      await sleep(10);
+
      return value;
+
    });
+

+
    const nextOutput = w.output.firstToPromise();
+

+
    w.submit(1);
+
    w.submit(2);
+
    w.submit(3);
+

+
    expect(await nextOutput).toEqual(3);
+
  });
+
});