diff --git a/src/commands/sync.js b/src/commands/sync.js index 11c0a6f9e..8d067060d 100644 --- a/src/commands/sync.js +++ b/src/commands/sync.js @@ -363,7 +363,7 @@ async function acquireSyncLock( priorityPollMs = PRIORITY_LOCK_POLL_MS, } = {}, ) { - const waitsForPriority = Boolean(opts.drain || opts.publishAccount); + const waitsForPriority = Boolean(opts.waitForLock || opts.drain || opts.publishAccount); let lock = await openLock(lockPath, { quietIfLocked: opts.auto || waitsForPriority, }); @@ -2835,6 +2835,7 @@ function parseArgs(argv) { fromOpenclaw: false, source: null, drain: false, + waitForLock: false, background: false, publishAccount: false, allLocalSources: false, @@ -2852,6 +2853,7 @@ function parseArgs(argv) { } else if (a.startsWith("--source=")) out.source = normalizeSyncSource(a.slice("--source=".length)); else if (a === "--drain") out.drain = true; + else if (a === "--wait-for-lock") out.waitForLock = true; else if (a === "--background" || a === "--lightweight") out.background = true; else if (a === "--publish-account") out.publishAccount = true; else if (a === "--all-local-sources") out.allLocalSources = true; diff --git a/src/lib/local-api.js b/src/lib/local-api.js index d1a9bc9f6..be01075b8 100644 --- a/src/lib/local-api.js +++ b/src/lib/local-api.js @@ -880,6 +880,7 @@ function runSyncCommand(extraEnv = {}, opts = {}) { if (opts.publishAccount === true) args.push("--publish-account"); if (opts.allLocalSources === true) args.push("--all-local-sources"); if (opts.drain === true) args.push("--drain"); + if (opts.waitForLock === true) args.push("--wait-for-lock"); const child = spawn(process.execPath, args, { env: { ...process.env, ...extraEnv }, stdio: ["ignore", "pipe", "pipe"], @@ -1971,6 +1972,8 @@ function createLocalApiHandler({ queuePath }) { background, publishAccount, allLocalSources, + waitForLock: + !drain && !background && Boolean(extraEnv.TOKENTRACKER_DEVICE_TOKEN), }); try { const { resetUsageLimitsCache } = require("./usage-limits"); diff --git a/test/local-api-background.test.js b/test/local-api-background.test.js index f7600b3ec..de2731067 100644 --- a/test/local-api-background.test.js +++ b/test/local-api-background.test.js @@ -210,7 +210,12 @@ test("local-api background and lightweight require boolean true", async () => { for (const body of cases) { const call = await runLocalSync({ auto: true, ...body }); const args = call.args; - assert.deepEqual(args.slice(-3), [path.join(process.cwd(), "bin/tracker.js"), "sync", "--auto"]); + assert.deepEqual(args.slice(-4), [ + path.join(process.cwd(), "bin/tracker.js"), + "sync", + "--auto", + "--wait-for-lock", + ]); } }); @@ -356,7 +361,11 @@ test("local-api manual and drain sync still issue relayed cloud device tokens", const savedBaseUrl = process.env.TOKENTRACKER_INSFORGE_BASE_URL; const savedFetch = global.fetch; const cases = [ - { prefix: "tokentracker-local-api-manual-cloud-", body: {}, expectedArgs: ["sync"] }, + { + prefix: "tokentracker-local-api-manual-cloud-", + body: {}, + expectedArgs: ["sync", "--wait-for-lock"], + }, { prefix: "tokentracker-local-api-drain-cloud-", body: { drain: true }, expectedArgs: ["sync", "--drain"] }, ]; diff --git a/test/local-api-security.test.js b/test/local-api-security.test.js index 738f36b4d..dfd7c5e04 100644 --- a/test/local-api-security.test.js +++ b/test/local-api-security.test.js @@ -492,7 +492,12 @@ test("local sync only treats boolean true as background or lightweight", async ( assert.equal(handled, true); assert.equal(res.statusCode, 200); assert.equal(calls.length, 1); - assert.deepEqual(calls[0].args.slice(-3), [path.join(process.cwd(), "bin/tracker.js"), "sync", "--auto"]); + assert.deepEqual(calls[0].args.slice(-4), [ + path.join(process.cwd(), "bin/tracker.js"), + "sync", + "--auto", + "--wait-for-lock", + ]); } finally { restore(); } @@ -533,7 +538,11 @@ test("local sync only treats boolean true as drain", async () => { assert.equal(handled, true); assert.equal(res.statusCode, 200); assert.equal(calls.length, 1); - assert.deepEqual(calls[0].args.slice(-2), [path.join(process.cwd(), "bin/tracker.js"), "sync"]); + assert.deepEqual(calls[0].args.slice(-3), [ + path.join(process.cwd(), "bin/tracker.js"), + "sync", + "--wait-for-lock", + ]); } finally { restore(); } @@ -587,7 +596,11 @@ test("local sync non-drain request mints a device token from relayed login when assert.equal(handled, true); assert.equal(res.statusCode, 200); assert.equal(calls.length, 1); - assert.deepEqual(calls[0].args.slice(-2), [path.join(process.cwd(), "bin/tracker.js"), "sync"]); + assert.deepEqual(calls[0].args.slice(-3), [ + path.join(process.cwd(), "bin/tracker.js"), + "sync", + "--wait-for-lock", + ]); assert.equal(calls[0].options.env.TOKENTRACKER_DEVICE_TOKEN, "issued-device-token"); assert.ok(fetchCalls.some((c) => c.url.endsWith("/api/auth/refresh?client_type=mobile"))); assert.ok(fetchCalls.some((c) => c.url.endsWith("/functions/tokentracker-device-token-issue"))); @@ -1022,7 +1035,11 @@ test("local sync non-drain request keeps explicit device token without relayed m assert.equal(handled, true); assert.equal(res.statusCode, 200); assert.equal(calls.length, 1); - assert.deepEqual(calls[0].args.slice(-2), [path.join(process.cwd(), "bin/tracker.js"), "sync"]); + assert.deepEqual(calls[0].args.slice(-3), [ + path.join(process.cwd(), "bin/tracker.js"), + "sync", + "--wait-for-lock", + ]); assert.equal(calls[0].options.env.TOKENTRACKER_DEVICE_TOKEN, "explicit-device-token"); } finally { restore(); diff --git a/test/sync-background.test.js b/test/sync-background.test.js index bfb2122f9..bec7706ef 100644 --- a/test/sync-background.test.js +++ b/test/sync-background.test.js @@ -265,6 +265,30 @@ test("native account publication waits for an overlapping sync instead of report }); }); +test("explicit wait-for-lock fails instead of reporting success when the lock stays busy", async () => { + await withTempSyncEnv(async (home) => { + const trackerDir = path.join(home, ".tokentracker", "tracker"); + await fs.mkdir(trackerDir, { recursive: true }); + const lockPath = path.join(trackerDir, "sync.lock"); + const active = await openLock(lockPath, { + quietIfLocked: true, + }); + assert.ok(active); + + try { + await assert.rejects( + cmdSync( + ["--wait-for-lock"], + { lockWaitOptions: { priorityWaitMs: 40, priorityPollMs: 5 } }, + ), + (error) => error.code === "SYNC_BUSY", + ); + } finally { + await active.release(); + } + }); +}); + test("manual drain fails explicitly when the sync lock stays busy", async () => { await withTempSyncEnv(async (home) => { const trackerDir = path.join(home, ".tokentracker", "tracker");