Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions server/services/process-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export interface IProcessController {
kill(signal?: NodeJS.Signals | number): void;
destroySockets(): void;
hasProcess(): boolean;
clearListeners(): void;
}

/**
Expand Down Expand Up @@ -218,4 +219,12 @@ export class ProcessController implements IProcessController {
hasProcess(): boolean {
return !!this.proc;
}

clearListeners(): void {
this.stdoutListeners = [];
this.stderrListeners = [];
this.stderrLineListeners = [];
this.closeListeners = [];
this.errorListeners = [];
}
}
23 changes: 19 additions & 4 deletions server/services/sandbox-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export class SandboxRunner {
private totalOutputBytes = 0;
private isSendingOutput = false;
private flushTimer: NodeJS.Timeout | null = null;
private stderrFallbackBuffer = ""; // Fallback buffer for unline-buffered stderr data

// Execution state
private processStartTime: number | null = null;
Expand Down Expand Up @@ -724,6 +725,8 @@ export class SandboxRunner {

// compile finished successfully, clear flag before running
this.isCompiling = false;
// Clear listeners from previous run before spawning new process
this.processController.clearListeners();
await this.processController.spawn(files.exeFile);
this.processStartTime = Date.now();
this.transitionTo(SimulationState.RUNNING);
Expand Down Expand Up @@ -827,6 +830,8 @@ export class SandboxRunner {
command: DockerCommandBuilder.buildCompileAndRunCommand(),
});

// Clear listeners from previous run before spawning new process
this.processController.clearListeners();
await this.processController.spawn("docker", dockerArgs);
this.logger.info("🚀 Docker: Compile + Run in single container");
this.processStartTime = Date.now();
Expand Down Expand Up @@ -1029,13 +1034,13 @@ export class SandboxRunner {
});

const useFallbackParser = !this.processController.supportsStderrLineStreaming();
let stderrFallbackBuffer = "";
this.stderrFallbackBuffer = ""; // Reset buffer for this run

this.processController.onStderr((data) => {
if (useFallbackParser) {
stderrFallbackBuffer += data.toString();
const lines = stderrFallbackBuffer.split(/\r?\n/);
stderrFallbackBuffer = lines.pop() || "";
this.stderrFallbackBuffer += data.toString();
const lines = this.stderrFallbackBuffer.split(/\r?\n/);
this.stderrFallbackBuffer = lines.pop() || "";

for (const line of lines) {
if (!line) continue;
Expand All @@ -1060,6 +1065,16 @@ export class SandboxRunner {
this.flushTimer = null;
}

// CRITICAL: Flush any remaining data in stderr fallback buffer to prevent line loss
if (this.stderrFallbackBuffer) {
const buffered = this.stderrFallbackBuffer;
this.stderrFallbackBuffer = "";
if (buffered.trim()) {
const parsed = this.stderrParser.parseStderrLine(buffered, this.processStartTime);
this.handleParsedLine(parsed, callbacks.onPinState, callbacks.onOutput, callbacks.onError);
}
}

// CRITICAL: Flush message queue before exit to prevent losing queued output
this.flushMessageQueue();

Expand Down
49 changes: 49 additions & 0 deletions tests/server/services/ghost-output.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, it, expect, vi } from 'vitest';
import { SandboxRunner } from '../../../server/services/sandbox-runner';

// give the test a generous timeout because the runner needs to compile/start
vi.setConfig({ testTimeout: 30000 });

describe('Ghost Output Reproduction', () => {
it('should keep ProcessController listener counts stable across runs', async () => {
const runner = new SandboxRunner();

const ctrl: any = (runner as any).processController;
// initial state should have no listeners
expect(ctrl.stdoutListeners.length).toBe(0);
expect(ctrl.stderrListeners.length).toBe(0);
expect(ctrl.stderrLineListeners.length).toBe(0);

// first run
runner.runSketch({ code: 'void setup() {}', onOutput: () => {}, onIORegistry: () => {} });
// wait until a process exists AND listeners have been attached
while (!(ctrl.hasProcess() && ctrl.stderrLineListeners.length > 0)) {
await new Promise(r => setTimeout(r, 10));
}

const firstStdoutCount = ctrl.stdoutListeners.length;
const firstStderrCount = ctrl.stderrListeners.length;
const firstStderrLineCount = ctrl.stderrLineListeners.length;

console.log('[TEST] After first run:', { firstStdoutCount, firstStderrCount, firstStderrLineCount });

await runner.stop();

// second run reusing the same runner
runner.runSketch({ code: 'void setup() {}', onOutput: () => {}, onIORegistry: () => {} });
while (!(ctrl.hasProcess() && ctrl.stderrLineListeners.length > 0)) {
await new Promise(r => setTimeout(r, 10));
}

const secondStdoutCount = ctrl.stdoutListeners.length;
const secondStderrCount = ctrl.stderrListeners.length;
const secondStderrLineCount = ctrl.stderrLineListeners.length;

console.log('[TEST] After second run:', { secondStdoutCount, secondStderrCount, secondStderrLineCount });

// listener counts should not increase (no Ghost Output accumulation)
expect(secondStdoutCount).toBe(firstStdoutCount);
expect(secondStderrCount).toBe(firstStderrCount);
expect(secondStderrLineCount).toBe(firstStderrLineCount);
});
});
Loading