Davide Angelocola

The Joy of Proper Encapsulation

27 March 2026

Today I pushed a commit to hosh. The commit message was innocent enough: “fix: avoid usage of jdk.internal.Signal”. The diff was 20 lines added, 31 removed. And it was wrong — not syntactically, but semantically. It changed the behavior of my program in a way I didn’t fully appreciate until I sat down and thought about what the Java Platform Module System was actually telling me.

This is a story about that mistake, and about why I think Java’s module system, for all the grief it gets, is one of the best things that happened to the platform (and soon it will be even better with JEP 500).

The problem

Hosh is an interactive shell. When you press Ctrl+C, I want the current pipeline to stop, not the whole JVM. This is a fundamental requirement: cancel the running command, return to the prompt.

In POSIX terms, I need to handle SIGINT without terminating the process.

For years, the code looked something like this:

import jdk.internal.misc.Signal;

private static final Signal INT = new Signal("INT");

private void cancelFuturesOnSigint() {
    Signal.handle(INT, signal -> {
        for (Future<ExitStatus> future : futures) {
            future.cancel(true);
        }
    });
}

This worked perfectly. It had exactly the semantics I needed: intercept the signal, cancel running work, keep the JVM alive. There was just one problem — jdk.internal.misc.Signal is an internal API. My module-info.java needed this in the build:

<arg>--add-exports</arg>
<arg>java.base/jdk.internal.misc=hosh.runtime</arg>

I had even left a @Todo annotation on the field: “this usage of internal API cannot be avoided as far as I know.”

The “fix” that wasn’t

With JDK 25 tightening encapsulation further, I decided to bite the bullet and remove the internal API usage. Since Claude was in rate limited mode, I asked another AI (to be fair, with little context) how to proceed and I replaced the signal handler with Runtime.addShutdownHook:

this.shutdownHook = Thread.ofVirtual().unstarted(() -> {
    for (Future<ExitStatus> future : futures) {
        future.cancel(true);
    }
});
Runtime.getRuntime().addShutdownHook(this.shutdownHook);

Clean. No internal APIs. No --add-exports. The module system was happy.

But the semantics were completely different.

A shutdown hook runs when the JVM is shutting down. That means Ctrl+C now kills the entire process. For a web server, that might be fine — you’re probably done anyway. For a shell, it’s a disaster. The whole point is to survive SIGINT and keep running.

The module system wasn’t just being pedantic when it hid jdk.internal.misc.Signal behind a wall. It was telling me something: this API is not for you, and if you use it, you’re taking on risk that the semantics might change or disappear. The problem was that I heard the message but didn’t have a good answer for it.

The landscape of non-answers

Let’s walk through every option available in 2026 for handling SIGINT in Java without terminating the JVM.

jdk.internal.misc.Signal — the API I was using. Requires --add-exports to break into java.base internals. JDK 25 makes this harder. JDK 26 will likely make it harder still. Not a long-term option.

sun.misc.Signal — the older version of the same API. Moved to the jdk.unsupported module in JDK 9. Here’s the thing: despite the scary name, jdk.unsupported is a real module that you can declare a dependency on. You add requires jdk.unsupported; to your module-info.java and you’re done. No --add-exports needed. JEP 260 explicitly classified sun.misc.Signal as a critical internal API that would remain accessible precisely because there is no replacement.

Runtime.addShutdownHook — different semantics entirely. Only fires during JVM shutdown. Not suitable for interactive applications that need to survive signals.

JNR-Signal — a small library that wraps native signal handling via JNR-FFI. Adds a dependency, brings in native code, and the project has 8 stars on GitHub. Not exactly battle-tested.

Panama FFM — you could register a POSIX sigaction handler through the Foreign Function & Memory API. Technically pure public API, but the amount of ceremony required to handle a two-letter signal is absurd. And you’d be reimplementing what the JVM already does internally.

What the module system got right

Here’s where my perspective might be unpopular: I think the module system is doing exactly what it should. Before modules, the boundary between “public API” and “implementation detail” was a gentleman’s agreement. You could import sun.misc.Signal. The compiler would warn you. Experienced developers would tell you not to. But nothing stopped you, and over time the entire ecosystem became dependent on internal APIs that were never designed to be stable.

The module system turned a social contract into an enforced one. When I had --add-exports java.base/jdk.internal.misc=hosh.runtime in my build, that flag was a declaration of technical debt. It was visible, searchable, and ugly on purpose. Every time I looked at it, I knew I was doing something I shouldn’t.

Compare that to the pre-module world where the same dependency would hide in a regular import statement, indistinguishable from any other.

This is the joy of proper encapsulation: it makes the wrong thing look wrong. Not wrong at runtime, not wrong in a code review comment that gets ignored — wrong in the structure of the build itself.

The right fix

The final fix is just delegating the hard work to JLine. No --add-exports. No broken semantics. Crucially, JLine is already a dependency of Hosh, it is well-maintained, and they recently released a FFM-based terminal that drops the JNA and JAnsi backends entirely — with proper module exports for all modules.

The updated Supervisor class:

class Supervisor implements AutoCloseable {

	private static final Logger LOGGER = LoggerFactory.forEnclosingClass();

	private Terminal terminal;
	private Terminal.SignalHandler previousSigintHandler;

	// With JLine-based SIGINT interception — used in Interpreter for top-level command execution.
	public Supervisor(Terminal terminal) {
		this.terminal = terminal;
	}

	// ... other code using the 2 methods below

	private void installSigintHandler() {
		if (terminal != null) {
			LOGGER.fine("register INT signal handler");
			previousSigintHandler = terminal.handle(Terminal.Signal.INT, signal -> {
				LOGGER.info("SIGINT received, cancelling futures...");
				for (Future<ExitStatus> future : futures) {
					LOGGER.finer(() -> String.format("cancelling future %s", future));
					future.cancel(true);
				}
			});
		}
	}

	private void restoreDefaultSigintHandler() {
		if (terminal != null && previousSigintHandler != null) {
			LOGGER.fine("restoring default INT signal handler");
			terminal.handle(Terminal.Signal.INT, previousSigintHandler);
			previousSigintHandler = null;
		}
	}
}

You might wonder: if sun.misc.Signal is officially supported via jdk.unsupported, why not just use that? The answer is that hosh already depends on JLine for terminal handling, and JLine wraps signal management as a first-class concern. Delegating to it means one less direct dependency on JDK internals — even blessed ones — and the signal handling composes naturally with the rest of the terminal lifecycle.

The lesson

The module system didn’t create this problem. What the module system did was make the problem visible. It forced me to confront the fact that I was depending on an implementation detail, and it made that dependency explicit.

When I tried to “fix” it by switching to shutdown hooks, I was optimizing for the wrong thing. I was making the module system happy at the cost of correctness. The module boundary was a signal (pun intended) that I should have listened to more carefully.

Good encapsulation doesn’t just protect you from other people’s implementation details. It protects you from your own wishful thinking. Document your module boundaries in the CLAUDE.md file (why).

Another small joy

While enforcing the zero-warnings policy, another case surfaced: javac emitted this when processing Compiler.java:

interface org.antlr.v4.runtime.tree.ParseTree in module org.antlr.antlr4.runtime
is not indirectly exported using 'requires transitive'

The root cause was subtle: InternalBug, a private exception class inside Compiler, accepted a ParseTree in its constructor just to call .getText() on it. This exposed an ANTLR type at the constructor signature level — forcing the module system to consider ParseTree part of the visible API, even though it was never meant to be.

The fix was simple:

// Before
throw new InternalBug(ctx);           // ctx is a ParseTree
// After
throw new InternalBug(ctx.getText()); // plain String — no ANTLR leakage

This eliminated the ParseTree import entirely. The InternalBug constructor now takes a String, keeping ANTLR as a true implementation detail of hosh.runtime with no surface area leaking outward.

A good example of how the module system surfaces coupling that would otherwise be invisible in a classpath-based build. The commit is ten lines.


The hosh source code, including the CLAUDE.md, is available on GitHub.