Skip to content

Commit 8ef2ff0

Browse files
docs(components): document app cleanup on shutdown via scope 'close' (#647)
* docs(components): document app cleanup on shutdown via scope 'close' The only coverage of shutdown cleanup was a single line in the Scope events list ("Emitted after `scope.close()` is called"), which reads as if the plugin author calls `scope.close()` themselves. Nothing stated that Harper calls it during shutdown and graceful restart, which is the reason an application would listen for the event at all. - Add a "Cleanup on Shutdown" section to plugin-api.md with the `scope.once('close', ...)` pattern, covering the shutdown sequence: async listeners are awaited, cleanup is bounded by the termination backstop, a rejecting listener is logged and stops the wait on its siblings, and each worker cleans up independently. - Clarify the `'close'` event and `scope.close()` entries in place and link them to the new section. - Add a "Shutdown Cleanup" pointer to applications.md so the application-building audience finds it (that page had no occurrence of "cleanup", "shutdown", or "teardown"). - Record the v5.1.3 behavior change in the Version History list. Verified against harper: componentLoader.ts:700 calls `scope.close()` on the SHUTDOWN ITC message, restartWorkers posts the same message, and Scope.ts:275-288 awaits promises returned by 'close' listeners. Closes #604 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(components): add v5.1.3 changed badge to scope.close() Review feedback on #647: scope.close() awaits promises returned by 'close' listeners as of v5.1.3 (v5.1.2 emitted 'close' without awaiting). Per CONTRIBUTING.md, behavior changes to existing surface get a standalone <VersionBadge type="changed" /> below the heading. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(components): scope shutdown cleanup to what needs an explicit hook Address review feedback on the new shutdown cleanup coverage. The section listed timers and open connections as things an application is responsible for tearing down. Node closes timers and sockets itself when a thread exits, so presenting them as the app's job is misleading. Reframe both the plugin-api.md section and the applications.md pointer around work that genuinely needs a hook: flushing buffered writes, deregistering from an external service, releasing a distributed lock, or closing a connection whose remote side expects a graceful goodbye. Also drop the incorrect justification for `scope.once()`. It claimed `once` avoids leaving a listener behind during a reload, but `Scope.close()` detaches its `'close'` listeners as part of closing and a reload builds a new Scope, so `on()` cannot strand a listener on a closed scope either. Recommend `once()` on the honest grounds that closing is a one-time lifecycle event. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 3356abc commit 8ef2ff0

2 files changed

Lines changed: 50 additions & 2 deletions

File tree

reference/components/applications.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,23 @@ harper deploy \
6666

6767
> Use `package=$(pwd)` if your current directory is the application directory.
6868
69+
## Shutdown Cleanup
70+
71+
Applications with work that must complete, or be acknowledged outside the process, before a thread exits — buffered writes to flush, a registration to withdraw from an external service, a distributed lock to release — need a hook to do it when Harper stops or restarts. Restarts are frequent during local development, since `harper dev` restarts worker threads on every file change, and deploying with `restart=true` does the same on a running instance.
72+
73+
Harper signals this by calling `scope.close()` on each worker thread, which emits a `'close'` event on the plugin API [`Scope`](./plugin-api.md#class-scope). Listen for it to run cleanup:
74+
75+
```js
76+
export function handleApplication(scope) {
77+
const service = startService();
78+
scope.once('close', async () => {
79+
await service.close();
80+
});
81+
}
82+
```
83+
84+
See [Cleanup on Shutdown](./plugin-api.md#cleanup-on-shutdown) for the full shutdown sequence, including how async cleanup is awaited and the time limit it must finish within.
85+
6986
## Remote Management
7087

7188
Managing applications on a remote Harper instance uses the same operations as local management. The recommended approach is to log in first using `harper login` to store an authentication token:

reference/components/plugin-api.md

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ The central object passed to `handleApplication()`. Provides access to configura
122122

123123
#### Events
124124

125-
- **`'close'`** — Emitted after `scope.close()` is called
125+
- **`'close'`** — Emitted after `scope.close()` is called. Harper calls `scope.close()` itself on shutdown and on graceful restart, so this is the hook for application teardown — see [Cleanup on Shutdown](#cleanup-on-shutdown)
126126
- **`'error'`** — `error: unknown` — An error occurred
127127
- **`'ready'`** — Emitted when the Scope is ready after loading the config file
128128

@@ -189,7 +189,11 @@ Returns: `string` — Root directory of the application component (where `config
189189

190190
#### `scope.close()`
191191

192-
Closes all associated entry handlers and the `scope.options` instance, emits `'close'`, and removes all listeners.
192+
<VersionBadge type="changed" version="v5.1.3" />
193+
194+
Closes all associated entry handlers and the `scope.options` instance, emits `'close'`, and removes all listeners. Promises returned by `'close'` listeners are awaited before it resolves.
195+
196+
Plugins rarely call this directly. Harper calls it on each worker thread during shutdown and graceful restart — see [Cleanup on Shutdown](#cleanup-on-shutdown).
193197

194198
### Class: `OptionsWatcher`
195199

@@ -379,6 +383,32 @@ Parsed representation of `config.yaml`.
379383

380384
Function signature for the `'all'` event handler passed to `scope.handleEntry()`.
381385

386+
## Cleanup on Shutdown
387+
388+
Harper calls `scope.close()` on every application scope when a worker thread shuts down, which emits the [`'close'`](#events) event. This happens both when Harper is stopping and on a graceful restart — including the automatic worker restarts triggered by `harper dev` file watching and by the `restart` operation.
389+
390+
Listen for `'close'` to finish work that must complete, or be acknowledged outside the process, before the thread goes away: flushing buffered writes, deregistering from an external service or registry, releasing a distributed lock or lease, or closing a connection whose remote side expects a graceful goodbye. Node tears down timers and sockets on its own when a thread exits, so purely in-process resources need no `'close'` listener.
391+
392+
```js
393+
export function handleApplication(scope) {
394+
const service = startService();
395+
scope.once('close', async () => {
396+
await service.close();
397+
});
398+
}
399+
```
400+
401+
Use `scope.once()` rather than `scope.on()` — closing is a one-time lifecycle event, so `once()` expresses that intent.
402+
403+
Notes on the shutdown sequence:
404+
405+
- **Async listeners are awaited.** Returning a promise from a `'close'` listener is supported: Harper awaits it before the worker exits, so cleanup completes rather than racing the process exit.
406+
- **Cleanup is bounded.** Shutdown has a backstop timer (10 seconds by default, longer under `harper dev`), after which the worker is force-exited even if a `'close'` listener is still running. Keep teardown short and avoid unbounded work such as retry loops without a deadline.
407+
- **A failed listener does not block shutdown.** If a `'close'` listener throws or rejects, Harper logs the error and continues shutting down. Because all listeners are awaited together, one that rejects also stops Harper from waiting on the others still in flight — so handle errors inside the listener rather than letting them escape.
408+
- **Each worker cleans up independently.** `handleApplication()` runs on every worker thread, so a `'close'` listener registered there runs once per worker. Cleanup that must happen only once for the whole instance needs its own coordination.
409+
410+
Handlers created with [`scope.handleEntry()`](#scopehandleentry) and the [`scope.options`](#scopeoptions) watcher are closed by Harper automatically; a `'close'` listener is only needed for resources the plugin manages itself.
411+
382412
## Example: Static File Server Plugin
383413

384414
A simplified form of the built-in `static` plugin demonstrating key Plugin API patterns. For a complete, production example of this API in action, read the plugin's open-source implementation in [`server/static.ts`](https://github.com/HarperFast/harper/blob/main/server/static.ts).
@@ -427,3 +457,4 @@ export function handleApplication(scope) {
427457

428458
- **v4.6.0** — Plugin API introduced (experimental)
429459
- **v4.7.0** — Further improvements to the Plugin API
460+
- **v5.1.3** — Shutdown awaits promises returned by `'close'` listeners; earlier releases emitted `'close'` without waiting, so async cleanup could be cut off by the worker exiting

0 commit comments

Comments
 (0)