Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
55 changes: 55 additions & 0 deletions packages/logger/lib/writers/InteractiveConsole.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ const BUILDING_TICK_MS = 120;
// errors from these modules, and all other levels, still pass through.
const BANNER_REDUNDANT_INFO_MODULES = new Set(["ProjectBuilder"]);

// DECTCEM "show cursor". log-update hides the cursor on each render; disable()
// emits this to restore it.
const CURSOR_SHOW = "[?25h";

// Signals that terminate the process while the live region is on-screen.
// SIGBREAK exists only on Windows; on other platforms the listener is inert.
const TERMINATION_SIGNALS = ["SIGHUP", "SIGINT", "SIGTERM", "SIGBREAK"];

// Decode the tail of `stream.write(chunk[, encoding][, callback])`. `encoding`
// falls back to "utf8" (Node's default) when not supplied.
function parseWriteArgs(encodingOrCallback, maybeCallback) {
Expand Down Expand Up @@ -97,6 +105,10 @@ class InteractiveConsole {
#onStopConsole;
#onResize;

// Termination-signal handlers (plus an `exit` catch-all), keyed by event
// name, so disable() detaches exactly what enable() registered.
#signalHandlers = new Map();

constructor({stderr = process.stderr} = {}) {
this.#stderr = stderr;
this.#headerState = createHeaderState();
Expand All @@ -117,6 +129,7 @@ class InteractiveConsole {
process.emit("ui5.log.stop-console");
this.#stopped = false;
this.#attachListeners();
this.#registerSignalHandlers();
if (!this.#logUpdate) {
this.#logUpdate = createLogUpdate(this.#stderr);
}
Expand All @@ -138,6 +151,9 @@ class InteractiveConsole {
this.#stopped = true;
this.#clearTick();
this.#detachListeners();
// Detach before the terminal teardown so a re-raised signal (see
// #handleTerminationSignal) doesn't re-enter our handler.
this.#deregisterSignalHandlers();
// Flush any partial (non-newline-terminated) buffered writes before
// we tear down the live region, then restore the process.* originals.
// Flushing first while the live region is still on-screen keeps the
Expand All @@ -148,9 +164,48 @@ class InteractiveConsole {
// and resets state. End on a clean newline so the prompt lands
// below the final frame.
this.#withRenderingGuard(() => this.#logUpdate?.done());
// done() is a no-op on a non-TTY stderr, so restore the cursor
// explicitly. This runs after #uninstallWriteInterceptors so the escape
// reaches the stream instead of being buffered as a partial write.
this.#stderr.write(CURSOR_SHOW);
this.#stderr.write("\n");
}

// ---- Termination-signal teardown -----------------------------------------
// On CTRL+C the process dies without disable() ever running, leaving the
// cursor hidden. Tear the live region down on the terminating signals, then
// re-raise so cooperating handlers (profile.js, ProjectBuilder) still run.

#registerSignalHandlers() {
for (const signal of TERMINATION_SIGNALS) {
const handler = () => this.#handleTerminationSignal(signal);
this.#signalHandlers.set(signal, handler);
process.on(signal, handler);
}
// `exit` covers cooperating handlers that call process.exit(); it never
// fires on a bare signal kill, hence the signal handlers above.
const onExit = () => this.disable();
this.#signalHandlers.set("exit", onExit);
process.on("exit", onExit);
}
Comment thread
RandomByte marked this conversation as resolved.

#deregisterSignalHandlers() {
for (const [event, handler] of this.#signalHandlers) {
process.off(event, handler);
}
this.#signalHandlers.clear();
}

#handleTerminationSignal(signal) {
// Re-raise only if this call did the teardown, so a stale handler
// invoked after disable() can't kill an already-stopped process.
const wasStopped = this.#stopped;
this.disable();
if (!wasStopped) {
process.kill(process.pid, signal);
}
}

// Compose the full live region as a single string. One block per region,
// separated by their own leading blank line. `log-update` handles wrapping
// and erase of the previous frame, so we just hand it the full text.
Expand Down
90 changes: 90 additions & 0 deletions packages/logger/test/lib/writers/InteractiveConsole.js
Original file line number Diff line number Diff line change
Expand Up @@ -986,3 +986,93 @@ test.serial("ui5.log.stop-console handler calls disable() on the active writer",
const output = stripAnsi(stderr.writes.join(""));
t.notRegex(output, /post-stop warn/, "listeners are detached after the stop-console handler");
});

// ---- Termination-signal teardown --------------------------------------------
// enable() registers a handler per terminating signal; on CTRL+C it tears the
// region down (restoring the cursor) and re-raises. The tests capture the
// handler via process.listeners(signal) and stub process.kill, so invoking it
// directly exercises the teardown without terminating the AVA worker.

const CURSOR_SHOW = "[?25h";

test.serial("enable() registers a handler for each termination signal", (t) => {
const {writer} = createWriter();
// Capture the writer's own handler per signal by identity, so the assertions
// hold regardless of other listeners present on the shared process object.
const registered = {};
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM", "SIGBREAK"]) {
const handler = process.listeners(signal).at(-1);
t.is(typeof handler, "function", `${signal} handler registered on enable`);
registered[signal] = handler;
}
writer.disable();
// disable() removes exactly what enable() added.
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM", "SIGBREAK"]) {
t.false(process.listeners(signal).includes(registered[signal]),
`${signal} handler removed on disable`);
}
});

