-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathqueue.mjs
More file actions
142 lines (126 loc) · 3.58 KB
/
Copy pathqueue.mjs
File metadata and controls
142 lines (126 loc) · 3.58 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
/**
* queue.mjs — serialize image generations so only ONE job drives ChatGPT at a time.
*
* Cursor/agents often fire multiple generate_image calls in parallel. They all
* attach to the same Edge CDP session and type into the same composer, which
* interleaves keystrokes into garbage. This module provides:
* 1) In-process mutex (same MCP server instance)
* 2) Cross-process file lock (multiple MCP processes / overlapping clients)
*/
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const LOCK_PATH =
process.env.IMAGE_GEN_LOCK_PATH || path.join(__dirname, ".generate.lock");
const LOCK_WAIT_MS = Number(process.env.IMAGE_GEN_LOCK_WAIT_MS || 12 * 60 * 1000);
const LOCK_STALE_MS = Number(process.env.IMAGE_GEN_LOCK_STALE_MS || 6 * 60 * 1000);
const POLL_MS = 350;
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
function pidAlive(pid) {
if (!pid || typeof pid !== "number") return false;
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function readLockMeta() {
try {
return JSON.parse(fs.readFileSync(LOCK_PATH, "utf8"));
} catch {
return null;
}
}
function tryRemoveStaleLock() {
const meta = readLockMeta();
if (!meta) {
try {
fs.unlinkSync(LOCK_PATH);
} catch {
/* ignore */
}
return;
}
const age = Date.now() - (meta.at || 0);
if (age > LOCK_STALE_MS || !pidAlive(meta.pid)) {
try {
fs.unlinkSync(LOCK_PATH);
console.error(
`[image-gen] Cleared stale queue lock (pid=${meta.pid}, age=${Math.round(age / 1000)}s)`,
);
} catch {
/* ignore */
}
}
}
/** Acquire exclusive lock file. Waits until free or times out. */
export async function acquireGenerateLock() {
const started = Date.now();
let loggedWait = false;
while (Date.now() - started < LOCK_WAIT_MS) {
try {
const fd = fs.openSync(LOCK_PATH, "wx");
fs.writeFileSync(
fd,
JSON.stringify({ pid: process.pid, at: Date.now() }, null, 2),
);
fs.closeSync(fd);
if (loggedWait) {
console.error(
`[image-gen] Queue lock acquired after ${Math.round((Date.now() - started) / 1000)}s`,
);
}
return;
} catch (err) {
if (err.code !== "EEXIST") throw err;
if (!loggedWait) {
console.error(
"[image-gen] Another generate_image is running — queued (same ChatGPT session cannot run in parallel)",
);
loggedWait = true;
}
tryRemoveStaleLock();
await sleep(POLL_MS);
}
}
throw new Error(
`Timed out after ${Math.round(LOCK_WAIT_MS / 1000)}s waiting for the image-gen queue. ` +
`If nothing is generating, delete ${LOCK_PATH} and retry.`,
);
}
export function releaseGenerateLock() {
try {
const meta = readLockMeta();
// Only delete if we own it (or it's unreadable).
if (!meta || meta.pid === process.pid) {
fs.unlinkSync(LOCK_PATH);
}
} catch {
/* ignore */
}
}
/** In-process FIFO chain so concurrent awaits inside one process stay ordered. */
let chain = Promise.resolve();
/**
* Run `fn` exclusively — waits for prior jobs in this process AND the file lock.
*/
export function withGenerateQueue(fn) {
const run = chain.then(async () => {
await acquireGenerateLock();
try {
return await fn();
} finally {
releaseGenerateLock();
}
});
// Keep the chain alive even if this job fails.
chain = run.then(
() => undefined,
() => undefined,
);
return run;
}