Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/cluster/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"egg-logger": "catalog:",
"get-ready": "catalog:",
"graceful-process": "catalog:",
"onelogger": "^1.0.1",

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

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

This repo uses pnpm catalog mode and already defines onelogger in the workspace catalog; this package should reference it via "catalog:" (consistent with the other deps here) instead of hard-coding ^1.0.1, to keep dependency versions centralized.

Suggested change
"onelogger": "^1.0.1",
"onelogger": "catalog:",

Copilot uses AI. Check for mistakes.
"sendmessage": "catalog:",
"terminal-link": "catalog:",
"utility": "catalog:"
Expand Down
20 changes: 20 additions & 0 deletions packages/cluster/src/app_worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { debuglog } from 'node:util';
import { importModule } from '@eggjs/utils';
import { EggConsoleLogger as ConsoleLogger } from 'egg-logger';

import { ipcLogger, formatIpcMessage, internalIpcLogEnabled } from './utils/ipc_logger.ts';
import { BaseAppWorker } from './utils/mode/base/app.ts';
import { AppProcessWorker } from './utils/mode/impl/process/app.ts';
import { AppThreadWorker } from './utils/mode/impl/worker_threads/app.ts';
Expand Down Expand Up @@ -48,6 +49,25 @@ async function main() {
AppWorker = AppThreadWorker as any;
} else {
AppWorker = AppProcessWorker as any;
// D. master -> app (recv): log every IPC message delivered to this worker via the cluster channel.
// Handle is present when master forwards a net.Socket (sticky-session mode).
// Read-only listener; other `process.on('message')` listeners (framework, sticky handler, etc.)
// are unaffected.
process.on('message', (msg: any, handle: any) => {
const body = typeof msg === 'string' ? { action: msg } : msg;
ipcLogger.info(formatIpcMessage(`app#${process.pid}<-master`, body, handle));
});
Comment on lines +56 to +59

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.

high

This IPC listener is always active and performs logging on every message received from the master. To avoid performance issues in production, this should be guarded by an opt-in check similar to the internal messages below.

Suggested change
process.on('message', (msg: any, handle: any) => {
const body = typeof msg === 'string' ? { action: msg } : msg;
ipcLogger.info(formatIpcMessage(`app#${process.pid}<-master`, body, handle));
});
if (internalIpcLogEnabled) {
process.on('message', (msg: any, handle: any) => {
const body = typeof msg === 'string' ? { action: msg } : msg;
ipcLogger.info(formatIpcMessage('app#' + process.pid + '<-master', body, handle));
});
}


// F. master -> app internal NODE_CLUSTER messages (newconn with fd, disconnect, suicide, ...).
// `internalMessage` is an undocumented but stable Node.js event.
// Opt-in via EGG_CLUSTER_IPC_LOG because `newconn` fires once per HTTP request.
if (internalIpcLogEnabled) {
process.on('internalMessage', (msg: { cmd?: string; act?: string; ack?: number }, handle: unknown) => {
if (!msg || msg.cmd !== 'NODE_CLUSTER') return;
const label = msg.act ? `cluster:${msg.act}` : `cluster:ack#${msg.ack ?? '?'}`;
ipcLogger.info(formatIpcMessage(`app#${process.pid}<-master`, { action: label, data: msg }, handle));
});
}
}

