Skip to content

Conversation

hahn-kev
Copy link
Collaborator

@hahn-kev hahn-kev commented Sep 9, 2025

It turns out that windows doesn't have a gracefull shutdown of a process outside of Ctrl+C which you can't just send to a process you've spawned. Hence this fix.

  • Introduces a dedicated shutdownFwLite function for graceful termination, replacing direct process killing.
  • Updates FwLiteWeb to properly handle shutdown commands via stdin.
  • Refines process stdio configuration for consistency.

* Replace direct process killing with a dedicated `shutdownFwLite` function for graceful termination.
* Update FwLiteWeb to properly handle `shutdown` commands via stdin.
* Adjust process stdio configuration for consistency.
Copy link

coderabbitai bot commented Sep 9, 2025

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

📝 Walkthrough

Walkthrough

Adds a console-driven shutdown path: the web app listens for "shutdown" on stdin and stops asynchronously. The extension adjusts process launch to enable stdin and introduces a helper to send "shutdown\n", await clean exit, and force-kill after 10 seconds if needed. Cleanup now uses the graceful shutdown helper.

Changes

Cohort / File(s) Summary
Web app stdin shutdown listener
backend/FwLite/FwLiteWeb/Program.cs
Starts a background Task to read stdin and, on exact "shutdown", calls app.StopAsync(). Leaves startup and app.WaitForShutdownAsync() flow intact. No public API changes.
Extension process management and graceful shutdown
platform.bible-extension/src/main.ts
Adds shutdownFwLite(...) to send "shutdown\n" via stdin, wait for exit, reject on non-zero, and hard-kill after 10s. Changes spawn stdio to enable stdin. Replaces direct kill() in cleanup with the new graceful shutdown. No exported signature changes.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Pre-merge checks (2 passed, 1 warning)

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title Check ✅ Passed The title “Improve shutdown for FwLiteWeb in Paratext” clearly and concisely summarizes the primary objective of the changeset by highlighting the enhancement of the shutdown mechanism for the FwLiteWeb process within Paratext.
Description Check ✅ Passed The description accurately outlines the motivation behind the changes—Windows’ inability to programmatically send Ctrl+C—and details the implementation of a graceful shutdown function along with related updates, directly reflecting the modifications in the pull request.

Poem

A bunny taps the console key,
“shutdown,” whispers quietly.
The server bows, the logs grow light,
No thumps, no bumps—just gentle night.
If stalls persist, a firm goodbye—
I twitch my nose, then swiftly fly.
Hop safe, sweet threads. 🌙🐇

✨ Finishing Touches
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch gracefull-shutdown-from-pt

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.

@github-actions github-actions bot added 💻 FW Lite issues related to the fw lite application, not miniLcm or crdt related 📙 Platform.Bible labels Sep 9, 2025
Copy link

github-actions bot commented Sep 9, 2025

UI unit Tests

  1 files  ±0   41 suites  ±0   17s ⏱️ ±0s
 90 tests ±0   90 ✅ ±0  0 💤 ±0  0 ❌ ±0 
124 runs  ±0  124 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit 032eea7. ± Comparison against base commit 31cea4c.

♻️ This comment has been updated with latest results.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
backend/FwLite/FwLiteWeb/Program.cs (1)

18-25: Optional: guard against unexpected read errors and log.

Wrap the loop in try/catch so unexpected I/O failures don’t tear down the background task silently. If you have logging on the host, log and call StopAsync().

platform.bible-extension/src/main.ts (1)

296-315: Optional: kill the process tree on Windows if FwLiteWeb spawns children.

If FwLiteWeb ever spawns child processes, consider terminating the whole tree (e.g., taskkill /T /F or a library like tree-kill) in the fallback, otherwise stdout/stderr handles may keep the parent alive.

(stackoverflow.com)

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 31cea4c and 525d58c.

📒 Files selected for processing (2)
  • backend/FwLite/FwLiteWeb/Program.cs (1 hunks)
  • platform.bible-extension/src/main.ts (3 hunks)
🧰 Additional context used
🪛 GitHub Actions: Platform.Bible extension CI/CD
platform.bible-extension/src/main.ts

[warning] 1-1: Code style issues found in 'src/main.ts'. Run 'prettier --write .' to fix.

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: frontend
  • GitHub Check: frontend-component-unit-tests
  • GitHub Check: Build FW Lite and run tests
  • GitHub Check: Analyze (csharp)
🔇 Additional comments (3)
platform.bible-extension/src/main.ts (3)

252-252: Good: register graceful cleanup instead of hard kill.

Hooking cleanup to a shutdown function is the right direction. Verify the activation host treats a rejected Promise from a disposer as a non-fatal deactivation; otherwise prefer resolving false instead of rejecting.


279-279: Stdin pipe is required here—LGTM.

Switching stdio to ['pipe','pipe','pipe'] is necessary so the backend can read the shutdown command.


1-1: Run Prettier via the package’s format script
CI flagged formatting in platform.bible-extension/src/main.ts; execute in that folder:

cd platform.bible-extension && pnpm run format

Likely an incorrect or invalid review comment.

Comment on lines +18 to +25
//windows is dumb and so you can't send SIGINT to a process, so we need to listen for a shutdown command
_ = Task.Run(async () =>
{
// Wait for the "shutdown" command from stdin
while (await Console.In.ReadLineAsync() is not "shutdown") { }

await app.StopAsync();
});
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Handle EOF and be resilient to whitespace/case; avoid a potential busy loop on closed stdin.

If stdin closes (parent dies or doesn’t pipe input), ReadLineAsync() returns null and the current loop spins forever without calling StopAsync(). Also, be lenient to trailing whitespace/casing.

Apply this diff:

-    //windows is dumb and so you can't send SIGINT to a process, so we need to listen for a shutdown command
-    _ = Task.Run(async () =>
-         {
-             // Wait for the "shutdown" command from stdin
-             while (await Console.In.ReadLineAsync() is not "shutdown") { }
-
-             await app.StopAsync();
-         });
+    // Windows can't receive SIGINT as a child; listen for a "shutdown" command on stdin.
+    _ = Task.Run(async () =>
+    {
+        string? line;
+        while ((line = await Console.In.ReadLineAsync()) is not null
+               && !string.Equals(line.Trim(), "shutdown", StringComparison.OrdinalIgnoreCase))
+        { /* keep waiting */ }
+
+        await app.StopAsync();
+    });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
//windows is dumb and so you can't send SIGINT to a process, so we need to listen for a shutdown command
_ = Task.Run(async () =>
{
// Wait for the "shutdown" command from stdin
while (await Console.In.ReadLineAsync() is not "shutdown") { }
await app.StopAsync();
});
// Windows can't receive SIGINT as a child; listen for a "shutdown" command on stdin.
_ = Task.Run(async () =>
{
string? line;
while ((line = await Console.In.ReadLineAsync()) is not null
&& !string.Equals(line.Trim(), "shutdown", StringComparison.OrdinalIgnoreCase))
{ /* keep waiting */ }
await app.StopAsync();
});

Comment on lines 296 to 315
function shutdownFwLite(fwLiteProcess: ReturnType<typeof launchFwLiteFwLiteWeb>['fwLiteProcess']): Promise<boolean> {
return new Promise((resolve, reject) => {
fwLiteProcess.once('exit', (code, signal) => {
if (code === 0) {
resolve(true);
} else {
reject(new Error(`FwLiteWeb exited with code ${code}, signal ${signal}`));
}
});
fwLiteProcess.once('error', (error) => {
reject(error);
});

fwLiteProcess.stdin.write('shutdown\n');
fwLiteProcess.stdin.end();
setTimeout(() => {
fwLiteProcess.kill('SIGKILL');
}, 10000);
});
}
Copy link

@coderabbitai coderabbitai bot Sep 9, 2025

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Make shutdown idempotent, clear the kill timer, and avoid rejecting on forced-kill.

Today:

  • A forced kill after 10s will likely cause exit with non-zero/Signal and the Promise rejects, which can bubble as an activation teardown error.
  • The kill timer isn’t cleared on early exit.
  • If the process already exited, the Promise never resolves.
  • If stdin is unavailable, write() will error.

Refactor to: resolve(true) on clean or forced termination, resolve(false) on error, clear the timer, and short-circuit if already exited. Also guard stdin.

Apply this diff:

-function shutdownFwLite(fwLiteProcess: ReturnType<typeof launchFwLiteFwLiteWeb>['fwLiteProcess']): Promise<boolean> {
-  return new Promise((resolve, reject) => {
-    fwLiteProcess.once('exit', (code, signal) => {
-      if (code === 0) {
-        resolve(true);
-      } else {
-        reject(new Error(`FwLiteWeb exited with code ${code}, signal ${signal}`));
-      }
-    });
-    fwLiteProcess.once('error', (error) => {
-      reject(error);
-    });
-
-    fwLiteProcess.stdin.write('shutdown\n');
-    fwLiteProcess.stdin.end();
-    setTimeout(() => {
-      fwLiteProcess.kill('SIGKILL');
-    }, 10000);
-  });
-}
+function shutdownFwLite(
+  fwLiteProcess: ReturnType<typeof launchFwLiteFwLiteWeb>['fwLiteProcess'],
+): Promise<boolean> {
+  return new Promise((resolve) => {
+    // Already exited?
+    if (fwLiteProcess.exitCode !== null) {
+      return resolve(fwLiteProcess.exitCode === 0);
+    }
+
+    let forced = false;
+    const killTimer = setTimeout(() => {
+      forced = true;
+      try { fwLiteProcess.kill('SIGKILL'); } catch { /* ignore */ }
+    }, 10_000);
+
+    const onExit = (code: number | null) => {
+      clearTimeout(killTimer);
+      // Treat forced kill as a successful shutdown to avoid failing deactivation.
+      resolve(forced || code === 0);
+    };
+    const onError = () => {
+      clearTimeout(killTimer);
+      resolve(false);
+    };
+
+    fwLiteProcess.once('exit', onExit);
+    fwLiteProcess.once('error', onError);
+
+    if (fwLiteProcess.stdin && !fwLiteProcess.stdin.destroyed) {
+      try {
+        fwLiteProcess.stdin.write('shutdown\n');
+        fwLiteProcess.stdin.end();
+      } catch {
+        // Fall back to forced termination if stdin write fails.
+        forced = true;
+        try { fwLiteProcess.kill('SIGKILL'); } catch { /* ignore */ }
+      }
+    } else {
+      forced = true;
+      try { fwLiteProcess.kill('SIGKILL'); } catch { /* ignore */ }
+    }
+  });
+}

