diff --git a/server/services/process-controller.ts b/server/services/process-controller.ts index 09430668..693243fc 100644 --- a/server/services/process-controller.ts +++ b/server/services/process-controller.ts @@ -38,6 +38,7 @@ export interface IProcessController { kill(signal?: NodeJS.Signals | number): void; destroySockets(): void; hasProcess(): boolean; + clearListeners(): void; } /** @@ -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 = []; + } } diff --git a/server/services/sandbox-runner.ts b/server/services/sandbox-runner.ts index 03bafa77..23802510 100644 --- a/server/services/sandbox-runner.ts +++ b/server/services/sandbox-runner.ts @@ -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; @@ -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); @@ -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(); @@ -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; @@ -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(); diff --git a/tests/server/services/ghost-output.test.ts b/tests/server/services/ghost-output.test.ts new file mode 100644 index 00000000..5f9be3b1 --- /dev/null +++ b/tests/server/services/ghost-output.test.ts @@ -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); + }); +});