Skip to content

Commit 6c174c3

Browse files
authored
fix(cli): exit gracefully (#1144)
1 parent e13ef5e commit 6c174c3

6 files changed

Lines changed: 257 additions & 3 deletions

File tree

.changeset/sour-lemons-cry.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"lingo.dev": patch
3+
---
4+
5+
exit cli gracefully

packages/cli/src/cli/cmd/i18n.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,7 @@ import createProcessor from "../processor";
2424
import { withExponentialBackoff } from "../utils/exp-backoff";
2525
import trackEvent from "../utils/observability";
2626
import { createDeltaProcessor } from "../utils/delta";
27-
import { tryReadFile, writeFile } from "../utils/fs";
28-
import { flatten, unflatten } from "flat";
27+
import { exitGracefully } from "../utils/exit-gracefully";
2928

3029
export default new Command()
3130
.command("i18n")
@@ -510,6 +509,7 @@ export default new Command()
510509
flags,
511510
});
512511
}
512+
exitGracefully();
513513
} catch (error: any) {
514514
ora.fail(error.message);
515515

packages/cli/src/cli/cmd/run/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ import {
1212
pauseIfDebug,
1313
renderSummary,
1414
} from "../../utils/ui";
15-
import chalk from "chalk";
1615
import trackEvent from "../../utils/observability";
1716
import { determineAuthId } from "./_utils";
17+
import { exitGracefully } from "../../utils/exit-gracefully";
1818

1919
export default new Command()
2020
.command("run")
@@ -117,6 +117,7 @@ export default new Command()
117117
config: ctx.config,
118118
flags: ctx.flags,
119119
});
120+
exitGracefully();
120121
} catch (error: any) {
121122
trackEvent(authId || "unknown", "cmd.run.error", {});
122123
process.exit(1);

packages/cli/src/cli/cmd/status.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import Table from "cli-table3";
2020
import { createDeltaProcessor } from "../utils/delta";
2121
import trackEvent from "../utils/observability";
2222
import { minimatch } from "minimatch";
23+
import { exitGracefully } from "../utils/exit-gracefully";
2324

2425
// Define types for our language stats
2526
interface LanguageStats {
@@ -626,6 +627,7 @@ export default new Command()
626627
totalWordsToTranslate,
627628
authenticated: !!authId,
628629
});
630+
exitGracefully();
629631
} catch (error: any) {
630632
ora.fail(error.message);
631633
trackEvent(authId || "status", "cmd.status.error", {
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
import { exitGracefully } from "./exit-gracefully";
3+
4+
// Mock process.exit
5+
const mockExit: any = vi.fn();
6+
const originalProcess = global.process;
7+
8+
describe("exitGracefully", () => {
9+
beforeEach(() => {
10+
// Mock process.exit
11+
vi.spyOn(process, "exit").mockImplementation(mockExit);
12+
13+
// Mock process._getActiveHandles and _getActiveRequests
14+
Object.defineProperty(process, "_getActiveHandles", {
15+
value: vi.fn(),
16+
writable: true,
17+
});
18+
Object.defineProperty(process, "_getActiveRequests", {
19+
value: vi.fn(),
20+
writable: true,
21+
});
22+
});
23+
24+
afterEach(() => {
25+
vi.restoreAllMocks();
26+
vi.clearAllTimers();
27+
mockExit.mockClear();
28+
});
29+
30+
it("should exit immediately when no pending operations", () => {
31+
// Mock no pending operations
32+
(process as any)._getActiveHandles.mockReturnValue([]);
33+
(process as any)._getActiveRequests.mockReturnValue([]);
34+
35+
exitGracefully();
36+
37+
expect(mockExit).toHaveBeenCalledWith(0);
38+
});
39+
40+
it("should wait and retry when there are pending operations", () => {
41+
vi.useFakeTimers();
42+
43+
// Mock pending operations
44+
(process as any)._getActiveHandles.mockReturnValue([
45+
{ hasRef: () => true, close: () => {} },
46+
]);
47+
(process as any)._getActiveRequests.mockReturnValue([]);
48+
49+
exitGracefully();
50+
51+
// Should not exit immediately
52+
expect(mockExit).not.toHaveBeenCalled();
53+
54+
// Fast-forward time to trigger retry
55+
vi.advanceTimersByTime(250);
56+
57+
// Should still not exit if operations are pending
58+
expect(mockExit).not.toHaveBeenCalled();
59+
60+
// Fast-forward to max wait time
61+
vi.advanceTimersByTime(1750);
62+
63+
// Should exit after max wait time
64+
expect(mockExit).toHaveBeenCalledWith(0);
65+
});
66+
67+
it("should exit after max wait interval even with pending operations", () => {
68+
vi.useFakeTimers();
69+
70+
// Mock persistent pending operations
71+
(process as any)._getActiveHandles.mockReturnValue([
72+
{ hasRef: () => true, close: () => {} },
73+
]);
74+
(process as any)._getActiveRequests.mockReturnValue([]);
75+
76+
exitGracefully();
77+
78+
// Fast-forward to max wait time (2000ms)
79+
vi.advanceTimersByTime(2000);
80+
81+
expect(mockExit).toHaveBeenCalledWith(0);
82+
});
83+
84+
it("should handle standard process handles correctly", () => {
85+
// Mock only standard handles
86+
(process as any)._getActiveHandles.mockReturnValue([
87+
process.stdin,
88+
process.stdout,
89+
process.stderr,
90+
]);
91+
(process as any)._getActiveRequests.mockReturnValue([]);
92+
93+
exitGracefully();
94+
95+
// Should exit immediately as standard handles are filtered out
96+
expect(mockExit).toHaveBeenCalledWith(0);
97+
});
98+
99+
it("should handle timers without ref correctly", () => {
100+
// Mock timers without ref
101+
(process as any)._getActiveHandles.mockReturnValue([
102+
{ hasRef: () => false },
103+
]);
104+
(process as any)._getActiveRequests.mockReturnValue([]);
105+
106+
exitGracefully();
107+
108+
// Should exit immediately as timers without ref are filtered out
109+
expect(mockExit).toHaveBeenCalledWith(0);
110+
});
111+
112+
it("should detect file watchers correctly", () => {
113+
// Mock file watcher handles
114+
(process as any)._getActiveHandles.mockReturnValue([{ close: () => {} }]);
115+
(process as any)._getActiveRequests.mockReturnValue([]);
116+
117+
exitGracefully();
118+
119+
// Should not exit immediately due to file watcher
120+
expect(mockExit).not.toHaveBeenCalled();
121+
});
122+
123+
it("should detect pending requests correctly", () => {
124+
// Mock pending requests
125+
(process as any)._getActiveHandles.mockReturnValue([]);
126+
(process as any)._getActiveRequests.mockReturnValue([
127+
{ someRequest: true },
128+
]);
129+
130+
exitGracefully();
131+
132+
// Should not exit immediately due to pending requests
133+
expect(mockExit).not.toHaveBeenCalled();
134+
});
135+
136+
it("should handle elapsed time parameter correctly", () => {
137+
vi.useFakeTimers();
138+
139+
// Mock pending operations
140+
(process as any)._getActiveHandles.mockReturnValue([
141+
{ hasRef: () => true, close: () => {} },
142+
]);
143+
(process as any)._getActiveRequests.mockReturnValue([]);
144+
145+
// Start with 1500ms already elapsed
146+
exitGracefully(1500);
147+
148+
// Should exit after 500ms more (reaching 2000ms max)
149+
vi.advanceTimersByTime(500);
150+
151+
expect(mockExit).toHaveBeenCalledWith(0);
152+
});
153+
154+
it("should exit immediately when elapsed time exceeds max wait interval", () => {
155+
// Mock pending operations but start with elapsed time > 2000ms
156+
(process as any)._getActiveHandles.mockReturnValue([
157+
{ hasRef: () => true, close: () => {} },
158+
]);
159+
(process as any)._getActiveRequests.mockReturnValue([]);
160+
161+
exitGracefully(2500);
162+
163+
// Should exit immediately as elapsed time exceeds max wait interval
164+
expect(mockExit).toHaveBeenCalledWith(0);
165+
});
166+
167+
it("should handle mixed types of pending operations", () => {
168+
vi.useFakeTimers();
169+
170+
// Mock mixed pending operations
171+
(process as any)._getActiveHandles.mockReturnValue([
172+
{ hasRef: () => true, close: () => {} },
173+
{ hasRef: () => false }, // Timer without ref
174+
process.stdin, // Standard handle
175+
]);
176+
(process as any)._getActiveRequests.mockReturnValue([
177+
{ someRequest: true },
178+
]);
179+
180+
exitGracefully();
181+
182+
// Should not exit immediately due to mixed pending operations
183+
expect(mockExit).not.toHaveBeenCalled();
184+
185+
// Fast-forward to max wait time
186+
vi.advanceTimersByTime(2000);
187+
188+
expect(mockExit).toHaveBeenCalledWith(0);
189+
});
190+
});
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
const STEP_WAIT_INTERVAL = 250;
2+
const MAX_WAIT_INTERVAL = 2000;
3+
4+
export function exitGracefully(elapsedMs = 0) {
5+
// Check if there are any pending operations
6+
const hasPendingOperations = checkForPendingOperations();
7+
8+
if (hasPendingOperations && elapsedMs < MAX_WAIT_INTERVAL) {
9+
// Wait a bit longer if there are pending operations
10+
setTimeout(
11+
() => exitGracefully(elapsedMs + STEP_WAIT_INTERVAL),
12+
STEP_WAIT_INTERVAL,
13+
);
14+
} else {
15+
// Exit immediately if no pending operations
16+
process.exit(0);
17+
}
18+
}
19+
20+
function checkForPendingOperations(): boolean {
21+
// Check for active handles and requests using internal Node.js methods
22+
const activeHandles = (process as any)._getActiveHandles?.() || [];
23+
const activeRequests = (process as any)._getActiveRequests?.() || [];
24+
25+
// Filter out standard handles that are always present
26+
const nonStandardHandles = activeHandles.filter((handle: any) => {
27+
// Skip standard handles like process.stdin, process.stdout, etc.
28+
if (
29+
handle === process.stdin ||
30+
handle === process.stdout ||
31+
handle === process.stderr
32+
) {
33+
return false;
34+
}
35+
// Skip timers that are part of the normal process
36+
if (
37+
handle &&
38+
typeof handle === "object" &&
39+
"hasRef" in handle &&
40+
!handle.hasRef()
41+
) {
42+
return false;
43+
}
44+
return true;
45+
});
46+
47+
// Check if there are any file watchers or other async operations
48+
const hasFileWatchers = nonStandardHandles.some(
49+
(handle: any) => handle && typeof handle === "object" && "close" in handle,
50+
);
51+
52+
// Check for pending promises or async operations
53+
const hasPendingPromises = activeRequests.length > 0;
54+
55+
return nonStandardHandles.length > 0 || hasFileWatchers || hasPendingPromises;
56+
}

0 commit comments

Comments
 (0)