This article is a repost of an ADR from Matanuska BASIC, my attempt to write a BASIC interpreter in TypeScript.
Context
Matanuska supports an exit
command, which exits the interpreter.
Currently, Matanuska's architecture supports exiting in the CLI through an exit handler configured in the Cli
class, and a special exception called Exit
. Currently, the Exit
exception only supports successful exits.
While implementing the exit
command in Matanuska, I first opted to emit an event on Runtime
(which would now be an EventEmitter
), listened to that event in the Commander
, and implemented a Host#exit
method to actually call process.exit
.
These two mechanisms are redundant and inconsistent. We would like to find one mechanism for exiting the application in a non-error context.
Why an Error? Why in Cli?
Exiting has to be implemented in Cli
because it contains the top-level error handling code. This is where we decide how to report on various Errors which may be thrown by other parts of the application, and how to exit.
The motivation for the exit handler override is entirely testing. When testing the Cli
abstraction, I want it to "exit" without actually ending the process. In the relevant tests, I override the exit handler that asserts the expected exit code and throws a test-only Error to stop execution and signal the exit.
The motivation for an Exit
Error type is that it allows for error handling to implement graceful shutdown - that is, error handling in the rest of the application can call its "finally" blocks to cleanly spin down resources before an exit. It's also inspired by click's API - the actual needs were unknown, but given I was implementing a CLI framework, following click's lead seemed reasonable.
A behavior to keep in mind is that the Exit
error type's message is written to output. This is so that Exit
can be used to share help text (and similar use cases) when doing options parsing in the Config
class.
Why Host#exit?
As Matanuska has evolved, it's become clear that the Host
abstraction owns much more than just logging - in fact, it owns all "os-level" actions. This includes things like getting the current UNIX user and the current working directory. Through this lens, it's appropriate for it to handle exits as well.
This also offers a clear, consistent mechanism for overriding exit behavior - that is, overriding the Host. The Cli
class already supports a custom Host
, and the tests include a MockConsoleHost
used for these purposes. It would be natural to extend MockConsoleHost
to implement a test-only behavior for exits as well.
Why an EventEmitter?
The event emitter interface is largely motivated by an interest in delegating exit behavior to the Commander
.
Given you are going to delegate to the Commander
, the alternative to an event is injecting it as a dependency to the Runtime
. Events allow the runtime to be unaware of the commander, at the cost of the commander being unable to "yield" data back to the runtime.
Unfortunately, there are already reasons to inject the commander into the runtime. In particular, the commander handles prompting, because it handles the readline
interface. This decision was made because readline requires asynchronous initialization, and because it's higher level than what the host provides. That decision isn't set in stone, but it is really convenient.
While it hasn't been implemented yet, the runtime will need to request input from the commander eventually - so we might as well inject it now and avoid two interfaces.
But we could also inject the host into the runtime, and have it call Host#exit
directly. In fact, the host is already injected, just not used.
The alternative to this is implementing proxy methods on the commander whenever the runtime needs to access anything from the host. But the host contains a lot of functionality, and effectively adding all of the host's functionality to the commander muddies its interface.
Decision
- The
Host
will be injected into theRuntime
, where itsexit
method will be called directly. This will follow a pattern which should become more common over time. - The
Runtime
will not inherit fromEventEmitter
, instead preferring to call methods on an injectedCommander
instance. This will create one consistent way to call back to theCommander
that supports "yielding". -
ConsoleHost#exit
will throw anExit
error. This will allow for graceful shutdown behavior, while using the host as the common path for exits within the interpreter. 4. TheExit
error will be extended to take an exit code. This will allow for its use with intentional non-zero exits. -
Cli
will continue to handle actual exit behavior. This will include overriding theexit
handler in tests. -
MockConsoleHost#exit
will throw aMockExit
error, maintaining the current structure of the tests.
In other words, when the runtime handles an OpCode.Exit
, the following will occur:
- The runtime will call
Host#exit
with the exit code. - The host will throw an
Exit
error with the exit code. - The error will be caught and handled in the
Cli
class.
Note a subtlety in testing: both Host#exit
and the CLI exit handler should throw an error to stop execution. This is to ensure that they short-circuit execution in tests as they do in practice. The code has been factored to include a return after the relevant calls, which should protect against that, but it's an easy footgun. This could be addressed through the typing system by returning never
instead of void
, but this is unimplemented in the interest of maximizing flexibility.
Therefore, both MockConsoleHost#exit
and the test exit handler throw a MockExit
. A consequence of this is that it's not possible to distinguish between a triggered exit and a "clean exit" - but the tests don't cover that distinction, instead simply asserting the exit code as 0.
Top comments (0)