const consoleLogger = new ConsoleLogger({
Expand Down
6 changes: 6 additions & 0 deletions packages/cluster/src/master.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import terminalLink from 'terminal-link';
import { readJSONSync } from 'utility';

import { ClusterWorkerExceptionError } from './error/ClusterWorkerExceptionError.ts';
import { ipcLogger, formatIpcMessage } from './utils/ipc_logger.ts';

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

Import internalIpcLogEnabled to guard the sticky-session logging point.

Suggested change
import { ipcLogger, formatIpcMessage } from './utils/ipc_logger.ts';
import { ipcLogger, formatIpcMessage, internalIpcLogEnabled } from './utils/ipc_logger.ts';

import { Messenger } from './utils/messenger.ts';
import { AgentProcessWorker, AgentProcessUtils as ProcessAgentWorker } from './utils/mode/impl/process/agent.ts';
import { AppProcessWorker, AppProcessUtils as ProcessAppWorker } from './utils/mode/impl/process/app.ts';
Expand Down Expand Up @@ -291,6 +292,11 @@ export class Master extends ReadyEventEmitter {
connection.destroy();
} else {
const worker = this.stickyWorker(connection.remoteAddress) as AppProcessWorker;
// A'. master -> app sticky-session direct send (bypasses AppProcessWorker#send),
// carries a net.Socket handle — safe-printed by formatIpcMessage's replacer.
ipcLogger.info(
formatIpcMessage(`master->app#${worker.workerId}`, { action: 'sticky-session:connection' }, connection),
);
Comment on lines +297 to +299

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.

high

Logging every sticky-session connection in the master process can be expensive. This should be guarded by an opt-in check.

            if (internalIpcLogEnabled) {
              ipcLogger.info(
                formatIpcMessage('master->app#' + worker.workerId, { action: 'sticky-session:connection' }, connection),
              );
            }

Comment on lines +295 to +299

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.

⚠️ Potential issue | 🟠 Major

Gate sticky-session connection logs behind the verbose flag.

This runs on the accept path before worker.instance.send() for every incoming socket. In sticky mode that adds synchronous formatting + logger work to the hottest path in the module and can materially reduce accept throughput under load. Please make this opt-in like the internal cluster logs, or drop it to a debug-only path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cluster/src/master.ts` around lines 295 - 299, The sticky-session
connection log (ipcLogger.info call that formats via formatIpcMessage for
`master->app#${worker.workerId}` with action 'sticky-session:connection') is on
the hot accept path; guard it behind a verbose flag or demote it to debug to
avoid synchronous formatting on every accept. Modify the code around the
ipcLogger.info invocation in master.ts so you only call formatIpcMessage and
ipcLogger.info when a verbose/debug mode is enabled (e.g., check an existing
ipcLogger.isVerbose/isDebug flag or a config option) or replace the call with
ipcLogger.debug to ensure the expensive stringification is skipped in normal
runs.

worker.instance.send('sticky-session:connection', connection);
}
},
Expand Down
86 changes: 86 additions & 0 deletions packages/cluster/src/utils/ipc_logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import net from 'node:net';

import { getLogger } from 'onelogger';

import type { MessageBody } from './messenger.ts';

/**
* onelogger instance for Node.js cluster module IPC traffic between master and app workers.
* Users can override the underlying sink via `onelogger.setLogger()` / `setCustomLogger()`.
*/
export const ipcLogger = getLogger('egg/cluster/ipc');

/**
* Whether internal-level cluster IPC logs (NODE_CLUSTER newconn/accepted/listening/...) are enabled.
* These are very verbose (one `cluster:newconn` per HTTP request), so they are opt-in
* via the `EGG_CLUSTER_IPC_LOG` environment variable (any truthy value enables).
*/
export const internalIpcLogEnabled = !!process.env.EGG_CLUSTER_IPC_LOG;

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.

high

The 'Application layer' IPC logging (Points A, B, C, D) is currently always enabled. Since formatIpcMessage performs a JSON.stringify on every message, this introduces significant performance overhead and event loop blocking in high-throughput scenarios. It is highly recommended to make all IPC logging opt-in via an environment variable (e.g., EGG_CLUSTER_IPC_LOG) to prevent unexpected performance degradation in production.

Comment on lines +13 to +18

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.

⚠️ Potential issue | 🟡 Minor

Parse EGG_CLUSTER_IPC_LOG explicitly.

!!process.env.EGG_CLUSTER_IPC_LOG treats 0, false, and no as enabled because they are non-empty strings. That makes the verbose internal listener easy to turn on by accident. Please check for an explicit allowlist such as '1' / 'true'.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cluster/src/utils/ipc_logger.ts` around lines 13 - 18,
internalIpcLogEnabled currently coerces any non-empty EGG_CLUSTER_IPC_LOG string
to true, enabling verbose IPC logs for values like "0" or "no"; change its logic
to explicitly parse allowed truthy values (e.g., '1' and 'true'
case-insensitively) by reading process.env.EGG_CLUSTER_IPC_LOG and checking
against an allowlist such as new Set(['1','true']), normalizing case and
trimming, so only explicit intent enables the flag (update the exported constant
internalIpcLogEnabled accordingly).


const MAX_STRING_LEN = 200;
const MAX_TOTAL_LEN = 1024;

function describeHandle(handle: unknown): string {
if (handle == null) return '';
if (handle instanceof net.Socket) {
const fd = (handle as unknown as { _handle?: { fd?: number } })._handle?.fd;

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

Accessing the internal _handle property of a net.Socket is brittle as it is not part of the public Node.js API and its structure may change between versions. While useful for debugging, this should be handled with caution.

return fd != null ? `<Socket fd=${fd}>` : '<Socket>';
}
if (handle instanceof net.Server) {
return '<Server>';
}
const ctor = (handle as { constructor?: { name?: string } })?.constructor?.name;
return ctor ? `<${ctor}>` : '<handle>';
}

function makeReplacer() {
const seen = new WeakSet<object>();
return function replacer(_key: string, value: unknown) {
if (value && typeof value === 'object') {
if (seen.has(value as object)) return '<Circular>';
seen.add(value as object);
if (value instanceof net.Socket) return describeHandle(value);
if (value instanceof net.Server) return '<Server>';
if (Buffer.isBuffer(value)) return `<Buffer len=${value.length}>`;
}
if (typeof value === 'string' && value.length > MAX_STRING_LEN) {
return `${value.slice(0, MAX_STRING_LEN)}...(truncated)`;
}
return value;
};
}

function stringifyData(data: unknown): string {
let out: string;
try {
out = JSON.stringify(data, makeReplacer());
} catch (err) {
Comment on lines +53 to +57

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

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

The new stringifyData()/replacer logic is central to keeping IPC logging safe (circular refs, Buffer handling, truncation). There are existing Vitest tests in this package; please add unit tests covering circular structures, long-string truncation, Buffer formatting, and total-length capping to prevent regressions.

Copilot uses AI. Check for mistakes.
return `<unserializable: ${(err as Error).message}>`;
}
if (out && out.length > MAX_TOTAL_LEN) {
out = `${out.slice(0, MAX_TOTAL_LEN)}...(truncated)`;
}
Comment on lines +60 to +62

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

Truncating the stringified output after JSON.stringify has completed does not prevent the initial memory allocation and CPU usage required to serialize a potentially massive object. If data is very large, this could lead to performance degradation or even Out-of-Memory (OOM) issues before the truncation occurs. Consider implementing a more efficient way to limit serialization depth or size.

return out;
Comment on lines +53 to +63

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.

⚠️ Potential issue | 🟠 Major

Do not serialize arbitrary IPC payloads into always-on logs.

This formatter emits msg.data for every normal app/master IPC log, and truncation does not prevent leaking tokens, cookies, or config secrets. Since the send/receive hooks are unconditional, any sensitive IPC payload becomes log output by default. Please switch this to metadata-only logging, or add redaction/allowlisting before serializing data.

As per coding guidelines: "Never expose sensitive configuration values in logs or error messages".

Also applies to: 72-80

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cluster/src/utils/ipc_logger.ts` around lines 53 - 63, The current
stringifyData function unconditionally JSON-serializes IPC payloads (using
makeReplacer()) and returns truncated output, which can leak secrets; change
this to only log safe metadata or perform explicit allowlist/redaction before
serializing. Update stringifyData (and the related serializer used around lines
72-80) to: 1) detect and return only non-sensitive metadata fields (e.g.,
message type, size, sender id, timestamp) instead of full payload by default, 2)
implement an allowlist of keys that may be serialized and replace all other keys
with a placeholder like "<redacted>", or 3) if full payload must be logged for a
specific allowlisted message type, apply a strict makeReplacer that masks values
for common secret keys (password, token, cookie, secret, apiKey) before
JSON.stringify; ensure the function returns no raw payload content unless
explicitly allowlisted.

}