test.serial("SIGINT handler restores the cursor and re-raises the signal", (t) => {
const killStub = sinon.stub(process, "kill");
t.teardown(() => killStub.restore());

const {stderr} = createWriter();
const handler = process.listeners("SIGINT").at(-1);
t.is(typeof handler, "function", "SIGINT handler captured");

stderr.writes.length = 0;
handler(); // invoke directly: process.kill is stubbed, so the worker survives

const raw = stderr.writes.join("");
t.true(raw.includes(CURSOR_SHOW), "show-cursor escape written on SIGINT teardown");
t.true(raw.endsWith("\n"), "trailing newline written after teardown");
t.true(killStub.calledOnceWithExactly(process.pid, "SIGINT"),
"signal re-raised to self rather than process.exit()");

// Own listener must be gone before the re-raise, else it loops.
t.false(process.listeners("SIGINT").includes(handler),
"own SIGINT listener removed before re-raise");
});

test.serial("signal teardown detaches ui5 event listeners", (t) => {
const killStub = sinon.stub(process, "kill");
t.teardown(() => killStub.restore());

const {stderr} = createWriter();
process.listeners("SIGINT").at(-1)();

stderr.writes.length = 0;
process.emit("ui5.log", {level: "warn", message: "post-signal warn"});
const output = stripAnsi(stderr.writes.join(""));
t.notRegex(output, /post-signal warn/, "ui5.* listeners detached after signal teardown");
});

test.serial("a stale signal handler invoked after disable() does not re-raise", (t) => {
const killStub = sinon.stub(process, "kill");
t.teardown(() => killStub.restore());

const {writer} = createWriter();
const handler = process.listeners("SIGINT").at(-1);
writer.disable(); // first teardown; removes the real listener

killStub.resetHistory();
// A stale handler invoked after disable() must be a no-op: disable()
// short-circuits on #stopped and the guarded re-raise never fires.
t.notThrows(() => handler());
t.true(killStub.notCalled, "no re-raise after the writer was already stopped");
});

test.serial("process 'exit' handler tears down as a best-effort catch-all", (t) => {
const {writer, stderr} = createWriter();
const exitHandler = process.listeners("exit").at(-1);
t.is(typeof exitHandler, "function", "exit handler registered on enable");

stderr.writes.length = 0;
exitHandler();

const raw = stderr.writes.join("");
t.true(raw.includes(CURSOR_SHOW), "cursor restored via the exit catch-all");
// Idempotent with a later explicit disable().
t.notThrows(() => writer.disable());
});
Loading