-
Notifications
You must be signed in to change notification settings - Fork 4
test: surface diagnostics when a completion-time command times out #141
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,11 +21,21 @@ let KUBO_API_PORT: number; | |
| let GATEWAY_PORT: number; | ||
| let rpcWsUrl: string; | ||
|
|
||
| // Generic subprocess runner with timeout | ||
| // Cap diagnostic dumps so a timeout error stays readable | ||
| const tailOf = (text: string, maxChars = 8_000): string => | ||
| text.length > maxChars ? `...(truncated)...${text.slice(-maxChars)}` : text; | ||
|
|
||
| // Generic subprocess runner with timeout. | ||
| // | ||
| // The vitest per-test timeouts below are intentionally LARGER than timeoutMs: | ||
| // this rejection (which carries the child's stdout/stderr and any extra | ||
| // diagnostics) must fire before vitest's generic "Test timed out", otherwise a | ||
| // hang in CI produces no information at all (issue #140). | ||
| const runBitsocialCommand = ( | ||
| args: string[], | ||
| env?: Record<string, string>, | ||
| timeoutMs = 10_000 | ||
| timeoutMs = 10_000, | ||
| collectTimeoutDiagnostics?: () => Promise<string> | ||
| ): Promise<{ stdout: string; stderr: string; exitCode: number | null }> => { | ||
| return new Promise((resolve, reject) => { | ||
| const proc = spawn("node", ["./bin/run", ...args], { | ||
|
|
@@ -41,11 +51,24 @@ const runBitsocialCommand = ( | |
| proc.stderr.on("data", (data: Buffer) => { | ||
| stderr += data.toString(); | ||
| }); | ||
| const timer = setTimeout(() => { | ||
| // Once the timeout fires, the SIGKILL below triggers "close" while we | ||
| // are still awaiting the diagnostics collection — the close handler | ||
| // must not resolve then, or the informative rejection would be lost. | ||
| let timedOut = false; | ||
| const timer = setTimeout(async () => { | ||
| timedOut = true; | ||
| proc.kill("SIGKILL"); | ||
| reject(new Error(`Command timed out after ${timeoutMs}ms: bitsocial ${args.join(" ")}\nstdout: ${stdout}\nstderr: ${stderr}`)); | ||
| let extraDiagnostics = ""; | ||
| if (collectTimeoutDiagnostics) | ||
| extraDiagnostics = await collectTimeoutDiagnostics().catch((e) => `failed to collect diagnostics: ${e}`); | ||
| reject( | ||
| new Error( | ||
| `Command timed out after ${timeoutMs}ms: bitsocial ${args.join(" ")}\nstdout: ${tailOf(stdout)}\nstderr: ${tailOf(stderr)}\n${extraDiagnostics}` | ||
| ) | ||
| ); | ||
| }, timeoutMs); | ||
| proc.on("close", (exitCode) => { | ||
| if (timedOut) return; | ||
|
Comment on lines
+57
to
+71
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- repository convention files ---'
find /tmp/coderabbit-repo-knowledge/bitsocialnet-bitsocial-cli-892ed36d/bitsocialnet-bitsocial-cli -type f -name '*.md' -maxdepth 3 -print 2>/dev/null || true
printf '%s\n' '--- learnings and architecture files ---'
find /tmp/coderabbit-repo-knowledge/bitsocialnet-bitsocial-cli-892ed36d/{learnings,architecture} -type f -name '*.md' -print 2>/dev/null || true
printf '%s\n' '--- target file outline and size ---'
wc -l test/cli/command-completion-time.test.ts
ast-grep outline test/cli/command-completion-time.test.ts 2>/dev/null || true
printf '%s\n' '--- target file ---'
cat -n test/cli/command-completion-time.test.ts
printf '%s\n' '--- related timeout symbols and tests ---'
rg -n --glob '*.{ts,tsx}' 'runBitsocialCommand|collectDaemonDiagnostics|timedOut|collectTimeoutDiagnostics|Command timed out|timeoutMs' test src . 2>/dev/null | head -240Repository: bitsocialnet/bitsocial-cli Length of output: 35148 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- repository convention files ---'
find /tmp/coderabbit-repo-knowledge/bitsocialnet-bitsocial-cli-892ed36d -type f -name '*.md' -print
printf '%s\n' '--- target file ---'
wc -l test/cli/command-completion-time.test.ts
cat -n test/cli/command-completion-time.test.ts
printf '%s\n' '--- related timeout symbols and tests ---'
rg -n --glob '*.{ts,tsx}' 'runBitsocialCommand|collectDaemonDiagnostics|timedOut|collectTimeoutDiagnostics|Command timed out|timeoutMs' test src . 2>/dev/null | head -240Repository: bitsocialnet/bitsocial-cli Length of output: 34903 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- repository-wide convention ---'
cat /tmp/coderabbit-repo-knowledge/bitsocialnet-bitsocial-cli-892ed36d/conventions/repo-wide.md
printf '%s\n' '--- test learning ---'
cat /tmp/coderabbit-repo-knowledge/bitsocialnet-bitsocial-cli-892ed36d/learnings/test.md
printf '%s\n' '--- package scripts and TypeScript test configuration ---'
cat package.json
find config -maxdepth 2 -type f -print -exec sh -c 'echo "--- $1"; cat "$1"' sh {} \; 2>/dev/null || true
printf '%s\n' '--- CLI entrypoint outline and relevant command definitions ---'
ast-grep outline bin/run 2>/dev/null || true
rg -n --glob '*.{js,ts,tsx}' 'setInterval|setTimeout|process\.stdin|process\.on|community|challenge|unknown command|command not found' bin src | head -240Repository: bitsocialnet/bitsocial-cli Length of output: 37789 🏁 Script executed: #!/bin/bash
set -eu
cat /tmp/coderabbit-repo-knowledge/bitsocialnet-bitsocial-cli-892ed36d/conventions/repo-wide.md
cat /tmp/coderabbit-repo-knowledge/bitsocialnet-bitsocial-cli-892ed36d/learnings/test.md
cat package.json
find config -maxdepth 2 -type f -print -exec sh -c 'echo "--- $1"; cat "$1"' sh {} \; 2>/dev/null || true
ast-grep outline bin/run 2>/dev/null || true
rg -n --glob '*.{js,ts,tsx}' 'setInterval|setTimeout|process\.stdin|process\.on|community|challenge|unknown command|command not found' bin src | head -240Repository: bitsocialnet/bitsocial-cli Length of output: 37610 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- target-file diff stat ---'
git diff --stat -- test/cli/command-completion-time.test.ts
printf '%s\n' '--- target-file diff ---'
git diff --unified=8 -- test/cli/command-completion-time.test.ts | sed -n '1,180p'
printf '%s\n' '--- target-file status ---'
git status --short -- test/cli/command-completion-time.test.tsRepository: bitsocialnet/bitsocial-cli Length of output: 246 🏁 Script executed: #!/bin/bash
set -eu
git diff --stat -- test/cli/command-completion-time.test.ts
git diff --unified=8 -- test/cli/command-completion-time.test.ts | sed -n '1,180p'
git status --short -- test/cli/command-completion-time.test.tsRepository: bitsocialnet/bitsocial-cli Length of output: 164 Add a timeout-race regression test. Add focused coverage for 🧰 Tools🪛 ast-grep (0.45.2)[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec. (detect-child-process-typescript) 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| clearTimeout(timer); | ||
| resolve({ stdout, stderr, exitCode }); | ||
| }); | ||
|
|
@@ -82,6 +105,27 @@ describe("CLI commands complete within 10s (real pkc instance)", () => { | |
| let stateHome: string; | ||
| let logDir: string; | ||
|
|
||
| // Daemon-side context for command timeouts: the daemon's captured output | ||
| // plus the tail of its log files, which otherwise live in a temp dir CI | ||
| // never surfaces (issue #140). | ||
| const collectDaemonDiagnostics = async (): Promise<string> => { | ||
| const parts: string[] = [ | ||
| `daemon stdout (tail): ${tailOf(daemonProcess?.capturedStdout ?? "<none>")}`, | ||
| `daemon stderr (tail): ${tailOf(daemonProcess?.capturedStderr ?? "<none>")}` | ||
| ]; | ||
| try { | ||
| const logFiles = (await fsPromise.readdir(logDir)).filter((f) => f.endsWith(".log")); | ||
| for (const logFile of logFiles) { | ||
| const content = await fsPromise.readFile(path.join(logDir, logFile), "utf8"); | ||
| parts.push(`daemon log ${logFile} (tail): ${tailOf(content)}`); | ||
| } | ||
| if (logFiles.length === 0) parts.push(`daemon log dir ${logDir} contains no .log files`); | ||
| } catch (e) { | ||
| parts.push(`failed to read daemon log dir ${logDir}: ${e}`); | ||
| } | ||
| return parts.join("\n"); | ||
| }; | ||
|
|
||
| beforeAll(async () => { | ||
| stateHome = randomDirectory(); | ||
| logDir = path.join(stateHome, "bitsocial"); | ||
|
|
@@ -119,93 +163,124 @@ describe("CLI commands complete within 10s (real pkc instance)", () => { | |
| ]); | ||
| }, 60_000); | ||
|
|
||
| it("community create completes within 10s", { timeout: 10_000 }, async () => { | ||
| it("community create completes within 10s", { timeout: 30_000 }, async () => { | ||
| const result = await runBitsocialCommand( | ||
| ["community", "create", "--description", "test community", "--pkcRpcUrl", rpcWsUrl] | ||
| ["community", "create", "--description", "test community", "--pkcRpcUrl", rpcWsUrl], | ||
| undefined, | ||
| 10_000, | ||
| collectDaemonDiagnostics | ||
| ); | ||
| expect(result.exitCode, `stderr: ${result.stderr}\nstdout: ${result.stdout}`).toBe(0); | ||
| communityAddress = result.stdout.trim(); | ||
| expect(communityAddress.length).toBeGreaterThan(0); | ||
| }); | ||
|
|
||
| it("community list -q completes within 10s", { timeout: 10_000 }, async () => { | ||
| it("community list -q completes within 10s", { timeout: 30_000 }, async () => { | ||
| const result = await runBitsocialCommand( | ||
| ["community", "list", "-q", "--pkcRpcUrl", rpcWsUrl] | ||
| ["community", "list", "-q", "--pkcRpcUrl", rpcWsUrl], | ||
| undefined, | ||
| 10_000, | ||
| collectDaemonDiagnostics | ||
| ); | ||
| expect(result.exitCode, `stderr: ${result.stderr}\nstdout: ${result.stdout}`).toBe(0); | ||
| expect(result.stdout).toContain(communityAddress); | ||
| }); | ||
|
|
||
| it("community list (table) completes within 10s", { timeout: 10_000 }, async () => { | ||
| it("community list (table) completes within 10s", { timeout: 30_000 }, async () => { | ||
| const result = await runBitsocialCommand( | ||
| ["community", "list", "--pkcRpcUrl", rpcWsUrl] | ||
| ["community", "list", "--pkcRpcUrl", rpcWsUrl], | ||
| undefined, | ||
| 10_000, | ||
| collectDaemonDiagnostics | ||
| ); | ||
| expect(result.exitCode, `stderr: ${result.stderr}\nstdout: ${result.stdout}`).toBe(0); | ||
| expect(result.stdout).toContain(communityAddress); | ||
| }); | ||
|
|
||
| it("community get completes within 10s", { timeout: 10_000 }, async () => { | ||
| it("community get completes within 10s", { timeout: 30_000 }, async () => { | ||
| const result = await runBitsocialCommand( | ||
| ["community", "get", communityAddress, "--pkcRpcUrl", rpcWsUrl] | ||
| ["community", "get", communityAddress, "--pkcRpcUrl", rpcWsUrl], | ||
| undefined, | ||
| 10_000, | ||
| collectDaemonDiagnostics | ||
| ); | ||
| expect(result.exitCode, `stderr: ${result.stderr}\nstdout: ${result.stdout}`).toBe(0); | ||
| const json = JSON.parse(result.stdout); | ||
| expect(json).toHaveProperty("address"); | ||
| }); | ||
|
|
||
| it("community edit completes within 10s", { timeout: 10_000 }, async () => { | ||
| it("community edit completes within 10s", { timeout: 30_000 }, async () => { | ||
| const result = await runBitsocialCommand( | ||
| ["community", "edit", communityAddress, "--title", "new title", "--pkcRpcUrl", rpcWsUrl] | ||
| ["community", "edit", communityAddress, "--title", "new title", "--pkcRpcUrl", rpcWsUrl], | ||
| undefined, | ||
| 10_000, | ||
| collectDaemonDiagnostics | ||
| ); | ||
| expect(result.exitCode, `stderr: ${result.stderr}\nstdout: ${result.stdout}`).toBe(0); | ||
| expect(result.stdout.trim()).toBe(communityAddress); | ||
| }); | ||
|
|
||
| it("community stop completes within 10s", { timeout: 10_000 }, async () => { | ||
| it("community stop completes within 10s", { timeout: 30_000 }, async () => { | ||
| const result = await runBitsocialCommand( | ||
| ["community", "stop", communityAddress, "--pkcRpcUrl", rpcWsUrl] | ||
| ["community", "stop", communityAddress, "--pkcRpcUrl", rpcWsUrl], | ||
| undefined, | ||
| 10_000, | ||
| collectDaemonDiagnostics | ||
| ); | ||
| expect(result.exitCode, `stderr: ${result.stderr}\nstdout: ${result.stdout}`).toBe(0); | ||
| expect(result.stdout.trim()).toBe(communityAddress); | ||
| }); | ||
|
|
||
| it("community start completes within 10s", { timeout: 10_000 }, async () => { | ||
| it("community start completes within 10s", { timeout: 30_000 }, async () => { | ||
| const result = await runBitsocialCommand( | ||
| ["community", "start", communityAddress, "--pkcRpcUrl", rpcWsUrl] | ||
| ["community", "start", communityAddress, "--pkcRpcUrl", rpcWsUrl], | ||
| undefined, | ||
| 10_000, | ||
| collectDaemonDiagnostics | ||
| ); | ||
| expect(result.exitCode, `stderr: ${result.stderr}\nstdout: ${result.stdout}`).toBe(0); | ||
| expect(result.stdout.trim()).toBe(communityAddress); | ||
| }); | ||
|
|
||
| it("community stop (before delete) completes within 10s", { timeout: 10_000 }, async () => { | ||
| it("community stop (before delete) completes within 10s", { timeout: 30_000 }, async () => { | ||
| const result = await runBitsocialCommand( | ||
| ["community", "stop", communityAddress, "--pkcRpcUrl", rpcWsUrl] | ||
| ["community", "stop", communityAddress, "--pkcRpcUrl", rpcWsUrl], | ||
| undefined, | ||
| 10_000, | ||
| collectDaemonDiagnostics | ||
| ); | ||
| expect(result.exitCode, `stderr: ${result.stderr}\nstdout: ${result.stdout}`).toBe(0); | ||
| expect(result.stdout.trim()).toBe(communityAddress); | ||
| }); | ||
|
|
||
| it("community delete completes within 30s", { timeout: 30_000 }, async () => { | ||
| it("community delete completes within 30s", { timeout: 60_000 }, async () => { | ||
| const result = await runBitsocialCommand( | ||
| ["community", "delete", communityAddress, "--pkcRpcUrl", rpcWsUrl], | ||
| undefined, | ||
| 30_000 | ||
| 30_000, | ||
| collectDaemonDiagnostics | ||
| ); | ||
| expect(result.exitCode, `stderr: ${result.stderr}\nstdout: ${result.stdout}`).toBe(0); | ||
| expect(result.stdout.trim()).toBe(communityAddress); | ||
| }); | ||
|
|
||
| it("community list -q shows no communities after delete", { timeout: 10_000 }, async () => { | ||
| it("community list -q shows no communities after delete", { timeout: 30_000 }, async () => { | ||
| const result = await runBitsocialCommand( | ||
| ["community", "list", "-q", "--pkcRpcUrl", rpcWsUrl] | ||
| ["community", "list", "-q", "--pkcRpcUrl", rpcWsUrl], | ||
| undefined, | ||
| 10_000, | ||
| collectDaemonDiagnostics | ||
| ); | ||
| expect(result.exitCode, `stderr: ${result.stderr}\nstdout: ${result.stdout}`).toBe(0); | ||
| expect(result.stdout.trim()).not.toContain(communityAddress); | ||
| }); | ||
|
|
||
| it("logs --tail 1 completes within 10s", { timeout: 10_000 }, async () => { | ||
| it("logs --tail 1 completes within 10s", { timeout: 30_000 }, async () => { | ||
| const result = await runBitsocialCommand( | ||
| ["logs", "--tail", "1", "--logPath", logDir] | ||
| ["logs", "--tail", "1", "--logPath", logDir], | ||
| undefined, | ||
| 10_000, | ||
| collectDaemonDiagnostics | ||
| ); | ||
| expect(result.exitCode, `stderr: ${result.stderr}\nstdout: ${result.stdout}`).toBe(0); | ||
| expect(result.stdout.length).toBeGreaterThan(0); | ||
|
|
@@ -226,39 +301,39 @@ describe("challenge commands complete within 10s", () => { | |
| dataPath = randomDirectory(); | ||
| }); | ||
|
|
||
| it("challenge list (empty) completes within 10s", { timeout: 10_000 }, async () => { | ||
| it("challenge list (empty) completes within 10s", { timeout: 30_000 }, async () => { | ||
| const result = await runBitsocialCommand( | ||
| ["challenge", "list", "--pkcOptions.dataPath", dataPath] | ||
| ); | ||
| expect(result.exitCode, `stderr: ${result.stderr}\nstdout: ${result.stdout}`).toBe(0); | ||
| expect(result.stdout).toContain("No challenge packages installed"); | ||
| }); | ||
|
|
||
| it("challenge install completes within 10s", { timeout: 10_000 }, async () => { | ||
| it("challenge install completes within 10s", { timeout: 30_000 }, async () => { | ||
| const result = await runBitsocialCommand( | ||
| ["challenge", "install", challengeSrcDir, "--pkcOptions.dataPath", dataPath] | ||
| ); | ||
| expect(result.exitCode, `stderr: ${result.stderr}\nstdout: ${result.stdout}`).toBe(0); | ||
| expect(result.stdout).toContain("added test-challenge@1.0.0 in"); | ||
| }); | ||
|
|
||
| it("challenge list (after install) completes within 10s", { timeout: 10_000 }, async () => { | ||
| it("challenge list (after install) completes within 10s", { timeout: 30_000 }, async () => { | ||
| const result = await runBitsocialCommand( | ||
| ["challenge", "list", "--pkcOptions.dataPath", dataPath] | ||
| ); | ||
| expect(result.exitCode, `stderr: ${result.stderr}\nstdout: ${result.stdout}`).toBe(0); | ||
| expect(result.stdout).toContain("test-challenge"); | ||
| }); | ||
|
|
||
| it("challenge remove completes within 10s", { timeout: 10_000 }, async () => { | ||
| it("challenge remove completes within 10s", { timeout: 30_000 }, async () => { | ||
| const result = await runBitsocialCommand( | ||
| ["challenge", "remove", "test-challenge", "--pkcOptions.dataPath", dataPath] | ||
| ); | ||
| expect(result.exitCode, `stderr: ${result.stderr}\nstdout: ${result.stdout}`).toBe(0); | ||
| expect(result.stdout).toContain("removed test-challenge@1.0.0"); | ||
| }); | ||
|
|
||
| it("challenge list (after remove) completes within 10s", { timeout: 10_000 }, async () => { | ||
| it("challenge list (after remove) completes within 10s", { timeout: 30_000 }, async () => { | ||
| const result = await runBitsocialCommand( | ||
| ["challenge", "list", "--pkcOptions.dataPath", dataPath] | ||
| ); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
Bound timeout diagnostics before rejecting.
Line 63 waits for diagnostics before
runBitsocialCommandrejects.collectDaemonDiagnosticsreads each complete log file at Line 119 and truncates it only afterward. A large or slow log can therefore delay the rejection until Vitest’s 30-second or 60-second timeout fires, losing the intended diagnostics. Read only a bounded tail and add a bounded fallback before awaiting diagnostics.Also applies to: 119-119
🧰 Tools
🪛 ast-grep (0.45.2)
[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { spawn } from "child_process";
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
(detect-child-process-typescript)
🤖 Prompt for AI Agents