diff --git a/package-lock.json b/package-lock.json index 27a9c29..337b463 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "license": "MIT", "dependencies": { "chalk": "^5.6.2", - "commander": "^14.0.2" + "commander": "^14.0.2", + "execa": "^5.1.1" }, "bin": { "polyman": "dist/cli.js" @@ -71,7 +72,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2977,7 +2977,6 @@ "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" @@ -2996,7 +2995,6 @@ "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3054,7 +3052,6 @@ "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/types": "8.47.0", @@ -3433,7 +3430,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3796,7 +3792,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -4084,7 +4079,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -4327,7 +4321,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4388,7 +4381,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -4640,6 +4632,29 @@ "node": ">=0.10.0" } }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -4887,6 +4902,18 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -5062,6 +5089,15 @@ "dev": true, "license": "MIT" }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, "node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -5234,11 +5270,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -5678,7 +5725,6 @@ "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", @@ -5732,6 +5778,12 @@ "dev": true, "license": "MIT" }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -5756,6 +5808,15 @@ "node": ">=8.6" } }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -5879,6 +5940,18 @@ "dev": true, "license": "MIT" }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5910,6 +5983,21 @@ "wrappy": "1" } }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5997,7 +6085,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6082,7 +6169,6 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -6132,7 +6218,6 @@ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -6690,6 +6775,7 @@ "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" @@ -6709,7 +6795,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -6722,7 +6807,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6739,7 +6823,6 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, "license": "ISC" }, "node_modules/source-map": { @@ -6804,6 +6887,15 @@ "node": ">=8" } }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -6969,7 +7061,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7131,7 +7222,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7454,7 +7544,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7649,7 +7738,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" diff --git a/package.json b/package.json index 5dec091..b03df01 100755 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ }, "dependencies": { "chalk": "^5.6.2", - "commander": "^14.0.2" + "commander": "^14.0.2", + "execa": "^5.1.1" } } diff --git a/src/executor.ts b/src/executor.ts index 55d5256..59a3682 100755 --- a/src/executor.ts +++ b/src/executor.ts @@ -1,10 +1,14 @@ /** * @fileoverview Command execution engine with timeout, memory limits, and process management. - * Provides safe execution of external programs (C++, Python, Java) with resource constraints. - * Handles process lifecycle, cleanup, and cross-platform compatibility. + * Thin wrapper over `execa` that adds the bits execa does not handle for a competitive-programming judge: + * - Memory limits via `ulimit -v` (Unix) and `-Xmx` (Java). + * - Cross-platform process-tree kill on TLE (`taskkill /T /F` on Windows, process groups on Unix). + * - MLE detection from exit codes (137, SIGABRT) and stderr patterns (`bad_alloc`, `OutOfMemory`, ...). + * - Callback-style completion (`onSuccess` / `onError` / `onTimeout` / `onMemoryExceeded`). */ -import { spawn, ChildProcess } from 'child_process'; +import { spawn } from 'child_process'; +import execa from 'execa'; import { fmt } from './formatter'; /** @@ -17,12 +21,6 @@ import { fmt } from './formatter'; * @property {boolean} success - Whether execution was successful * @property {boolean} [timedOut] - Whether process exceeded time limit * @property {boolean} [memoryExceeded] - Whether process exceeded memory limit - * - * @example - * const result = await executor.execute('./solution', { timeout: 1000 }); - * if (result.success) { - * console.log(result.stdout); - * } */ export interface ExecutionResult { stdout: string; @@ -45,14 +43,6 @@ export interface ExecutionResult { * @property {Function} [onTimeout] - Callback on timeout * @property {Function} [onMemoryExceeded] - Callback on memory limit exceeded * @property {boolean} [silent] - Suppress console output if true - * - * @example - * const options = { - * timeout: 2000, - * memoryLimitMB: 256, - * onTimeout: (result) => console.log('Process timed out'), - * silent: false - * }; */ export interface ExecutionOptions { timeout: number; @@ -68,11 +58,12 @@ export interface ExecutionOptions { const TIMEOUT_EXIT_CODE = 124; const OOM_KILL_EXIT_CODE = 137; const KILL_GRACE_PERIOD_MS = 100; +const WINDOWS_HANDLE_RELEASE_DELAY_MS = 150; /** * Command executor for running external programs with resource constraints. * Manages process lifecycle, timeout enforcement, memory limits, and cleanup. - * Supports cross-platform execution (Unix/Windows) with platform-specific optimizations. + * Supports cross-platform execution (Unix/Windows) with platform-specific tree-kill. * * @class CommandExecutor * @example @@ -86,34 +77,19 @@ const KILL_GRACE_PERIOD_MS = 100; */ export class CommandExecutor { private tempFiles: string[] = []; - private activeProcesses: Set = new Set(); + private activeProcesses: Set = new Set(); /** * Execute a shell command with timeout and memory limits. - * Automatically kills the process if it exceeds time or memory constraints. + * Automatically kills the process tree if it exceeds time or memory constraints. * * @param {string} command - Shell command to execute * @param {ExecutionOptions} options - Execution configuration * @returns {Promise} Execution result with stdout, stderr, and status * - * @throws {Error} If command fails and no onError callback is provided - * @throws {Error} If timeout occurs and no onTimeout callback is provided - * @throws {Error} If memory limit exceeded and no onMemoryExceeded callback is provided - * - * @example - * // Run a C++ solution - * const result = await executor.execute('./solution', { - * timeout: 1000, - * memoryLimitMB: 256 - * }); - * - * @example - * // With error handling - * const result = await executor.execute('./buggy_solution', { - * timeout: 2000, - * onError: (result) => console.log('Solution failed:', result.stderr), - * onTimeout: (result) => console.log('TLE') - * }); + * @throws {Error} If command fails and no `onError` callback is provided + * @throws {Error} If timeout occurs and no `onTimeout` callback is provided + * @throws {Error} If memory limit exceeded and no `onMemoryExceeded` callback is provided */ async execute( command: string, @@ -125,63 +101,97 @@ export class CommandExecutor { ); const platformCommand = this.normalizeCommandForPlatform(wrappedCommand); - return new Promise((resolve, reject) => { - const { process: child, result } = this.spawnProcess( - platformCommand, + const execaOptions: execa.Options = { + shell: true, + detached: process.platform !== 'win32', + reject: false, + stripFinalNewline: false, + ...(options.cwd !== undefined ? { cwd: options.cwd } : {}), + }; + const subprocess = execa(platformCommand, execaOptions); + this.activeProcesses.add(subprocess); + + let didTimeout = false; + const timeoutId = setTimeout(() => { + didTimeout = true; + if (subprocess.pid !== undefined) { + this.killProcessTree(subprocess.pid); + } + // Backup: ensure the direct child dies even if the tree-kill path missed it. + try { + subprocess.kill('SIGKILL', { + forceKillAfterTimeout: KILL_GRACE_PERIOD_MS, + }); + } catch { + // Already terminated. + } + }, options.timeout); + + let raw: execa.ExecaReturnValue | execa.ExecaError; + try { + raw = await subprocess; + } catch (err) { + // With `reject: false` execa should not throw for runtime failures, but + // defensive: surface anything that slips through as a spawn-style error. + clearTimeout(timeoutId); + this.activeProcesses.delete(subprocess); + return this.finalizeSpawnError( + err instanceof Error ? err : new Error(String(err)), options ); - const collectors = this.createOutputCollectors(child); + } - // This ensures that we can cancel the timeout if the process ends early - const [timeoutPromise, cancelTimeout] = this.cancellableDelay( - options.timeout - ); - timeoutPromise - .then(() => { - this.handleTimeout( - child, - options, - result, - collectors, - resolve, - reject - ); - }) - .catch(err => { - reject(err instanceof Error ? err : new Error(String(err))); - }); + clearTimeout(timeoutId); + this.activeProcesses.delete(subprocess); + + if (process.platform === 'win32') { + // Give Windows a moment to release file handles before callers (e.g. checker) + // try to read redirected output files. + await delay(WINDOWS_HANDLE_RELEASE_DELAY_MS); + } - this.attachEventHandlers( - child, - options, - result, - collectors, - cancelTimeout, - resolve, - reject + const stdout = typeof raw.stdout === 'string' ? raw.stdout : ''; + const stderr = typeof raw.stderr === 'string' ? raw.stderr : ''; + const exitCode = raw.exitCode ?? 1; + const signal = (raw.signal ?? null) as NodeJS.Signals | null; + + if (didTimeout) { + return this.finalizeTimeout(stdout, options); + } + + // Spawn failure (ENOENT, EACCES, ...). execa marks `failed: true` and leaves + // `exitCode` undefined; the human-readable reason lives on `shortMessage`. + if (raw.failed && raw.exitCode === undefined) { + const errLike = raw as execa.ExecaError; + return this.finalizeSpawnError( + new Error(errLike.shortMessage || errLike.message || stderr), + options ); - }); + } + + const result: ExecutionResult = { + stdout, + stderr, + exitCode, + success: false, + timedOut: false, + memoryExceeded: false, + }; + + if (this.isMemoryError(exitCode, signal, stderr)) { + return this.finalizeMemoryError(result, exitCode, signal, options); + } + + if (exitCode === 0 && !raw.failed) { + return this.finalizeSuccess(result, options); + } + return this.finalizeError(result, options); } /** * Applies memory limit to a command using platform-specific methods. - * For Java programs, uses -Xmx flag. For Unix, uses ulimit. - * Windows memory limits are not fully supported. - * - * @private - * @param {string} command - Command to wrap with memory limit - * @param {number} [memoryLimitMB] - Memory limit in megabytes - * @returns {string} Command wrapped with memory limit enforcement - * - * @example - * // Java command - * applyMemoryLimit('java Solution', 256) - * // Returns: 'java -Xmx256m Solution' - * - * @example - * // C++ command on Unix - * applyMemoryLimit('./solution', 256) - * // Returns: '(ulimit -v 262144; ./solution)' + * For Java programs, uses `-Xmx` flag. For Unix, wraps in a `ulimit -v` subshell. + * Windows memory limits are not enforced. */ private applyMemoryLimit(command: string, memoryLimitMB?: number): string { if (!memoryLimitMB) return command; @@ -203,182 +213,48 @@ export class CommandExecutor { /** * Normalizes command syntax for the current platform. - * - On Windows, converts './foo' to '.\\foo' and forward slashes in the - * executable path to backslashes to avoid ERROR_PATH_NOT_FOUND (3). - * - Leaves arguments and redirections intact. - * - * @private - * @param {string} command - Command line string to normalize - * @returns {string} Normalized command string appropriate for the platform + * On Windows, converts `./foo` to `.\foo` and forward slashes in the executable + * path to backslashes to avoid `ERROR_PATH_NOT_FOUND (3)` from cmd.exe. + * Leaves arguments and redirections intact. */ private normalizeCommandForPlatform(command: string): string { if (process.platform !== 'win32') return command; - // Split out the first token (executable) from the rest while preserving redirections const match = command.match(/^(?:"([^"]+)"|([^\s<>|]+))(.*)$/); if (!match) return command; const executable = (match[1] ?? match[2] ?? '').trim(); const rest = match[3] ?? ''; - // Only adjust if the executable looks like a path (contains a slash or starts with ./) let normalizedExec = executable; if (executable.startsWith('./')) { normalizedExec = '.\\' + executable.slice(2); } if (/\//.test(normalizedExec)) { - // Replace forward slashes with backslashes in the executable part only normalizedExec = normalizedExec.replace(/\//g, '\\'); } - // Re-wrap in quotes if the original was quoted const wasQuoted = !!match[1]; const finalExec = wasQuoted ? `"${normalizedExec}"` : normalizedExec; return `${finalExec}${rest}`; } /** - * Spawns a child process for command execution. - * Creates a detached process in shell mode to allow process tree management. - * Registers process in active processes set for cleanup tracking. - * - * @private - * @param {string} command - Shell command to spawn - * @param {ExecutionOptions} options - Execution options including cwd - * @returns {{process: ChildProcess, result: ExecutionResult}} Spawned process and empty result object - * - * @example - * const { process, result } = this.spawnProcess('./solution', { timeout: 1000 }); + * Finalizes a timed-out execution: emits the warning, populates the result, + * and routes to the onTimeout callback or throws. */ - private spawnProcess(command: string, options: ExecutionOptions) { - const child = spawn(command, { - shell: true, - cwd: options.cwd, - // if on window, we cannot use detached processes properly - detached: process.platform !== 'win32', - }); - - this.activeProcesses.add(child); - - return { - process: child, - result: this.createEmptyResult(), - }; - } - - /** - * Creates an empty execution result object with default values. - * Used as initial state before process execution completes. - * - * @private - * @returns {ExecutionResult} Empty result with default values - * - * @example - * const result = this.createEmptyResult(); - * // Returns: { stdout: '', stderr: '', exitCode: 0, success: false, ... } - */ - private createEmptyResult(): ExecutionResult { - return { - stdout: '', - stderr: '', - exitCode: 0, - success: false, - timedOut: false, - memoryExceeded: false, - }; - } - - /** - * Creates output collectors for stdout and stderr streams. - * Attaches data event handlers to accumulate process output. - * Tracks whether the result has been resolved to prevent race conditions. - * - * @private - * @param {ChildProcess} child - Child process to collect output from - * @returns {{stdout: string, stderr: string, isResolved: boolean}} Output collectors object - * - * @example - * const collectors = this.createOutputCollectors(child); - * // collectors.stdout and collectors.stderr accumulate as process runs - */ - private createOutputCollectors(child: ChildProcess) { - const collectors = { stdout: '', stderr: '', isResolved: false }; - - child.stdout?.on('data', (data: Buffer) => { - collectors.stdout += data.toString(); - }); - - child.stderr?.on('data', (data: Buffer) => { - collectors.stderr += data.toString(); - }); - - return collectors; - } - /** - * Creates a cancellable delay promise for timeout implementation. - * Returns both the timeout promise and a cancel function. - * Cancel function clears the timeout to prevent unnecessary process kills. - * - * @private - * @param {number} ms - Delay duration in milliseconds - * @returns {[Promise, () => void]} Tuple of [timeout promise, cancel function] - * - * @example - * const [timeoutPromise, cancelTimeout] = this.cancellableDelay(1000); - * timeoutPromise.then(() => console.log('Timed out')); - * // Later, if process finishes early: - * cancelTimeout(); - */ - private cancellableDelay(ms: number): [Promise, () => void] { - let timeoutId: NodeJS.Timeout; - - const promise = new Promise(resolve => { - timeoutId = setTimeout(resolve, ms); - }); - - const cancel = () => { - clearTimeout(timeoutId); - }; - - return [promise, cancel]; - } - - /** - * Handles timeout scenario when process exceeds time limit. - * Kills the process tree, updates result with timeout status, and invokes callbacks. - * Uses SIGKILL after grace period to ensure process termination. - * - * @private - * @param {ChildProcess} child - Process that timed out - * @param {ExecutionOptions} options - Execution options with timeout callback - * @param {ExecutionResult} result - Result object to update - * @param {{stdout: string, stderr: string, isResolved: boolean}} collectors - Output collectors - * @param {(value: ExecutionResult) => void} resolve - Promise resolve function - * @param {(reason?: unknown) => void} reject - Promise reject function - * - * @example - * // Called internally when timeout occurs - * this.handleTimeout(child, options, result, collectors, resolve, reject); - */ - private handleTimeout( - child: ChildProcess, - options: ExecutionOptions, - result: ExecutionResult, - collectors: { stdout: string; stderr: string; isResolved: boolean }, - resolve: (value: ExecutionResult) => void, - reject: (reason?: unknown) => void - ) { - if (collectors.isResolved || child.killed) return; - - collectors.isResolved = true; - result.timedOut = true; - - Object.assign(result, { - stdout: collectors.stdout, + private async finalizeTimeout( + stdout: string, + options: ExecutionOptions + ): Promise { + const result: ExecutionResult = { + stdout, stderr: `Command timed out after ${options.timeout}ms`, exitCode: TIMEOUT_EXIT_CODE, success: false, - }); + timedOut: true, + memoryExceeded: false, + }; if (!options.silent) { fmt.warning( @@ -386,177 +262,22 @@ export class CommandExecutor { ); } - // Kill process and wait for cleanup before resolving - this.killProcessTree(child.pid!); - - const finalizeTimeout = () => { - child.kill('SIGSEGV'); - - // On Windows, wait for file handles to be released before resolving - const cleanupDelay = process.platform === 'win32' ? 1000 : 0; - - setTimeout(() => { - if (options.onTimeout) { - this.cleanup() - .then(() => { - try { - options.onTimeout!(result); - resolve(result); - } catch (error) { - reject( - error instanceof Error ? error : new Error(String(error)) - ); - } - }) - .catch(error => - reject(error instanceof Error ? error : new Error(String(error))) - ); - } else { - reject( - new Error(`Process killed after ${options.timeout}ms timeout`) - ); - } - }, cleanupDelay); - }; - - setTimeout(finalizeTimeout, KILL_GRACE_PERIOD_MS); - } - - /** - * Attaches event handlers for process lifecycle events (close, error). - * Handles process completion, cleanup, and error scenarios. - * Ensures timeout is cancelled when process ends naturally. - * - * @private - * @param {ChildProcess} child - Child process to attach handlers to - * @param {ExecutionOptions} options - Execution options - * @param {ExecutionResult} result - Result object to populate - * @param {{stdout: string, stderr: string, isResolved: boolean}} collectors - Output collectors - * @param {() => void} cancelTimeout - Function to cancel timeout - * @param {(value: ExecutionResult) => void} resolve - Promise resolve function - * @param {(reason?: unknown) => void} reject - Promise reject function - * - * @example - * // Called internally during execute() - * this.attachEventHandlers(child, options, result, collectors, cancelTimeout, resolve, reject); - */ - private attachEventHandlers( - child: ChildProcess, - options: ExecutionOptions, - result: ExecutionResult, - collectors: { stdout: string; stderr: string; isResolved: boolean }, - cancelTimeout: () => void, - resolve: (value: ExecutionResult) => void, - reject: (reason?: unknown) => void - ) { - child.on('close', (code, signal) => { - if (collectors.isResolved) return; - collectors.isResolved = true; - cancelTimeout(); - - // On Windows, add a small delay to ensure file handles are released - - const cleanup = () => { - this.activeProcesses.delete(child); - try { - this.handleProcessClose( - code, - signal, - options, - result, - collectors, - resolve, - reject - ); - } catch (error) { - reject(error instanceof Error ? error : new Error(String(error))); - } - }; - - if (process.platform === 'win32') { - setTimeout(cleanup, 50); - } else { - this.activeProcesses.delete(child); - cleanup(); - } - }); - - child.on('error', err => { - if (collectors.isResolved) return; - cancelTimeout(); - this.activeProcesses.delete(child); - collectors.isResolved = true; - try { - this.handleProcessError(err, options, result, resolve, reject); - } catch (error) { - reject(error instanceof Error ? error : new Error(String(error))); - } - }); - } - - /** - * Handles process close event and determines outcome. - * Routes to appropriate handler based on exit code and signals. - * Checks for memory errors, success (code 0), or general errors. - * - * @private - * @param {number | null} code - Process exit code - * @param {NodeJS.Signals | null} signal - Process termination signal - * @param {ExecutionOptions} options - Execution options - * @param {ExecutionResult} result - Result object to populate - * @param {{stdout: string, stderr: string}} collectors - Output collectors - * @param {(value: ExecutionResult) => void} resolve - Promise resolve function - * @param {(reason?: unknown) => void} reject - Promise reject function - * - * @example - * // Called internally by close event handler - * this.handleProcessClose(0, null, options, result, collectors, resolve, reject); - */ - private handleProcessClose( - code: number | null, - signal: NodeJS.Signals | null, - options: ExecutionOptions, - result: ExecutionResult, - collectors: { stdout: string; stderr: string }, - resolve: (value: ExecutionResult) => void, - reject: (reason?: unknown) => void - ) { - Object.assign(result, { - stdout: collectors.stdout, - stderr: collectors.stderr, - exitCode: code ?? 1, - }); - - if (this.isMemoryError(code, signal, collectors.stderr)) { - this.handleMemoryError(code, signal, options, result, resolve, reject); - return; + if (process.platform === 'win32') { + // Same Windows file-handle race as the success path. + await delay(WINDOWS_HANDLE_RELEASE_DELAY_MS); } - if (code === 0) { - this.handleSuccess(options, result, resolve, reject); - } else { - this.handleError(options, result, resolve, reject); + if (options.onTimeout) { + await this.cleanup(); + options.onTimeout(result); + return result; } + throw new Error(`Process killed after ${options.timeout}ms timeout`); } /** - * Detects if process failed due to memory issues. + * Detects if a process failed due to memory issues. * Checks both exit codes (137, SIGABRT) and error messages in stderr. - * Recognizes common memory error patterns across languages. - * - * @private - * @param {number | null} code - Process exit code - * @param {NodeJS.Signals | null} signal - Process termination signal - * @param {string} stderr - Standard error output - * @returns {boolean} True if memory error detected - * - * @example - * // Exit code 137 indicates OOM kill - * this.isMemoryError(137, null, '') // returns true - * - * @example - * // C++ bad_alloc exception - * this.isMemoryError(1, null, 'terminate called after throwing bad_alloc') // returns true */ private isMemoryError( code: number | null, @@ -564,9 +285,7 @@ export class CommandExecutor { stderr: string ): boolean { const hasMemoryExitCode = - code === OOM_KILL_EXIT_CODE || - // code === ABORT_EXIT_CODE || - signal === 'SIGABRT'; + code === OOM_KILL_EXIT_CODE || signal === 'SIGABRT'; const hasMemoryErrorMessage = stderr.includes('OutOfMemory') || @@ -578,30 +297,14 @@ export class CommandExecutor { } /** - * Handles memory limit exceeded scenario. - * Updates result with MLE status and invokes appropriate callback. - * Displays warning unless silent mode is enabled. - * - * @private - * @param {number | null} code - Process exit code - * @param {NodeJS.Signals | null} signal - Process termination signal - * @param {ExecutionOptions} options - Execution options with MLE callback - * @param {ExecutionResult} result - Result object to update - * @param {(value: ExecutionResult) => void} resolve - Promise resolve function - * @param {(reason?: unknown) => void} reject - Promise reject function - * - * @example - * // Called when OOM detected - * this.handleMemoryError(137, null, options, result, resolve, reject); + * Finalizes a memory-limit-exceeded execution. */ - private handleMemoryError( - code: number | null, - signal: NodeJS.Signals | null, - options: ExecutionOptions, + private finalizeMemoryError( result: ExecutionResult, - resolve: (value: ExecutionResult) => void, - reject: (reason?: unknown) => void - ) { + code: number, + signal: NodeJS.Signals | null, + options: ExecutionOptions + ): ExecutionResult { result.memoryExceeded = true; result.success = false; @@ -612,72 +315,38 @@ export class CommandExecutor { } if (options.onMemoryExceeded) { - try { - options.onMemoryExceeded(result); - resolve(result); - } catch (error) { - reject(error); - } - } else { - reject(new Error('Memory limit exceeded')); + options.onMemoryExceeded(result); + return result; } + throw new Error('Memory limit exceeded'); } /** - * Handles successful process execution (exit code 0). - * Updates result status, displays output unless silent, and invokes success callback. - * - * @private - * @param {ExecutionOptions} options - Execution options with success callback - * @param {ExecutionResult} result - Result object to update - * @param {(value: ExecutionResult) => void} resolve - Promise resolve function - * - * @example - * // Called when process exits with code 0 - * this.handleSuccess(options, result, resolve); + * Finalizes a successful execution (exit code 0). */ - private handleSuccess( - options: ExecutionOptions, + private finalizeSuccess( result: ExecutionResult, - resolve: (value: ExecutionResult) => void, - reject: (reason?: unknown) => void - ) { + options: ExecutionOptions + ): ExecutionResult { result.success = true; if (!options.silent && result.stdout) { fmt.dim(result.stdout.trim()); } - try { - if (options.onSuccess) { - options.onSuccess(result); - } - resolve(result); - } catch (error) { - reject(error instanceof Error ? error : new Error(String(error))); + if (options.onSuccess) { + options.onSuccess(result); } + return result; } /** - * Handles failed process execution (non-zero exit code). - * Updates result status, displays error unless silent, and invokes error callback or rejects. - * - * @private - * @param {ExecutionOptions} options - Execution options with error callback - * @param {ExecutionResult} result - Result object to update - * @param {(value: ExecutionResult) => void} resolve - Promise resolve function - * @param {(reason?: unknown) => void} reject - Promise reject function - * - * @example - * // Called when process exits with non-zero code - * this.handleError(options, result, resolve, reject); + * Finalizes a failed execution (non-zero exit code). */ - private handleError( - options: ExecutionOptions, + private finalizeError( result: ExecutionResult, - resolve: (value: ExecutionResult) => void, - reject: (reason?: unknown) => void - ) { + options: ExecutionOptions + ): ExecutionResult { result.success = false; if (!options.silent && result.stderr) { @@ -685,80 +354,45 @@ export class CommandExecutor { } if (options.onError) { - try { - options.onError(result); - resolve(result); - } catch (error) { - reject(error instanceof Error ? error : new Error(String(error))); - } - } else { - reject( - new Error( - `Command failed with exit code ${result.exitCode}\n${result.stderr}` - ) - ); + options.onError(result); + return result; } + throw new Error( + `Command failed with exit code ${result.exitCode}\n${result.stderr}` + ); } /** - * Handles process spawn errors (e.g., command not found, permission denied). - * Updates result with error information and invokes error callback or rejects. - * - * @private - * @param {Error} err - Error from process spawn - * @param {ExecutionOptions} options - Execution options with error callback - * @param {ExecutionResult} result - Result object to update - * @param {(value: ExecutionResult) => void} resolve - Promise resolve function - * @param {(reason?: unknown) => void} reject - Promise reject function - * - * @example - * // Called when spawn() throws error - * this.handleProcessError(new Error('ENOENT'), options, result, resolve, reject); + * Finalizes a spawn-time failure (ENOENT, EACCES, ...). */ - private handleProcessError( + private finalizeSpawnError( err: Error, - options: ExecutionOptions, - result: ExecutionResult, - resolve: (value: ExecutionResult) => void, - reject: (reason?: unknown) => void - ) { - result.success = false; - result.stderr = err.message; - result.exitCode = 1; + options: ExecutionOptions + ): ExecutionResult { + const result: ExecutionResult = { + stdout: '', + stderr: err.message, + exitCode: 1, + success: false, + timedOut: false, + memoryExceeded: false, + }; if (!options.silent) { fmt.error(`${fmt.cross()} ${err.message}`); } if (options.onError) { - try { - options.onError(result); - resolve(result); - } catch (error) { - reject(error instanceof Error ? error : new Error(String(error))); - } - } else { - reject(err); + options.onError(result); + return result; } + throw err; } /** * Kills a process and its entire child process tree. - * Uses platform-specific methods: taskkill on Windows, process groups on Unix. - * Attempts to kill process group first, falls back to single process. - * - * @private - * @param {number} pid - Process ID to kill - * - * @example - * // Kill process tree on Unix - * this.killProcessTree(12345); - * // Sends SIGKILL to -12345 (process group) - * - * @example - * // Kill process tree on Windows - * this.killProcessTree(12345); - * // Runs: taskkill /pid 12345 /T /F + * Uses `taskkill /T /F` on Windows, process-group SIGKILL on Unix. + * Falls back to single-process kill if the group call fails. */ private killProcessTree(pid: number) { try { @@ -769,9 +403,9 @@ export class CommandExecutor { }); } else { try { - process.kill(-pid, 'SIGSEGV'); + process.kill(-pid, 'SIGKILL'); } catch { - process.kill(pid, 'SIGSEGV'); + process.kill(pid, 'SIGKILL'); } } } catch (error: unknown) { @@ -784,22 +418,7 @@ export class CommandExecutor { /** * Execute a command with input/output file redirection. - * Wraps the execute method with automatic stdin/stdout redirection. - * - * @param {string} command - Shell command to execute - * @param {ExecutionOptions} options - Execution configuration - * @param {string} [inputFile] - Path to file for stdin redirection (optional) - * @param {string} [outputFile] - Path to file for stdout redirection (optional) - * @returns {Promise} Execution result - * - * @example - * // Run solution with test input and capture output - * await executor.executeWithRedirect( - * './solution', - * { timeout: 1000 }, - * 'tests/test1.txt', - * 'output.txt' - * ); + * Wraps the execute method with shell `<` / `>` redirections. */ async executeWithRedirect( command: string, @@ -817,21 +436,7 @@ export class CommandExecutor { /** * Builds a shell command with input/output redirection. - * Appends stdin (<) and stdout (>) redirections as needed. - * - * @private - * @param {string} command - Base command to execute - * @param {string} [inputFile] - Optional input file path for stdin - * @param {string} [outputFile] - Optional output file path for stdout - * @returns {string} Command with redirection operators - * - * @example - * buildRedirectedCommand('./solution', 'input.txt', 'output.txt') - * // Returns: './solution < input.txt > output.txt' - * - * @example - * buildRedirectedCommand('./solution', 'input.txt') - * // Returns: './solution < input.txt' + * Quotes paths to survive spaces and normalizes them for the current platform. */ private buildRedirectedCommand( command: string, @@ -839,7 +444,6 @@ export class CommandExecutor { outputFile?: string ): string { let result = command; - // Normalize file paths for the current platform and quote for spaces const normalizePathForPlatform = (p: string) => { if (process.platform !== 'win32') return p; let q = p; @@ -862,12 +466,6 @@ export class CommandExecutor { /** * Register a temporary file for tracking. - * Useful for cleanup operations after execution completes. - * - * @param {string} filePath - Path to the temporary file - * - * @example - * executor.registerTempFile('/tmp/test_output.txt'); */ registerTempFile(filePath: string) { this.tempFiles.push(filePath); @@ -875,23 +473,11 @@ export class CommandExecutor { /** * Clean up all active processes and clear temp file registry. - * Kills any running processes and resets internal state. - * Should be called when shutting down or after batch operations. - * On Windows, waits for file handles to be released. - * - * @returns {Promise} Resolves when cleanup is complete - * - * @example - * try { - * await runAllTests(); - * } finally { - * await executor.cleanup(); - * } + * On Windows, waits briefly for file handles to be released. */ async cleanup(): Promise { - // On Windows, wait for file handles to be released if (process.platform === 'win32' && this.activeProcesses.size > 0) { - await new Promise(resolve => setTimeout(resolve, 150)); + await delay(WINDOWS_HANDLE_RELEASE_DELAY_MS); } this.killAllActiveProcesses(); @@ -899,23 +485,12 @@ export class CommandExecutor { this.tempFiles = []; } - /** - * Kills all currently active processes tracked by the executor. - * Iterates through active processes set and kills each process tree. - * Used during cleanup or shutdown operations. - * - * @private - * - * @example - * // Called by cleanup() method - * this.killAllActiveProcesses(); - */ private killAllActiveProcesses() { - for (const process of this.activeProcesses) { - if (process.pid) { + for (const child of this.activeProcesses) { + if (child.pid !== undefined) { try { - this.killProcessTree(process.pid); - process.kill('SIGKILL'); + this.killProcessTree(child.pid); + child.kill('SIGKILL'); } catch { // Process already dead } @@ -925,38 +500,25 @@ export class CommandExecutor { /** * Get a copy of the registered temporary files list. - * - * @returns {string[]} Array of temporary file paths - * - * @example - * const tempFiles = executor.getTempFiles(); - * tempFiles.forEach(file => fs.unlinkSync(file)); */ getTempFiles(): string[] { return [...this.tempFiles]; } } +const delay = (ms: number) => + new Promise(resolve => setTimeout(resolve, ms)); + /** * Singleton instance of CommandExecutor. - * Import and use this instance throughout the application. - * * @constant * @type {CommandExecutor} - * @example - * import { executor } from './executor'; - * - * const result = await executor.execute('./solution', { - * timeout: 2000, - * memoryLimitMB: 256 - * }); */ export const executor = new CommandExecutor(); /** * Register signal handlers to ensure child processes are cleaned up * when the parent process is terminated (e.g., via Ctrl+C). - * This prevents orphan/zombie processes from lingering after exit. */ const setupProcessCleanup = () => { let isCleaningUp = false; @@ -971,32 +533,26 @@ const setupProcessCleanup = () => { // Ignore cleanup errors during exit } - // Re-emit the signal after cleanup so the process exits properly process.exit(signal === 'SIGINT' ? 130 : signal === 'SIGTERM' ? 143 : 1); }; - // Handle Ctrl+C process.on('SIGINT', () => { void cleanupAndExit('SIGINT'); }); - // Handle termination signal (e.g., from kill command) process.on('SIGTERM', () => { void cleanupAndExit('SIGTERM'); }); - // Handle uncaught exceptions - kill children before crashing process.on('uncaughtException', err => { console.error('Uncaught exception:', err); void cleanupAndExit('uncaughtException'); }); - // Handle unhandled promise rejections process.on('unhandledRejection', reason => { console.error('Unhandled rejection:', reason); void cleanupAndExit('unhandledRejection'); }); }; -// Initialize cleanup handlers setupProcessCleanup(); diff --git a/tests/executor.test.ts b/tests/executor.test.ts index 3965390..dff56db 100644 --- a/tests/executor.test.ts +++ b/tests/executor.test.ts @@ -1,18 +1,24 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { Mock, MockedFunction, MockInstance } from 'vitest'; -import { CommandExecutor } from '../src/executor'; -import type { ExecutionOptions, ExecutionResult } from '../src/executor'; import { spawn } from 'child_process'; -import type { ChildProcess } from 'child_process'; -import EventEmitter from 'events'; +import execa from 'execa'; +import { CommandExecutor } from '../src/executor'; +import type { ExecutionOptions } from '../src/executor'; import { fmt } from '../src/formatter'; -// Mock child_process +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock('execa', () => ({ + __esModule: true, + default: vi.fn(), +})); + vi.mock('child_process', () => ({ spawn: vi.fn(), })); -// Mock formatter to avoid console noise during tests vi.mock('../src/formatter', () => ({ fmt: { dim: vi.fn(), @@ -25,32 +31,88 @@ vi.mock('../src/formatter', () => ({ }, })); -/** - * Minimal typed mock of a {@link ChildProcess} suitable for the assertions in - * this suite. Only the surface area touched by `CommandExecutor` is modelled. - */ -interface MockChild extends EventEmitter { +// --------------------------------------------------------------------------- +// Mock subprocess: a then-able with `.pid` / `.kill()` matching the surface of +// an `execa.ExecaChildProcess` that the executor actually touches. +// --------------------------------------------------------------------------- + +interface MockExecaResult { + stdout: string; + stderr: string; + exitCode: number | undefined; + signal: string | undefined; + failed: boolean; + shortMessage?: string; + message?: string; + command: string; + escapedCommand: string; + killed: boolean; + isCanceled: boolean; + timedOut: boolean; +} + +interface MockSubprocess extends Promise { pid: number | undefined; - stdout: EventEmitter | undefined; - stderr: EventEmitter | undefined; kill: Mock; killed: boolean; + /** Resolve the underlying execa promise with the given (partial) result. */ + __resolveWith: (overrides?: Partial) => void; + __rejectWith: (err: unknown) => void; } +const baseResult = (over: Partial = {}): MockExecaResult => ({ + stdout: '', + stderr: '', + exitCode: 0, + signal: undefined, + failed: false, + command: '', + escapedCommand: '', + killed: false, + isCanceled: false, + timedOut: false, + ...over, +}); + /** - * Shape exposing the private members of {@link CommandExecutor} that the tests - * need to spy on. Casting through this interface keeps the type system honest - * without resorting to `any`. + * Default `kill` behavior auto-resolves the promise with a killed-by-signal + * result, mimicking what real execa does after the child is killed. Tests that + * want to inspect kill without auto-resolving can overwrite `sub.kill`. */ -interface ExecutorPrivate { - handleProcessClose: (...args: unknown[]) => unknown; - handleProcessError: (...args: unknown[]) => unknown; - cancellableDelay: (ms: number) => [Promise, () => void]; - activeProcesses: Set; -} +const createMockSubprocess = ( + pid: number | undefined = 12345 +): MockSubprocess => { + let resolveFn!: (r: MockExecaResult) => void; + let rejectFn!: (e: unknown) => void; + const promise = new Promise((resolve, reject) => { + resolveFn = resolve; + rejectFn = reject; + }) as MockSubprocess; + + promise.pid = pid; + promise.killed = false; + promise.kill = vi.fn((signal?: string) => { + promise.killed = true; + resolveFn( + baseResult({ + killed: true, + exitCode: undefined, + signal: signal ?? 'SIGTERM', + failed: true, + shortMessage: `Command was killed with ${signal ?? 'SIGTERM'}`, + }) + ); + }); + promise.__resolveWith = over => resolveFn(baseResult(over)); + promise.__rejectWith = rejectFn; + return promise; +}; + +// --------------------------------------------------------------------------- describe('CommandExecutor', () => { let executor: CommandExecutor; + let mockExeca: MockedFunction; let mockSpawn: MockedFunction; const originalPlatform = process.platform; @@ -58,80 +120,60 @@ describe('CommandExecutor', () => { beforeEach(() => { vi.clearAllMocks(); executor = new CommandExecutor(); + mockExeca = vi.mocked(execa); mockSpawn = vi.mocked(spawn); - // Default to linux for most tests to ensure stable baseline - Object.defineProperty(process, 'platform', { - value: 'linux', - }); + Object.defineProperty(process, 'platform', { value: 'linux' }); vi.spyOn(process, 'kill').mockImplementation(() => true); }); afterEach(() => { - Object.defineProperty(process, 'platform', { - value: originalPlatform, - }); + Object.defineProperty(process, 'platform', { value: originalPlatform }); }); - // Helper to create a mock child process - const createMockChild = (pid: number | undefined = 12345): MockChild => { - const child = new EventEmitter() as MockChild; - child.pid = pid; - child.stdout = new EventEmitter(); - child.stderr = new EventEmitter(); - child.kill = vi.fn(); - child.killed = false; - return child; + /** Prime the next execa() call to return the given mock subprocess. */ + const primeExeca = (sub: MockSubprocess) => { + (mockExeca as unknown as Mock).mockReturnValueOnce(sub); }; - /** - * Convenience cast: the spawn mock returns a `ChildProcess`, but in tests we - * feed it our `MockChild`. This helper performs the unavoidable widening in - * one place so callers stay readable. - */ - const asChildProcess = (child: MockChild): ChildProcess => - child as unknown as ChildProcess; - - const setSpawnReturn = (child: MockChild) => { - mockSpawn.mockReturnValue(asChildProcess(child)); - }; + // ------------------------------------------------------------------------- + // execute() — happy path & basic error routing + // ------------------------------------------------------------------------- describe('execute', () => { - it('should execute command successfully (exit code 0)', async () => { - const mockChild = createMockChild(); - setSpawnReturn(mockChild); + it('returns a successful result on exit code 0', async () => { + const sub = createMockSubprocess(); + primeExeca(sub); const promise = executor.execute('echo success', { timeout: 1000 }); - - // Emit some output - mockChild.stdout?.emit('data', Buffer.from('output line 1\n')); - mockChild.stdout?.emit('data', Buffer.from('output line 2')); - - // Emit close - mockChild.emit('close', 0, null); + sub.__resolveWith({ stdout: 'output line 1\noutput line 2' }); const result = await promise; - expect(result.success).toBe(true); expect(result.exitCode).toBe(0); expect(result.stdout).toBe('output line 1\noutput line 2'); - expect(mockSpawn).toHaveBeenCalledWith('echo success', expect.anything()); + expect(mockExeca).toHaveBeenCalledWith( + 'echo success', + expect.objectContaining({ shell: true }) + ); }); - it('should handle command failure (non-zero exit code)', async () => { - const mockChild = createMockChild(); - setSpawnReturn(mockChild); + it('throws on non-zero exit when no onError callback is given', async () => { + const sub = createMockSubprocess(); + primeExeca(sub); const promise = executor.execute('badcommand', { timeout: 1000 }); - - mockChild.stderr?.emit('data', Buffer.from('command not found')); - mockChild.emit('close', 1, null); + sub.__resolveWith({ + exitCode: 1, + failed: true, + stderr: 'command not found', + }); await expect(promise).rejects.toThrow('Command failed with exit code 1'); }); - it('should call onError callback on failure if provided', async () => { - const mockChild = createMockChild(); - setSpawnReturn(mockChild); + it('routes failure through onError when provided', async () => { + const sub = createMockSubprocess(); + primeExeca(sub); const onError: MockedFunction> = vi.fn(); @@ -139,33 +181,36 @@ describe('CommandExecutor', () => { timeout: 1000, onError, }); - - mockChild.stderr?.emit('data', Buffer.from('error details')); - mockChild.emit('close', 127, null); + sub.__resolveWith({ + exitCode: 127, + failed: true, + stderr: 'error details', + }); const result = await promise; - expect(result.success).toBe(false); expect(result.exitCode).toBe(127); expect(onError).toHaveBeenCalledWith(result); }); - it('should handle process error event (spawn failure)', async () => { - const mockChild = createMockChild(); - // Ensure we don't resolve from close before error - setSpawnReturn(mockChild); + it('throws on spawn failure (failed + undefined exit) when no onError', async () => { + const sub = createMockSubprocess(); + primeExeca(sub); const promise = executor.execute('fail_spawn', { timeout: 1000 }); - - const spawnError = new Error('spawn ENOENT'); - mockChild.emit('error', spawnError); + sub.__resolveWith({ + failed: true, + exitCode: undefined, + shortMessage: 'spawn ENOENT', + stderr: 'spawn ENOENT', + }); await expect(promise).rejects.toThrow('spawn ENOENT'); }); - it('should use onError for spawn failure if provided', async () => { - const mockChild = createMockChild(); - setSpawnReturn(mockChild); + it('routes spawn failure through onError when provided', async () => { + const sub = createMockSubprocess(); + primeExeca(sub); const onError: MockedFunction> = vi.fn(); @@ -173,33 +218,36 @@ describe('CommandExecutor', () => { timeout: 1000, onError, }); - - mockChild.emit('error', new Error('spawn failed')); + sub.__resolveWith({ + failed: true, + exitCode: undefined, + shortMessage: 'spawn ENOENT', + stderr: '', + }); const result = await promise; expect(result.success).toBe(false); expect(onError).toHaveBeenCalled(); }); - it('should handle silent mode', async () => { - const mockChild = createMockChild(); - setSpawnReturn(mockChild); + it('runs in silent mode without throwing on success', async () => { + const sub = createMockSubprocess(); + primeExeca(sub); + const dimMock = vi.spyOn(fmt, 'dim'); const promise = executor.execute('quiet', { timeout: 1000, silent: true, }); - - mockChild.emit('close', 0, null); + sub.__resolveWith({ stdout: 'should not be printed' }); await promise; - // Since we mocked formatter, we can't easily check if it wasn't called without spying on the mock module itself - // But assuming the code logic uses `if (!options.silent) fmt...`, coverage will verify the branch. + expect(dimMock).not.toHaveBeenCalled(); }); - it('should call onSuccess callback', async () => { - const mockChild = createMockChild(); - setSpawnReturn(mockChild); + it('invokes onSuccess on exit 0', async () => { + const sub = createMockSubprocess(); + primeExeca(sub); const onSuccess: MockedFunction< NonNullable > = vi.fn(); @@ -208,8 +256,7 @@ describe('CommandExecutor', () => { timeout: 1000, onSuccess, }); - - mockChild.emit('close', 0, null); + sub.__resolveWith({ exitCode: 0 }); const result = await promise; expect(result.success).toBe(true); @@ -217,31 +264,33 @@ describe('CommandExecutor', () => { }); }); - describe('Timeout Handling', () => { - it('should reject with error when timeout occurs and no callback provided', async () => { + // ------------------------------------------------------------------------- + // Timeout handling + // ------------------------------------------------------------------------- + + describe('Timeout handling', () => { + it('throws when the timeout fires and no onTimeout is provided', async () => { vi.useFakeTimers(); - const mockChild = createMockChild(); - setSpawnReturn(mockChild); + const sub = createMockSubprocess(); + primeExeca(sub); const promise = executor.execute('sleep 10', { timeout: 100 }); - - // Expect rejection concurrently with timer advancement - const testPromise = expect(promise).rejects.toThrow( + const assertion = expect(promise).rejects.toThrow( 'Process killed after 100ms timeout' ); - // Advance time to trigger timeout - await vi.advanceTimersByTimeAsync(1000); - - await testPromise; + // Advance past the timeout. Default `kill` auto-resolves the subprocess + // so the awaited execa promise unblocks. + await vi.advanceTimersByTimeAsync(200); + await assertion; vi.useRealTimers(); }); - it('should call onTimeout callback and cleanup', async () => { + it('invokes onTimeout and returns a timed-out result', async () => { vi.useFakeTimers(); - const mockChild = createMockChild(); - setSpawnReturn(mockChild); + const sub = createMockSubprocess(); + primeExeca(sub); const onTimeout: MockedFunction< NonNullable > = vi.fn(); @@ -250,11 +299,9 @@ describe('CommandExecutor', () => { timeout: 100, onTimeout, }); + const advance = vi.advanceTimersByTimeAsync(500); - // Advance time and wait for promise - const advancePromise = vi.advanceTimersByTimeAsync(1000); - - const [result] = await Promise.all([promise, advancePromise]); + const [result] = await Promise.all([promise, advance]); expect(result.timedOut).toBe(true); expect(result.success).toBe(false); @@ -263,16 +310,13 @@ describe('CommandExecutor', () => { vi.useRealTimers(); }); - it('should resolve normally if process finishes before timeout', async () => { + it('resolves normally if the process finishes before the timeout', async () => { vi.useFakeTimers(); - const mockChild = createMockChild(); - setSpawnReturn(mockChild); + const sub = createMockSubprocess(); + primeExeca(sub); const promise = executor.execute('quick', { timeout: 5000 }); - - // Finish quickly - mockChild.emit('close', 0, null); - + sub.__resolveWith({ exitCode: 0 }); await vi.runAllTimersAsync(); const result = await promise; @@ -282,10 +326,14 @@ describe('CommandExecutor', () => { }); }); - describe('Memory Limit Handling', () => { - it('should detect memory limit exceeded via exit code 137', async () => { - const mockChild = createMockChild(); - setSpawnReturn(mockChild); + // ------------------------------------------------------------------------- + // Memory limit / MLE detection + // ------------------------------------------------------------------------- + + describe('Memory limit handling', () => { + it('detects MLE via exit code 137', async () => { + const sub = createMockSubprocess(); + primeExeca(sub); const onMemoryExceeded: MockedFunction< NonNullable > = vi.fn(); @@ -294,8 +342,7 @@ describe('CommandExecutor', () => { timeout: 1000, onMemoryExceeded, }); - - mockChild.emit('close', 137, null); + sub.__resolveWith({ exitCode: 137, failed: true }); const result = await promise; expect(result.memoryExceeded).toBe(true); @@ -303,9 +350,9 @@ describe('CommandExecutor', () => { expect(onMemoryExceeded).toHaveBeenCalled(); }); - it('should detect memory limit exceeded via stderr message', async () => { - const mockChild = createMockChild(); - setSpawnReturn(mockChild); + it('detects MLE via bad_alloc in stderr', async () => { + const sub = createMockSubprocess(); + primeExeca(sub); const onMemoryExceeded: MockedFunction< NonNullable > = vi.fn(); @@ -314,104 +361,126 @@ describe('CommandExecutor', () => { timeout: 1000, onMemoryExceeded, }); - - mockChild.stderr?.emit( - 'data', - Buffer.from('terminate called after throwing bad_alloc') - ); - mockChild.emit('close', 1, null); + sub.__resolveWith({ + exitCode: 1, + failed: true, + stderr: 'terminate called after throwing bad_alloc', + }); const result = await promise; expect(result.memoryExceeded).toBe(true); expect(onMemoryExceeded).toHaveBeenCalled(); }); - it('should throw error if memory exceeded and no callback provided', async () => { - const mockChild = createMockChild(); - setSpawnReturn(mockChild); + it('detects MLE via Python MemoryError', async () => { + const sub = createMockSubprocess(); + primeExeca(sub); + const onMemoryExceeded: MockedFunction< + NonNullable + > = vi.fn(); - const promise = executor.execute('oom_no_cb', { timeout: 1000 }); + const promise = executor.execute('py_oom', { + timeout: 1000, + onMemoryExceeded, + }); + sub.__resolveWith({ + exitCode: 1, + failed: true, + stderr: 'MemoryError: Out of memory', + }); - mockChild.emit('close', 137, null); + await promise; + expect(onMemoryExceeded).toHaveBeenCalled(); + }); + + it('throws when MLE detected and no callback provided', async () => { + const sub = createMockSubprocess(); + primeExeca(sub); + + const promise = executor.execute('oom_no_cb', { timeout: 1000 }); + sub.__resolveWith({ exitCode: 137, failed: true }); await expect(promise).rejects.toThrow('Memory limit exceeded'); }); }); - describe('Platform Specifics', () => { + // ------------------------------------------------------------------------- + // Platform specifics + // ------------------------------------------------------------------------- + + describe('Platform specifics', () => { describe('Windows', () => { beforeEach(() => { Object.defineProperty(process, 'platform', { value: 'win32' }); }); - it('should normalize paths in command', async () => { + it('normalizes ./ paths to .\\', async () => { vi.useFakeTimers(); - const mockChild = createMockChild(); - setSpawnReturn(mockChild); + const sub = createMockSubprocess(); + primeExeca(sub); const promise = executor.execute('./my-prog/bin', { timeout: 1000 }); - mockChild.emit('close', 0); - - await vi.advanceTimersByTimeAsync(100); + sub.__resolveWith({ exitCode: 0 }); + // file-handle release delay + await vi.advanceTimersByTimeAsync(200); await promise; - expect(mockSpawn).toHaveBeenCalledWith( + expect(mockExeca).toHaveBeenCalledWith( expect.stringContaining('.\\my-prog\\bin'), expect.anything() ); vi.useRealTimers(); }); - it('should quote executables with spaces if needed', async () => { + it('normalizes inner forward slashes in the executable', async () => { vi.useFakeTimers(); - const mockChild = createMockChild(); - setSpawnReturn(mockChild); + const sub = createMockSubprocess(); + primeExeca(sub); const promise = executor.execute('bin/executable arg1', { timeout: 1000, }); - mockChild.emit('close', 0); - await vi.advanceTimersByTimeAsync(100); + sub.__resolveWith({ exitCode: 0 }); + await vi.advanceTimersByTimeAsync(200); await promise; - expect(mockSpawn).toHaveBeenCalledWith( + expect(mockExeca).toHaveBeenCalledWith( expect.stringContaining('bin\\executable arg1'), expect.anything() ); vi.useRealTimers(); }); - it('should set detached: false for spawned process', async () => { + it('passes detached: false to execa', async () => { vi.useFakeTimers(); - const mockChild = createMockChild(); - setSpawnReturn(mockChild); + const sub = createMockSubprocess(); + primeExeca(sub); const promise = executor.execute('cmd', { timeout: 1000 }); - mockChild.emit('close', 0); - await vi.advanceTimersByTimeAsync(100); + sub.__resolveWith({ exitCode: 0 }); + await vi.advanceTimersByTimeAsync(200); await promise; - expect(mockSpawn).toHaveBeenCalledWith( + expect(mockExeca).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ detached: false }) ); vi.useRealTimers(); }); - it('should use taskkill for killing process tree', async () => { + it('uses taskkill /T /F to kill the tree on timeout', async () => { vi.useFakeTimers(); - const mockChild = createMockChild(9999); - setSpawnReturn(mockChild); + const sub = createMockSubprocess(9999); + primeExeca(sub); const promise = executor.execute('long_run', { timeout: 100, onTimeout: () => {}, }); - - await vi.advanceTimersByTimeAsync(1500); + await vi.advanceTimersByTimeAsync(500); await promise; - expect(mockSpawn).toHaveBeenLastCalledWith( + expect(mockSpawn).toHaveBeenCalledWith( 'taskkill', expect.arrayContaining(['/pid', '9999', '/T', '/F']), expect.anything() @@ -419,84 +488,81 @@ describe('CommandExecutor', () => { vi.useRealTimers(); }); - it('should wait for file handles during cleanup (delay check)', async () => { + it('waits for the file-handle release delay during cleanup', async () => { vi.useFakeTimers(); + const sub = createMockSubprocess(); + primeExeca(sub); - const mockChild = createMockChild(); - setSpawnReturn(mockChild); - void executor.execute('cmd', { timeout: 50 }); + // Start a long-running process; it stays in activeProcesses. + void executor.execute('cmd', { timeout: 5000 }).catch(() => {}); const cleanupPromise = executor.cleanup(); - - // Should be waiting 150ms await vi.advanceTimersByTimeAsync(200); await cleanupPromise; vi.useRealTimers(); }); - it('should ignore memory limit on Windows (line 193)', async () => { - const mockChild = createMockChild(); - setSpawnReturn(mockChild); + it('does not wrap the command with ulimit / -Xmx on Windows', async () => { + const sub = createMockSubprocess(); + primeExeca(sub); - // Pass memory limit, expecting it to be ignored in the command const promise = executor.execute('cmd', { timeout: 1000, memoryLimitMB: 256, }); - mockChild.emit('close', 0, null); - + sub.__resolveWith({ exitCode: 0 }); + // Need to flush the post-resolve windows file-handle delay. + vi.useFakeTimers(); + await vi.advanceTimersByTimeAsync(200); await promise; + vi.useRealTimers(); - expect(mockSpawn).toHaveBeenCalledWith('cmd', expect.anything()); - expect(mockSpawn).not.toHaveBeenCalledWith( - expect.stringContaining('ulimit'), - expect.anything() - ); - expect(mockSpawn).not.toHaveBeenCalledWith( - expect.stringContaining('Xmx'), - expect.anything() - ); + const cmd = mockExeca.mock.calls[0]?.[0]; + expect(cmd).toBe('cmd'); + expect(cmd).not.toContain('ulimit'); + expect(cmd).not.toContain('Xmx'); }); }); describe('Linux/Unix', () => { - beforeEach(() => { - Object.defineProperty(process, 'platform', { value: 'linux' }); - }); - - it('should use ulimit for memory limits', () => { - const mockChild = createMockChild(); - setSpawnReturn(mockChild); + it('wraps the command in `ulimit -v` for memory limits', async () => { + const sub = createMockSubprocess(); + primeExeca(sub); - void executor.execute('./prog', { timeout: 1000, memoryLimitMB: 128 }); - mockChild.emit('close', 0); + const promise = executor.execute('./prog', { + timeout: 1000, + memoryLimitMB: 128, + }); + sub.__resolveWith({ exitCode: 0 }); + await promise; - expect(mockSpawn).toHaveBeenCalledWith( + expect(mockExeca).toHaveBeenCalledWith( expect.stringContaining('ulimit -v 131072; ./prog'), expect.anything() ); }); - it('should use -Xmx for Java memory limits', () => { - const mockChild = createMockChild(); - setSpawnReturn(mockChild); + it('rewrites Java commands with -Xmx', async () => { + const sub = createMockSubprocess(); + primeExeca(sub); - void executor.execute('java Main', { + const promise = executor.execute('java Main', { timeout: 1000, memoryLimitMB: 256, }); - mockChild.emit('close', 0); + sub.__resolveWith({ exitCode: 0 }); + await promise; - expect(mockSpawn).toHaveBeenCalledWith( + expect(mockExeca).toHaveBeenCalledWith( expect.stringContaining('java -Xmx256m Main'), expect.anything() ); }); - it('should kill process group (negative pid)', async () => { + it('kills the process group with negative PID + SIGKILL on timeout', async () => { vi.useFakeTimers(); - const mockChild = createMockChild(5555); - setSpawnReturn(mockChild); + const sub = createMockSubprocess(5555); + primeExeca(sub); const killSpy: MockInstance = vi .spyOn(process, 'kill') @@ -506,25 +572,23 @@ describe('CommandExecutor', () => { timeout: 100, onTimeout: () => {}, }); - // Timeout (100) + Grace (100) + Buffer - await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(500); await promise; - expect(killSpy).toHaveBeenCalledWith(-5555, 'SIGSEGV'); + expect(killSpy).toHaveBeenCalledWith(-5555, 'SIGKILL'); vi.useRealTimers(); }); - it('should handle errors during process killing (ESRCH)', async () => { + it('swallows ESRCH errors from process.kill', async () => { vi.useFakeTimers(); - const mockChild = createMockChild(5555); - setSpawnReturn(mockChild); + const sub = createMockSubprocess(5555); + primeExeca(sub); - const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { + vi.spyOn(process, 'kill').mockImplementation(() => { const err = new Error('ESRCH') as Error & { code?: string }; err.code = 'ESRCH'; throw err; }); - const consoleSpy = vi .spyOn(console, 'log') .mockImplementation(() => {}); @@ -533,24 +597,22 @@ describe('CommandExecutor', () => { timeout: 100, onTimeout: () => {}, }); - await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(500); await promise; - expect(killSpy).toHaveBeenCalled(); - expect(consoleSpy).not.toHaveBeenCalled(); // Should be swallowed - + expect(consoleSpy).not.toHaveBeenCalled(); vi.useRealTimers(); }); - it('should log non-ESRCH errors during process killing', async () => { + + it('logs non-ESRCH kill errors', async () => { vi.useFakeTimers(); - const mockChild = createMockChild(5555); - setSpawnReturn(mockChild); + const sub = createMockSubprocess(5555); + primeExeca(sub); - const error = new Error('Unexpected Error'); - const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { - throw error; + const killErr = new Error('Unexpected Error'); + vi.spyOn(process, 'kill').mockImplementation(() => { + throw killErr; }); - const consoleSpy = vi .spyOn(console, 'log') .mockImplementation(() => {}); @@ -559,514 +621,359 @@ describe('CommandExecutor', () => { timeout: 100, onTimeout: () => {}, }); - await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(500); await promise; - expect(killSpy).toHaveBeenCalled(); - expect(consoleSpy).toHaveBeenCalledWith(error); - + expect(consoleSpy).toHaveBeenCalledWith(killErr); vi.useRealTimers(); }); }); }); - describe('Edge Case Error Handling', () => { - it('should reject if onTimeout callback throws', async () => { + // ------------------------------------------------------------------------- + // Callback edge cases + // ------------------------------------------------------------------------- + + describe('Callback edge cases', () => { + it('propagates onTimeout callback errors', async () => { vi.useFakeTimers(); - const mockChild = createMockChild(); - setSpawnReturn(mockChild); + const sub = createMockSubprocess(); + primeExeca(sub); - const error = new Error('Callback Error'); + const cbErr = new Error('Callback Error'); const promise = executor.execute('run', { timeout: 100, onTimeout: () => { - throw error; + throw cbErr; }, }); + const assertion = expect(promise).rejects.toThrow('Callback Error'); + await vi.advanceTimersByTimeAsync(500); + await assertion; - const testPromise = expect(promise).rejects.toThrow('Callback Error'); - await vi.advanceTimersByTimeAsync(1000); - await testPromise; vi.useRealTimers(); }); - it('should reject if onMemoryExceeded callback throws', async () => { - const mockChild = createMockChild(); - setSpawnReturn(mockChild); + it('propagates onMemoryExceeded callback errors', async () => { + const sub = createMockSubprocess(); + primeExeca(sub); - const error = new Error('MLE Callback Error'); + const cbErr = new Error('MLE Callback Error'); const promise = executor.execute('run', { timeout: 1000, onMemoryExceeded: () => { - throw error; + throw cbErr; }, }); - - mockChild.emit('close', 137, null); + sub.__resolveWith({ exitCode: 137, failed: true }); await expect(promise).rejects.toThrow('MLE Callback Error'); }); - it('should handle error inside handleProcessClose (line 474)', async () => { - const mockChild = createMockChild(); - setSpawnReturn(mockChild); - - // Spy on private method via the typed ExecutorPrivate cast - const error = new Error('Internal Close Error'); - vi.spyOn( - executor as unknown as ExecutorPrivate, - 'handleProcessClose' - ).mockImplementation(() => { - throw error; - }); + it('propagates onError callback errors', async () => { + const sub = createMockSubprocess(); + primeExeca(sub); - const promise = executor.execute('run', { timeout: 1000 }); - mockChild.emit('close', 0, null); + const cbErr = new Error('onError Boom'); + const promise = executor.execute('fail', { + timeout: 1000, + onError: () => { + throw cbErr; + }, + }); + sub.__resolveWith({ exitCode: 1, failed: true, stderr: 'oops' }); - await expect(promise).rejects.toThrow('Internal Close Error'); + await expect(promise).rejects.toThrow('onError Boom'); }); + }); - it('should handle error inside handleProcessError (line 494)', async () => { - const mockChild = createMockChild(); - // Ensure expect matches return value before emitting error - setSpawnReturn(mockChild); - - const error = new Error('Internal Error Error'); - vi.spyOn( - executor as unknown as ExecutorPrivate, - 'handleProcessError' - ).mockImplementation(() => { - throw error; - }); + // ------------------------------------------------------------------------- + // Redirection & utilities + // ------------------------------------------------------------------------- - const promise = executor.execute('run', { timeout: 1000 }); - mockChild.emit('error', new Error('Spawn Failed')); + describe('Redirection & utilities', () => { + it('builds a redirected command with input and output files', async () => { + const sub = createMockSubprocess(); + primeExeca(sub); + + const promise = executor.executeWithRedirect( + './prog', + { timeout: 1000 }, + 'in.txt', + 'out.txt' + ); + sub.__resolveWith({ exitCode: 0 }); + await promise; - await expect(promise).rejects.toThrow('Internal Error Error'); + const cmd = mockExeca.mock.calls[0]?.[0]; + expect(cmd).toContain('< "in.txt"'); + expect(cmd).toContain('> "out.txt"'); }); - it('should reject if cleanup fails during timeout (line 414)', async () => { + it('normalizes redirected paths for Windows', async () => { vi.useFakeTimers(); - const mockChild = createMockChild(); - setSpawnReturn(mockChild); - - const error = new Error('Cleanup Error'); - vi.spyOn(executor, 'cleanup').mockRejectedValue(error); - - const promise = executor.execute('run', { - timeout: 100, - onTimeout: () => {}, - }); + Object.defineProperty(process, 'platform', { value: 'win32' }); + const sub = createMockSubprocess(); + primeExeca(sub); - const testPromise = expect(promise).rejects.toThrow('Cleanup Error'); - await vi.advanceTimersByTimeAsync(1000); - await testPromise; + const promise = executor.executeWithRedirect( + './prog', + { timeout: 1000 }, + './folder/in.txt', + 'out.txt' + ); + sub.__resolveWith({ exitCode: 0 }); + await vi.advanceTimersByTimeAsync(200); + await promise; + const cmd = mockExeca.mock.calls[0]?.[0]; + expect(cmd).toContain('< ".\\folder\\in.txt"'); vi.useRealTimers(); }); - it('should handle error inside timeout handling (line 151)', async () => { + it('handles Windows redirection with parent-dir paths', async () => { vi.useFakeTimers(); - const mockChild = createMockChild(); - setSpawnReturn(mockChild); - - const error = new Error('Timeout Handling Error'); - // Spy on cancellableDelay to return a promise that rejects - vi.spyOn( - executor as unknown as ExecutorPrivate, - 'cancellableDelay' - ).mockReturnValue([Promise.reject(error), () => {}]); + Object.defineProperty(process, 'platform', { value: 'win32' }); + const sub = createMockSubprocess(); + primeExeca(sub); - const promise = executor.execute('run', { timeout: 100 }); + const promise = executor.executeWithRedirect( + 'cmd', + { timeout: 1000 }, + '../input.txt', + '../output.txt' + ); + sub.__resolveWith({ exitCode: 0 }); + await vi.advanceTimersByTimeAsync(200); + await promise; - await expect(promise).rejects.toThrow('Timeout Handling Error'); + const cmd = mockExeca.mock.calls[0]?.[0]; + expect(cmd).toContain('..\\input.txt'); + expect(cmd).toContain('..\\output.txt'); vi.useRealTimers(); }); - }); - describe('Redirection & Utils', () => { - it('should build redirected command correctly', async () => { - const mockChild = createMockChild(); - setSpawnReturn(mockChild); + it('builds a partial redirection (input only)', async () => { + const sub = createMockSubprocess(); + primeExeca(sub); const promise = executor.executeWithRedirect( - './prog', + 'cmd', { timeout: 1000 }, - 'in.txt', - 'out.txt' + 'in.txt' ); - - mockChild.emit('close', 0); + sub.__resolveWith({ exitCode: 0 }); await promise; - const calledCommand = mockSpawn.mock.calls[0]?.[0]; - expect(calledCommand).toContain('< "in.txt"'); - expect(calledCommand).toContain('> "out.txt"'); + const cmd = mockExeca.mock.calls[0]?.[0]; + expect(cmd).toContain('< "in.txt"'); + expect(cmd).not.toContain('>'); }); - it('should normalize paths in redirection for Windows', async () => { - vi.useFakeTimers(); - Object.defineProperty(process, 'platform', { value: 'win32' }); - const mockChild = createMockChild(); - setSpawnReturn(mockChild); + it('builds a partial redirection (output only)', async () => { + const sub = createMockSubprocess(); + primeExeca(sub); const promise = executor.executeWithRedirect( - './prog', + 'cmd', { timeout: 1000 }, - './folder/in.txt', + undefined, 'out.txt' ); - - mockChild.emit('close', 0); - // Advance for Windows close delay - await vi.advanceTimersByTimeAsync(100); + sub.__resolveWith({ exitCode: 0 }); await promise; - const calledCommand = mockSpawn.mock.calls[0]?.[0]; - expect(calledCommand).toContain('< ".\\folder\\in.txt"'); - Object.defineProperty(process, 'platform', { value: 'linux' }); - vi.useRealTimers(); + const cmd = mockExeca.mock.calls[0]?.[0]; + expect(cmd).toContain('> "out.txt"'); + expect(cmd).not.toContain('<'); }); - it('should register and retrieve temp files', () => { + it('registers and retrieves temp files', () => { executor.registerTempFile('tmp1'); executor.registerTempFile('tmp2'); expect(executor.getTempFiles()).toEqual(['tmp1', 'tmp2']); }); - it('should cleanup temp files and processes', async () => { - const mockChild = createMockChild(); - setSpawnReturn(mockChild); + it('cleans up temp files and kills active processes', async () => { + const sub = createMockSubprocess(); + primeExeca(sub); - // Start a process - void executor.execute('run', { timeout: 10000 }); + void executor.execute('run', { timeout: 10000 }).catch(() => {}); executor.registerTempFile('somefile'); - - // Cleanup await executor.cleanup(); - expect(mockChild.kill).toHaveBeenCalledWith('SIGKILL'); + expect(sub.kill).toHaveBeenCalledWith('SIGKILL'); expect(executor.getTempFiles()).toEqual([]); }); - }); - describe('Coverage Improvements', () => { - it('should ignore close event if already resolved (e.g. after timeout)', async () => { - vi.useFakeTimers(); - const mockChild = createMockChild(); - setSpawnReturn(mockChild); - const onTimeout: MockedFunction< - NonNullable - > = vi.fn(); - - const promise = executor.execute('slow', { timeout: 100, onTimeout }); - - // Trigger timeout - await vi.advanceTimersByTimeAsync(1000); - - // Now trigger close - should be ignored - const handleCloseSpy = vi.spyOn( - executor as unknown as ExecutorPrivate, - 'handleProcessClose' - ); - mockChild.emit('close', 0, null); - - await promise; - - expect(onTimeout).toHaveBeenCalled(); - expect(handleCloseSpy).not.toHaveBeenCalled(); + it('tolerates kill() throwing during cleanup', async () => { + const sub = createMockSubprocess(); + sub.kill = vi.fn(() => { + throw new Error('Process dead'); + }); + primeExeca(sub); - vi.useRealTimers(); + void executor.execute('run', { timeout: 10000 }).catch(() => {}); + await expect(executor.cleanup()).resolves.toBeUndefined(); + expect(sub.kill).toHaveBeenCalledWith('SIGKILL'); }); - it('should ignore error event if already resolved', async () => { - vi.useFakeTimers(); - const mockChild = createMockChild(); - setSpawnReturn(mockChild); + it('skips kill() during cleanup when pid is undefined', async () => { + const sub = createMockSubprocess(); + sub.pid = undefined; + primeExeca(sub); - const promise = executor.execute('slow', { - timeout: 100, - onTimeout: vi.fn(), - }); + void executor.execute('run', { timeout: 10000 }).catch(() => {}); + await executor.cleanup(); + expect(sub.kill).not.toHaveBeenCalled(); + }); + }); - await vi.advanceTimersByTimeAsync(1000); + // ------------------------------------------------------------------------- + // Output / silent / misc + // ------------------------------------------------------------------------- - // Trigger error - should be ignored - const handleErrorSpy = vi.spyOn( - executor as unknown as ExecutorPrivate, - 'handleProcessError' - ); - mockChild.emit('error', new Error('Late Error')); + describe('Output & misc', () => { + it('prints stdout via fmt.dim on success when not silent', async () => { + const sub = createMockSubprocess(); + primeExeca(sub); + const dimMock = vi.spyOn(fmt, 'dim'); + const promise = executor.execute('ok', { timeout: 1000 }); + sub.__resolveWith({ stdout: 'Output' }); await promise; - expect(handleErrorSpy).not.toHaveBeenCalled(); - vi.useRealTimers(); + expect(dimMock).toHaveBeenCalledWith('Output'); }); - it('should handle silent mode for memory errors', async () => { - const mockChild = createMockChild(); - setSpawnReturn(mockChild); + it('does not print warnings on MLE in silent mode', async () => { + const sub = createMockSubprocess(); + primeExeca(sub); + const warningMock = vi.spyOn(fmt, 'warning'); const onMemoryExceeded: MockedFunction< NonNullable > = vi.fn(); - const warningMock = vi.spyOn(fmt, 'warning'); - const promise = executor.execute('oom', { timeout: 1000, silent: true, onMemoryExceeded, }); - - mockChild.emit('close', 137, null); + sub.__resolveWith({ exitCode: 137, failed: true }); await promise; expect(onMemoryExceeded).toHaveBeenCalled(); expect(warningMock).not.toHaveBeenCalled(); }); - it('should handle silent mode for process errors', async () => { - const mockChild = createMockChild(); - setSpawnReturn(mockChild); - + it('does not print errors on spawn failure in silent mode', async () => { + const sub = createMockSubprocess(); + primeExeca(sub); const errorMock = vi.spyOn(fmt, 'error'); const promise = executor.execute('fail', { timeout: 1000, silent: true, }); - - mockChild.emit('error', new Error('Spawn fail')); + sub.__resolveWith({ + failed: true, + exitCode: undefined, + shortMessage: 'spawn failed', + }); await expect(promise).rejects.toThrow(); expect(errorMock).not.toHaveBeenCalled(); }); - it('should handle Windows redirection with parent directory (../)', async () => { - Object.defineProperty(process, 'platform', { value: 'win32' }); - const mockChild = createMockChild(); - setSpawnReturn(mockChild); - - const promise = executor.executeWithRedirect( - 'cmd', - { timeout: 1000 }, - '../input.txt', - '../output.txt' - ); - mockChild.emit('close', 0); - await promise; - - const cmd = mockSpawn.mock.calls[0]?.[0]; - expect(cmd).toContain('..\\input.txt'); - expect(cmd).toContain('..\\output.txt'); - - Object.defineProperty(process, 'platform', { value: 'linux' }); - }); - - it('should handle killing process that is already dead in cleanup', async () => { - const mockChild = createMockChild(); - mockChild.kill.mockImplementation(() => { - throw new Error('Process dead'); - }); - setSpawnReturn(mockChild); - - void executor.execute('run', { timeout: 1000 }); - - // Should not throw - await executor.cleanup(); - expect(mockChild.kill).toHaveBeenCalledWith('SIGKILL'); - }); - - it('should handle handleProcessClose without silence and with success', async () => { - const mockChild = createMockChild(); - setSpawnReturn(mockChild); - const dimMock = vi.spyOn(fmt, 'dim'); - - const promise = executor.execute('ok', { timeout: 1000, silent: false }); - - // Emit data after listener attached - mockChild.stdout?.emit('data', 'Output'); - mockChild.emit('close', 0); - - await promise; - expect(dimMock).toHaveBeenCalledWith('Output'); - }); - - it('should handle memory error message explicitly', async () => { - const mockChild = createMockChild(); - setSpawnReturn(mockChild); - const onMemoryExceeded: MockedFunction< - NonNullable - > = vi.fn(); - - const promise = executor.execute('oom', { - timeout: 1000, - onMemoryExceeded, - }); - - mockChild.stderr?.emit('data', 'MemoryError: Out of memory'); - mockChild.emit('close', 1); - - await promise; - expect(onMemoryExceeded).toHaveBeenCalled(); - }); - - it('should normalize executable path starting with ./ on Windows', async () => { + it('normalizes ./solution to .\\solution on Windows (single token)', async () => { + vi.useFakeTimers(); Object.defineProperty(process, 'platform', { value: 'win32' }); - const mockChild = createMockChild(); - setSpawnReturn(mockChild); + const sub = createMockSubprocess(); + primeExeca(sub); const promise = executor.execute('./solution', { timeout: 1000 }); - mockChild.emit('close', 0); + sub.__resolveWith({ exitCode: 0 }); + await vi.advanceTimersByTimeAsync(200); await promise; - const cmd = mockSpawn.mock.calls[0]?.[0]; - expect(cmd).toEqual('.\\solution'); - - Object.defineProperty(process, 'platform', { value: 'linux' }); + expect(mockExeca.mock.calls[0]?.[0]).toBe('.\\solution'); + vi.useRealTimers(); }); - it('should normalize executable path containing / on Windows', async () => { + it('normalizes bin/solution to bin\\solution on Windows', async () => { + vi.useFakeTimers(); Object.defineProperty(process, 'platform', { value: 'win32' }); - const mockChild = createMockChild(); - setSpawnReturn(mockChild); + const sub = createMockSubprocess(); + primeExeca(sub); const promise = executor.execute('bin/solution', { timeout: 1000 }); - mockChild.emit('close', 0); + sub.__resolveWith({ exitCode: 0 }); + await vi.advanceTimersByTimeAsync(200); await promise; - const cmd = mockSpawn.mock.calls[0]?.[0]; - expect(cmd).toEqual('bin\\solution'); - - Object.defineProperty(process, 'platform', { value: 'linux' }); - }); - - it('should handle timeout without onTimeout callback (fallback path)', async () => { - vi.useFakeTimers(); - const mockChild = createMockChild(); - setSpawnReturn(mockChild); - - const promise = executor.execute('sleep', { timeout: 100 }); - - const testPromise = expect(promise).rejects.toThrow( - 'Process killed after 100ms timeout' - ); - await vi.advanceTimersByTimeAsync(1000); - - await testPromise; + expect(mockExeca.mock.calls[0]?.[0]).toBe('bin\\solution'); vi.useRealTimers(); }); - it('should handle partial redirection (input only)', async () => { - const mockChild = createMockChild(); - setSpawnReturn(mockChild); - const promise = executor.executeWithRedirect( - 'cmd', - { timeout: 1000 }, - 'in.txt' - ); - mockChild.emit('close', 0); - await promise; - const cmd = mockSpawn.mock.calls[0]?.[0]; - expect(cmd).toContain('< "in.txt"'); - expect(cmd).not.toContain('>'); - }); - it('should handle partial redirection (output only)', async () => { - const mockChild = createMockChild(); - setSpawnReturn(mockChild); - const promise = executor.executeWithRedirect( - 'cmd', - { timeout: 1000 }, - undefined, - 'out.txt' - ); - mockChild.emit('close', 0); - await promise; - const cmd = mockSpawn.mock.calls[0]?.[0]; - expect(cmd).toContain('> "out.txt"'); - expect(cmd).not.toContain('<'); - }); + it('treats undefined exitCode without failed-flag as a generic error', async () => { + const sub = createMockSubprocess(); + primeExeca(sub); - it('should default exit code to 1 if null (signal termination)', async () => { - const mockChild = createMockChild(); - setSpawnReturn(mockChild); - // Expect rejection because exit code 1 means failure const promise = executor.execute('run', { timeout: 1000 }); - mockChild.emit('close', null, 'SIGTERM'); + // failed: false + exitCode: undefined → defaults to exitCode 1, error path. + sub.__resolveWith({ exitCode: undefined, failed: false }); - await expect(promise).rejects.toThrow(); + await expect(promise).rejects.toThrow('Command failed with exit code 1'); }); - it('should handle non-Error rejection in timeout cleanup', async () => { - vi.useFakeTimers(); - const mockChild = createMockChild(); - setSpawnReturn(mockChild); - - vi.spyOn(executor, 'cleanup').mockRejectedValue('String Error'); + it('handles execa rejecting (defensive spawn-error path)', async () => { + const sub = createMockSubprocess(); + primeExeca(sub); - const promise = executor.execute('run', { - timeout: 100, - onTimeout: vi.fn(), - }); - const testPromise = expect(promise).rejects.toThrow('String Error'); + const promise = executor.execute('run', { timeout: 1000 }); + sub.__rejectWith(new Error('Unexpected execa rejection')); - await vi.advanceTimersByTimeAsync(1000); - await testPromise; - vi.useRealTimers(); + await expect(promise).rejects.toThrow('Unexpected execa rejection'); }); - it('should handle active process with no pid in cleanup', async () => { - const mockChild = createMockChild(); - mockChild.pid = undefined; - // Manually add to set - (executor as unknown as ExecutorPrivate).activeProcesses.add( - asChildProcess(mockChild) - ); + it('runs an empty command string', async () => { + const sub = createMockSubprocess(); + primeExeca(sub); - await executor.cleanup(); - // Should not throw and not call kill - expect(mockChild.kill).not.toHaveBeenCalled(); - }); + const promise = executor.execute('', { timeout: 1000 }); + sub.__resolveWith({ exitCode: 0 }); + await promise; - it('should ignore timeout if child is already killed', async () => { - vi.useFakeTimers(); - const mockChild = createMockChild(); - mockChild.killed = true; - setSpawnReturn(mockChild); - const onTimeout: MockedFunction< - NonNullable - > = vi.fn(); + expect(mockExeca).toHaveBeenCalledWith('', expect.anything()); + }); - const promise = executor.execute('run', { timeout: 100, onTimeout }); - await vi.advanceTimersByTimeAsync(1000); + it('passes cwd through to execa when provided', async () => { + const sub = createMockSubprocess(); + primeExeca(sub); - expect(onTimeout).not.toHaveBeenCalled(); - // Manually resolve promise to finish test - mockChild.emit('close', 0); + const promise = executor.execute('run', { + timeout: 1000, + cwd: '/tmp/run', + }); + sub.__resolveWith({ exitCode: 0 }); await promise; - vi.useRealTimers(); + + expect(mockExeca).toHaveBeenCalledWith( + 'run', + expect.objectContaining({ cwd: '/tmp/run' }) + ); }); - it('should handle spawn with missing stdout/stderr', async () => { - const mockChild = createMockChild(); - mockChild.stdout = undefined; - mockChild.stderr = undefined; - setSpawnReturn(mockChild); + it('omits cwd from execa options when not provided', async () => { + const sub = createMockSubprocess(); + primeExeca(sub); const promise = executor.execute('run', { timeout: 1000 }); - mockChild.emit('close', 0); - const result: ExecutionResult = await promise; - expect(result.stdout).toBe(''); - }); - - it('should handle empty command string', async () => { - const mockChild = createMockChild(); - setSpawnReturn(mockChild); - const promise = executor.execute('', { timeout: 1000 }); - mockChild.emit('close', 0); + sub.__resolveWith({ exitCode: 0 }); await promise; - expect(mockSpawn).toHaveBeenCalledWith('', expect.anything()); + + const opts = mockExeca.mock.calls[0]?.[1] as Record; + expect(opts.cwd).toBeUndefined(); }); }); });