Skip to content

Latest commit

 

History

History
220 lines (127 loc) · 23.3 KB

File metadata and controls

220 lines (127 loc) · 23.3 KB

Design Rationales

This document records the reasoning behind key design decisions in Resurrector — the why, not just the what. For the architecture and implementation details, see Design & Architecture.

Why Resurrector Exists

The Problem: No Process-Level Monitor for Windows Interactive Sessions

On Linux, tools like systemd provide robust process supervision out of the box. On Windows, however, the Interactive Session (the desktop session where the user is logged in) has no equivalent built-in mechanism for monitoring and auto-restarting arbitrary processes.

The closest existing tool is "Restart on Crash", but it takes a fundamentally different approach: it monitors whether a specific executable file (e.g. notepad.exe) is running. This works for simple cases but breaks down when the same executable hosts multiple unrelated applications.

Example: Consider two separate Node.js applications — a dev server and a build watcher. Both run as node.exe. "Restart on Crash" cannot distinguish between them because it only sees the executable name. If one crashes, it has no way of knowing which application needs restarting, or whether the other node.exe instance is the one it should be monitoring.

Resurrector solves this by owning the entire process lifecycle. It spawns each monitored application as a child process bound to a Windows Job Object, giving it a unique identity tied to the specific command, arguments, and working directory — not just the executable name. This means two node.exe instances with different arguments are tracked as completely separate entities.

Why Core and UI Are Separate Executables

Resurrector is split into two independent binaries: the core process (resurrector.exe) and the UI process (resurrector-ui.exe). This is not an accident — it is a direct consequence of Resurrector's design goal: do as little as possible, as efficiently as possible.

Over its lifetime, Resurrector spends more than 99% of its time doing one thing: quietly watching processes via WaitForSingleObject. During this steady state, there is no user interaction, no rendering, no UI framework loaded in memory. The core is a pure Go binary with a minimal footprint — a few megabytes of RAM at most.

If the UI were bundled into the same process, the Wails runtime, the Svelte application, and the WebView2 renderer would all be loaded into memory permanently — even though the user might open the settings screen once a week (or never). That is a waste of resources for a tool whose entire purpose is to sit invisibly in the system tray.

By launching the UI as a separate, disposable process, Resurrector pays the cost of the UI only when the user explicitly requests it. When the settings window is closed, the UI process terminates and all of its memory is reclaimed by the OS. The core continues running undisturbed.

This separation also enforces a clean architectural boundary: after first-run bootstrap of a missing file, the core does not write to config.toml, and the UI never manages processes. Communication flows through the config file (UI → Core, via atomic writes and fsnotify) and through stdio-based IPC (Core → UI, for real-time status updates).

Why Core and UI Communicate Over STDIO

The communication needs between the core and UI are intentionally narrow. The core launches the UI as its child process, pushes state updates while the window is open, and stops communicating when the window closes. This is not a general-purpose RPC system between independent long-lived services; it is a short-lived parent-child coordination channel.

For that reason, stdio is a better fit than either named pipes or sockets: unlike those mechanisms, it does not require publishing a separately reachable IPC endpoint, and it fits Resurrector's architecture well for three reasons:

  • Lifecycle coupling is automatic: the channel exists only for that specific UI process instance.
  • Failure detection is simple: if the core exits, the pipe closes and the UI can shut down immediately.
  • The protocol is one-way and small: the UI only needs a stream of monitor state updates, not a rich bidirectional command channel.

Most importantly, this minimizes vulnerability surface. Resurrector does not publish a named pipe or socket that could be contacted unintentionally by external processes. By keeping communication inside the already-established parent-child stdio channel, it avoids introducing a separately discoverable IPC endpoint and reduces the chance of bugs turning into externally reachable attack paths.

Just as importantly, Resurrector does not want the UI-to-core control path to go through IPC at all. Configuration changes go through config.toml, which remains the Single Source of Truth. Using stdio only for transient status push keeps IPC narrow, keeps the core simple, and avoids creating a second state mutation interface alongside the config file.

Why the Config File is TOML instead of XXX?

On Windows, many applications have historically used INI-style configuration files. That convention matters: a user-edited config file should feel immediately familiar instead of looking like an application-specific mini-language.

Resurrector uses TOML because it preserves much of that INI-like feel while fixing the limitations of INI itself. Table-based structure, key = value assignments, and overall visual simplicity make it approachable to users who are already accustomed to editing Windows config files by hand.

At the same time, TOML provides the expressiveness that plain INI lacks. Resurrector's config needs to represent booleans, integers, arrays such as args, and multiple named app entries in a way that is both structured and predictable. TOML supports that naturally without introducing much syntactic noise.