Note: On Windows, Node emulates signals; child_process.kill('SIGKILL') forces termination and cannot be handled. This is fine for the fallback. (github.com, nodejs.org)

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function shutdownFwLite(fwLiteProcess: ReturnType<typeof launchFwLiteFwLiteWeb>['fwLiteProcess']): Promise<boolean> {
return new Promise((resolve, reject) => {
fwLiteProcess.once('exit', (code, signal) => {
if (code === 0) {
resolve(true);
} else {
reject(new Error(`FwLiteWeb exited with code ${code}, signal ${signal}`));
}
});
fwLiteProcess.once('error', (error) => {
reject(error);
});
fwLiteProcess.stdin.write('shutdown\n');
fwLiteProcess.stdin.end();
setTimeout(() => {
fwLiteProcess.kill('SIGKILL');
}, 10000);
});
}
function shutdownFwLite(
fwLiteProcess: ReturnType<typeof launchFwLiteFwLiteWeb>['fwLiteProcess'],
): Promise<boolean> {
return new Promise((resolve) => {
// Already exited?
if (fwLiteProcess.exitCode !== null) {
return resolve(fwLiteProcess.exitCode === 0);
}
let forced = false;
const killTimer = setTimeout(() => {
forced = true;
try { fwLiteProcess.kill('SIGKILL'); } catch { /* ignore */ }
}, 10_000);
const onExit = (code: number | null) => {
clearTimeout(killTimer);
// Treat forced kill as a successful shutdown to avoid failing deactivation.
resolve(forced || code === 0);
};
const onError = () => {
clearTimeout(killTimer);
resolve(false);
};
fwLiteProcess.once('exit', onExit);
fwLiteProcess.once('error', onError);
if (fwLiteProcess.stdin && !fwLiteProcess.stdin.destroyed) {
try {
fwLiteProcess.stdin.write('shutdown\n');
fwLiteProcess.stdin.end();
} catch {
// Fall back to forced termination if stdin write fails.
forced = true;
try { fwLiteProcess.kill('SIGKILL'); } catch { /* ignore */ }
}
} else {
forced = true;
try { fwLiteProcess.kill('SIGKILL'); } catch { /* ignore */ }
}
});
}
🤖 Prompt for AI Agents
In platform.bible-extension/src/main.ts around lines 296-315, make
shutdownFwLite idempotent and robust: short-circuit and immediately resolve if
the child already has an exitCode or is killed; install a single timeout kill
timer and clear it when the process exits or an error occurs; on 'exit'
resolve(true) for clean (code === 0) or forced termination (non-null signal or
non-zero code after our kill) instead of rejecting, and resolve(false) only for
genuine errors; on 'error' resolve(false) (do not reject); guard writes to stdin
by checking fwLiteProcess.stdin && fwLiteProcess.stdin.writable and wrapping
write/end in try/catch; ensure listeners/timer are cleaned up exactly once by
using a local finished flag to short-circuit further callbacks.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I already did this

Copy link

Choose a reason for hiding this comment

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

@hahn-kev, I can see in the current code that the shutdownFwLite function still has the original implementation from the diff. Looking at the current version, it still:

  • Uses reject instead of only resolve
  • Doesn't check if the process has already exited
  • Doesn't clear the kill timer on exit/error
  • Doesn't guard the stdin write operations

The function currently looks like this:

function shutdownFwLite(fwLiteProcess: ReturnType<typeof launchFwLiteFwLiteWeb>['fwLiteProcess']): Promise<boolean> {
  return new Promise((resolve, reject) => {
    fwLiteProcess.once('exit', (code, signal) => {
      if (code === 0) {
        resolve(true);
      } else {
        reject(new Error(`FwLiteWeb exited with code ${code}, signal ${signal}`));
      }
    });
    fwLiteProcess.once('error', (error) => {
      reject(error);
    });

    fwLiteProcess.stdin.write('shutdown\n');
    fwLiteProcess.stdin.end();
    setTimeout(() => {
      fwLiteProcess.kill('SIGKILL');
    }, 10000);
  });
}

Would you like me to verify if there are any recent commits that haven't been reflected in this view, or are you planning to implement the suggested improvements in a follow-up commit?

Copy link

argos-ci bot commented Sep 9, 2025

The latest updates on your projects. Learn more about Argos notifications ↗︎

Build Status Details Updated (UTC)
default (Inspect) ✅ No changes detected - Sep 9, 2025, 7:56 AM

Copy link
Collaborator

@myieye myieye left a comment

Choose a reason for hiding this comment

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

LGTM 🙃 😬


function shutdownFwLite(fwLiteProcess: ReturnType<typeof launchFwLiteFwLiteWeb>['fwLiteProcess']): Promise<boolean> {
return new Promise((resolve, _) => {
fwLiteProcess.once('exit', (code, signal) => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Does this make the fwLiteProcess.once('exit', ... in lines 280-282 unnecessary?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
💻 FW Lite issues related to the fw lite application, not miniLcm or crdt related 📙 Platform.Bible
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants