feat(FN-3106): add draft planning workflow with createAiSession plugin API
This merge delivers the full draft planning feature (FN-3106) — API routes for planning subtasks, the AiSessionStore backend for draft sessions, a draft planning API client, auto-creation of planning drafts from user input, and corresponding UI polish in PlanningModeModal with accessibility-focused Fusion-Task-Id: FN-3106
This commit is contained in:
14
.changeset/dashboard-pty-flush-throttle.md
Normal file
14
.changeset/dashboard-pty-flush-throttle.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix dashboard freezing for several seconds while a Fusion agent runs a long verification command (e.g. `pnpm test`).
|
||||||
|
|
||||||
|
Root cause was in `runVerificationCommand`'s output capture (`packages/engine/src/run-verification-tool.ts`). The captured stdout/stderr buffers used a string-concat + re-encode pattern: once total output exceeded 200 KB, every subsequent line did `Buffer.from(buf.tail).subarray(...).toString("utf8")`, allocating and re-decoding the entire ~100 KB tail per line. A vitest run dumping 50k+ lines produced multiple GB of GC churn, which stalled the dashboard event loop in stop-the-world pauses (matching the symptom: occasional multi-second freezes with no CPU spike on the host).
|
||||||
|
|
||||||
|
The buffer is now stored as a chunk array; tail compaction runs only when accumulated size grows past 2× the cap, making per-line append amortized O(1). All 12 existing `run-verification-command` tests pass unchanged.
|
||||||
|
|
||||||
|
Two follow-on changes shipped in the same patch:
|
||||||
|
|
||||||
|
- **Embedded terminal PTY ingestion** (`packages/dashboard/src/terminal-service.ts`) had the same anti-pattern: `outputBuffer.slice(0, 4096)` + `outputBuffer.slice(4096)` on every 4 ms flush tick. Switched to a chunk array with O(1) drain. Throttle bumped from 4 ms to 16 ms (60 fps) and per-flush cap from 4 KB to 64 KB. This was not the cause of the user-reported freeze, but the same O(N²) hazard would surface under any flood from a terminal pane.
|
||||||
|
- **Vitest worker fan-out tightened**: per-package cap lowered from `min(6, cpus()-1)` to `min(4, cpus()-1)` in cli/dashboard/desktop/mobile/plugin-sdk/engine (engine had no cap before). Each config now explicitly pins `pool` (`forks` or `threads`) and only sets the matching `poolOptions`, removing the dual-pool declaration. Worst-case `pnpm test` fan-out: ~12 workers → ~8.
|
||||||
@@ -2,11 +2,11 @@ import { defineConfig } from "vitest/config";
|
|||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { cpus } from "node:os";
|
import { cpus } from "node:os";
|
||||||
|
|
||||||
// Cap fan-out to 6 so high-core dev machines don't spawn 27+ workers per
|
// Cap fan-out to 4 so high-core dev machines don't spawn 27+ workers per
|
||||||
// package — that saturates the box when multiple workspace packages test
|
// package — that saturates the box when multiple workspace packages test
|
||||||
// concurrently or when the dashboard has agents running tests in parallel.
|
// concurrently or when the dashboard has agents running tests in parallel.
|
||||||
// Override with VITEST_MAX_WORKERS for explicit fast/serial runs.
|
// Override with VITEST_MAX_WORKERS for explicit fast/serial runs.
|
||||||
const defaultMaxWorkers = Math.min(6, Math.max(1, cpus().length - 1));
|
const defaultMaxWorkers = Math.min(4, Math.max(1, cpus().length - 1));
|
||||||
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? String(defaultMaxWorkers), 10);
|
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? String(defaultMaxWorkers), 10);
|
||||||
const maxWorkers = Math.max(1, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : defaultMaxWorkers);
|
const maxWorkers = Math.max(1, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : defaultMaxWorkers);
|
||||||
process.env.VITEST_MAX_WORKERS = String(maxWorkers);
|
process.env.VITEST_MAX_WORKERS = String(maxWorkers);
|
||||||
@@ -38,8 +38,9 @@ export default defineConfig({
|
|||||||
resolve(__dirname, "../core/src/__test-utils__/vitest-setup.ts"),
|
resolve(__dirname, "../core/src/__test-utils__/vitest-setup.ts"),
|
||||||
],
|
],
|
||||||
globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")],
|
globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")],
|
||||||
|
pool: "forks",
|
||||||
maxWorkers,
|
maxWorkers,
|
||||||
poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers }, forks: { minForks: 1, maxForks: maxWorkers } },
|
poolOptions: { forks: { minForks: 1, maxForks: maxWorkers } },
|
||||||
fileParallelism: true,
|
fileParallelism: true,
|
||||||
coverage: {
|
coverage: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ export default defineConfig({
|
|||||||
"./src/__test-utils__/vitest-setup.ts",
|
"./src/__test-utils__/vitest-setup.ts",
|
||||||
],
|
],
|
||||||
globalSetup: ["./src/__test-utils__/vitest-teardown.ts"],
|
globalSetup: ["./src/__test-utils__/vitest-teardown.ts"],
|
||||||
|
pool: "forks",
|
||||||
maxWorkers,
|
maxWorkers,
|
||||||
poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers }, forks: { minForks: 1, maxForks: maxWorkers } },
|
poolOptions: { forks: { minForks: 1, maxForks: maxWorkers } },
|
||||||
fileParallelism: true,
|
fileParallelism: true,
|
||||||
// Core runs a large SQLite-heavy suite while other workspace packages test concurrently.
|
// Core runs a large SQLite-heavy suite while other workspace packages test concurrently.
|
||||||
// Use a slightly higher timeout to reduce nondeterministic slow-machine flakes.
|
// Use a slightly higher timeout to reduce nondeterministic slow-machine flakes.
|
||||||
|
|||||||
@@ -569,9 +569,9 @@ describe("TerminalService", () => {
|
|||||||
// Advance exactly to the resize debounce boundary
|
// Advance exactly to the resize debounce boundary
|
||||||
vi.advanceTimersByTime(150);
|
vi.advanceTimersByTime(150);
|
||||||
|
|
||||||
// The resize debounce should have moved suppressed data to outputBuffer
|
// The resize debounce should have moved suppressed data to the output
|
||||||
// and scheduled a flush. Advance past the flush throttle.
|
// queue and scheduled a flush. Advance past the flush throttle window.
|
||||||
vi.advanceTimersByTime(10);
|
vi.advanceTimersByTime(20);
|
||||||
|
|
||||||
expect(dataListener).toHaveBeenCalledWith(session.id, "shell prompt> ");
|
expect(dataListener).toHaveBeenCalledWith(session.id, "shell prompt> ");
|
||||||
|
|
||||||
|
|||||||
@@ -190,9 +190,12 @@ const MIN_MAX_SESSIONS = 1;
|
|||||||
const MAX_MAX_SESSIONS = 100;
|
const MAX_MAX_SESSIONS = 100;
|
||||||
const DEFAULT_MAX_SESSIONS = 10;
|
const DEFAULT_MAX_SESSIONS = 10;
|
||||||
|
|
||||||
// Throttle output to prevent overwhelming WebSocket under heavy load
|
// Throttle output to prevent overwhelming WebSocket under heavy load.
|
||||||
const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive input
|
// 16ms = 60fps, plenty for terminal redraw while leaving event-loop budget
|
||||||
const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency
|
// for the rest of the dashboard. Larger flush cap keeps `pnpm test`-class
|
||||||
|
// floods from generating thousands of timer-driven micro-flushes.
|
||||||
|
const OUTPUT_THROTTLE_MS = 16;
|
||||||
|
const OUTPUT_BATCH_SIZE = 64 * 1024; // 64KB per WebSocket frame
|
||||||
|
|
||||||
// Stale session threshold: sessions inactive for more than 5 minutes are eligible for eviction
|
// Stale session threshold: sessions inactive for more than 5 minutes are eligible for eviction
|
||||||
export const STALE_SESSION_THRESHOLD_MS = 300_000; // 5 minutes
|
export const STALE_SESSION_THRESHOLD_MS = 300_000; // 5 minutes
|
||||||
@@ -230,7 +233,14 @@ export interface TerminalSession {
|
|||||||
lastActivityAt: Date;
|
lastActivityAt: Date;
|
||||||
shell: string;
|
shell: string;
|
||||||
scrollbackBuffer: string;
|
scrollbackBuffer: string;
|
||||||
outputBuffer: string;
|
/**
|
||||||
|
* Pending output chunks awaiting flush to clients. Stored as an array
|
||||||
|
* (not a single concatenated string) so heavy bursts — e.g. a `pnpm test`
|
||||||
|
* run inside an embedded terminal — don't trigger O(N²) string copies on
|
||||||
|
* every flush tick, which previously caused multi-second event-loop stalls.
|
||||||
|
*/
|
||||||
|
outputChunks: string[];
|
||||||
|
outputBytes: number;
|
||||||
flushTimeout: NodeJS.Timeout | null;
|
flushTimeout: NodeJS.Timeout | null;
|
||||||
resizeInProgress: boolean;
|
resizeInProgress: boolean;
|
||||||
resizeDebounceTimeout: NodeJS.Timeout | null;
|
resizeDebounceTimeout: NodeJS.Timeout | null;
|
||||||
@@ -241,7 +251,7 @@ export interface TerminalSession {
|
|||||||
* This prevents the initial shell prompt (and other output) from being lost
|
* This prevents the initial shell prompt (and other output) from being lost
|
||||||
* when it falls inside the 150 ms resize-suppression window.
|
* when it falls inside the 150 ms resize-suppression window.
|
||||||
*/
|
*/
|
||||||
resizeSuppressedBuffer: string;
|
resizeSuppressedChunks: string[];
|
||||||
/** Internal flush callback set by createSession; used by resize debounce */
|
/** Internal flush callback set by createSession; used by resize debounce */
|
||||||
_flushOutput: (() => void) | null;
|
_flushOutput: (() => void) | null;
|
||||||
}
|
}
|
||||||
@@ -659,17 +669,21 @@ export class TerminalService extends EventEmitter {
|
|||||||
lastActivityAt: new Date(),
|
lastActivityAt: new Date(),
|
||||||
shell,
|
shell,
|
||||||
scrollbackBuffer: "",
|
scrollbackBuffer: "",
|
||||||
outputBuffer: "",
|
outputChunks: [],
|
||||||
|
outputBytes: 0,
|
||||||
flushTimeout: null,
|
flushTimeout: null,
|
||||||
resizeInProgress: false,
|
resizeInProgress: false,
|
||||||
resizeDebounceTimeout: null,
|
resizeDebounceTimeout: null,
|
||||||
resizeSuppressedBuffer: "",
|
resizeSuppressedChunks: [],
|
||||||
_flushOutput: null,
|
_flushOutput: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.sessions.set(id, session);
|
this.sessions.set(id, session);
|
||||||
|
|
||||||
// Flush buffered output to clients (throttled)
|
// Flush buffered output to clients (throttled).
|
||||||
|
// Drains chunks from the front of the queue up to OUTPUT_BATCH_SIZE bytes
|
||||||
|
// per frame. Using an array avoids the O(N) string-slice that the previous
|
||||||
|
// implementation paid on every tick under heavy load.
|
||||||
const flushOutput = () => {
|
const flushOutput = () => {
|
||||||
// Guard against firing after session was killed
|
// Guard against firing after session was killed
|
||||||
if (!this.sessions.has(id)) {
|
if (!this.sessions.has(id)) {
|
||||||
@@ -677,21 +691,29 @@ export class TerminalService extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.outputBuffer.length === 0) {
|
if (session.outputChunks.length === 0) {
|
||||||
|
session.outputBytes = 0;
|
||||||
session.flushTimeout = null;
|
session.flushTimeout = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let dataToSend = session.outputBuffer;
|
const drained: string[] = [];
|
||||||
if (dataToSend.length > OUTPUT_BATCH_SIZE) {
|
let drainedBytes = 0;
|
||||||
dataToSend = session.outputBuffer.slice(0, OUTPUT_BATCH_SIZE);
|
while (session.outputChunks.length > 0 && drainedBytes < OUTPUT_BATCH_SIZE) {
|
||||||
session.outputBuffer = session.outputBuffer.slice(OUTPUT_BATCH_SIZE);
|
const next = session.outputChunks.shift() as string;
|
||||||
|
drained.push(next);
|
||||||
|
drainedBytes += next.length;
|
||||||
|
}
|
||||||
|
session.outputBytes = Math.max(0, session.outputBytes - drainedBytes);
|
||||||
|
|
||||||
|
if (session.outputChunks.length > 0) {
|
||||||
|
// More to send — schedule another flush after the throttle window.
|
||||||
session.flushTimeout = setTimeout(flushOutput, OUTPUT_THROTTLE_MS);
|
session.flushTimeout = setTimeout(flushOutput, OUTPUT_THROTTLE_MS);
|
||||||
} else {
|
} else {
|
||||||
session.outputBuffer = "";
|
|
||||||
session.flushTimeout = null;
|
session.flushTimeout = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dataToSend = drained.length === 1 ? drained[0] : drained.join("");
|
||||||
this.dataCallbacks.forEach((cb) => cb(id, dataToSend));
|
this.dataCallbacks.forEach((cb) => cb(id, dataToSend));
|
||||||
this.emit("data", id, dataToSend);
|
this.emit("data", id, dataToSend);
|
||||||
};
|
};
|
||||||
@@ -712,12 +734,13 @@ export class TerminalService extends EventEmitter {
|
|||||||
// to avoid rendering artifacts, but queue the data so it is flushed to
|
// to avoid rendering artifacts, but queue the data so it is flushed to
|
||||||
// clients once the resize debounce completes (no data loss).
|
// clients once the resize debounce completes (no data loss).
|
||||||
if (session.resizeInProgress) {
|
if (session.resizeInProgress) {
|
||||||
session.resizeSuppressedBuffer += data;
|
session.resizeSuppressedChunks.push(data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Buffer output for throttled delivery
|
// Buffer output for throttled delivery
|
||||||
session.outputBuffer += data;
|
session.outputChunks.push(data);
|
||||||
|
session.outputBytes += data.length;
|
||||||
|
|
||||||
if (!session.flushTimeout) {
|
if (!session.flushTimeout) {
|
||||||
session.flushTimeout = setTimeout(flushOutput, OUTPUT_THROTTLE_MS);
|
session.flushTimeout = setTimeout(flushOutput, OUTPUT_THROTTLE_MS);
|
||||||
@@ -737,7 +760,9 @@ export class TerminalService extends EventEmitter {
|
|||||||
session.resizeDebounceTimeout = null;
|
session.resizeDebounceTimeout = null;
|
||||||
}
|
}
|
||||||
session._flushOutput = null;
|
session._flushOutput = null;
|
||||||
session.resizeSuppressedBuffer = "";
|
session.resizeSuppressedChunks.length = 0;
|
||||||
|
session.outputChunks.length = 0;
|
||||||
|
session.outputBytes = 0;
|
||||||
this.sessions.delete(id);
|
this.sessions.delete(id);
|
||||||
this.exitCallbacks.forEach((cb) => cb(id, exitCode ?? 0));
|
this.exitCallbacks.forEach((cb) => cb(id, exitCode ?? 0));
|
||||||
this.emit("exit", id, exitCode ?? 0);
|
this.emit("exit", id, exitCode ?? 0);
|
||||||
@@ -812,9 +837,12 @@ export class TerminalService extends EventEmitter {
|
|||||||
// This ensures the initial shell prompt (and any other output that
|
// This ensures the initial shell prompt (and any other output that
|
||||||
// landed inside the suppression window) is delivered to clients
|
// landed inside the suppression window) is delivered to clients
|
||||||
// rather than being silently dropped.
|
// rather than being silently dropped.
|
||||||
if (session.resizeSuppressedBuffer.length > 0) {
|
if (session.resizeSuppressedChunks.length > 0) {
|
||||||
session.outputBuffer += session.resizeSuppressedBuffer;
|
for (const chunk of session.resizeSuppressedChunks) {
|
||||||
session.resizeSuppressedBuffer = "";
|
session.outputChunks.push(chunk);
|
||||||
|
session.outputBytes += chunk.length;
|
||||||
|
}
|
||||||
|
session.resizeSuppressedChunks.length = 0;
|
||||||
if (!session.flushTimeout && session._flushOutput) {
|
if (!session.flushTimeout && session._flushOutput) {
|
||||||
session.flushTimeout = setTimeout(session._flushOutput, OUTPUT_THROTTLE_MS);
|
session.flushTimeout = setTimeout(session._flushOutput, OUTPUT_THROTTLE_MS);
|
||||||
}
|
}
|
||||||
@@ -920,7 +948,8 @@ export class TerminalService extends EventEmitter {
|
|||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) return null;
|
if (!session) return null;
|
||||||
|
|
||||||
session.outputBuffer = "";
|
session.outputChunks.length = 0;
|
||||||
|
session.outputBytes = 0;
|
||||||
if (session.flushTimeout) {
|
if (session.flushTimeout) {
|
||||||
clearTimeout(session.flushTimeout);
|
clearTimeout(session.flushTimeout);
|
||||||
session.flushTimeout = null;
|
session.flushTimeout = null;
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import react from "@vitejs/plugin-react";
|
|||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { cpus } from "node:os";
|
import { cpus } from "node:os";
|
||||||
|
|
||||||
// Cap fan-out to 6 so high-core dev machines don't spawn 27+ workers per
|
// Cap fan-out to 4 so high-core dev machines don't spawn 27+ workers per
|
||||||
// package — that saturates the box when workspace packages test concurrently.
|
// package — that saturates the box when workspace packages test concurrently.
|
||||||
const defaultMaxWorkers = Math.min(6, Math.max(1, cpus().length - 1));
|
const defaultMaxWorkers = Math.min(4, Math.max(1, cpus().length - 1));
|
||||||
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? String(defaultMaxWorkers), 10);
|
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? String(defaultMaxWorkers), 10);
|
||||||
const maxWorkers = Math.max(1, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : defaultMaxWorkers);
|
const maxWorkers = Math.max(1, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : defaultMaxWorkers);
|
||||||
process.env.VITEST_MAX_WORKERS = String(maxWorkers);
|
process.env.VITEST_MAX_WORKERS = String(maxWorkers);
|
||||||
@@ -33,8 +33,9 @@ export default defineConfig({
|
|||||||
"./vitest.setup.ts",
|
"./vitest.setup.ts",
|
||||||
],
|
],
|
||||||
globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")],
|
globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")],
|
||||||
|
pool: "forks",
|
||||||
maxWorkers,
|
maxWorkers,
|
||||||
poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers }, forks: { minForks: 1, maxForks: maxWorkers } },
|
poolOptions: { forks: { minForks: 1, maxForks: maxWorkers } },
|
||||||
fileParallelism: true,
|
fileParallelism: true,
|
||||||
isolate: true,
|
isolate: true,
|
||||||
// Dashboard route and integration-heavy suites can exceed the Vitest
|
// Dashboard route and integration-heavy suites can exceed the Vitest
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { defineConfig } from "vitest/config";
|
|||||||
import { cpus } from "node:os";
|
import { cpus } from "node:os";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
// Cap fan-out to 6 to avoid saturating high-core machines under workspace concurrency.
|
// Cap fan-out to 4 to avoid saturating high-core machines under workspace concurrency.
|
||||||
const defaultMaxWorkers = Math.min(6, Math.max(1, cpus().length - 1));
|
const defaultMaxWorkers = Math.min(4, Math.max(1, cpus().length - 1));
|
||||||
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? String(defaultMaxWorkers), 10);
|
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? String(defaultMaxWorkers), 10);
|
||||||
const maxWorkers = Math.max(1, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : defaultMaxWorkers);
|
const maxWorkers = Math.max(1, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : defaultMaxWorkers);
|
||||||
process.env.VITEST_MAX_WORKERS = String(maxWorkers);
|
process.env.VITEST_MAX_WORKERS = String(maxWorkers);
|
||||||
@@ -14,8 +14,9 @@ export default defineConfig({
|
|||||||
globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")],
|
globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")],
|
||||||
testTimeout: 30_000,
|
testTimeout: 30_000,
|
||||||
hookTimeout: 30_000,
|
hookTimeout: 30_000,
|
||||||
|
pool: "threads",
|
||||||
maxWorkers,
|
maxWorkers,
|
||||||
poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers }, forks: { minForks: 1, maxForks: maxWorkers } },
|
poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers } },
|
||||||
fileParallelism: true,
|
fileParallelism: true,
|
||||||
passWithNoTests: true,
|
passWithNoTests: true,
|
||||||
projects: [
|
projects: [
|
||||||
|
|||||||
@@ -91,36 +91,60 @@ export interface VerificationResult {
|
|||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Output buffer helper — keeps head + tail within the byte cap
|
// Output buffer helper — keeps head + tail within the byte cap
|
||||||
|
//
|
||||||
|
// Stores head/tail as chunk arrays rather than concatenated strings.
|
||||||
|
// The previous implementation re-encoded the entire ~100 KB tail through
|
||||||
|
// `Buffer.from(...).subarray(...).toString()` on *every* appended line once
|
||||||
|
// output crossed MAX_OUTPUT_BYTES — for a `pnpm test` run dumping 50k lines
|
||||||
|
// that produced gigabytes of GC churn and stalled the dashboard event loop.
|
||||||
|
// Now we just push chunks and only compact the tail when its byte size grows
|
||||||
|
// past 2× the cap, making the amortized cost per append O(1).
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function appendToBuffer(
|
interface OutputBuffer {
|
||||||
buf: { head: string; tail: string; totalBytes: number },
|
headChunks: string[];
|
||||||
chunk: string,
|
headBytes: number;
|
||||||
): void {
|
tailChunks: string[];
|
||||||
|
tailBytes: number;
|
||||||
|
totalBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBuffer(): OutputBuffer {
|
||||||
|
return { headChunks: [], headBytes: 0, tailChunks: [], tailBytes: 0, totalBytes: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendToBuffer(buf: OutputBuffer, chunk: string): void {
|
||||||
const chunkBytes = Buffer.byteLength(chunk, "utf8");
|
const chunkBytes = Buffer.byteLength(chunk, "utf8");
|
||||||
buf.totalBytes += chunkBytes;
|
buf.totalBytes += chunkBytes;
|
||||||
|
|
||||||
if (buf.totalBytes <= MAX_OUTPUT_BYTES) {
|
if (buf.headBytes + chunkBytes <= MAX_OUTPUT_BYTES) {
|
||||||
buf.head += chunk;
|
buf.headChunks.push(chunk);
|
||||||
|
buf.headBytes += chunkBytes;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Overflow: funnel excess into tail (keep at most half the cap in tail)
|
// Overflow: funnel into tail. Keep at most half the cap in tail, but only
|
||||||
|
// compact when we're well over so per-line cost stays amortized O(1).
|
||||||
const tailCap = MAX_OUTPUT_BYTES / 2;
|
const tailCap = MAX_OUTPUT_BYTES / 2;
|
||||||
buf.tail += chunk;
|
buf.tailChunks.push(chunk);
|
||||||
if (Buffer.byteLength(buf.tail, "utf8") > tailCap) {
|
buf.tailBytes += chunkBytes;
|
||||||
// Truncate tail from the front — keep newest content
|
if (buf.tailBytes > tailCap * 2) {
|
||||||
const bytes = Buffer.from(buf.tail, "utf8");
|
// Drop oldest chunks until under the cap.
|
||||||
buf.tail = bytes.subarray(bytes.length - tailCap).toString("utf8");
|
while (buf.tailChunks.length > 1 && buf.tailBytes - Buffer.byteLength(buf.tailChunks[0], "utf8") >= tailCap) {
|
||||||
|
const dropped = buf.tailChunks.shift() as string;
|
||||||
|
buf.tailBytes -= Buffer.byteLength(dropped, "utf8");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function flattenBuffer(buf: { head: string; tail: string; totalBytes: number }): string {
|
function flattenBuffer(buf: OutputBuffer): string {
|
||||||
if (buf.tail.length === 0) return buf.head;
|
const head = buf.headChunks.join("");
|
||||||
|
if (buf.tailChunks.length === 0) return head;
|
||||||
|
const tail = buf.tailChunks.join("");
|
||||||
return (
|
return (
|
||||||
buf.head +
|
head +
|
||||||
`\n\n[... output truncated — ${buf.totalBytes} bytes total, showing head + tail ...]\n\n` +
|
`\n\n[... output truncated — ${buf.totalBytes} bytes total, showing head + tail ...]\n\n` +
|
||||||
buf.tail
|
tail
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,8 +174,8 @@ export async function runVerificationCommand(
|
|||||||
const startMs = Date.now();
|
const startMs = Date.now();
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
||||||
const stdoutBuf = { head: "", tail: "", totalBytes: 0 };
|
const stdoutBuf = createBuffer();
|
||||||
const stderrBuf = { head: "", tail: "", totalBytes: 0 };
|
const stderrBuf = createBuffer();
|
||||||
|
|
||||||
return new Promise<VerificationResult>((resolve) => {
|
return new Promise<VerificationResult>((resolve) => {
|
||||||
// Use shell: true so Node picks the platform default — /bin/sh on POSIX,
|
// Use shell: true so Node picks the platform default — /bin/sh on POSIX,
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { defineConfig } from "vitest/config";
|
|||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { cpus } from "node:os";
|
import { cpus } from "node:os";
|
||||||
|
|
||||||
const defaultMaxWorkers = Math.max(1, cpus().length - 1);
|
// Cap fan-out to 4 to avoid saturating high-core machines under workspace concurrency.
|
||||||
|
const defaultMaxWorkers = Math.min(4, Math.max(1, cpus().length - 1));
|
||||||
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? String(defaultMaxWorkers), 10);
|
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? String(defaultMaxWorkers), 10);
|
||||||
const maxWorkers = Math.max(1, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : defaultMaxWorkers);
|
const maxWorkers = Math.max(1, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : defaultMaxWorkers);
|
||||||
process.env.VITEST_MAX_WORKERS = String(maxWorkers);
|
process.env.VITEST_MAX_WORKERS = String(maxWorkers);
|
||||||
@@ -23,10 +24,10 @@ export default defineConfig({
|
|||||||
resolve(__dirname, "../core/src/__test-utils__/vitest-setup.ts"),
|
resolve(__dirname, "../core/src/__test-utils__/vitest-setup.ts"),
|
||||||
],
|
],
|
||||||
globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")],
|
globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")],
|
||||||
maxWorkers,
|
|
||||||
poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers }, forks: { minForks: 1, maxForks: maxWorkers } },
|
|
||||||
fileParallelism: true,
|
|
||||||
pool: "threads",
|
pool: "threads",
|
||||||
|
maxWorkers,
|
||||||
|
poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers } },
|
||||||
|
fileParallelism: true,
|
||||||
// Enable isolate to allow parallel execution of tests with conflicting mocks
|
// Enable isolate to allow parallel execution of tests with conflicting mocks
|
||||||
isolate: true,
|
isolate: true,
|
||||||
coverage: {
|
coverage: {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { defineConfig } from "vitest/config";
|
|||||||
import { cpus } from "node:os";
|
import { cpus } from "node:os";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
// Cap fan-out to 6 to avoid saturating high-core machines under workspace concurrency.
|
// Cap fan-out to 4 to avoid saturating high-core machines under workspace concurrency.
|
||||||
const defaultMaxWorkers = Math.min(6, Math.max(1, cpus().length - 1));
|
const defaultMaxWorkers = Math.min(4, Math.max(1, cpus().length - 1));
|
||||||
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? String(defaultMaxWorkers), 10);
|
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? String(defaultMaxWorkers), 10);
|
||||||
const maxWorkers = Math.max(1, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : defaultMaxWorkers);
|
const maxWorkers = Math.max(1, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : defaultMaxWorkers);
|
||||||
process.env.VITEST_MAX_WORKERS = String(maxWorkers);
|
process.env.VITEST_MAX_WORKERS = String(maxWorkers);
|
||||||
@@ -13,8 +13,9 @@ export default defineConfig({
|
|||||||
include: ["src/**/*.test.ts"],
|
include: ["src/**/*.test.ts"],
|
||||||
setupFiles: [resolve(__dirname, "../core/src/__test-utils__/vitest-setup.ts")],
|
setupFiles: [resolve(__dirname, "../core/src/__test-utils__/vitest-setup.ts")],
|
||||||
globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")],
|
globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")],
|
||||||
|
pool: "forks",
|
||||||
maxWorkers,
|
maxWorkers,
|
||||||
poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers }, forks: { minForks: 1, maxForks: maxWorkers } },
|
poolOptions: { forks: { minForks: 1, maxForks: maxWorkers } },
|
||||||
fileParallelism: true,
|
fileParallelism: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { cpus } from "node:os";
|
|||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
// Cap fan-out to 6 to avoid saturating high-core machines under workspace concurrency.
|
// Cap fan-out to 4 to avoid saturating high-core machines under workspace concurrency.
|
||||||
const defaultMaxWorkers = Math.min(6, Math.max(1, cpus().length - 1));
|
const defaultMaxWorkers = Math.min(4, Math.max(1, cpus().length - 1));
|
||||||
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? String(defaultMaxWorkers), 10);
|
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? String(defaultMaxWorkers), 10);
|
||||||
const maxWorkers = Math.max(1, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : defaultMaxWorkers);
|
const maxWorkers = Math.max(1, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : defaultMaxWorkers);
|
||||||
process.env.VITEST_MAX_WORKERS = String(maxWorkers);
|
process.env.VITEST_MAX_WORKERS = String(maxWorkers);
|
||||||
@@ -21,6 +21,6 @@ export default defineConfig({
|
|||||||
globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")],
|
globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")],
|
||||||
pool: "threads",
|
pool: "threads",
|
||||||
maxWorkers,
|
maxWorkers,
|
||||||
poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers }, forks: { minForks: 1, maxForks: maxWorkers } },
|
poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user