Skip to content

Commit 13ab5eb

Browse files
authored
chore(e2e): stop and reap leaked isolated Obsidian E2E instances (#197)
Add stop:e2e-obsidian + reap-on-start (worktree-gone marker) + orca.yaml archive hook so removed/merged worktrees no longer leak an Obsidian process tree or /private/tmp vault dir. Targets only this worktree's instance; never touches the shared dev vault, other worktrees, or quickadd. Tooling only; no plugin behavior change.
1 parent 858e280 commit 13ab5eb

7 files changed

Lines changed: 1036 additions & 0 deletions

AGENTS.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,40 @@ npm run obsidian:e2e -- dev:errors
141141
`main.js` to exist). `start:e2e-obsidian` reloads PodNotes when it reuses a
142142
running instance, so the exported instance is never stale.
143143

144+
### Stopping an isolated instance (avoid leaks)
145+
146+
Each started instance is a real Obsidian process tree plus a private profile
147+
directory under `/private/tmp/podnotes-obsidian-e2e/<vault>-<hash>/`. Removing a
148+
worktree does **not** stop it, so a finished worktree would leak an Obsidian
149+
process tree and a `/private/tmp` directory. Stop it explicitly:
150+
151+
```bash
152+
npm run stop:e2e-obsidian # stop THIS worktree's instance + remove its tmp dir
153+
npm run stop:e2e-obsidian -- --dry-run # show what would be stopped/removed
154+
npm run stop:e2e-obsidian -- --prune # also reap orphaned instances (worktree gone)
155+
```
156+
157+
The teardown identifies only this worktree's instance by its private
158+
`--user-data-dir` token (which contains a per-worktree hash), terminates that
159+
process tree (SIGTERM, then SIGKILL for stragglers), and removes its profile
160+
directory. It never touches the shared `dev` vault, other worktrees, or quickadd
161+
instances.
162+
163+
Two layers keep instances from leaking, so you rarely need to run `stop` by hand:
164+
165+
- **Orca archive hook**`orca.yaml` defines a `scripts.archive` hook that runs
166+
this teardown for the worktree being removed. Remove worktrees with
167+
`orca worktree rm --worktree <selector> --run-hooks` so the hook fires (Orca
168+
skips archive hooks without `--run-hooks`).
169+
- **Reap on next start**`start:e2e-obsidian` and `obsidian:e2e` reap any
170+
orphaned instance (one whose backing worktree no longer exists on disk, i.e.
171+
it was removed) before launching, even if its Obsidian is still running. An
172+
idle instance for a worktree that still exists is left alone so concurrent
173+
workers can reuse it. Reaping scans the default profile root
174+
(`/tmp/podnotes-obsidian-e2e`); instances started under a custom
175+
`--profile-root` are only reaped by a start that uses that same root, so stop
176+
those explicitly.
177+
144178
## Documentation
145179
Docs live in `docs/docs/` and are configured by `docs/mkdocs.yml`. Update docs
146180
with user-facing behavior changes, new commands, API changes, template syntax,

orca.yaml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Orca worktree lifecycle hooks for PodNotes.
2+
# See AGENTS.md > "Isolated worktree vault" for the full workflow.
3+
#
4+
# `archive` runs on `orca worktree rm --run-hooks`, before the worktree is
5+
# removed, with the cwd set to the worktree and $ORCA_WORKTREE_PATH pointing at
6+
# it. It stops this worktree's isolated Obsidian E2E instance and removes its
7+
# /private/tmp profile directory, so a merged or deleted worktree never leaks an
8+
# Obsidian process tree or vault dir. It targets only this worktree's instance
9+
# (matched by its private --user-data-dir token); the shared dev vault, other
10+
# worktrees, and quickadd instances are untouched.
11+
#
12+
# Notes:
13+
# - Orca skips this hook unless `--run-hooks` is passed; `start:e2e-obsidian`
14+
# also reaps orphaned instances on the next start as a safety net.
15+
# - A failed archive hook is logged by Orca but never blocks removal. `|| true`
16+
# keeps the archive log clean when no instance was ever started.
17+
# - This assumes the default per-worktree vault name (podnotes-<worktree>) and
18+
# profile root. If you started an instance with a custom --vault or
19+
# --profile-root, stop it with the matching
20+
# `npm run stop:e2e-obsidian -- --vault <name> [--profile-root <path>]` before
21+
# removing the worktree.
22+
# - The hook needs `node` on the GUI app's PATH (Orca runs it via non-login
23+
# /bin/bash). It is guarded with `command -v node` so a missing node degrades
24+
# to a no-op (the reap-on-next-start safety net then handles cleanup) instead
25+
# of erroring.
26+
scripts:
27+
archive: |
28+
command -v node >/dev/null 2>&1 && node scripts/stop-obsidian-e2e-instance.mjs --worktree "$ORCA_WORKTREE_PATH" || true

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"obsidian:e2e": "node scripts/obsidian-e2e-cli.mjs",
1717
"provision:e2e-vault": "node scripts/provision-obsidian-e2e-vault.mjs",
1818
"start:e2e-obsidian": "node scripts/start-obsidian-e2e-instance.mjs",
19+
"stop:e2e-obsidian": "node scripts/stop-obsidian-e2e-instance.mjs",
1920
"check:a11y": "svelte-check --fail-on-warnings",
2021
"docs:build": "mkdocs build -f docs/mkdocs.yml -d site",
2122
"docs:deploy": "npm run docs:build && npx wrangler pages deploy docs/site --project-name podnotes --branch master"

