This article is a repost of an ADR fromMatanuska BASIC, my attempt to write a BASIC interpreter in TypeScript.
Context
Matanuska supports anexit
command, which exits the interpreter.
Currently, Matanuska's architecture supports exiting in the CLI through an exit handler configured in theCli
class, and a special exception calledExit
. Currently, theExit
exception only supports successful exits.
While implementing theexit
command in Matanuska, I first opted to emit an event onRuntime
(which would now be anEventEmitter
), listened to that event in theCommander
, and implemented aHost#exit
method to actually callprocess.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 inCli
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 theCli
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 anExit
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 byclick'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 theExit
error type's message is written to output. This is so thatExit
can be used to share help text (and similar use cases) when doing options parsing in theConfig
class.
Why Host#exit?
As Matanuska has evolved, it's become clear that theHost
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. TheCli
class already supports a customHost
, and the tests include aMockConsoleHost
used for these purposes. It would be natural to extendMockConsoleHost
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 theCommander
.
Given youare going to delegate to theCommander
, the alternative to an event is injecting it as a dependency to theRuntime
. 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 thereadline
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 itis really convenient.
While it hasn't been implemented yet, the runtime will need to request input from the commandereventually - 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 callHost#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 alot 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
willnot 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 anOpCode.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: bothHost#exit
and the CLI exit handlershould 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, whichshould protect against that, but it's an easy footgun. This could be addressed through the typing system by returningnever
instead ofvoid
, but this is unimplemented in the interest of maximizing flexibility.
Therefore, bothMockConsoleHost#exit
and the test exit handler throw aMockExit
. 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)
For further actions, you may consider blocking this person and/orreporting abuse