Skip to content

fix(codeflow-store): terminal-sessions idle purge, grace period, and SIGKILL escalation - #41

Merged
nehraa merged 3 commits into
mainfrom
fix/pr30-terminal-sessions
Jun 11, 2026
Merged

fix(codeflow-store): terminal-sessions idle purge, grace period, and SIGKILL escalation#41
nehraa merged 3 commits into
mainfrom
fix/pr30-terminal-sessions

Conversation

@nehraa

@nehraa nehraa commented Jun 11, 2026

Copy link
Copy Markdown
Owner

Summary

Applies the review feedback from PR #30 (Gemini, Qodo, Copilot) to packages/codeflow-store/src/shared/terminal-sessions.ts and fixes the React 18/19 peerDeps conflict in packages/codeflow-canvas/package.json.

The original PR #30 was opened against a src/lib/server/terminal-sessions.ts that no longer exists — it was moved to packages/codeflow-store/src/shared/terminal-sessions.ts by the cleanup commit a896899. This PR applies the same review fixes to the new location.

Changes

packages/codeflow-store/src/shared/terminal-sessions.ts

Fix A — Gemini CRITICAL (purgeIdleSessions):

  • Running idle sessions are no longer deleted from the map. SIGTERM is sent and lastActivityAt is updated. The close event handler transitions status to "exited" and refreshes lastActivityAt, so the session remains visible to the user for the 60s EXPIRED_OUTPUT_GRACE_MS grace period before deletion in a future purge.
  • Only sessions already in "exited" status are deleted (after the grace period).

Fix B — Qodo #1 (background scheduler):

  • New ensurePurgeScheduler() registers a 30s setInterval that calls purgeIdleSessions().
  • .unref()-ed so it doesn't block process exit.
  • First-call-guarded with a module-level purgeTimer check so test re-imports don't double-register.
  • Kicked off lazily on first createTerminalSession().

Fix C — Qodo #2 (SIGTERM→SIGKILL escalation):

  • New killWithEscalation(child, signal) helper: sends the signal, schedules SIGKILL after SIGKILL_TIMEOUT_MS = 5_000, .unref()s the timer, returns the timer for cancellation. Timeout is cleared on close.
  • Applied in both purgeIdleSessions and closeTerminalSession.
  • Map deletion waits for the process to actually exit.

Fix D — Copilot (writeTerminalInput):

  • Now calls purgeIdleSessions() at the top so an idle session cannot be revived by a stray write.

packages/codeflow-canvas/package.json

Fix E — React 18/19 peerDeps conflict:

  • Moved react and react-dom from dependencies to devDependencies.
  • Widened peerDependencies to ^18 || ^19 so consumers on either major can install.
  • Root of monorepo uses React 19; the canvas package no longer pins a conflicting major.

Skipped (out of scope or obsolete)

  • ChatGPT Codex "avoid killing quiet jobs" P2 — semantics tradeoff, not a bug. Would require user-facing config not in scope.
  • "Session limit → 429" — the /api/terminal/sessions POST route no longer exists in the new structure. Would be implementing dead code.
  • pnpm-lock.yaml changes from original PR — not required for these fixes.

Test plan

  • 11/11 new tests in terminal-sessions.test.ts pass
  • pnpm -F @abhinav2203/codeflow-store check — 0 errors in terminal-sessions.ts and terminal-sessions.test.ts
  • pnpm -F @abhinav2203/codeflow-store test — 192/192 tests pass in files that compile on origin/main
  • 4 pre-existing test failures (checkpoint/reasoning.test.ts etc.) are due to Cannot find package 'zod' on origin/main (cleanup commit side effect) — unrelated to this PR
  • Manual probe: real sleep 60 child + killWithEscalation confirmed close handler clears the escalation timer before it fires (race-safety)

Code review

  • ✅ Spec compliance approved (4/4 fixes correctly address review comments)
  • ⚠️ Code quality approved with notes — 2 Important findings flagged as follow-up material (see below)

Follow-up (not in this PR)

Code quality review surfaced 2 issues that are not part of PR #30's original review thread and are recommended for a separate PR:

  1. shutdownAllTerminalSessions (terminal-sessions.ts:367-375) bypasses the SIGKILL escalation path — direct kill("SIGTERM") then sessions.clear() orphans escalation timers and can leak SIGTERM-ignoring children past process exit. Fix: reuse killWithEscalation and unref the timer.
  2. writeTerminalInput does a full purgeIdleSessions per write — O(N) per keystroke. Fix: short-circuit by checking the target session's lastActivityAt first.

🤖 Generated with Claude Code

nehraa and others added 3 commits June 11, 2026 11:24
…LL escalation