scripts/obsidian-e2e-cli.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
launchObsidianInstance,
88
parseArgs as parseInstanceArgs,
99
prepareObsidianProfile,
10+
reapStaleInstances,
1011
reloadPodNotes,
1112
resolveInstanceOptions,
1213
trustVaultAndVerifyPodNotes,
@@ -165,6 +166,7 @@ async function main() {
165166
const options = resolveInstanceOptions(
166167
parseInstanceArgs(parsed.instanceArgs),
167168
);
169+
await reapStaleInstances(options);
168170
await ensureObsidianInstance(options);
169171
process.exitCode = await spawnObsidian(options, parsed.commandArgs);
170172
}

scripts/start-obsidian-e2e-instance.mjs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ import {
1414

1515
const execFileAsync = promisify(execFile);
1616
const DEFAULT_PROFILE_ROOT = "/tmp/podnotes-obsidian-e2e";
17+
// Sidecar written at the instance root recording which worktree the instance
18+
// belongs to. The teardown reaper reads it to reap an instance only once its
19+
// worktree is gone (a removed/merged worktree) — the reliable leak signal.
20+
export const INSTANCE_MARKER_FILE = "podnotes-e2e-instance.json";
1721
const DEFAULT_OBSIDIAN_APP = "Obsidian";
1822
const DEFAULT_OBSIDIAN_BIN = "obsidian";
1923
const READY_TIMEOUT_MS = 30_000;
@@ -168,6 +172,14 @@ export async function prepareObsidianProfile(options) {
168172
},
169173
});
170174

175+
// Record which worktree this instance belongs to so the teardown reaper can
176+
// reap it once that worktree is removed (see INSTANCE_MARKER_FILE).
177+
await writeJson(path.join(options.instancePath, INSTANCE_MARKER_FILE), {
178+
worktreePath: options.worktreePath,
179+
vaultName: options.vaultName,
180+
vaultPath: options.vaultPath,
181+
});
182+
171183
return {
172184
obsidianJsonPath,
173185
userDataPath,
@@ -406,6 +418,29 @@ function shellQuote(value) {
406418
return `'${String(value).replaceAll("'", "'\\''")}'`;
407419
}
408420

421+
// Self-healing safety net: before launching our own instance, reap any leaked
422+
// instances whose backing worktree is gone (e.g. removed on merge without the
423+
// orca archive hook running). Best-effort — a reap failure must never block a
424+
// start. The reaper is imported lazily so the static module graph stays acyclic
425+
// (the stop module imports our option resolver; we only need its reaper at
426+
// runtime). Logs go to stderr so `--print-env` keeps stdout to `export …` lines.
427+
export async function reapStaleInstances(options) {
428+
try {
429+
const { reapOrphanedInstances } = await import(
430+
"./stop-obsidian-e2e-instance.mjs"
431+
);
432+
await reapOrphanedInstances({
433+
profileRoot: options.profileRoot,
434+
exceptInstancePath: options.instancePath,
435+
log: console.error,
436+
});
437+
} catch (error) {
438+
console.error(
439+
`Skipping stale-instance reap: ${error instanceof Error ? error.message : error}`,
440+
);
441+
}
442+
}
443+
409444
async function main() {
410445
const rawOptions = parseArgs(process.argv.slice(2));
411446
if (rawOptions.help) {
@@ -414,6 +449,7 @@ async function main() {
414449
}
415450

416451
const options = resolveInstanceOptions(rawOptions);
452+
await reapStaleInstances(options);
417453
const provisionResult = await provisionVault(options);
418454
const profileResult = await prepareObsidianProfile(options);
419455
options.userDataPath = profileResult.userDataPath;

0 commit comments

Comments
 (0)