Skip to content

fix: prevent E2E worker teardown timeout with close deadline#10528

Open
SebTardif wants to merge 3 commits into
cline:mainfrom
SebTardif:fix/e2e-teardown-timeout
Open

fix: prevent E2E worker teardown timeout with close deadline#10528
SebTardif wants to merge 3 commits into
cline:mainfrom
SebTardif:fix/e2e-teardown-timeout

Conversation

@SebTardif

Copy link
Copy Markdown

Problem

E2E tests intermittently fail with:

Worker teardown timeout of 60000ms exceeded.
1) [e2e tests] › src/test/e2e/editor.test.ts:9:5 › Code Actions and Editor Panel › Single Root

This affects ~13% of E2E runs on main (2/15 recent runs). The test itself passes — the failure occurs during fixture teardown when app.close() hangs because VS Code's Electron process doesn't exit cleanly.

Root Cause

Playwright fixtures tear down in reverse order: sidebarpage.close()app.close() → temp dir cleanup. The worker has a 60s budget for all teardown combined.

app.close() sends a close signal to the Electron process and waits for it to exit. If the VS Code extension host is busy (file watchers, terminal process cleanup, background tasks), the process can hang indefinitely, consuming the entire teardown budget.

retries: 1 in the Playwright config doesn't help because it retries tests, not fixture teardown.

Fix

Race both close operations against deadlines, with force-kill as fallback:

Operation Deadline Fallback
page.close() 5s Skip (app.close handles it)
app.close() 15s SIGKILL the Electron process

Total worst-case teardown: ≤21s (page 5s + app 15s + cleanup ~1s) vs the 60s budget.

// app.close() with deadline and force-kill fallback
try {
    await Promise.race([
        app.close(),
        new Promise<never>((_, reject) =>
            setTimeout(() => reject(new Error("app.close() timed out")), 15_000),
        ),
    ])
} catch {
    try { app.process().kill("SIGKILL") } catch {}
}

Why these timeouts

  • 5s for page.close(): Closing a BrowserWindow is fast. If it takes >5s, the renderer is unresponsive and app-level termination is needed anyway.
  • 15s for app.close(): Generous enough for normal VS Code shutdown (typically 2-5s), but bounded to prevent the 60s+ hangs that trigger the worker timeout.
  • SIGKILL fallback: If graceful close hangs, the process is stuck. SIGKILL is the only reliable way to free the resources so temp dir cleanup can proceed.

Files Changed

  • src/test/e2e/utils/helpers.ts — Added timeout + force-kill to app fixture teardown, timeout guard to page fixture teardown

The E2E tests intermittently fail with 'Worker teardown timeout of
60000ms exceeded' because app.close() hangs when VS Code's Electron
process doesn't exit cleanly (extension host busy with file watchers,
terminal cleanup, etc.).

Fix by racing app.close() against a 15s deadline. If the deadline
fires, force-kill the Electron process with SIGKILL so fixture
teardown completes well within the 60s worker budget.

Also add a 5s deadline to page.close() for defense in depth — if
the renderer is unresponsive, skip page close and let app.close()
handle process termination.

The teardown budget is now bounded:
  page.close()  ≤ 5s
  app.close()   ≤ 15s
  rmSync cleanup ~1s
  Total         ≤ 21s  (vs 60s budget)
Copilot AI review requested due to automatic review settings May 3, 2026 18:33
@changeset-bot

changeset-bot Bot commented May 3, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 3c7ce10

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@greptile-apps

greptile-apps Bot commented May 3, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes intermittent E2E worker teardown timeouts by wrapping app.close() and page.close() in Promise.race deadline guards with a SIGKILL fallback, capping worst-case teardown at ~21 s well inside the 60 s budget. The approach is sound and directly addresses the root cause described in the PR.

Confidence Score: 4/5

Safe to merge; only a minor timer-leak style issue found, no functional regressions.

All findings are P2 (style/best-practice). The fix is logically correct and well-bounded. The sole concern is that the losing setTimeout handles in both race blocks are never cleared, which can keep Node's event loop alive transiently but won't break tests.

src/test/e2e/utils/helpers.ts — orphaned timer handles in both fixture teardown blocks.

Important Files Changed

Filename Overview
src/test/e2e/utils/helpers.ts Added Promise.race timeout guards to both app and page fixture teardown; one minor timer-leak issue (uncleared setTimeout handles) but the overall fix is correct and well-reasoned.
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
src/test/e2e/utils/helpers.ts:294-303
**Orphaned timers after successful close**

Both `setTimeout` handles created inside the losing `Promise` are never cleared. When `app.close()` (or `page.close()`) completes before the deadline, the timer is still running and will fire after the race settles — keeping Node's event loop alive unnecessarily. While this won't break the test in most cases, it can delay teardown and is easily avoided by clearing the handle.

```typescript
let appCloseTimer: ReturnType<typeof setTimeout> | undefined;
try {
    await Promise.race([
        app.close(),
        new Promise<never>((_, reject) => {
            appCloseTimer = setTimeout(() => reject(new Error("app.close() timed out")), 15_000);
        }),
    ]);
} catch {
    try { app.process().kill("SIGKILL") } catch {}
} finally {
    clearTimeout(appCloseTimer);
}
```

The same applies to the 5 s timer in the `page` fixture.

Reviews (1): Last reviewed commit: "fix: prevent E2E worker teardown timeout..." | Re-trigger Greptile

Comment on lines +294 to +303
try {
await Promise.race([
app.close(),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("app.close() timed out")), 15_000)),
])
} catch {
try {
app.process().kill("SIGKILL")
} catch {}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Orphaned timers after successful close

Both setTimeout handles created inside the losing Promise are never cleared. When app.close() (or page.close()) completes before the deadline, the timer is still running and will fire after the race settles — keeping Node's event loop alive unnecessarily. While this won't break the test in most cases, it can delay teardown and is easily avoided by clearing the handle.

let appCloseTimer: ReturnType<typeof setTimeout> | undefined;
try {
    await Promise.race([
        app.close(),
        new Promise<never>((_, reject) => {
            appCloseTimer = setTimeout(() => reject(new Error("app.close() timed out")), 15_000);
        }),
    ]);
} catch {
    try { app.process().kill("SIGKILL") } catch {}
} finally {
    clearTimeout(appCloseTimer);
}

The same applies to the 5 s timer in the page fixture.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/test/e2e/utils/helpers.ts
Line: 294-303

Comment:
**Orphaned timers after successful close**

Both `setTimeout` handles created inside the losing `Promise` are never cleared. When `app.close()` (or `page.close()`) completes before the deadline, the timer is still running and will fire after the race settles — keeping Node's event loop alive unnecessarily. While this won't break the test in most cases, it can delay teardown and is easily avoided by clearing the handle.

```typescript
let appCloseTimer: ReturnType<typeof setTimeout> | undefined;
try {
    await Promise.race([
        app.close(),
        new Promise<never>((_, reject) => {
            appCloseTimer = setTimeout(() => reject(new Error("app.close() timed out")), 15_000);
        }),
    ]);
} catch {
    try { app.process().kill("SIGKILL") } catch {}
} finally {
    clearTimeout(appCloseTimer);
}
```

The same applies to the 5 s timer in the `page` fixture.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3c7ce10 — both Promise.race blocks now store the timer handle and clear it in a finally block, preventing orphaned timers from keeping the event loop alive after successful close.

Call app.evaluate(app => app.quit()) before app.close() so Electron
fires its before-quit/will-quit lifecycle events. This gives the
VS Code extension host a chance to clean up (file watchers, terminal
processes, etc.) rather than just waiting for the process to exit.

The timeout + SIGKILL fallback remains as a last resort for stuck
IPC handlers, but app.quit() should resolve most cases gracefully.

This follows the Playwright-recommended pattern for Electron apps
(see microsoft/playwright#12189).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

The setTimeout handles inside the losing Promise.race branch were
never cleared, keeping Node's event loop alive unnecessarily after
successful close. Now both app and page close blocks clear their
timer in a finally block.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants