Skip to content

refactor(logger): Restore cursor on ui5 serve termination#1475

Open
RandomByte wants to merge 2 commits into
mainfrom
refactor/interactive-console-cursor-restore
Open

refactor(logger): Restore cursor on ui5 serve termination#1475
RandomByte wants to merge 2 commits into
mainfrom
refactor/interactive-console-cursor-restore

Conversation

@RandomByte

@RandomByte RandomByte commented Jul 23, 2026

Copy link
Copy Markdown
Member

The log-update package hides the terminal cursor on every render and only shows it again in its own done(), which InteractiveConsole calls from disable(). On CTRL+C the process is signal-terminated without disable() ever running, so the cursor stays hidden in the user's shell. The restore-cursor safety net (a bare \x1b[?25h with no newline) is also swallowed: the stderr write interceptor buffers non-newline-terminated writes into partial and only flushes them in disable().

Register a handler for SIGINT/SIGTERM/SIGHUP/SIGBREAK (and an exit catch-all) in enable(), removed in disable(). Each signal handler tears the live region down via disable(), then re-raises the signal with process.kill(process.pid, signal) rather than calling process.exit(). Re-raising lets the cooperating handlers in profile.js and ProjectBuilder still run their async cleanup, and lets the default disposition set the 128+n exit code. disable() removes the handlers before re-raise so the re-sent signal reaches the default disposition instead of looping.

disable() now also writes the show-cursor escape explicitly to its own stream, after uninstalling the interceptor. log-update's done() targets the real process.stderr and is gated on isTTY, so it can be a no-op; the explicit write restores the cursor regardless and is observable in tests.

log-update hides the terminal cursor on every render and only shows it
again in its own done(), which InteractiveConsole calls from disable().
On CTRL+C the process is signal-terminated without disable() ever
running, so the cursor stays hidden in the user's shell. The
restore-cursor safety net (a bare "\x1b[?25h" with no newline) is also
swallowed: the stderr write interceptor buffers non-newline-terminated
writes into `partial` and only flushes them in disable().

Register a handler for SIGINT/SIGTERM/SIGHUP/SIGBREAK (and an `exit`
catch-all) in enable(), removed in disable(). Each signal handler tears
the live region down via disable(), then re-raises the signal with
process.kill(process.pid, signal) rather than calling process.exit().
Re-raising lets the cooperating handlers in profile.js and ProjectBuilder
still run their async cleanup, and lets the default disposition set the
128+n exit code. disable() removes the handlers before re-raise so the
re-sent signal reaches the default disposition instead of looping.

disable() now also writes the show-cursor escape explicitly to its own
stream, after uninstalling the interceptor. log-update's done() targets
the real process.stderr and is gated on isTTY, so it can be a no-op; the
explicit write restores the cursor regardless and is observable in tests.
@RandomByte
RandomByte marked this pull request as ready for review July 23, 2026 15:02
@RandomByte
RandomByte requested a review from a team July 23, 2026 15:02
Comment on lines +179 to +190
#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);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This method is not idempotent. Calling it twice, will just override this.#signalHandlers, and the firstly registered handlers will not be able to be deleted.
A check for registered signal in this.#signalHandlers will solve the issue

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good point! Fixed 👍

A second enable() without an intervening disable() overwrote every
#signalHandlers map entry with fresh closures, stranding the first set
of handlers on process. #deregisterSignalHandlers() iterates the map, so
it could only detach the latest set, leaking the originals.

Guard #registerSignalHandlers() on the map already being populated and
return early, mirroring how disable() guards on #stopped. Registration
and deregistration happen as a unit, so a single check covers all
signals.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants