From bd18aebca66c4d74a69cceceab0a6fb7f06f7397 Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Tue, 30 Jun 2026 12:36:15 +0200 Subject: [PATCH] debug: run opencode prompt session in Windows PTY --- .github/workflows/test.yml | 2 +- script/repro-windows-opentui-crash.ps1 | 25 +- script/repro-windows-opentui-pty-session.mjs | 239 +++++++++++++++++++ 3 files changed, 258 insertions(+), 8 deletions(-) create mode 100644 script/repro-windows-opentui-pty-session.mjs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fcacd79fa9..5f107b569d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -181,5 +181,5 @@ jobs: node-version: "24.11.1" - name: Run Windows standalone repro - timeout-minutes: 12 + timeout-minutes: 15 run: ./script/repro-windows-opentui-crash.ps1 -Version "${{ matrix.version }}" diff --git a/script/repro-windows-opentui-crash.ps1 b/script/repro-windows-opentui-crash.ps1 index 0e365f9552..4b34a0004d 100644 --- a/script/repro-windows-opentui-crash.ps1 +++ b/script/repro-windows-opentui-crash.ps1 @@ -1,9 +1,10 @@ param( [Parameter(Mandatory = $true)] [string]$Version, - [int]$McpLoops = 8, - [int]$McpServers = 80, - [int]$StartupSeconds = 130 + [int]$McpLoops = 0, + [int]$McpServers = 20, + [int]$StartupSeconds = 20, + [int]$PtySeconds = 120 ) $ErrorActionPreference = "Stop" @@ -134,7 +135,8 @@ New-Item -ItemType Directory -Force $env:XDG_CONFIG_HOME, $env:XDG_DATA_HOME, $e $emptyProject = Join-Path $root "empty-project" $mcpProject = Join-Path $root "mcp-project" -New-Item -ItemType Directory -Force $emptyProject, $mcpProject | Out-Null +$sessionProject = Join-Path $root "session-project" +New-Item -ItemType Directory -Force $emptyProject, $mcpProject, $sessionProject | Out-Null Invoke-Opencode -Label "version" -Exe $exe -Args @("--version") -WorkingDirectory $emptyProject -TimeoutSeconds 30 Invoke-Opencode -Label "help" -Exe $exe -Args @("--help") -WorkingDirectory $emptyProject -TimeoutSeconds 30 @@ -161,9 +163,18 @@ for ($i = 1; $i -le $McpLoops; $i++) { } Invoke-Opencode -Label "empty-startup-tui" -Exe $exe -Args @() -WorkingDirectory $emptyProject -TimeoutSeconds $StartupSeconds -AllowTimeout -AllowNonZero -Invoke-Opencode -Label "mini-demo-question" -Exe $exe -Args @("--mini", "--demo", "--prompt", "/question single") -WorkingDirectory $emptyProject -TimeoutSeconds 30 -AllowTimeout -AllowNonZero -Invoke-Opencode -Label "mini-demo-permission" -Exe $exe -Args @("--mini", "--demo", "--prompt", "/permission bash") -WorkingDirectory $emptyProject -TimeoutSeconds 30 -AllowTimeout -AllowNonZero -Invoke-Opencode -Label "mini-demo-mix" -Exe $exe -Args @("--mini", "--demo", "--prompt", "/fmt mix") -WorkingDirectory $emptyProject -TimeoutSeconds 30 -AllowTimeout -AllowNonZero + +Write-Section "Install PTY dependency" +$ptyRoot = Join-Path $root "pty-harness" +New-Item -ItemType Directory -Force $ptyRoot | Out-Null +Push-Location $ptyRoot +try { + npm init -y | Out-Host + npm install "@lydell/node-pty@1.2.0-beta.12" | Out-Host + node (Join-Path $PSScriptRoot "repro-windows-opentui-pty-session.mjs") -- --exe $exe --project $sessionProject --version $Version --seconds $PtySeconds +} finally { + Pop-Location +} Write-Section "Result" Write-Host "No native crash signature detected for opencode-ai@$Version" diff --git a/script/repro-windows-opentui-pty-session.mjs b/script/repro-windows-opentui-pty-session.mjs new file mode 100644 index 0000000000..88871e414c --- /dev/null +++ b/script/repro-windows-opentui-pty-session.mjs @@ -0,0 +1,239 @@ +import { createRequire } from "node:module" +import { createServer } from "node:http" +import { mkdirSync, writeFileSync } from "node:fs" +import { join, resolve } from "node:path" +import { setTimeout as delay } from "node:timers/promises" + +const require = createRequire(join(process.cwd(), "pty-harness.cjs")) +const pty = require("@lydell/node-pty") + +const args = process.argv.slice(2) +const value = (name, fallback) => { + const index = args.indexOf(name) + if (index === -1) return fallback + return args[index + 1] +} + +const exe = value("--exe") +const project = value("--project") +const version = value("--version") +const seconds = Number(value("--seconds", "120")) + +if (!exe || !project || !version) { + throw new Error("Usage: repro-windows-opentui-pty-session.mjs -- --exe --project --version ") +} + +const crashPattern = /Segmentation fault|Bun has crashed|ACCESS_VIOLATION|0xC0000005|322122|panic\(main thread\)/i +const outputFile = join(project, `pty-output-${version}.log`) +const requestsFile = join(project, `provider-requests-${version}.jsonl`) +let output = "" +let providerRequests = 0 +let exited = false +let exitCode = undefined +let exitSignal = undefined + +mkdirSync(project, { recursive: true }) + +function appendOutput(text) { + output += text + writeFileSync(outputFile, output) +} + +async function readBody(req) { + const chunks = [] + for await (const chunk of req) chunks.push(chunk) + return Buffer.concat(chunks).toString("utf8") +} + +function writeSse(res, event) { + res.write(`data: ${JSON.stringify(event)}\n\n`) +} + +const server = createServer(async (req, res) => { + const body = await readBody(req) + providerRequests++ + writeFileSync( + requestsFile, + JSON.stringify({ method: req.method, url: req.url, body: body ? JSON.parse(body) : undefined }) + "\n", + { flag: "a" }, + ) + + if (req.url?.endsWith("/models")) { + res.writeHead(200, { "content-type": "application/json" }) + res.end(JSON.stringify({ object: "list", data: [{ id: "test-model", object: "model" }] })) + return + } + + if (req.url?.endsWith("/chat/completions")) { + const parsed = body ? JSON.parse(body) : {} + if (parsed.stream) { + res.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }) + writeSse(res, { + id: "chatcmpl-opentui-repro", + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: "test-model", + choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], + }) + await delay(50) + writeSse(res, { + id: "chatcmpl-opentui-repro", + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: "test-model", + choices: [{ index: 0, delta: { content: "This is a deterministic CI response from the fake provider." }, finish_reason: null }], + }) + await delay(50) + writeSse(res, { + id: "chatcmpl-opentui-repro", + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: "test-model", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }) + res.write("data: [DONE]\n\n") + res.end() + return + } + + res.writeHead(200, { "content-type": "application/json" }) + res.end( + JSON.stringify({ + id: "chatcmpl-opentui-repro", + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model: "test-model", + choices: [ + { + index: 0, + message: { role: "assistant", content: "This is a deterministic CI response from the fake provider." }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), + ) + return + } + + res.writeHead(404, { "content-type": "application/json" }) + res.end(JSON.stringify({ error: { message: `Unhandled fake provider route: ${req.url}` } })) +}) + +await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen)) +const { port } = server.address() +const baseURL = `http://127.0.0.1:${port}/v1` + +writeFileSync( + join(project, "opencode.json"), + `${JSON.stringify( + { + $schema: "https://opencode.ai/config.json", + autoupdate: false, + enabled_providers: ["test"], + provider: { + test: { + name: "Test", + id: "test", + env: [], + npm: "@ai-sdk/openai-compatible", + models: { + "test-model": { + id: "test-model", + name: "Test Model", + attachment: false, + reasoning: false, + temperature: false, + tool_call: true, + release_date: "2025-01-01", + limit: { context: 100000, output: 10000 }, + cost: { input: 0, output: 0 }, + options: {}, + }, + }, + options: { apiKey: "test-key", baseURL }, + }, + }, + }, + null, + 2, + )}\n`, +) + +console.log(`fake provider baseURL=${baseURL}`) +console.log(`pty output log=${outputFile}`) +console.log(`provider requests log=${requestsFile}`) + +const env = { + ...process.env, + OPENCODE_DISABLE_AUTOUPDATE: "1", + OPENCODE_DISABLE_EXTERNAL_SKILLS: "1", + OPENCODE_DISABLE_LSP_DOWNLOAD: "1", + OPENCODE_LOG_LEVEL: "DEBUG", + OPENCODE_PRINT_LOGS: "1", + OPENCODE_PURE: "1", + OTUI_DEBUG: "1", + OTUI_DUMP_CAPTURES: "1", + OTUI_USE_CONSOLE: "1", + OTUI_SHOW_STATS: "1", +} + +const proc = pty.spawn( + exe, + ["--model", "test/test-model", "--prompt", "start a CI repro session and answer briefly"], + { + name: "xterm-256color", + cols: 120, + rows: 40, + cwd: resolve(project), + env, + }, +) + +proc.onData((data) => { + appendOutput(data) + process.stdout.write(data) +}) + +proc.onExit((event) => { + exited = true + exitCode = event.exitCode + exitSignal = event.signal +}) + +const deadline = Date.now() + seconds * 1000 +while (Date.now() < deadline) { + if (crashPattern.test(output)) break + if (exited) break + if (providerRequests > 0 && output.includes("fake provider")) break + await delay(500) +} + +if (!exited) { + proc.write("\x03") + await delay(1500) +} +if (!exited) { + proc.kill() + await delay(500) +} + +server.close() + +console.log(`\npty exitCode=${exitCode} signal=${exitSignal} providerRequests=${providerRequests}`) + +if (crashPattern.test(output)) { + throw new Error("native crash signature detected in PTY session") +} + +if (exitCode === 3 || exitCode === -1073741819 || exitCode === -1073740791 || exitCode === -1073741571) { + throw new Error(`native crash exit code detected in PTY session: ${exitCode}`) +} + +if (providerRequests === 0) { + throw new Error("PTY session did not send a request to the fake provider") +}