/**
* Format a single IPC message into a one-line log string.
* @param direction e.g. 'master->app#12345' / 'app#12345<-master'
* @param msg the message body (supports cluster internal msgs via `action: 'cluster:<act>'`)
* @param handle optional handle (net.Socket / net.Server / TCP) attached to the IPC message
*/
export function formatIpcMessage(
direction: string,
msg: Partial<MessageBody> & { action?: string; data?: unknown },
handle?: unknown,
): string {
const action = msg?.action ?? '<unknown>';
let out = `[${direction}] action=${action}`;
if (msg && msg.data !== undefined) {
out += ` data=${stringifyData(msg.data)}`;
}
Comment on lines +79 to +81

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

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

formatIpcMessage() logs msg.data for all IPC messages at info level. Since MessageBody.data is arbitrary user/framework payload, this can unintentionally persist sensitive values into logs. Consider defaulting to action-only logging and gating data= behind an opt-in flag / higher log level (or redacting common secret keys) to reduce leakage risk.

Copilot uses AI. Check for mistakes.
if (handle) {
out += ` +handle=${describeHandle(handle)}`;
}
return out;
}
22 changes: 21 additions & 1 deletion packages/cluster/src/utils/mode/impl/process/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { cfork } from 'cfork';
import { graceful as gracefulExit, type Options as gracefulExitOptions } from 'graceful-process';
import { sendmessage } from 'sendmessage';

import { ipcLogger, formatIpcMessage, internalIpcLogEnabled } from '../../../ipc_logger.ts';
import type { MessageBody } from '../../../messenger.ts';
import { terminate } from '../../../terminate.ts';
import { BaseAppWorker, BaseAppUtils } from '../../base/app.ts';
Expand All @@ -29,6 +30,7 @@ export class AppProcessWorker extends BaseAppWorker<ClusterProcessWorker> {
}

send(message: MessageBody): void {
ipcLogger.info(formatIpcMessage(`master->app#${this.workerId}`, message));

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.

high

Logging every message sent from master to app workers using JSON.stringify in the hot path will introduce latency. This should be guarded by an opt-in check.

    if (internalIpcLogEnabled) {
      ipcLogger.info(formatIpcMessage('master->app#' + this.workerId, message));
    }

sendmessage(this.instance, message);
}

Expand All @@ -48,6 +50,7 @@ export class AppProcessWorker extends BaseAppWorker<ClusterProcessWorker> {

static send(message: MessageBody): void {
message.senderWorkerId = String(process.pid);
ipcLogger.info(formatIpcMessage(`app#${process.pid}->master`, message));

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.

high

Logging every message sent from app to master in the hot path will introduce latency. This should be guarded by an opt-in check.

Suggested change
ipcLogger.info(formatIpcMessage(`app#${process.pid}->master`, message));
if (internalIpcLogEnabled) {
ipcLogger.info(formatIpcMessage('app#' + process.pid + '->master', message));
}

// cluster won't get `listening` event when reusePort is true,
// use cluster `message` event instead
if (message.action === 'app-start' && message.reusePort) {
Expand Down Expand Up @@ -90,7 +93,10 @@ export class AppProcessUtils extends BaseAppUtils {
const appWorker = new AppProcessWorker(worker);
this.emit('worker_forked', appWorker);
appWorker.disableRefork = true;
worker.on('message', (msg) => {
// B. master <- app (recv). Handle is present when master receives a net.Socket
// (e.g. forwarded sticky-session connection).
// Log AFTER forwarding to avoid adding log-serialization latency to the forward path.
worker.on('message', (msg, handle) => {
if (typeof msg === 'string') {
msg = {
action: msg,
Expand All @@ -99,7 +105,21 @@ export class AppProcessUtils extends BaseAppUtils {
}
msg.from = 'app';
this.messenger.send(msg);
ipcLogger.info(formatIpcMessage(`master<-app#${worker.process.pid}`, msg, handle));

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.

high

Logging every message received by master from app workers in the hot path will introduce latency. This should be guarded by an opt-in check.

        if (internalIpcLogEnabled) {
          ipcLogger.info(formatIpcMessage('master<-app#' + worker.process.pid, msg, handle));
        }

});

// E. cluster internal NODE_CLUSTER messages from worker to master:
// listening / online / queryServer / accepted (fd ack) / close / ...
// Must hook on `worker.process` (ChildProcess) — `cluster.Worker` doesn't forward `internalMessage`.
// `internalMessage` is undocumented but stable across Node.js versions.
// Opt-in via EGG_CLUSTER_IPC_LOG because this is verbose under load.
if (internalIpcLogEnabled) {
worker.process.on('internalMessage', (msg: { cmd?: string; act?: string; ack?: number }, handle: unknown) => {
if (!msg || msg.cmd !== 'NODE_CLUSTER') return;
const label = msg.act ? `cluster:${msg.act}` : `cluster:ack#${msg.ack ?? '?'}`;
ipcLogger.info(formatIpcMessage(`master<-app#${worker.process.pid}`, { action: label, data: msg }, handle));
});
}
this.log(
'[master] app_worker#%s:%s start, state: %s, current workers: %j',
appWorker.id,
Expand Down
Loading