Skip to content

Conversation

@cyfung1031
Copy link
Collaborator

@cyfung1031 cyfung1031 commented Jan 31, 2026

背景 / Background

在用户脚本环境中,同一个脚本可能同时在多个执行上下文中运行,例如多个 iframe(内嵌框架)、多个标签页或窗口,甚至后台 worker(工作者线程)。这些上下文如果同时访问共享资源(如存储在 GM.getValue / GM.setValue 中的数据、IndexedDB 数据库,或需要按顺序执行的异步任务),就会容易出现“竞争条件”(race condition),导致数据不一致或操作失败。例如,两个标签页同时尝试更新同一个计数器值,其中一个的更新可能会覆盖另一个的,导致最终结果错误。这是一个常见的问题,尤其在复杂脚本或多窗口协作场景中,开发者往往需要自己编写复杂的锁机制或轮询来避免,但这增加了代码复杂度和潜在 bug。

In userscript environments, the same script may run simultaneously in multiple execution contexts, such as multiple iframes, tabs/windows, or background workers. When these contexts access shared resources (e.g., data stored via GM.getValue / GM.setValue, IndexedDB, or sequential async tasks) at the same time, it can lead to "race conditions," causing data inconsistencies or failed operations. For instance, two tabs trying to update the same counter value might overwrite each other, resulting in incorrect outcomes. This is a common issue in complex scripts or multi-window collaborations, where developers often have to implement custom locks or polling, increasing code complexity and potential bugs.

问题所在与改进点 / Problem and Improvements

现有 GM API 缺乏内置的跨上下文排他执行机制,导致开发者难以可靠地串行化操作。改进点在于引入一个简单、高效的 API 来处理这些并发问题:它自动管理全局锁,确保同一时间只有一个上下文执行关键代码,其他上下文会顺序等待。这样不仅解决了竞争问题,还简化了开发流程,无需额外库或手动实现锁逻辑。

The existing GM API lacks built-in support for cross-context exclusive execution, making it hard for developers to reliably serialize operations. The improvement introduces a simple, efficient API to handle these concurrency issues: it automatically manages a global lock, ensuring only one context executes critical code at a time, with others waiting in sequence. This not only resolves race conditions but also simplifies development, eliminating the need for extra libraries or manual lock implementations.

为何需要此功能 / Why This Feature is Needed

用户脚本常用于浏览器增强,如自动化任务、数据同步或多窗口交互(如游戏辅助或跨标签数据共享)。在现代网页中,iframe 和多标签使用频繁,如果没有排他机制,脚本容易出错,影响用户体验。该功能填补了 GM API 的空白,提供标准化方式来处理并发,类似于编程中的互斥锁(mutex),但更易用且针对用户脚本优化。它特别适用于需要持久化状态或异步协作的脚本,帮助开发者构建更可靠的应用。

Userscripts are often used for browser enhancements, like automating tasks, data syncing, or multi-window interactions (e.g., game assistants or cross-tab data sharing). In modern web pages, iframes and multiple tabs are common, and without an exclusive mechanism, scripts are prone to errors, degrading user experience. This feature fills a gap in the GM API by providing a standardized way to handle concurrency, similar to a mutex in programming but more user-friendly and optimized for userscripts. It's especially useful for scripts requiring persistent state or async collaboration, enabling developers to build more reliable applications.

新增 API / New API

GM.runExclusive<T>(key: string, callback: () => Promise<T>): Promise<T>

行为说明 / Behavior Description

  • key 是全局锁的唯一标识符(跨所有 iframe、标签页和上下文共享)。

  • 在同一时间,只有 一个 执行上下文可以运行对应 keycallback 函数,其他调用会自动等待锁释放,然后按调用顺序依次执行。

  • callback 执行完毕(无论成功 resolve 或失败 reject)后,锁会自动释放,让下一个等待者继续。

  • callback 内可以安全地放置需要串行化的操作,如读写共享存储或异步 API 调用。

  • The key is a unique identifier for a global lock (shared across all iframes, tabs, and contexts).

  • At any time, only one execution context can run the callback for a given key; other calls will wait for the lock to release and execute in the order they were called.

  • The lock is automatically released after the callback completes (whether it resolves or rejects), allowing the next waiter to proceed.

  • Inside the callback, you can safely place operations that need serialization, like reading/writing shared storage or async API calls.

使用示例 / Usage Example

await GM.runExclusive('counter-lock', async () => {
  const value = await GM.getValue('counter', 0);
  await GM.setValue('counter', value + 1);
  await doSomethingAsync();  // 一些异步操作
});

即使这段代码在多个 iframe 或标签页中同时触发,也能保证 counter 值按顺序递增,不会丢失更新。想象一下:如果没有这个 API,两个上下文可能同时读取值为 5,然后都设置为 6,导致最终值错误地停留在 6 而非 7。

Even if this code is triggered simultaneously in multiple iframes or tabs, it ensures the counter value increments sequentially without lost updates. Imagine: without this API, two contexts might both read the value as 5 and set it to 6, resulting in a final value of 6 instead of 7 by mistake.

优点 / Benefits

  • 直接消除并发竞争,减少 bug 和调试时间。

  • API 设计简洁,支持 async/await,自然融入现代 JavaScript 代码。

  • 无需开发者管理锁的获取/释放,降低出错风险。

  • 适用于各种场景,如多 iframe 协作、后台 worker 或跨窗口数据同步。

  • Directly eliminates concurrency races, reducing bugs and debugging time.

  • The API is concise, supporting async/await and integrating naturally into modern JavaScript code.

  • No need for developers to manage lock acquisition/release, lowering error risks.

  • Applicable to various scenarios, like multi-iframe collaboration, background workers, or cross-window data syncing.

设计说明 / Design Notes

  • 不同 key 的锁相互独立,不会干扰。

  • 不暴露锁的内部细节(如手动释放),以避免误用和复杂化。

  • 如果 callback 抛出异常,锁仍会自动释放,确保系统不死锁。

  • 实现依赖引擎内部机制(如消息传递或共享存储),但 API 语义保持一致。

  • Locks for different keys are independent and don't interfere.

  • No exposure of internal lock details (e.g., manual release) to avoid misuse and complexity.

  • If the callback throws an error, the lock is still automatically released to prevent deadlocks.

  • Implementation relies on engine internals (e.g., messaging or shared storage), but the API semantics remain consistent.

兼容性 / Compatibility

这是一个纯新增功能,不会影响现有的 GM API 或脚本。无需担心兼容性问题,现有的代码可以无缝运行。

This is a pure addition and won't affect existing GM APIs or scripts. No compatibility concerns; existing code runs seamlessly.

示例与演示 / Demo

本 PR 包含一个演示用户脚本,展示多个 iframe 如何顺序获取锁、更新共享状态,并记录执行日志。
可以通过提供的 Demo URL 测试:https://example.com/?runExclusive
这有助于直观理解锁的顺序性和一致性。

This PR includes a demo userscript showing how multiple iframes acquire the lock sequentially, update shared state, and log execution.
Test via the provided Demo URL: https://example.com/?runExclusive
This visually demonstrates the lock's ordering and consistency.

Demo Code 01

https://example.com/?runExclusive

// ==UserScript==
// @name         GM.runExclusive Demo
// @namespace    https://docs.scriptcat.org/
// @version      0.1.2
// @match        https://example.com/*?runExclusive*
// @grant        GM.runExclusive
// @grant        GM.setValue
// @grant        GM.getValue
// @run-at       document-start
// @allFrames
// ==/UserScript==

(async function () {
    'use strict';

    const delayMatch = location.href.match(/runExclusive(\d+)_(\d*)/);
    const timeDelay = delayMatch ? +delayMatch[1] : 0;
    const timeoutValue = (delayMatch ? +delayMatch[2] : 0) || -1;
    const isWorker = !!timeDelay;

    const sheet = new CSSStyleSheet();
    sheet.replaceSync(`
        #exclusive-test-panel {
            all: unset;
        }
        #exclusive-test-panel div, #exclusive-test-panel p, #exclusive-test-panel span {
            opacity: 1.0;
            line-height: 1;
            font-size: 10pt;
        }
    `);
    document.adoptedStyleSheets = document.adoptedStyleSheets.concat(sheet);

    /* ---------- Shared UI helpers ---------- */
    const panel = document.createElement('div');
    panel.id = "exclusive-test-panel";
    Object.assign(panel.style, {
        opacity: "1.0",
        position: 'fixed',
        boxSizing: 'border-box',
        top: '10px',
        right: '10px',
        background: '#3e3e3e',
        color: '#e0e0e0',
        padding: '14px',
        borderRadius: '8px',
        fontFamily: 'monospace',
        zIndex: 99999,
        width: '420px'
    });
    document.documentElement.appendChild(panel);

    const logContainer = document.createElement('div');
    panel.appendChild(logContainer);

    const getTimeWithMilliseconds = date => `${date.toLocaleTimeString('it-US')}.${date.getMilliseconds()}`;

    const log = (msg, color = '#ccc') => {
        const line = document.createElement('div');
        line.textContent = msg.startsWith(" ") ? msg : `[${getTimeWithMilliseconds(new Date())}] ${msg}`;
        line.style.color = color;
        logContainer.appendChild(line);
    };

    /* ======================================================
       MAIN PAGE (Controller)
    ====================================================== */
    if (!isWorker) {
        panel.style.width = "480px";
        panel.innerHTML = `
            <h3 style="margin-top:0">GM.runExclusive Demo</h3>
            <p>Pick worker durations (ms):</p>
            <div style="display:flex; flex-direction:row; gap: 4px;">
                <input id="durations" value="1200,2400,3800,400"
                   style="width:140px; margin: 0;" />
                <input id="timeout" value="5000"
                   style="width:45px; margin: 0;" />
                <button id="run">Run Demo</button>
                <button id="reset">Reset Counters</button>
            </div>
            <div id="iframeContainer"></div>
        `;

        const iframeContainer = panel.querySelector('#iframeContainer');

        panel.querySelector('#reset').onclick = async () => {
            await GM.setValue('mValue01', 0);
            await GM.setValue('order', 0);
            iframeContainer.innerHTML = '';
            log('Shared counters reset', '#ff0');
        };

        panel.querySelector('#run').onclick = async () => {
            iframeContainer.innerHTML = '';
            await GM.setValue('mValue01', 0);
            await GM.setValue('order', 0);

            const delays = panel
                .querySelector('#durations')
                .value.split(',')
                .map(v => +v.trim())
                .filter(Boolean);
            
            let timeoutQ = +panel.querySelector("#timeout").value.trim() || "";

            log(`Launching workers: ${delays.join(', ')}`, '#0f0');

            delays.forEach(delay => {
                const iframe = document.createElement('iframe');
                iframe.src = `${location.pathname}?runExclusive${delay}_${timeoutQ}`;
                iframe.style.width = '100%';
                iframe.style.height = '160px';
                iframe.style.border = '1px solid #444';
                iframe.style.marginTop = '8px';
                iframeContainer.appendChild(iframe);
            });
        };

        window.addEventListener('message', (e) => {
            if (e.data?.type !== 'close-worker') return;
            const iframes = iframeContainer.querySelectorAll('iframe');
            for (const iframe of iframes) {
                if (iframe.src.includes(`runExclusive${e.data.delay}_`)) {
                    iframe.remove();
                    log(`Closed worker ${e.data.delay}ms`, '#ff9800');
                    return;
                }
            }
        });

        return;
    }

    /* ======================================================
       WORKER IFRAME
    ====================================================== */

    const closeBtn = document.createElement('button');
    closeBtn.textContent = 'Close';
    Object.assign(closeBtn.style, {
        margin: '8px',
        padding: '4px 8px',
        cursor: 'pointer',
        position: 'absolute',
        top: '0px',
        right: '0px',
        boxSizing: 'border-box'
    });
    closeBtn.onclick = () => {
        window.parent.postMessage({ type: 'close-worker', delay: timeDelay }, '*');
    };
    panel.appendChild(closeBtn);

    log(` [Worker] duration=${timeDelay}ms${timeoutValue > 0 ? " timeout=" + timeoutValue + "ms" : ""}`, '#fff');
    log('Waiting for exclusive lock…', '#0af');

    const startWait = performance.now();

    try {
        const result = await GM.runExclusive('demo-lock-key', async () => {
            const waited = Math.round(performance.now() - startWait);

            const order = (await GM.getValue('order')) + 1;
            await GM.setValue('order', order);

            log(`Lock acquired (#${order}, waited ${waited}ms)`, '#0f0');

            const val = await GM.getValue('mValue01');
            await GM.setValue('mValue01', val + timeDelay);

            log(`Working ${timeDelay}ms…`, '#ff0');
            await new Promise(r => setTimeout(r, timeDelay));

            const final = await GM.getValue('mValue01');
            log(`Done. Shared value = ${final}`, '#f55');

            return { order, waited, final };
        }, timeoutValue);
        log(`Result: ${JSON.stringify(result)}`, '#fff');
    } catch (e) {
        log(`Error: ${JSON.stringify(e?.message || e)}`, '#f55');
    }

})();

Demo Code 02

// ==UserScript==
// @name         GM.runExclusive Increment Race UI Test
// @namespace    https://docs.scriptcat.org/
// @version      0.1.0
// @match        https://example.com/*?atomicRace*
// @grant        GM.runExclusive
// @grant        GM.setValue
// @grant        GM.getValue
// @run-at       document-start
// @allFrames
// ==/UserScript==

(async function () {
    'use strict';

    const params = new URLSearchParams(location.search);
    const isWorker = params.has('worker');

    /* ---------- Style ---------- */
    const sheet = new CSSStyleSheet();
    sheet.replaceSync(`
        #exclusive-test-panel {
            all: unset;
        }
        #exclusive-test-panel * {
            box-sizing: border-box;
            font-family: monospace;
            font-size: 10pt;
            line-height: 1.2;
        }
    `);
    document.adoptedStyleSheets = document.adoptedStyleSheets.concat(sheet);

    /* ---------- Panel ---------- */
    const panel = document.createElement('div');
    panel.id = 'exclusive-test-panel';
    Object.assign(panel.style, {
        position: 'fixed',
        top: '10px',
        right: '10px',
        background: '#3e3e3e',
        color: '#e0e0e0',
        padding: '12px',
        borderRadius: '8px',
        zIndex: 99999,
        width: '420px'
    });
    document.documentElement.appendChild(panel);

    /* ---------- Log ---------- */
    const logContainer = document.createElement('div');
    Object.assign(logContainer.style, {
        marginTop: '8px',
        background: '#1e1e1e',
        padding: '6px',
        borderRadius: '4px',
        height: '120px',
        overflow: 'auto'
    });
    panel.appendChild(logContainer);

    const log = (msg, color = '#ccc') => {
        const isBottom = (logContainer.offsetHeight > logContainer.scrollTop - 2) || (logContainer.scrollTop > logContainer.scrollHeight - logContainer.offsetHeight - 2);
        const line = document.createElement('div');
        line.textContent = msg;
        line.style.color = color;
        logContainer.appendChild(line);
        if (isBottom) {
            logContainer.scrollTop = logContainer.scrollHeight;
        }
    };

    /* ======================================================
       MAIN PAGE
    ====================================================== */
    if (!isWorker) {
        panel.style.width = "480px";
        panel.innerHTML = `
            <h3 style="margin:0 0 6px 0">GM.runExclusive Increment Race</h3>
            <div style="display:flex; gap:4px; align-items:center">
                <span>Workers</span>
                <input id="workers" value="3" style="width:40px">
                <span>Increments</span>
                <input id="count" value="5000" style="width:60px">
                <button id="run">Run</button>
                <button id="reset">Reset</button>
            </div>
            <div id="iframeContainer" style="margin-top:8px"></div>
        `;

        const iframeContainer = panel.querySelector('#iframeContainer');

        panel.querySelector('#reset').onclick = async () => {
            await GM.setValue('counter', 0);
            iframeContainer.innerHTML = '';
            logContainer.innerHTML = '';
            log('Shared counter reset', '#ff0');
        };

        panel.querySelector('#run').onclick = async () => {
            iframeContainer.innerHTML = '';
            logContainer.innerHTML = '';
            await GM.setValue('counter', 0);

            const workers = +panel.querySelector('#workers').value || 1;
            const count = +panel.querySelector('#count').value || 1;

            log(`Launching ${workers} workers × ${count}`, '#0f0');

            for (let i = 0; i < workers; i++) {
                const iframe = document.createElement('iframe');
                iframe.src =
                    `${location.pathname}?atomicRace&worker=${i}&count=${count}`;
                iframe.style.width = '100%';
                iframe.style.height = '160px';
                iframe.style.border = '1px solid #444';
                iframe.style.marginTop = '6px';
                iframeContainer.appendChild(iframe);
            }
        };

        return;
    }

    /* ======================================================
       WORKER
    ====================================================== */

    const workerId = params.get('worker');
    const TOTAL = +params.get('count') || 1;

    /* ---------- Last-5 UI ---------- */
    const lastPanel = document.createElement('div');
    Object.assign(lastPanel.style, {
        display: 'grid',
        gridTemplateColumns: '20px auto',
        gap: '2px 6px',
        background: '#111',
        padding: '6px',
        borderRadius: '4px',
        marginBottom: '6px',
        position: "absolute",
        right: "36px",
        bottom: "12px",
        top:"12px",
        width: "100px",
        zIndex: "999",
        padding: "12px",
    });
    panel.insertBefore(lastPanel, panel.firstChild);

    const slots = ['A', 'B', 'C', 'D', 'E'];
    const slotElems = [];

    for (const s of slots) {
        const label = document.createElement('div');
        label.textContent = s;
        label.style.color = '#0af';

        const value = document.createElement('div');
        value.textContent = '-';
        value.style.color = '#fff';

        lastPanel.appendChild(label);
        lastPanel.appendChild(value);
        slotElems.push(value);
    }

    let writeCount = 0;

    log(`[Worker ${workerId}] start (${TOTAL} ops)`, '#0af');

    const updateUIAsync = async function (next) {
        // ---- update ring UI ----
        const idx = writeCount % 5;
        slotElems[idx].textContent = next;
        writeCount++;

        log(`+1 → ${next}`, '#fff');
    }

    for (let i = 1; i <= TOTAL; i++) {
        // 视使用需要。GM.runExclusive 可以不加 await
        await GM.runExclusive('atomic-counter-lock', async () => {
            const v = await GM.getValue('counter');
            const next = v + 1;
            await GM.setValue('counter', next);
            updateUIAsync(next);
        });
    }

    const final = await GM.getValue('counter');
    log(`[Worker ${workerId}] done, counter=${final}`, '#ff9800');


})();

@cyfung1031 cyfung1031 force-pushed the pr-GM_runExclusive-001 branch from 87d4970 to bbb083a Compare January 31, 2026 06:27
@cyfung1031 cyfung1031 added the enhancement New feature or request label Jan 31, 2026
@cyfung1031 cyfung1031 changed the title feat: 新增 GM.runExclusive API,用于跨上下文的排他执行 | feat: add GM.runExclusive API for cross-context exclusive execution feat: 新增 GM.runExclusive API,实现跨上下文的互斥执行 | feat: add GM.runExclusive API for cross-context exclusive execution Jan 31, 2026
@cyfung1031 cyfung1031 changed the title feat: 新增 GM.runExclusive API,实现跨上下文的互斥执行 | feat: add GM.runExclusive API for cross-context exclusive execution feat: 新增 GM.runExclusive API,实现跨页面互斥执行 | feat: add GM.runExclusive API for cross-context exclusive execution Jan 31, 2026
@CodFrm
Copy link
Member

CodFrm commented Jan 31, 2026

总感觉怪怪的,或者类似cas之类的实现?等1.4再仔细看看

@CodFrm CodFrm changed the title feat: 新增 GM.runExclusive API,实现跨页面互斥执行 | feat: add GM.runExclusive API for cross-context exclusive execution [v1.4] feat: 新增 GM.runExclusive API,实现跨页面互斥执行 | feat: add GM.runExclusive API for cross-context exclusive execution Jan 31, 2026
@cyfung1031
Copy link
Collaborator Author

cyfung1031 commented Jan 31, 2026

等1.4再仔细看看

ok

总感觉怪怪的,或者类似cas之类的实现?

stackAsyncTask 的 API 版

前后台SW通讯做 atomic 是不切实际。 做不到CAS。

@cyfung1031 cyfung1031 force-pushed the pr-GM_runExclusive-001 branch from e3667c7 to 2b173d3 Compare January 31, 2026 07:21
@cyfung1031 cyfung1031 force-pushed the pr-GM_runExclusive-001 branch from 515339b to 61591ba Compare January 31, 2026 08:57
@cyfung1031
Copy link
Collaborator Author

cyfung1031 commented Feb 2, 2026

新增了 Demo Code 02 展示大量运算 (无延迟的压力测试)

Screenshot 2026-02-02 at 21 48 59
  • 虽然每次都要打开和关闭连接,但实际使用上速度可以接受。不需要一个长期开启的连接。倒过来如果因为一点性能考虑而造成卡死,这是本末倒置。
  • 现在的执行速度大概是 5~8ms 执行一次 ( 8 * 5000 * 3 = 2 分钟 )

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants