-
Notifications
You must be signed in to change notification settings - Fork 3.1k
fix(test): wait for interactive PTY sessions to end during cleanup #11001
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
base: main
Are you sure you want to change the base?
Changes from all commits
161c784
4b9222f
01b7633
4d8a642
2e6c42e
0b3f089
55aa9d4
f02543b
9b34a6e
38fb1ad
6ff44c4
605b0a3
4e37d2e
2897955
fc8801c
b22948e
654835a
8d89d4d
7b43e33
8269a66
062eb23
d1648f8
eb92aa8
69878a2
2a5e729
a6a77c9
68fbc1d
edaf7e1
2775328
1a28655
c19a32d
646b726
c7ed49d
cac0a60
78b1c47
0d5f53b
a5ec379
080ebb5
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 |
|---|---|---|
|
|
@@ -129,6 +129,62 @@ export function validateModelOutput( | |
| return true; | ||
| } | ||
|
|
||
| // The CLI traps SIGHUP and exits only once `runExitCleanup()` has drained, a | ||
| // chain it bounds at 5s (packages/cli/src/utils/cleanup.ts), so the grace has | ||
| // to outlast that. It also has to stay inside the 10s hookTimeout vitest | ||
| // defaults to, because cleanup() runs in afterEach hooks: a grace that eats | ||
| // the whole hook budget is reported as a generic "Hook timed out" instead of | ||
| // naming the child that never exited. | ||
| export const INTERACTIVE_EXIT_GRACE_MS = 8_000; | ||
|
|
||
| // `process.kill(-pid, 0)` reports whether anything is left in the process | ||
| // group `pid` leads; it throws once the group is empty. Windows has no | ||
| // negative-pid process groups and always throws, so there the child's own exit | ||
| // stays the whole bound. | ||
| function sessionAlive(pid: number): boolean { | ||
| try { | ||
| process.kill(-pid, 0); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| // Resolves true once the whole PTY session is gone, false when `ms` elapses | ||
| // first. `exited` covers only the process node-pty spawned, and node-pty | ||
| // signals only that pid — on the installed-release lane it is the bin wrapper, | ||
| // which relaunches the real CLI with spawnSync and dies on SIGHUP's default | ||
| // action at once, so `exited` settles while that CLI is still draining into | ||
| // the PTY. The spawned process leads its own process group and the | ||
| // relaunched CLI stays in it, and a group reads empty only once every member | ||
| // has exited and been reaped, so wait for both inside the same grace. Timers | ||
| // are unrefed so a lost race leaves no handle holding the worker's event loop | ||
| // open. | ||
| function sessionEndsWithin( | ||
| pid: number, | ||
| exited: Promise<unknown>, | ||
| ms: number, | ||
| ): Promise<boolean> { | ||
| return new Promise((resolve) => { | ||
| const deadline = Date.now() + ms; | ||
| let childExited = false; | ||
| const check = () => { | ||
| const gone = childExited && !sessionAlive(pid); | ||
| if (gone || Date.now() >= deadline) { | ||
| clearInterval(timer); | ||
| resolve(gone); | ||
| } | ||
| }; | ||
| const onChildExit = () => { | ||
| childExited = true; | ||
| check(); | ||
| }; | ||
| const timer = setInterval(check, 50); | ||
| timer.unref(); | ||
| void exited.then(onChildExit, onChildExit); | ||
| }); | ||
| } | ||
|
|
||
| // Simulates typing a string one character at a time to avoid paste detection. | ||
| export async function type(ptyProcess: pty.IPty, text: string) { | ||
| const delay = 5; | ||
|
|
@@ -205,7 +261,10 @@ export class TestRig { | |
| testName?: string; | ||
| _lastRunStdout?: string; | ||
| _interactiveOutput = ''; | ||
| private readonly interactiveProcesses: pty.IPty[] = []; | ||
| private readonly interactiveProcesses: Array<{ | ||
| ptyProcess: pty.IPty; | ||
| exited: Promise<unknown>; | ||
| }> = []; | ||
|
|
||
| constructor() { | ||
| this.bundlePath = join(__dirname, '..', 'dist/cli.js'); | ||
|
|
@@ -512,13 +571,26 @@ export class TestRig { | |
| async cleanup() { | ||
| // A session a test never closed keeps its CLI child forwarding PTY bytes | ||
| // into this worker's stdout; after vitest tears the worker down those | ||
| // writes EPIPE and fail an otherwise all-green run (#10969). | ||
| for (const ptyProcess of this.interactiveProcesses.splice(0)) { | ||
| // writes EPIPE and fail an otherwise all-green run (#10969). Signalling | ||
| // alone still returns with the child alive and writing, so wait for it to | ||
|
Collaborator
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. [Critical] R11-1: [certifies-falsely] [new-surface] The root-cause diagnosis this change is justified by is contradicted by the CI logs of every run it cites, so the The premise is that the OpenTUI leg "keeps reddening Why the issue named no test is also measurable, and it is not an unhandled error. The detector run that filed issue 10990 logged The real cause is already in this PR's base. Commit So merging as written closes issue 10990 as fixed while the recurring OpenTUI signature and the detector's log-fetch fallback both ship untouched, and the next red leg gets investigated through a closed issue whose stated cause was never the cause. To be explicit about what this does not say: the harness wait itself is sound and we measured it working. Removing the process-group gate reds this PR's own Witness: Keep the wait as teardown hygiene — the orphan measurement the description already contains justifies it on its own — and drop the attribution: the If the intent is to keep a tracker attached, point it at the recurring OpenTUI signature rather than at a per-commit alert. The repair that signature needs is One premise not to break on the way: do not "fix" There is no test to add for an attribution change, and that absence is itself the evidence: all three cases this PR adds to 中文说明本改动所依据的根因判断,被它自己引用的每一次 CI 运行的日志所否证;因此 前提是这样的:OpenTUI 这条 leg "一直在让 "issue 为什么没有指出测试"同样是可测量的,而原因不是 unhandled error。创建 issue 10990 的那次 detector 运行记录了 真正的原因已经在本 PR 的 base 里。commit 因此按现状合入的后果是:issue 10990 以"已修复"关闭,而真正反复出现的 OpenTUI 失败特征、以及 detector 的日志下载降级路径,都原样上线;下一次这条 leg 变红时,人们会顺着 issue 10990 去查一个已关闭、且其所陈述的原因从来就不是真实原因的问题。 需要明确本发现没有说什么:harness 的等待本身是可靠的,我们实测它有效。移除进程组门控会让本 PR 自己的 建议保留这个等待,作为 teardown 卫生改进 —— 描述中已有的孤儿进程实测数据本身就足以支撑它 —— 并去掉归因: 如果本意是要挂一个 tracker,请把它指向那个反复出现的 OpenTUI 失败特征,而不是一个按 commit 生成的告警。该特征需要的修复是 顺带一个不要破坏的前提:不要去"修" 归因类改动没有可添加的测试,而这个"没有"本身就是证据:本 PR 在 — qwen3.8-max via Qwen Code /review (v0.23.0)
Collaborator
Author
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. Implemented in One part stays open, and it is why this thread is left unresolved: the remaining 中文说明已在 仍有一项保持开放,这也是本 thread 不关闭的原因:剩余的 |
||
| // actually go away. | ||
| for (const { ptyProcess, exited } of this.interactiveProcesses.splice(0)) { | ||
| try { | ||
| ptyProcess.kill(); | ||
| } catch { | ||
| // Process may have already exited | ||
| } | ||
| const ended = await sessionEndsWithin( | ||
| ptyProcess.pid, | ||
| exited, | ||
| INTERACTIVE_EXIT_GRACE_MS, | ||
| ); | ||
| if (!ended) { | ||
| console.warn( | ||
| `interactive CLI process group ${ptyProcess.pid} did not end ` + | ||
| `within ${INTERACTIVE_EXIT_GRACE_MS}ms; continuing cleanup`, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| // Clean up test directory | ||
|
|
@@ -988,7 +1060,10 @@ export class TestRig { | |
| ...e2eRendererEnv(renderer), | ||
| } as { [key: string]: string }, | ||
| }); | ||
| this.interactiveProcesses.push(ptyProcess); | ||
| const exited = new Promise<void>((resolve) => { | ||
| ptyProcess.onExit(() => resolve()); | ||
| }); | ||
| this.interactiveProcesses.push({ ptyProcess, exited }); | ||
|
|
||
| ptyProcess.onData((data) => { | ||
| this._interactiveOutput += data; | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.