Apply review feedback from PR #30:

  - Fix A (Gemini CRITICAL): purgeIdleSessions no longer drops a running
    idle session from the map. Instead, the session is SIGTERMed (with
    SIGKILL escalation) and its lastActivityAt is refreshed; the existing
    close handler transitions the entry to "exited", and a future purge
    cycle deletes it after EXPIRED_OUTPUT_GRACE_MS. This preserves the
    60 s grace period semantics for callers holding the snapshot.

  - Fix C (Qodo): SIGTERM is now paired with a 5 s SIGKILL escalation
    timer (killWithEscalation helper). The timer is unref'd so it does
    not keep the event loop alive. closeTerminalSession waits for the
    close event before deleting the map entry, so a SIGTERM in flight
    cannot leave a leaked session pointer.

  - Fix B (Qodo): a module-level setInterval (30 s) sweeps the session
    map, so idle sessions are reaped even when no inbound API call
    triggers purgeIdleSessions. The interval is unref'd and gated on a
    lazy "first call" guard so test re-imports cannot double-start it.

  - Fix D (Copilot): writeTerminalInput calls purgeIdleSessions at the
    top so a client cannot POST input to a long-idle session and revive
    it. Defense in depth alongside the background scheduler.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add a vitest suite for the four fix areas from PR #30 review:

  - Fix A: purgeIdleSessions does not remove a running session from
    the map; its lastActivityAt is refreshed; the close handler
    transitions status to "exited" and a future purge cycle deletes
    the entry after EXPIRED_OUTPUT_GRACE_MS.

  - Fix B: a module-level scheduler is started by the first
    createTerminalSession call and not double-started on subsequent
    creates.

  - Fix C: closeTerminalSession sends SIGTERM and schedules a
    SIGKILL escalation timer; the map entry is removed only after
    the close event.

  - Fix D: writeTerminalInput calls purgeIdleSessions and rejects
    writes against a long-idle (now exited) session.