Just as importantly, TOML avoids the ambiguity that comes with looser configuration formats. Resurrector treats config.toml as a declarative source of truth, so the format should be easy for humans to read while remaining straightforward for Go code to parse into a strictly typed schema. In that sense, TOML was chosen as a practical successor to the traditional Windows INI style: familiar in shape, but with enough structure and clarity for a modern config file.

Why the Config File Lives Under ~/.config

Resurrector deliberately does not store its primary configuration under %APPDATA%. That directory is conventionally treated as application-managed data: settings, caches, session state, update files, and other implementation details from many different applications can be mixed together there. It is therefore a poor place for a file that users are expected to find, edit, back up, and carry to another PC.

config.toml is different from application-internal state. It is a user-managed, declarative description of the applications that Resurrector should supervise. Users can edit it with a text editor, keep it under version control, or copy it when migrating their Resurrector setup. The location should communicate that this is the user's configuration rather than disposable application data.

The default location follows the XDG Base Directory convention: %USERPROFILE%\\.config\\resurrector\\config.toml. XDG is specified for Unix-like systems, so this is not presented as a native Windows convention. However, the ~/.config layout is also used by a number of developer-oriented tools on Windows, including tools in the Git, Scoop, Firebase, and AI-agent ecosystems. Resurrector targets a similar developer audience, so adopting this cross-platform convention provides a recognizable home for user-managed configuration without placing it among opaque application data under %APPDATA%.

This choice has a trade-off: Windows users who expect all application files to live under AppData may not discover the file immediately. Resurrector documents the path, offers the -f option for an explicit alternative, and treats the config file as a migration and backup responsibility of the user rather than silently treating it as disposable application state.

Why command and stop_command Are Split Into an Executable and an Args Array

Both the main launch command and the optional shutdown command in config.toml are expressed as a single executable string plus an array of arguments:

command = "node.exe"
args = ["server.js", "--port", "8080"]

stop_command = "taskkill"
stop_args = ["/PID", "${PID}", "/T"]

An obvious alternative would be to pack everything into a single string — command = "node.exe server.js --port 8080" — the way systemd's ExecStart= or a shell prompt does. Resurrector deliberately does not do this, for one overriding reason: a single-string form invites users to believe they can write shell syntax in it.

Shell Syntax Does Not Work Here

Resurrector does not hand the command line to a shell at all. It constructs the process directly from an executable and an argv, without any intermediate shell interpretation. That means things like the following will not do what the user expects:

  • myapp.exe > output.log — the > is passed as a literal argument to myapp.exe, not interpreted as a redirection. No file is opened, nothing is redirected.
  • myapp.exe | tee log.txt — the | is a literal argument. There is no pipe.
  • myapp.exe && cleanup.exe — && is a literal argument. The second command is never run.
  • myapp.exe "$USER" — environment variable expansion does not happen. The literal string $USER is passed through.

These characters are shell features, not process-creation features. If the config format accepted a single string, users would reasonably assume that shell features work there (because they work in every shell prompt, .bat file, and ExecStart= line they have seen). The result would be silent misconfiguration: the process starts, but not in the way the user intended.

Why Not Just Pick a Shell and Interpret the String?

One way to make a single-string form "work" would be for Resurrector to pass the command to a shell internally. But on Windows, there is no single obvious choice of shell, and every option has significant drawbacks:

  • cmd.exe is the traditional Windows shell. It supports >, |, &&, %VAR%, but its quoting and escaping rules are famously idiosyncratic, and it cannot express many things that modern users expect.
  • PowerShell is the modern Windows shell. It supports >, |, $env:VAR, but its syntax is fundamentally different from cmd.exe — it has its own quoting rules, its own redirection semantics (e.g. 2>&1 works differently), and its own built-in aliases that do not exist in cmd.exe.
  • Bash / sh may or may not be present, depending on whether Git for Windows, WSL, or MSYS2 is installed.

If Resurrector silently chose one of these, users writing commands for another shell would be surprised. A string like myapp.exe 2>&1 | Tee-Object log.txt is valid PowerShell but nonsense to cmd.exe; myapp.exe && echo done works in both cmd.exe and bash but with subtly different semantics around exit codes. There is no correct default.

The cleanest way to avoid this entire class of ambiguity is to not interpret the command as shell syntax at all. Resurrector sidesteps the question of "which shell?" by declaring, at the config level, that the command is not shell input. That removes an entire dimension of complexity — no shell selection, no quoting rules to document, no cross-shell portability concerns.

Why an Array Avoids the Illusion

By requiring the arguments to be written as an explicit array, the config format makes it visually obvious that each element is a separate argv entry, not a shell command line. There is no place to put a redirection — > in an array element is just a string. The format itself tells the user, "this is argv, not shell input."

If the user genuinely needs shell features (pipes, redirection, conditional execution, variable expansion), the escape hatch is explicit and unambiguous — and, crucially, the user chooses which shell to invoke:

# Use cmd.exe explicitly
command = "cmd.exe"
args = ["/c", "myapp.exe > output.log"]

# Or PowerShell explicitly
command = "powershell.exe"
args = ["-Command", "myapp.exe 2>&1 | Tee-Object log.txt"]

Here, the user is clearly invoking a specific shell and handing it a command line to interpret. There is no ambiguity about who is parsing what, or which dialect applies.

Consistency With the Rest of the Ecosystem

This design also matches the convention used by most modern process-management tools — Docker (ENTRYPOINT / CMD), Kubernetes (command / args), VS Code tasks.json (command / args), and PM2 (script / args) all separate the executable from its arguments, for essentially the same reason. Tools that fold everything into a single string (systemd, supervisord) do so for historical continuity with shell-script-based init systems, and they pay for it with a custom parser whose quoting and escaping rules are a frequent source of bugs.

Resurrector chooses the argv-separated style because it is safer, more explicit, and free of shell illusions — and it applies the same rule to both command and stop_command so the config schema stays consistent.

Why Placeholders Use ${NAME} and $$

Once the argv-separated design is in place, argv elements are still plain strings, and there are two things users legitimately want to inject into them that Resurrector has to produce itself:

  • Environment values — paths like ${USERPROFILE}\bin\myapp.exe or ${APPDATA}\myapp\config.json that differ per user or per machine, so that the same config.toml can be reused across environments.
  • The monitored PID — required in stop_args for commands like taskkill /PID ... /T because the PID is not known until the process has actually been launched.

Why ${NAME}, Not %NAME% or $NAME

For environment variables, there are three candidate syntaxes on Windows:

  • %NAME% is the native cmd.exe form. It is familiar to long-time Windows users, but it collides with nothing else and is also a strong signal that "this is a cmd.exe thing." Using it in Resurrector would imply cmd-style semantics (e.g. delayed expansion, %% escaping) that Resurrector does not implement.
  • $NAME (no braces) is the traditional POSIX shell form. It has well-known parsing ambiguities — $PATHfoo vs. ${PATH}foo — and would force users to remember where a name ends.
  • ${NAME} is unambiguous, widely recognized across modern tools (Make, Docker Compose, GitHub Actions, os.Expand in Go, most shells), and visually distinct from surrounding text. It does not imply any particular shell's semantics.

Resurrector uses ${NAME} for exactly the unambiguity reason: the boundaries of the name are explicit, and the syntax reads as "template variable" rather than "cmd-style variable" or "shell-style variable."

Why $$ Is the Escape

With ${...} as the placeholder syntax, the user still needs a way to write a literal $. Resurrector uses $$ → $, matching the convention established by Make, Docker Compose, and most template engines. This gives the syntax two properties that matter in practice:

  • Any template is writable. A literal ${PID} can be produced by writing $${PID} (the $$ escapes to $, and the following {PID} is plain text because it has no leading $). This avoids a corner case where some desired output would be inexpressible.
  • A stray $ is a syntax error, not a silent surprise. A lone $ that is not followed by $ or { is rejected at config load time (for non-stop_args fields) or at stop time (for stop_args). This catches typos like $PATH — a common POSIX-style mistake — instead of silently passing them through.

Expansion is deliberately single-pass: the replacement produced by one lookup is not re-scanned for further placeholders. This keeps the semantics simple and predictable (a value from ${FOO} is always treated literally, even if it happens to contain ${PID} or $$), and it avoids introducing a second template layer that users would have to reason about.

Why We Do Not Inspect Exit Codes

The Premise: Monitored Apps Are "Always-On"

Resurrector's purpose is to keep always-on applications running. These are processes that, by design, should never terminate on their own. A web server, a background daemon, a dev tool — if it exits, something went wrong, regardless of the exit code.

Under this premise:

  • Exit code 0 ("success") does not mean "the app finished its job successfully." It means "the app stopped running, and it shouldn't have." A clean exit from a process that is supposed to run forever is just as much of a problem as a crash.
  • Exit code non-zero ("failure") is the more obvious case, but the action is the same: restart.

The Only Meaningful Distinction: Intentional vs. Unintentional

Rather than classifying exits by their code, Resurrector classifies them by who caused the exit:

Exit cause Action How it's detected
Resurrector stopped the process (user disabled it, config removed, app shutting down) Do not restart stopChan is closed before the process exits
The process exited on its own (crash, runtime error, unexpected clean exit, etc.) Restart WaitForMultipleObjects signals the process handle

This is the only distinction that matters. Exit codes are an application-level concern and carry no universal meaning that a process supervisor can reliably act on.

Comparison with Other Systems

  • systemd offers Restart=on-failure (restart only on non-zero exit) and Restart=always (restart regardless of exit code). For always-on daemons, Restart=always is the standard recommendation — which is exactly what Resurrector does.
  • Windows SCM (Service Control Manager) by default ignores exit codes entirely. Recovery actions trigger based on whether the service reported a failure to the SCM, not based on the exit code itself.

Resurrector's approach aligns with the Restart=always philosophy: if it stopped, bring it back.

The Role of healthy_timeout_sec and max_retries

Crash Loop Prevention, Not Exit Classification

Since every unintentional exit triggers a restart, there is a risk of an infinite crash loop: a misconfigured or broken application that starts, immediately crashes, restarts, immediately crashes, and so on forever.

The healthy_timeout_sec and max_retries parameters exist solely to address this:

  • healthy_timeout_sec: Defines the minimum uptime (in seconds) for a run to be considered "stable." If the process runs for at least this long before exiting, the restart counter resets to 0. If it exits sooner, the counter increments. A value of 0 disables the uptime-based reset: the counter increments on every exit so max_retries remains meaningful with the defaults.
  • max_retries: The maximum number of consecutive "unstable" restarts before Resurrector gives up and marks the app as Failed. A negative value means infinite retries (never give up).

Together, they form a crash loop breaker: rapid repeated crashes are detected and eventually stopped, while a process that runs for a reasonable period and then crashes is given a fresh set of retries.

Why Not Use Exit Codes for This?

One might argue that exit codes could help distinguish "real crashes" from "intentional stops." In practice, this adds complexity without meaningful benefit:

  1. No universal contract: There is no standard that says exit code 0 means "I'm done, don't restart me." Many applications exit with 0 on unhandled signals or graceful shutdown paths that the user did not initiate.
  2. Runtime-specific behavior: Different runtimes (Node.js, Python, Go, etc.) have different conventions for exit codes. A process supervisor cannot reliably interpret them without per-application configuration.
  3. Resurrector already knows: If Resurrector itself requested the stop (via stopChan / Job Object termination), it already knows not to restart. No exit code inspection is needed.

The current design keeps the monitor simple, predictable, and runtime-agnostic.

Why Graceful Stop Prefers WM_CLOSE and CTRL_BREAK_EVENT over TerminateProcess

When Resurrector needs to stop a monitored process (because the user disabled it, the entry was removed from config.toml, the config was modified in a way that requires a restart, or the core is shutting down), the ultimate fallback is always TerminateProcess. But Resurrector does not jump to it immediately. Instead, it first tries WM_CLOSE for processes that own a top-level window, and CTRL_BREAK_EVENT for console-attached processes, waiting up to stop_timeout_sec before escalating.

The reason is simple: TerminateProcess gives the target no opportunity to clean up.

TerminateProcess is a hard kill. The OS unmaps the process immediately, without running any user-mode code in the target. This means:

  • Open files are not flushed. Buffered writes held in the process's userspace (language runtime buffers, stdio buffers, application-level caches) are lost. Even files that the OS will eventually close can be left in an inconsistent on-disk state if the application was mid-write.
  • defer / atexit / destructors do not run. Language-level cleanup hooks — defer in Go, finally / atexit in Python, destructors in C++, SIGTERM handlers in Node.js — are all bypassed. Any invariant the application maintains through those hooks is violated.
  • Child processes and temp files may be orphaned. The Job Object guarantees descendant processes are killed, but temp files, lock files, named pipes, and similar artifacts on disk that a well-behaved shutdown would remove are left behind.
  • Databases and stateful services may corrupt. Embedded databases (SQLite, LevelDB, etc.) often rely on a shutdown sequence to checkpoint or release locks. A hard kill can leave them requiring recovery on next start, or in the worst case, corrupt.

WM_CLOSE and CTRL_BREAK_EVENT are each the idiomatic "please shut down" signal for their respective kinds of Windows programs:

  • WM_CLOSE is what the OS sends when the user clicks the window's close button. GUI applications typically handle it by running their normal shutdown path — saving state, confirming unsaved changes (or, in well-behaved always-on apps, silently exiting cleanly).
  • CTRL_BREAK_EVENT is the closest Windows equivalent to a Unix SIGTERM for console programs. Runtimes like Node.js, Go, and Python translate it into a signal/event their application code can catch and respond to.

Neither is guaranteed to succeed — an application may ignore WM_CLOSE, or a console app may not install a control handler. That is exactly why stop_timeout_sec exists: Resurrector asks nicely, gives the application a bounded window to comply, and then escalates to TerminateProcess only if the graceful path fails. The design goal is graceful first, forceful fallback — respecting the application's cleanup path whenever possible, while still guaranteeing that desired state eventually converges.