Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/multipart/test/dynamic-option.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ describe('test/dynamic-option.test.ts', () => {
});

afterAll(async () => {
await fs.rm(app.config.multipart.tmpdir, { force: true, recursive: true });
await fs.rm(app.config.multipart.tmpdir, { force: true, recursive: true, maxRetries: 3 });
});
afterAll(() => app.close());
afterAll(() => server.close());
Comment on lines 23 to 27

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.

medium

In Vitest, afterAll hooks are executed sequentially in the order they are registered. Currently, the temporary directory removal (fs.rm) is registered before app.close() and server.close(). This means the test suite attempts to delete the temporary directory while the server and application are still active and potentially holding open file handles or writing files, which is the primary cause of the flaky ENOTEMPTY / EBUSY errors.

Reordering the hooks so that server.close() and app.close() run first ensures that all network activity has ceased and all file handles are released before fs.rm is executed.

Suggested change
afterAll(async () => {
await fs.rm(app.config.multipart.tmpdir, { force: true, recursive: true });
await fs.rm(app.config.multipart.tmpdir, { force: true, recursive: true, maxRetries: 3 });
});
afterAll(() => app.close());
afterAll(() => server.close());
afterAll(() => server.close());
afterAll(() => app.close());
afterAll(async () => {
await fs.rm(app.config.multipart.tmpdir, { force: true, recursive: true, maxRetries: 3 });
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks, but this rests on an incorrect premise about Vitest. Vitest's default sequence.hooks is 'stack', so afterAll hooks run in reverse registration order — verified empirically on v4.1.9 (3rd-registered runs first, 1st-registered last). The current order therefore already executes server.close()app.close()fs.rm(...): the tmpdir is removed last, after the server and app are closed. Applying the suggested reorder would move fs.rm to run first, reintroducing exactly the race described. The residual ENOTEMPTY is a transient FS-level race during the recursive removal (macOS APFS / async per-request file cleanup draining), which maxRetries: 3 handles. Keeping the current order + maxRetries.

Expand Down
2 changes: 1 addition & 1 deletion plugins/multipart/test/enable-pathToRegexpModule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ describe.skip('test/enable-pathToRegexpModule.test.ts', () => {
host = 'http://127.0.0.1:' + server.address().port;
});
afterAll(async () => {
await fs.rm(app.config.multipart.tmpdir, { force: true, recursive: true });
await fs.rm(app.config.multipart.tmpdir, { force: true, recursive: true, maxRetries: 3 });
});
afterAll(() => app.close());
afterAll(() => server.close());
Comment on lines 25 to 29

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.

medium

In Vitest, afterAll hooks are executed sequentially in the order they are registered. Currently, the temporary directory removal (fs.rm) is registered before app.close() and server.close(). This means the test suite attempts to delete the temporary directory while the server and application are still active and potentially holding open file handles or writing files, which is the primary cause of the flaky ENOTEMPTY / EBUSY errors.

Reordering the hooks so that server.close() and app.close() run first ensures that all network activity has ceased and all file handles are released before fs.rm is executed.

Suggested change
afterAll(async () => {
await fs.rm(app.config.multipart.tmpdir, { force: true, recursive: true });
await fs.rm(app.config.multipart.tmpdir, { force: true, recursive: true, maxRetries: 3 });
});
afterAll(() => app.close());
afterAll(() => server.close());
afterAll(() => server.close());
afterAll(() => app.close());
afterAll(async () => {
await fs.rm(app.config.multipart.tmpdir, { force: true, recursive: true, maxRetries: 3 });
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks, but this rests on an incorrect premise about Vitest. Vitest's default sequence.hooks is 'stack', so afterAll hooks run in reverse registration order — verified empirically on v4.1.9 (3rd-registered runs first, 1st-registered last). The current order therefore already executes server.close()app.close()fs.rm(...): the tmpdir is removed last, after the server and app are closed. Applying the suggested reorder would move fs.rm to run first, reintroducing exactly the race described. The residual ENOTEMPTY is a transient FS-level race during the recursive removal (macOS APFS / async per-request file cleanup draining), which maxRetries: 3 handles. Keeping the current order + maxRetries.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ describe('test/file-mode-limit-filesize-per-request.test.ts', () => {
host = 'http://127.0.0.1:' + server.address().port;
});
afterAll(async () => {
await fs.rm(app.config.multipart.tmpdir, { force: true, recursive: true });
await fs.rm(app.config.multipart.tmpdir, { force: true, recursive: true, maxRetries: 3 });
});
afterAll(() => app.close());
afterAll(() => server.close());
Comment on lines 24 to 28

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.

medium

In Vitest, afterAll hooks are executed sequentially in the order they are registered. Currently, the temporary directory removal (fs.rm) is registered before app.close() and server.close(). This means the test suite attempts to delete the temporary directory while the server and application are still active and potentially holding open file handles or writing files, which is the primary cause of the flaky ENOTEMPTY / EBUSY errors.

Reordering the hooks so that server.close() and app.close() run first ensures that all network activity has ceased and all file handles are released before fs.rm is executed.

Suggested change
afterAll(async () => {
await fs.rm(app.config.multipart.tmpdir, { force: true, recursive: true });
await fs.rm(app.config.multipart.tmpdir, { force: true, recursive: true, maxRetries: 3 });
});
afterAll(() => app.close());
afterAll(() => server.close());
afterAll(() => server.close());
afterAll(() => app.close());
afterAll(async () => {
await fs.rm(app.config.multipart.tmpdir, { force: true, recursive: true, maxRetries: 3 });
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks, but this rests on an incorrect premise about Vitest. Vitest's default sequence.hooks is 'stack', so afterAll hooks run in reverse registration order — verified empirically on v4.1.9 (3rd-registered runs first, 1st-registered last). The current order therefore already executes server.close()app.close()fs.rm(...): the tmpdir is removed last, after the server and app are closed. Applying the suggested reorder would move fs.rm to run first, reintroducing exactly the race described. The residual ENOTEMPTY is a transient FS-level race during the recursive removal (macOS APFS / async per-request file cleanup draining), which maxRetries: 3 handles. Keeping the current order + maxRetries.

Expand Down
2 changes: 1 addition & 1 deletion plugins/multipart/test/file-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ describe('test/file-mode.test.ts', () => {
host = 'http://127.0.0.1:' + server.address().port;
});
afterAll(async () => {
await fs.rm(app.config.multipart.tmpdir, { force: true, recursive: true });
await fs.rm(app.config.multipart.tmpdir, { force: true, recursive: true, maxRetries: 3 });
});
afterAll(() => app.close());
afterAll(() => server.close());
Comment on lines 30 to 34

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.

medium

In Vitest, afterAll hooks are executed sequentially in the order they are registered. Currently, the temporary directory removal (fs.rm) is registered before app.close() and server.close(). This means the test suite attempts to delete the temporary directory while the server and application are still active and potentially holding open file handles or writing files, which is the primary cause of the flaky ENOTEMPTY / EBUSY errors.

Reordering the hooks so that server.close() and app.close() run first ensures that all network activity has ceased and all file handles are released before fs.rm is executed.

Suggested change
afterAll(async () => {
await fs.rm(app.config.multipart.tmpdir, { force: true, recursive: true });
await fs.rm(app.config.multipart.tmpdir, { force: true, recursive: true, maxRetries: 3 });
});
afterAll(() => app.close());
afterAll(() => server.close());
afterAll(() => server.close());
afterAll(() => app.close());
afterAll(async () => {
await fs.rm(app.config.multipart.tmpdir, { force: true, recursive: true, maxRetries: 3 });
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks, but this rests on an incorrect premise about Vitest. Vitest's default sequence.hooks is 'stack', so afterAll hooks run in reverse registration order — verified empirically on v4.1.9 (3rd-registered runs first, 1st-registered last). The current order therefore already executes server.close()app.close()fs.rm(...): the tmpdir is removed last, after the server and app are closed. Applying the suggested reorder would move fs.rm to run first, reintroducing exactly the race described. The residual ENOTEMPTY is a transient FS-level race during the recursive removal (macOS APFS / async per-request file cleanup draining), which maxRetries: 3 handles. Keeping the current order + maxRetries.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ describe('test/stream-mode-with-filematch-glob.test.ts', () => {
});
afterAll(async () => {
try {
await fs.rm(app.config.multipart.tmpdir, { force: true, recursive: true });
await fs.rm(app.config.multipart.tmpdir, { force: true, recursive: true, maxRetries: 3 });
} catch (err) {
console.error(err);
}
Expand Down
2 changes: 1 addition & 1 deletion plugins/multipart/test/stream-mode-with-filematch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ describe('test/stream-mode-with-filematch.test.ts', () => {
});
afterAll(async () => {
try {
await fs.rm(app.config.multipart.tmpdir, { force: true, recursive: true });
await fs.rm(app.config.multipart.tmpdir, { force: true, recursive: true, maxRetries: 3 });
} catch (err) {
console.error(err);
}
Expand Down
2 changes: 1 addition & 1 deletion plugins/multipart/test/ts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ describe('test/ts.test.ts', () => {
host = 'http://127.0.0.1:' + server.address().port;
});
afterAll(() => {
return fs.rm(app.config.multipart.tmpdir, { force: true, recursive: true });
return fs.rm(app.config.multipart.tmpdir, { force: true, recursive: true, maxRetries: 3 });
});
afterAll(() => app.close());
afterAll(() => server.close());
Comment on lines 27 to 31

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.

medium

In Vitest, afterAll hooks are executed sequentially in the order they are registered. Currently, the temporary directory removal (fs.rm) is registered before app.close() and server.close(). This means the test suite attempts to delete the temporary directory while the server and application are still active and potentially holding open file handles or writing files, which is the primary cause of the flaky ENOTEMPTY / EBUSY errors.

Reordering the hooks so that server.close() and app.close() run first ensures that all network activity has ceased and all file handles are released before fs.rm is executed.

Suggested change
afterAll(() => {
return fs.rm(app.config.multipart.tmpdir, { force: true, recursive: true });
return fs.rm(app.config.multipart.tmpdir, { force: true, recursive: true, maxRetries: 3 });
});
afterAll(() => app.close());
afterAll(() => server.close());
afterAll(() => server.close());
afterAll(() => app.close());
afterAll(() => {
return fs.rm(app.config.multipart.tmpdir, { force: true, recursive: true, maxRetries: 3 });
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks, but this rests on an incorrect premise about Vitest. Vitest's default sequence.hooks is 'stack', so afterAll hooks run in reverse registration order — verified empirically on v4.1.9 (3rd-registered runs first, 1st-registered last). The current order therefore already executes server.close()app.close()fs.rm(...): the tmpdir is removed last, after the server and app are closed. Applying the suggested reorder would move fs.rm to run first, reintroducing exactly the race described. The residual ENOTEMPTY is a transient FS-level race during the recursive removal (macOS APFS / async per-request file cleanup draining), which maxRetries: 3 handles. Keeping the current order + maxRetries.

Expand Down
28 changes: 22 additions & 6 deletions tools/scripts/test/stop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { mm, restore } from 'mm';
import { describe, it, beforeAll, afterAll, beforeEach, afterEach, expect } from 'vitest';

import { isWindows } from '../src/helper.ts';
import { cleanup, replaceWeakRefMessage, type Coffee } from './utils.ts';
import { cleanup, replaceWeakRefMessage, waitFor, type Coffee } from './utils.ts';

const __dirname = import.meta.dirname;

Expand Down Expand Up @@ -45,7 +45,9 @@ describe('test/stop.test.ts', () => {
]) as Coffee;
app.debug();
app.expect('code', 0);
await scheduler.wait(waitTime);
// Poll until the app reports it has started instead of a fixed delay: a 2-worker
// egg app can take well over a second to boot on a loaded CI runner.
await waitFor(() => app.stdout, /custom-framework started on http:\/\/127\.0\.0\.1:\d+/);

expect(replaceWeakRefMessage(app.stderr)).toBe('');
expect(app.stdout).toMatch(/custom-framework started on http:\/\/127\.0\.0\.1:\d+/);
Expand Down Expand Up @@ -155,7 +157,8 @@ describe('test/stop.test.ts', () => {
]) as Coffee;
// app.debug();
app.expect('code', 0);
await scheduler.wait(waitTime);
// Poll until the app reports it has started (see note above).
await waitFor(() => app.stdout, /custom-framework started on http:\/\/127\.0\.0\.1:\d+/);

expect(replaceWeakRefMessage(app.stderr)).toBe('');
expect(app.stdout).toMatch(/custom-framework started on http:\/\/127\.0\.0\.1:\d+/);
Expand Down Expand Up @@ -314,7 +317,8 @@ describe('test/stop.test.ts', () => {
app.debug();
app.expect('code', 0);

await scheduler.wait(waitTime);
// Poll until the app reports it has started (see note above).
await waitFor(() => app.stdout, /http:\/\/127\.0\.0\.1:\d+/);

// assert.equal(replaceWeakRefMessage(app.stderr), '');
expect(app.stdout).toMatch(/http:\/\/127\.0\.0\.1:\d+/);
Expand All @@ -332,7 +336,13 @@ describe('test/stop.test.ts', () => {
killer.debug();
killer.expect('code', 0);
await killer.end();
await scheduler.wait(waitTime);
// Poll until the app logs its shutdown instead of a fixed delay. On Windows the
// SIGTERM signal is not handled (no shutdown log), so keep a fixed settle there.
if (isWindows) {
await scheduler.wait(waitTime);
} else {
await waitFor(() => app.stdout, /\[master] master is killed by signal SIGTERM, closing/);
}

// make sure is kill not auto exist
expect(app.stdout).not.toMatch(/exist by env/);
Expand All @@ -356,7 +366,13 @@ describe('test/stop.test.ts', () => {
killer.expect('code', 0);

// await killer.end();
await scheduler.wait(waitTime);
// Poll until the app has fully exited (master exits last, after workers/agent),
// instead of a fixed delay. On Windows the signal is not handled, so settle fixed.
if (isWindows) {
await scheduler.wait(waitTime);
} else {
await waitFor(() => app.stdout, /\[master] exit with code:0/);
}

// make sure is kill not auto exist
expect(app.stdout).not.toMatch(/exist by env/);
Expand Down
16 changes: 16 additions & 0 deletions tools/scripts/test/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,19 @@ export function replaceWeakRefMessage(stderr: string) {
}
return stderr;
}

/**
* Poll until `getText()` matches `pattern`, up to `timeout` ms (default 10s),
* checking every 100ms. Returns as soon as it matches, and resolves anyway on
* timeout so the caller's own `expect(...).toMatch(...)` still produces a useful
* diff. Use this instead of a fixed `scheduler.wait(n)` before asserting on a
* forked process's stdout: a loaded CI runner may not have finished booting (or
* shutting down) within `n`, which is the classic source of these flaky timeouts.
*/
export async function waitFor(getText: () => string, pattern: RegExp, timeout = 10000): Promise<void> {
const deadline = Date.now() + timeout;
while (!pattern.test(getText())) {
Comment on lines +67 to +69

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP --type=ts 'waitFor\s*\([^,]+,\s*/(?:\\.|[^/])+/[a-z]*[gy][a-z]*' tools/scripts/test

Repository: eggjs/egg

Length of output: 147


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== utils.ts ==\n'
sed -n '1,180p' tools/scripts/test/utils.ts

printf '\n== waitFor call sites ==\n'
rg -n --type=ts 'waitFor\s*\(' tools/scripts/test

Repository: eggjs/egg

Length of output: 3423


Reset regex state before polling. RegExp.test() advances lastIndex for /g and /y, so a stateful regex can make this loop miss a match or behave inconsistently. Clone the regex or set lastIndex = 0 before each test.

🧰 Tools
🪛 ast-grep (0.44.0)

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { ChildProcess } from 'node:child_process';
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/scripts/test/utils.ts` around lines 67 - 69, Reset the regex state in
waitFor before each poll: RegExp.test() on a stateful pattern with /g or /y can
advance lastIndex and cause missed matches, so update waitFor to use a fresh
RegExp instance or explicitly set pattern.lastIndex = 0 before calling
pattern.test(getText()).

if (Date.now() >= deadline) return;
await scheduler.wait(100);
}
}
Loading