Tests use real child processes (the shell is the system of record)
and manipulate the system clock with vi.setSystemTime to drive
IDLE_TIMEOUT_MS and EXPIRED_OUTPUT_GRACE_MS cutoffs without waiting
real-world time.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The monorepo root (packages/Codeflow_master) installs react and
react-dom at ^19.0.0, but the canvas package pinned ^18.0.0 in
both dependencies and peerDependencies. pnpm reported these as
unmet-peer conflicts on every install.

  - Move `react` and `react-dom` out of `dependencies` (where they
    shadowed the consumer's React) and into `devDependencies` (where
    the package's own build, type-check, and tests need them).
  - Widen `peerDependencies` to `^18 || ^19` so consumers on either
    React major can install.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@nehraa, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 23 minutes and 14 seconds. Learn how PR review limits work.

Your organization has reached its usage spending cap. Adjust your spending cap in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 580b10e9-db3a-4d8f-8db3-8ba990563404

📥 Commits

Reviewing files that changed from the base of the PR and between 49ec136 and 9c56747.

📒 Files selected for processing (3)
  • packages/codeflow-canvas/package.json
  • packages/codeflow-store/src/shared/terminal-sessions.test.ts
  • packages/codeflow-store/src/shared/terminal-sessions.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/pr30-terminal-sessions

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request updates React peer dependencies in codeflow-canvas and implements a robust terminal session idle purging and SIGKILL escalation mechanism in codeflow-store, complete with comprehensive unit tests. The feedback suggests several improvements to the session lifecycle management: checking the return value of child.kill() to prevent scheduling unnecessary escalation timers, skipping already-killed sessions during the purge sweep to avoid redundant signals, and ensuring the escalation timer is explicitly cleared when a session is manually closed.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +150 to +156
try {
session.child.kill(signal);
} catch {
// Process may have exited between our status check and the kill call;
// the close handler will run on its own and clean up state.
return null;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The child.kill() method in Node.js returns a boolean indicating whether the signal was successfully delivered. If the process has already exited, it returns false without throwing an error. Checking this return value allows us to avoid scheduling an unnecessary setTimeout escalation timer when signal delivery fails.

  try {
    const sent = session.child.kill(signal);
    if (!sent) {
      return null;
    }
  } catch {
    // Process may have exited between our status check and the kill call;
    // the close handler will run on its own and clean up state.
    return null;
  }

Comment on lines +191 to +205
for (const [id, session] of sessions) {
if (session.status !== "running") {
// Exited or error sessions: only delete after the grace period has elapsed.
const lastActivity = Date.parse(session.lastActivityAt);
if (Number.isFinite(lastActivity) && lastActivity <= expiredCutoff) {
sessions.delete(id);
}
continue;
}

// Running session: refresh activity before deciding it's idle.
const lastActivity = Date.parse(session.lastActivityAt);
if (!Number.isFinite(lastActivity) || lastActivity > idleCutoff) {
continue;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

If a session is already in the process of being terminated (i.e., session.child.killed is true), we should skip it during the idle purge sweep to avoid sending redundant signals or scheduling duplicate escalation timers.

Suggested change
for (const [id, session] of sessions) {
if (session.status !== "running") {
// Exited or error sessions: only delete after the grace period has elapsed.
const lastActivity = Date.parse(session.lastActivityAt);
if (Number.isFinite(lastActivity) && lastActivity <= expiredCutoff) {
sessions.delete(id);
}
continue;
}
// Running session: refresh activity before deciding it's idle.
const lastActivity = Date.parse(session.lastActivityAt);
if (!Number.isFinite(lastActivity) || lastActivity > idleCutoff) {
continue;
}
for (const [id, session] of sessions) {
if (session.status !== "running") {
// Exited or error sessions: only delete after the grace period has elapsed.
const lastActivity = Date.parse(session.lastActivityAt);
if (Number.isFinite(lastActivity) && lastActivity <= expiredCutoff) {
sessions.delete(id);
}
continue;
}
if (session.child.killed) {
continue;
}
// Running session: refresh activity before deciding it's idle.
const lastActivity = Date.parse(session.lastActivityAt);
if (!Number.isFinite(lastActivity) || lastActivity > idleCutoff) {
continue;
}

Comment on lines +353 to +356
if (escalation !== null) {
session.child.once("close", () => {
sessions.delete(id);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

When a terminal session is closed manually, the escalation timer is scheduled but never cleared if the process exits before the timeout. Although the timer is unreferenced, clearing it explicitly prevents unnecessary execution of the timeout callback and avoids leaving active timers in the event loop.

    if (escalation !== null) {
      session.child.once("close", () => {
        clearTimeout(escalation);
        sessions.delete(id);
      });
    }

@nehraa
nehraa merged commit 652e816 into main Jun 11, 2026
1 check passed
@nehraa
nehraa deleted the fix/pr30-terminal-sessions branch June 11, 2026 10:10
nehraa added a commit that referenced this pull request Jun 11, 2026
…SIGKILL escalation (#41)

* fix(codeflow-store): terminal-sessions purge, grace period, and SIGKILL escalation

Apply review feedback from PR #30:

  - Fix A (Gemini CRITICAL): purgeIdleSessions no longer drops a running
    idle session from the map. Instead, the session is SIGTERMed (with
    SIGKILL escalation) and its lastActivityAt is refreshed; the existing
    close handler transitions the entry to "exited", and a future purge
    cycle deletes it after EXPIRED_OUTPUT_GRACE_MS. This preserves the
    60 s grace period semantics for callers holding the snapshot.

  - Fix C (Qodo): SIGTERM is now paired with a 5 s SIGKILL escalation
    timer (killWithEscalation helper). The timer is unref'd so it does
    not keep the event loop alive. closeTerminalSession waits for the
    close event before deleting the map entry, so a SIGTERM in flight
    cannot leave a leaked session pointer.

  - Fix B (Qodo): a module-level setInterval (30 s) sweeps the session
    map, so idle sessions are reaped even when no inbound API call
    triggers purgeIdleSessions. The interval is unref'd and gated on a
    lazy "first call" guard so test re-imports cannot double-start it.

  - Fix D (Copilot): writeTerminalInput calls purgeIdleSessions at the
    top so a client cannot POST input to a long-idle session and revive
    it. Defense in depth alongside the background scheduler.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test(codeflow-store): cover terminal-sessions purge, grace, and SIGKILL

Add a vitest suite for the four fix areas from PR #30 review:

  - Fix A: purgeIdleSessions does not remove a running session from
    the map; its lastActivityAt is refreshed; the close handler
    transitions status to "exited" and a future purge cycle deletes
    the entry after EXPIRED_OUTPUT_GRACE_MS.

  - Fix B: a module-level scheduler is started by the first
    createTerminalSession call and not double-started on subsequent
    creates.

  - Fix C: closeTerminalSession sends SIGTERM and schedules a
    SIGKILL escalation timer; the map entry is removed only after
    the close event.

  - Fix D: writeTerminalInput calls purgeIdleSessions and rejects
    writes against a long-idle (now exited) session.

Tests use real child processes (the shell is the system of record)
and manipulate the system clock with vi.setSystemTime to drive
IDLE_TIMEOUT_MS and EXPIRED_OUTPUT_GRACE_MS cutoffs without waiting
real-world time.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(codeflow-canvas): align React peerDeps with monorepo React 19 root

The monorepo root (packages/Codeflow_master) installs react and
react-dom at ^19.0.0, but the canvas package pinned ^18.0.0 in
both dependencies and peerDependencies. pnpm reported these as
unmet-peer conflicts on every install.

  - Move `react` and `react-dom` out of `dependencies` (where they
    shadowed the consumer's React) and into `devDependencies` (where
    the package's own build, type-check, and tests need them).
  - Widen `peerDependencies` to `^18 || ^19` so consumers on either
    React major can install.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: nehraa <nehraa@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
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.

1 participant