diff --git a/.changeset/bright-pandas-lock.md b/.changeset/bright-pandas-lock.md new file mode 100644 index 0000000..fed2070 --- /dev/null +++ b/.changeset/bright-pandas-lock.md @@ -0,0 +1,5 @@ +--- +"@elgato/utils": patch +--- + +Added `Mutex` class for mutual exclusion, ensuring only one asynchronous operation can access a protected resource at a time. diff --git a/.changeset/smooth-foxes-run.md b/.changeset/smooth-foxes-run.md new file mode 100644 index 0000000..85db8cb --- /dev/null +++ b/.changeset/smooth-foxes-run.md @@ -0,0 +1,5 @@ +--- +"@elgato/streamdeck": minor +--- + +Added `setSettings` overload that accepts an update function, allowing settings to be modified based on the current value. diff --git a/packages/plugin/src/plugin/actions/__tests__/action.test.ts b/packages/plugin/src/plugin/actions/__tests__/action.test.ts index 436bd1a..db035d7 100644 --- a/packages/plugin/src/plugin/actions/__tests__/action.test.ts +++ b/packages/plugin/src/plugin/actions/__tests__/action.test.ts @@ -264,6 +264,7 @@ describe("Action", () => { expect(action.isKey()).toBe(true); expect(action.isDial()).toBe(false); + expect(action.isNeoInfobar()).toBe(false); }); /** @@ -324,6 +325,112 @@ describe("Action", () => { }); }); + /** + * Asserts {@link ActionBase.setSettings} with a synchronous update function uses cached settings and sends updated settings. + */ + it("setSettings with sync update function", async () => { + // Arrange. + const action = new ActionBase(source); + settingsCache.set(action.id, { name: "Original" }); + + // Act. + await action.setSettings((current) => ({ + ...current, + name: `${current.name} Updated`, + })); + + // Assert (only setSettings command sent, getSettings used cache). + expect(connection.send).toHaveBeenCalledTimes(1); + expect(connection.send).toHaveBeenLastCalledWith<[SetSettings]>({ + context: action.id, + event: "setSettings", + payload: { name: "Original Updated" }, + }); + }); + + /** + * Asserts {@link ActionBase.setSettings} with an async update function uses cached settings and sends updated settings. + */ + it("setSettings with async update function", async () => { + // Arrange. + const action = new ActionBase(source); + settingsCache.set(action.id, { name: "Current" }); + + // Act. + await action.setSettings(async (current) => { + await Promise.resolve(); // Simulate async work. + return { + ...current, + name: `${current.name} Async`, + }; + }); + + // Assert (only setSettings command sent, getSettings used cache). + expect(connection.send).toHaveBeenCalledTimes(1); + expect(connection.send).toHaveBeenLastCalledWith<[SetSettings]>({ + context: action.id, + event: "setSettings", + payload: { name: "Current Async" }, + }); + }); + + /** + * Asserts {@link ActionBase.setSettings} with an update function invalidates the settings cache. + */ + it("setSettings with update function invalidates cache", async () => { + // Arrange. + const action = new ActionBase(source); + settingsCache.set(action.id, { name: "Cached" }); + + // Act. + await action.setSettings((current) => ({ + ...current, + name: "Updated via function", + })); + + // Assert. + expect(settingsCache.get(action.id)).toBeUndefined(); + }); + + /** + * Asserts {@link ActionBase.setSettings} with concurrent update functions serializes updates correctly. + */ + it("setSettings with concurrent update functions serializes updates", async () => { + // Arrange. + const action = new ActionBase(source); + const callOrder: number[] = []; + let callCount = 0; + + // Mock send to track call order and repopulate cache to simulate Stream Deck response. + vi.mocked(connection.send).mockImplementation(async (msg) => { + if (msg.event === "setSettings") { + callOrder.push(++callCount); + // Simulate Stream Deck updating the cache with the new settings. + settingsCache.set(action.id, msg.payload as JsonObject); + } + }); + + settingsCache.set(action.id, { count: 0 }); + + // Act - two concurrent increments should both succeed sequentially. + await Promise.all([ + action.setSettings((current) => ({ count: (current as { count: number }).count + 1 })), + action.setSettings((current) => ({ count: (current as { count: number }).count + 1 })), + ]); + + // Assert - both updates should have been serialized (mutex ensures sequential execution). + expect(connection.send).toHaveBeenCalledTimes(2); + expect(callOrder).toEqual([1, 2]); // Sequential, not interleaved. + + // Verify the payloads show incremental values (0→1 then 1→2), not both 0→1. + const calls = vi.mocked(connection.send).mock.calls; + const payloads = calls + .filter((call) => call[0].event === "setSettings") + .map((call) => (call[0] as SetSettings).payload); + + expect(payloads).toEqual([{ count: 1 }, { count: 2 }]); + }); + /** * Asserts {@link ActionBase.showAlert} forwards the command to the {@link connection}. */ diff --git a/packages/plugin/src/plugin/actions/action-base.ts b/packages/plugin/src/plugin/actions/action-base.ts index f7519d9..b87a4dc 100644 --- a/packages/plugin/src/plugin/actions/action-base.ts +++ b/packages/plugin/src/plugin/actions/action-base.ts @@ -1,4 +1,4 @@ -import { type EventArgs, type JsonObject, withResolvers } from "@elgato/utils"; +import { type EventArgs, type JsonObject, Mutex, withResolvers } from "@elgato/utils"; import { randomUUID } from "node:crypto"; import type { @@ -21,6 +21,8 @@ import type { NeoInfobarAction } from "./neo-infobar.js"; const REQUEST_TIMEOUT = 15 * 1000; // 15s +const setSettingsMutex = new Mutex(); + /** * Provides a contextualized instance of an action, allowing for direct communication with the Stream Deck. * @template TSettings The type of settings associated with the action. @@ -114,15 +116,40 @@ export class ActionBase extends ActionContext { /** * Sets the settings associated with this action instance. - * @param value Settings to persist. - * @returns `Promise` resolved when the settings are sent to Stream Deck. + * @param settings The new settings. + * @returns Promise that resolves when the settings are updated. + */ + public setSettings(settings: TSettings): Promise; + /** + * Sets the settings associated with this action instance. + * @param update Function used to update the current settings. + * @returns Promise that resolves when the settings are updated. */ - public setSettings(value: TSettings): Promise { + public setSettings(update: (current: TSettings) => Promise | TSettings): Promise; + /** + * Sets the settings associated with this action instance. + * @param settingsOrUpdate The new settings or function used to update the current settings. + * @returns Promise that resolves when the settings are updated. + */ + public async setSettings( + settingsOrUpdate: TSettings | ((current: TSettings) => Promise | TSettings), + ): Promise { + if (typeof settingsOrUpdate === "function") { + await setSettingsMutex.run(async () => { + const currSettings = await this.getSettings(); + const newSettings = await settingsOrUpdate(currSettings); + + await this.setSettings(newSettings); + }); + + return; + } + settingsCache.delete(this.id); - return connection.send({ + await connection.send({ event: "setSettings", context: this.id, - payload: value, + payload: settingsOrUpdate, }); } diff --git a/packages/utils/README.md b/packages/utils/README.md index 1ce3859..ec0c2dd 100644 --- a/packages/utils/README.md +++ b/packages/utils/README.md @@ -147,6 +147,29 @@ const lazy = new Lazy(() => "Hello world"); lazy.value; // "Hello world"; ``` +### `Mutex` + +A mutual exclusion lock that ensures only one asynchronous operation can access a protected resource at a time. + +```ts +import { Mutex } from "@elgato/utils"; + +const mutex = new Mutex(); + +// Using run (recommended) +await mutex.run(async () => { + await updateSharedResource(); +}); + +// Using wait and release +await mutex.wait(); +try { + await updateSharedResource(); +} finally { + mutex.release(); +} +``` + ## Objects ### `get(source, path)` diff --git a/packages/utils/src/__tests__/mutex.test.ts b/packages/utils/src/__tests__/mutex.test.ts new file mode 100644 index 0000000..5f7eb8b --- /dev/null +++ b/packages/utils/src/__tests__/mutex.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it } from "vitest"; + +import { Mutex } from "../mutex.js"; +import { withResolvers } from "../promises.js"; + +describe("Mutex", () => { + describe("wait and release", () => { + it("acquires lock immediately when not locked", async () => { + // Arrange. + const mutex = new Mutex(); + + // Act. + const waitPromise = mutex.wait(); + + // Assert - allow microtask to complete. + await Promise.resolve(); + await expect(getPromiseState(waitPromise)).resolves.toBe("complete"); + }); + + it("blocks second caller until first releases", async () => { + // Arrange. + const mutex = new Mutex(); + await mutex.wait(); + + // Act. + const secondWait = mutex.wait(); + + // Assert - second caller should be blocked. + await expect(getPromiseState(secondWait)).resolves.toBe("pending"); + + // Release and verify second caller proceeds. + mutex.release(); + await Promise.resolve(); + await expect(getPromiseState(secondWait)).resolves.toBe("complete"); + }); + + it("processes waiters in FIFO order", async () => { + // Arrange. + const mutex = new Mutex(); + const order: number[] = []; + + await mutex.wait(); + + // Act - queue up multiple waiters. + const waiter1 = mutex.wait().then(() => order.push(1)); + const waiter2 = mutex.wait().then(() => order.push(2)); + const waiter3 = mutex.wait().then(() => order.push(3)); + + // Release each in turn. + mutex.release(); + await waiter1; + + mutex.release(); + await waiter2; + + mutex.release(); + await waiter3; + + // Assert. + expect(order).toEqual([1, 2, 3]); + }); + + it("allows reacquiring lock after release", async () => { + // Arrange. + const mutex = new Mutex(); + await mutex.wait(); + mutex.release(); + + // Act. + const reacquire = mutex.wait(); + + // Assert - allow microtask to complete. + await Promise.resolve(); + await expect(getPromiseState(reacquire)).resolves.toBe("complete"); + }); + }); + + describe("run", () => { + it("executes function and releases lock", async () => { + // Arrange. + const mutex = new Mutex(); + let executed = false; + + // Act. + await mutex.run(() => { + executed = true; + }); + + // Assert. + expect(executed).toBe(true); + + // Verify lock is released by acquiring it again. + const reacquire = mutex.wait(); + await Promise.resolve(); + await expect(getPromiseState(reacquire)).resolves.toBe("complete"); + }); + + it("executes async function", async () => { + // Arrange. + const mutex = new Mutex(); + let executed = false; + + // Act. + await mutex.run(async () => { + await Promise.resolve(); + executed = true; + }); + + // Assert. + expect(executed).toBe(true); + }); + + it("releases lock even when function throws", async () => { + // Arrange. + const mutex = new Mutex(); + + // Act. + await expect( + mutex.run(() => { + throw new Error("Test error"); + }), + ).rejects.toThrow("Test error"); + + // Assert - lock should be released. + const reacquire = mutex.wait(); + await Promise.resolve(); + await expect(getPromiseState(reacquire)).resolves.toBe("complete"); + }); + + it("releases lock even when async function rejects", async () => { + // Arrange. + const mutex = new Mutex(); + + // Act. + await expect( + mutex.run(async () => { + await Promise.resolve(); + throw new Error("Async error"); + }), + ).rejects.toThrow("Async error"); + + // Assert - lock should be released. + const reacquire = mutex.wait(); + await Promise.resolve(); + await expect(getPromiseState(reacquire)).resolves.toBe("complete"); + }); + + it("ensures mutual exclusion during concurrent runs", async () => { + // Arrange. + const mutex = new Mutex(); + let concurrentCount = 0; + let maxConcurrent = 0; + const { promise: gate, resolve: openGate } = withResolvers(); + + // Act - start multiple concurrent operations. + const run1 = mutex.run(async () => { + concurrentCount++; + maxConcurrent = Math.max(maxConcurrent, concurrentCount); + await gate; + concurrentCount--; + }); + + const run2 = mutex.run(async () => { + concurrentCount++; + maxConcurrent = Math.max(maxConcurrent, concurrentCount); + await gate; + concurrentCount--; + }); + + const run3 = mutex.run(async () => { + concurrentCount++; + maxConcurrent = Math.max(maxConcurrent, concurrentCount); + await gate; + concurrentCount--; + }); + + // Let all operations complete. + openGate(); + await Promise.all([run1, run2, run3]); + + // Assert - only one should have run at a time. + expect(maxConcurrent).toBe(1); + }); + + it("executes runs in order", async () => { + // Arrange. + const mutex = new Mutex(); + const order: number[] = []; + const gates: Array<{ promise: Promise; resolve: () => void }> = []; + + for (let i = 0; i < 3; i++) { + gates.push(withResolvers()); + } + + // Act - start concurrent operations. + const run1 = mutex.run(async () => { + order.push(1); + await gates[0].promise; + }); + + const run2 = mutex.run(async () => { + order.push(2); + await gates[1].promise; + }); + + const run3 = mutex.run(async () => { + order.push(3); + await gates[2].promise; + }); + + // Release gates one at a time. + gates[0].resolve(); + await run1; + + gates[1].resolve(); + await run2; + + gates[2].resolve(); + await run3; + + // Assert. + expect(order).toEqual([1, 2, 3]); + }); + }); +}); + +/** + * Gets the state of a promise. + * @param promise The promise to check. + * @returns The state of the promise, either 'pending', 'complete' or 'error'. + */ +async function getPromiseState(promise: Promise): Promise<"complete" | "error" | "pending"> { + const other = {}; + try { + const winner = await Promise.race([promise, other]); + return winner == other ? "pending" : "complete"; + } catch { + return "error"; + } +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index eb6e55a..a1ca8bb 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -3,6 +3,7 @@ export * from "./event-emitter.js"; export * from "./explicit-resource-management/index.js"; export * from "./json.js"; export * from "./lazy.js"; +export { Mutex } from "./mutex.js"; export { freeze } from "./objects/freeze.js"; export { get } from "./objects/get.js"; export { set } from "./objects/set.js"; diff --git a/packages/utils/src/mutex.ts b/packages/utils/src/mutex.ts new file mode 100644 index 0000000..262977f --- /dev/null +++ b/packages/utils/src/mutex.ts @@ -0,0 +1,75 @@ +import { withResolvers } from "./promises.js"; + +/** + * A mutual exclusion lock that ensures only one asynchronous operation can access a protected + * resource at a time. + * @example + * const mutex = new Mutex(); + * + * await mutex.run(async () => { + * // Critical section - only one caller executes at a time + * await updateSharedResource(); + * }); + */ +export class Mutex { + /** + * Determines whether the mutex is currently locked. + */ + #locked: boolean = false; + + /** + * Queue of waiters. + */ + #queue = new Array<(value: PromiseLike | void) => void>(); + + /** + * Releases the lock. If other callers are waiting, the next one in the queue acquires the lock. + * + * Must be called after `wait` to allow other operations to proceed. + */ + public release(): void { + const next = this.#queue.shift(); + + if (next) { + next(); + } else { + this.#locked = false; + } + } + + /** + * Acquires the lock, executes the function, and releases the lock when complete. + * + * If the lock is held by another caller, waits until it becomes available. + * @param fn The function to execute while holding the lock. + * @returns Promise that resolves when the function completes and the lock is released. + */ + public async run(fn: () => Promise | void): Promise { + await this.wait(); + + try { + await fn(); + } finally { + this.release(); + } + } + + /** + * Acquires the lock. If the lock is already held, waits until it becomes available. + * + * Callers are processed in FIFO order. + * @returns Promise that resolves when the lock is acquired. + */ + public async wait(): Promise { + const { promise, resolve } = withResolvers(); + + if (this.#locked) { + this.#queue.push(resolve); + } else { + this.#locked = true; + resolve(); + } + + await promise; + } +}