chore: format to 2-space and bump changelog
This commit is contained in:
@@ -3,53 +3,53 @@ import { describe, expect, it } from "vitest";
|
||||
import { enqueueCommand, getQueueSize } from "./command-queue.js";
|
||||
|
||||
describe("command queue", () => {
|
||||
it("runs tasks one at a time in order", async () => {
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
const calls: number[] = [];
|
||||
it("runs tasks one at a time in order", async () => {
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
const calls: number[] = [];
|
||||
|
||||
const makeTask = (id: number) => async () => {
|
||||
active += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
calls.push(id);
|
||||
await new Promise((resolve) => setTimeout(resolve, 15));
|
||||
active -= 1;
|
||||
return id;
|
||||
};
|
||||
const makeTask = (id: number) => async () => {
|
||||
active += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
calls.push(id);
|
||||
await new Promise((resolve) => setTimeout(resolve, 15));
|
||||
active -= 1;
|
||||
return id;
|
||||
};
|
||||
|
||||
const results = await Promise.all([
|
||||
enqueueCommand(makeTask(1)),
|
||||
enqueueCommand(makeTask(2)),
|
||||
enqueueCommand(makeTask(3)),
|
||||
]);
|
||||
const results = await Promise.all([
|
||||
enqueueCommand(makeTask(1)),
|
||||
enqueueCommand(makeTask(2)),
|
||||
enqueueCommand(makeTask(3)),
|
||||
]);
|
||||
|
||||
expect(results).toEqual([1, 2, 3]);
|
||||
expect(calls).toEqual([1, 2, 3]);
|
||||
expect(maxActive).toBe(1);
|
||||
expect(getQueueSize()).toBe(0);
|
||||
});
|
||||
expect(results).toEqual([1, 2, 3]);
|
||||
expect(calls).toEqual([1, 2, 3]);
|
||||
expect(maxActive).toBe(1);
|
||||
expect(getQueueSize()).toBe(0);
|
||||
});
|
||||
|
||||
it("invokes onWait callback when a task waits past the threshold", async () => {
|
||||
let waited: number | null = null;
|
||||
let queuedAhead: number | null = null;
|
||||
it("invokes onWait callback when a task waits past the threshold", async () => {
|
||||
let waited: number | null = null;
|
||||
let queuedAhead: number | null = null;
|
||||
|
||||
// First task holds the queue long enough to trigger wait notice.
|
||||
const first = enqueueCommand(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
});
|
||||
// First task holds the queue long enough to trigger wait notice.
|
||||
const first = enqueueCommand(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
});
|
||||
|
||||
const second = enqueueCommand(async () => {}, {
|
||||
warnAfterMs: 5,
|
||||
onWait: (ms, ahead) => {
|
||||
waited = ms;
|
||||
queuedAhead = ahead;
|
||||
},
|
||||
});
|
||||
const second = enqueueCommand(async () => {}, {
|
||||
warnAfterMs: 5,
|
||||
onWait: (ms, ahead) => {
|
||||
waited = ms;
|
||||
queuedAhead = ahead;
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all([first, second]);
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(waited).not.toBeNull();
|
||||
expect(waited as number).toBeGreaterThanOrEqual(5);
|
||||
expect(queuedAhead).toBe(0);
|
||||
});
|
||||
expect(waited).not.toBeNull();
|
||||
expect(waited as number).toBeGreaterThanOrEqual(5);
|
||||
expect(queuedAhead).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,57 +2,57 @@
|
||||
// Ensures only one command runs at a time across webhook, poller, and web inbox flows.
|
||||
|
||||
type QueueEntry = {
|
||||
task: () => Promise<unknown>;
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
enqueuedAt: number;
|
||||
warnAfterMs: number;
|
||||
onWait?: (waitMs: number, queuedAhead: number) => void;
|
||||
task: () => Promise<unknown>;
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
enqueuedAt: number;
|
||||
warnAfterMs: number;
|
||||
onWait?: (waitMs: number, queuedAhead: number) => void;
|
||||
};
|
||||
|
||||
const queue: QueueEntry[] = [];
|
||||
let draining = false;
|
||||
|
||||
async function drainQueue() {
|
||||
if (draining) return;
|
||||
draining = true;
|
||||
while (queue.length) {
|
||||
const entry = queue.shift() as QueueEntry;
|
||||
const waitedMs = Date.now() - entry.enqueuedAt;
|
||||
if (waitedMs >= entry.warnAfterMs) {
|
||||
entry.onWait?.(waitedMs, queue.length);
|
||||
}
|
||||
try {
|
||||
const result = await entry.task();
|
||||
entry.resolve(result);
|
||||
} catch (err) {
|
||||
entry.reject(err);
|
||||
}
|
||||
}
|
||||
draining = false;
|
||||
if (draining) return;
|
||||
draining = true;
|
||||
while (queue.length) {
|
||||
const entry = queue.shift() as QueueEntry;
|
||||
const waitedMs = Date.now() - entry.enqueuedAt;
|
||||
if (waitedMs >= entry.warnAfterMs) {
|
||||
entry.onWait?.(waitedMs, queue.length);
|
||||
}
|
||||
try {
|
||||
const result = await entry.task();
|
||||
entry.resolve(result);
|
||||
} catch (err) {
|
||||
entry.reject(err);
|
||||
}
|
||||
}
|
||||
draining = false;
|
||||
}
|
||||
|
||||
export function enqueueCommand<T>(
|
||||
task: () => Promise<T>,
|
||||
opts?: {
|
||||
warnAfterMs?: number;
|
||||
onWait?: (waitMs: number, queuedAhead: number) => void;
|
||||
},
|
||||
task: () => Promise<T>,
|
||||
opts?: {
|
||||
warnAfterMs?: number;
|
||||
onWait?: (waitMs: number, queuedAhead: number) => void;
|
||||
},
|
||||
): Promise<T> {
|
||||
const warnAfterMs = opts?.warnAfterMs ?? 2_000;
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
queue.push({
|
||||
task: () => task(),
|
||||
resolve: (value) => resolve(value as T),
|
||||
reject,
|
||||
enqueuedAt: Date.now(),
|
||||
warnAfterMs,
|
||||
onWait: opts?.onWait,
|
||||
});
|
||||
void drainQueue();
|
||||
});
|
||||
const warnAfterMs = opts?.warnAfterMs ?? 2_000;
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
queue.push({
|
||||
task: () => task(),
|
||||
resolve: (value) => resolve(value as T),
|
||||
reject,
|
||||
enqueuedAt: Date.now(),
|
||||
warnAfterMs,
|
||||
onWait: opts?.onWait,
|
||||
});
|
||||
void drainQueue();
|
||||
});
|
||||
}
|
||||
|
||||
export function getQueueSize() {
|
||||
return queue.length + (draining ? 1 : 0);
|
||||
return queue.length + (draining ? 1 : 0);
|
||||
}
|
||||
|
||||
@@ -8,86 +8,86 @@ const execFileAsync = promisify(execFile);
|
||||
|
||||
// Simple promise-wrapped execFile with optional verbosity logging.
|
||||
export async function runExec(
|
||||
command: string,
|
||||
args: string[],
|
||||
opts: number | { timeoutMs?: number; maxBuffer?: number } = 10_000,
|
||||
command: string,
|
||||
args: string[],
|
||||
opts: number | { timeoutMs?: number; maxBuffer?: number } = 10_000,
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const options =
|
||||
typeof opts === "number"
|
||||
? { timeout: opts, encoding: "utf8" as const }
|
||||
: {
|
||||
timeout: opts.timeoutMs,
|
||||
maxBuffer: opts.maxBuffer,
|
||||
encoding: "utf8" as const,
|
||||
};
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(command, args, options);
|
||||
if (isVerbose()) {
|
||||
if (stdout.trim()) logDebug(stdout.trim());
|
||||
if (stderr.trim()) logError(stderr.trim());
|
||||
}
|
||||
return { stdout, stderr };
|
||||
} catch (err) {
|
||||
if (isVerbose()) {
|
||||
logError(danger(`Command failed: ${command} ${args.join(" ")}`));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const options =
|
||||
typeof opts === "number"
|
||||
? { timeout: opts, encoding: "utf8" as const }
|
||||
: {
|
||||
timeout: opts.timeoutMs,
|
||||
maxBuffer: opts.maxBuffer,
|
||||
encoding: "utf8" as const,
|
||||
};
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(command, args, options);
|
||||
if (isVerbose()) {
|
||||
if (stdout.trim()) logDebug(stdout.trim());
|
||||
if (stderr.trim()) logError(stderr.trim());
|
||||
}
|
||||
return { stdout, stderr };
|
||||
} catch (err) {
|
||||
if (isVerbose()) {
|
||||
logError(danger(`Command failed: ${command} ${args.join(" ")}`));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export type SpawnResult = {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
killed: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
killed: boolean;
|
||||
};
|
||||
|
||||
export type CommandOptions = {
|
||||
timeoutMs: number;
|
||||
cwd?: string;
|
||||
timeoutMs: number;
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
export async function runCommandWithTimeout(
|
||||
argv: string[],
|
||||
optionsOrTimeout: number | CommandOptions,
|
||||
argv: string[],
|
||||
optionsOrTimeout: number | CommandOptions,
|
||||
): Promise<SpawnResult> {
|
||||
const options: CommandOptions =
|
||||
typeof optionsOrTimeout === "number"
|
||||
? { timeoutMs: optionsOrTimeout }
|
||||
: optionsOrTimeout;
|
||||
const { timeoutMs, cwd } = options;
|
||||
const options: CommandOptions =
|
||||
typeof optionsOrTimeout === "number"
|
||||
? { timeoutMs: optionsOrTimeout }
|
||||
: optionsOrTimeout;
|
||||
const { timeoutMs, cwd } = options;
|
||||
|
||||
// Spawn with inherited stdin (TTY) so tools like `claude` don't hang.
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn(argv[0], argv.slice(1), {
|
||||
stdio: ["inherit", "pipe", "pipe"],
|
||||
cwd,
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
}, timeoutMs);
|
||||
// Spawn with inherited stdin (TTY) so tools like `claude` don't hang.
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn(argv[0], argv.slice(1), {
|
||||
stdio: ["inherit", "pipe", "pipe"],
|
||||
cwd,
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
}, timeoutMs);
|
||||
|
||||
child.stdout?.on("data", (d) => {
|
||||
stdout += d.toString();
|
||||
});
|
||||
child.stderr?.on("data", (d) => {
|
||||
stderr += d.toString();
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
child.on("close", (code, signal) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve({ stdout, stderr, code, signal, killed: child.killed });
|
||||
});
|
||||
});
|
||||
child.stdout?.on("data", (d) => {
|
||||
stdout += d.toString();
|
||||
});
|
||||
child.stderr?.on("data", (d) => {
|
||||
stderr += d.toString();
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
child.on("close", (code, signal) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve({ stdout, stderr, code, signal, killed: child.killed });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user