This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

lean-core: gc clean-only + delete pty up/down + trim GcResult; bump 0.12.0

Sub-batch 2/N of the reboot cutover branch. Executes §8 items 1–8 + 10
of notes/lean-pty-core-supervision-spec.md.

BREAKING — CLI surface:
- Removed `pty up` and `pty down`. Manifest processing moves to
`convoy up` / `convoy down`. `pty.toml` file name stays; convoy reads
it verbatim via `readPtyFile` + `commandWithEnvExports`, both still
exported from `@myobie/pty/client`.
- Removed `pty gc --fast-fail-window` and `--fast-fail-limit`.
- `pty gc` is clean-only: STEP 1 (orphan-kill), STEP 1.5 (abandoned-reap
cwd-gone + opt-in idle), STEP 3 (sweep exited non-permanent). STEP 2
(permanent respawn) is gone; permanent sessions that exit stay
in-place on disk for convoy's reconcile loop.

BREAKING — API surface:
- GcResult trimmed to `{ removed, killedOrphanChildren, abandoned }`.
- Deleted sessions.ts:respawnPermanent + classifyFlapping + the
FlappingDecision interface.
- Removed the `[flapping]` badge from strategyMarker / `pty list`
(convoy renders its own list if desired).
- SessionFlappingEvent + EventType.SESSION_FLAPPING stay exported for
convoy to import + emit via appendEventSync. Payload frozen per
spec §8.1.

Tests:
- Deleted: tests/gc-flapping.test.ts, tests/gc-permanent.test.ts,
tests/up-down.test.ts, tests/up-name-decouple.test.ts.
- Extracted PTY_ROOT length backstop tests into
tests/pty-root-length-backstop.test.ts (from the previous
gc-flap-clear-badge-root-len.test.ts, dropping the flap-clear +
badge cases).
- Updated tests/gc-abandoned.test.ts + gc-parent-child.test.ts to
assert post-reboot behavior.
- Full suite: 1158 passed + 1 flake (demos/agent-teams timestamp
timing, unrelated to §8), 21 skipped, 0 real failures.

Version bump 0.11.0 → 0.12.0. `flake.nix` npmDepsHash refreshed.

BREAKING — Storage format on 0.12.0:
- No tag or event REMOVED from the wire — pty stops writing the
`strategy.status` / `strategy.consecutive-fast-fails` /
`strategy.last-respawn-at` / `strategy.command-hash` tags, but they
remain reserved-shape for convoy to write. `session_flapping` event
stays defined.

Cutover-branch commit; does NOT merge until reboot moment.

Nathan Herald (Jul 7, 2026, 3:50 PM +0200) 51c2cd1c 0b454ded

+194 -2360
+32
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + ## 0.12.0 — reboot cutover 4 + 5 + Reboot-moment release: pty relocates permanent-session respawn and the fast-fail crash-loop cap out to convoy. `pty gc` becomes clean-only; `pty-daemon` is spawned by the host rather than pty; `pty up` / `pty down` move to `convoy up` / `convoy down`. See `notes/lean-pty-core-supervision-spec.md` for the full contract. 6 + 7 + ### BREAKING — CLI surface 8 + 9 + - **Removed `pty up` and `pty down`.** Manifest processing is now `convoy up` / `convoy down`. `pty.toml` stays as the manifest file name; convoy reads it verbatim. `readPtyFile` + `commandWithEnvExports` remain exported on `@myobie/pty/client` so convoy shares the parser without vendoring. 10 + - **Removed `pty gc --fast-fail-window` and `--fast-fail-limit`.** The fast-fail respawn cap moved to convoy's reconcile loop. Per-session `strategy.fast-fail-window` / `strategy.fast-fail-limit` tags still work but are now read by convoy, not pty. 11 + - **`pty gc` is clean-only.** The reconciliation pass keeps STEP 1 (orphan-kill on `parent=` tag), STEP 1.5 (abandoned-reap: cwd-gone; opt-in idle via `--idle-days` or `strategy.idle-days` tag), and STEP 3 (sweep exited non-permanent metadata). STEP 2 (permanent respawn) is gone. Permanent sessions that are gone stay in-place on disk for convoy's next reconcile tick to notice. Never spawns anything. 12 + 13 + ### BREAKING — API surface 14 + 15 + - **`GcResult` trimmed.** `respawned`, `respawnFailed`, `flapped`, and `flappingSkipped` fields removed. The surviving shape is `{ removed, killedOrphanChildren, abandoned }`. 16 + - **`sessions.ts:respawnPermanent` deleted.** The helper's role (respawn a `strategy=permanent` session, re-read pty.toml, thread bookkeeping tags) is now convoy's. Convoy either `execFile`'s `pty run -d` (recommended, per Nathan's Q2) or imports `spawnDaemon` from `@myobie/pty/client` directly. 17 + - **`sessions.ts:classifyFlapping` deleted.** The classifier's role (fast-fail counter + command-hash divergence reset + flapping-status flip) is now convoy's. `commandFingerprint` stays exported so convoy computes byte-identical hashes; see spec §8.1 wire-format freeze. 18 + - **`[flapping]` badge removed from `strategyMarker` / `pty list`.** Convoy renders its own list view if desired; pty stays neutral on the status. 19 + - **`SessionFlappingEvent` interface + `EventType.SESSION_FLAPPING` stay exported.** Convoy imports both from `@myobie/pty/client` and emits the event via `appendEventSync`. The event payload shape is frozen (spec §8.1): `{ session, type: "session_flapping", ts, counter, limit, window }`. Any change requires a joint pty ⇄ convoy version bump. 20 + 21 + ### Non-breaking additions (from the pre-cutover branch) 22 + 23 + - **Exported from `@myobie/pty/client`**: `commandFingerprint`, `DEFAULT_FAST_FAIL_WINDOW_SEC`, `DEFAULT_FAST_FAIL_LIMIT` (wire-format primitives convoy needs); `readPtyFile`, `commandWithEnvExports` (parser convoy shares). 24 + 25 + ### Tests 26 + 27 + - Deleted: `tests/gc-flapping.test.ts`, `tests/gc-permanent.test.ts`, `tests/up-down.test.ts`, `tests/up-name-decouple.test.ts` — all exercised behavior that moved out of pty. 28 + - Extracted `tests/pty-root-length-backstop.test.ts` from the previous `tests/gc-flap-clear-badge-root-len.test.ts`, keeping only the PTY_ROOT length backstop cases. 29 + - `tests/gc-abandoned.test.ts` and `tests/gc-parent-child.test.ts` updated to assert the post-reboot behavior (exited permanents are left in-place; orphan-kill still removes children including permanent ones). 30 + 31 + ### Migration 32 + 33 + - Reboot-only. This release is not incremental — it changes ownership of respawn. Bringing 0.11.0 pty online alongside a convoy that expects 0.12.0 pty (or vice versa) will leave permanent sessions with no respawn owner. Sequencing on Nathan's machine (or Johannes's): quiesce network → install pty 0.12.0 + convoy → run `convoy up`. 34 + 3 35 ## 0.11.0 4 36 5 37 ### `@myobie/pty/tui` — `text()` accepts an object form `{ fg, bold, ... }`
+13 -35
README.md
··· 104 104 pty restart myserver # restart an exited session 105 105 pty kill myserver # terminate a running session 106 106 pty rm myserver # remove an exited session's metadata 107 - pty gc # reconcile sessions: kill orphan children, respawn permanents, sweep exited 107 + pty gc # clean-only reconcile: orphan-kill, abandoned-reap, sweep exited 108 108 pty gc --dry-run # preview what gc would do without changing anything 109 + pty gc --idle-days N # also reap permanent sessions with no attach in N days 109 110 pty gc --print-launchd-plist > ~/Library/LaunchAgents/com.myobie.pty.gc.plist # install macOS auto-gc 110 111 pty tag myserver role=web env=prod # set one or more tags on a session 111 112 pty tag myserver --rm role --rm env # remove one or more tags 112 113 pty tag-multi --filter-tag role=web env=prod # bulk write across matching sessions 113 114 pty tag-multi --all --json # bulk read tags across every session 114 115 pty tag-multi --all --yes audit=today # write to every session (--yes required) 116 + ``` 115 117 116 - pty up # start all sessions from ./pty.toml 117 - pty up ./backend # start sessions from ./backend/pty.toml 118 - pty up claude dev # start specific sessions from ./pty.toml 119 - pty down # stop all sessions from ./pty.toml 120 - pty down claude # stop specific sessions 121 - ``` 118 + Manifest-processing (`pty up` / `pty down`) has moved to convoy. `pty.toml` is now read by `convoy up`; pty's public API keeps `readPtyFile` + `commandWithEnvExports` exported on `@myobie/pty/client` so convoy shares the parser without vendoring. See `notes/lean-pty-core-supervision-spec.md`. 122 119 123 120 ### Nesting Prevention 124 121 ··· 186 183 tags = { role = "server" } 187 184 ``` 188 185 189 - Run `pty up` in the project directory (or `pty up /path/to/project`) to start all sessions. Run `pty down` to stop them. You can also start specific sessions: `pty up dev serve`. 186 + `pty.toml` manifest processing (start / stop / reconcile) is owned by convoy — run `convoy up` in the project directory to start the declared sessions. Convoy reads the same file verbatim; pty's public API exposes `readPtyFile` and `commandWithEnvExports` on `@myobie/pty/client` so convoy shares the parser. For ad-hoc single sessions, `pty run -d -- <cmd>` still creates a background session directly. 190 187 191 188 Each session also supports two optional fields: 192 189 ··· 210 207 LOG_LEVEL = "debug" 211 208 ``` 212 209 213 - The values are exported into the session's shell before the command runs — `pty up` wraps every toml-managed session in `/bin/sh -c` so the `export K='V'; …` prefix is honored. They take effect on the next `pty up` after the session has stopped — restarting a still-running session via `pty restart` reuses the existing spawn args, so `pty kill <name>` followed by `pty up` is the way to pick up a changed env block on an already-running session. 210 + The values are exported into the session's shell before the command runs — convoy's `up` wraps every toml-managed session in `/bin/sh -c` so the `export K='V'; …` prefix is honored. They take effect on the next `convoy up` after the session has stopped. Restarting a still-running session via `pty restart` reuses the existing spawn args, so `pty kill <name>` (convoy respawns on its next reconcile tick) is the way to pick up a changed env block on an already-running session. 214 211 215 212 ### Permanent sessions 216 213 217 - Tag a session with `strategy=permanent` and `pty gc` will respawn it whenever its daemon exits or vanishes: 214 + Tag a session with `strategy=permanent` and convoy's reconcile loop will respawn it whenever its daemon exits or vanishes. `pty gc` is clean-only: it kills orphan children (`parent=` tag), reaps abandoned permanents (cwd-gone; opt-in idle), and sweeps exited non-permanent metadata — never respawns. 218 215 219 216 ```sh 220 217 pty tag myserver strategy=permanent 221 218 222 - # After myserver exits — manually or by crash — the next `pty gc` run 223 - # brings it back. No backoff, no retry budget; the cron interval below 224 - # is the rate limit. Sessions managed by pty.toml re-read the toml on 225 - # respawn so command/env edits take effect immediately. 226 - pty gc 219 + # After myserver exits — manually or by crash — convoy's next reconcile 220 + # tick respawns it (default 30 s). Sessions managed by pty.toml re-read 221 + # the toml on respawn so command/env edits take effect immediately. 227 222 ``` 228 223 229 224 From `pty.toml`: ··· 234 229 tags = { strategy = "permanent" } 235 230 ``` 236 231 237 - Restart is stateless — every `pty gc` invocation re-derives intent from on-disk metadata. There's no in-memory restart counter, no `[failed]` state, no persisted bookkeeping. If a session's binary isn't reachable (volume not mounted, broken symlink), `pty gc` reports `Respawn failed:` and the next tick tries again. 238 - 239 - **Fast-fail cap** — a permanent session whose leaf exits within `strategy.fast-fail-window` seconds of its previous `pty gc` respawn counts as a fast fail. After `strategy.fast-fail-limit` consecutive fast fails, `pty gc` writes `strategy.status=flapping` on the session, emits a `session_flapping` event, and stops respawning it. Subsequent gc ticks print `Skipped (flapping): <name>` and take no action. Defaults: 60 s window, 3 consecutive fast fails. 240 - 241 - A flagged session shows `[flapping]` (red) in `pty list` in place of `[permanent]` — the operator's expectation has changed, so the badge reflects that. 242 - 243 - Reset a flagged session with one of: 244 - 245 - - `pty restart <name>` or `pty up` — the manual respawn drops all fast-fail bookkeeping (`strategy.status`, `strategy.consecutive-fast-fails`, `strategy.last-respawn-at`, `strategy.command-hash`), treating restart as an operator "please try again" signal. 246 - - `pty tag <name> --rm strategy.status` — surgical reset that clears only the mark, leaving the counter intact for observability. 247 - - Edit the session's `pty.toml` command — the classifier notices the SHA-256 fingerprint change and auto-resets the counter and mark on the next gc tick. 248 - 249 - Per-session overrides tune the cap without editing gc's globals: 250 - 251 - ```sh 252 - pty tag myserver strategy.fast-fail-window=120 # allow 2min of runtime before "fast" 253 - pty tag myserver strategy.fast-fail-limit=5 # tolerate 5 fast fails before flapping 254 - ``` 232 + The reboot moment relocated respawn + the fast-fail crash-loop cap from `pty gc` to convoy. Convoy owns the classifier (fast-fail window + counter + flapping mark + command-hash reset); pty owns the wire-format for the tags convoy writes (`strategy.consecutive-fast-fails`, `strategy.last-respawn-at`, `strategy.command-hash`, `strategy.status`). See `notes/lean-pty-core-supervision-spec.md` §5 + §8.1 for the full contract. 255 233 256 - CLI globals mirror the per-session tags (`--fast-fail-window=N`, `--fast-fail-limit=N`); the per-session tag wins when both are set. 234 + The **operator-restart path** post-reboot: `pty kill <ref>` → convoy's reconcile tick sees the session gone → respawns via `pty run -d`. A manual kill on a long-lived agent doesn't count as a fast fail (it lived past the window). `pty restart` still works for a one-verb "kill and respawn now" that also strips convoy's bookkeeping tags to give the restart a clean slate. 257 235 258 236 ### Parent-child sessions 259 237 ··· 265 243 # If `webserver` dies, the next `pty gc` SIGTERMs `webserver-tail`. 266 244 ``` 267 245 268 - What triggers the kill: the parent's metadata file is gone OR the parent's pid file is gone OR the parent's process isn't alive. What doesn't: the parent's exit code, the parent's `exitedAt` timestamp. Combinator with `strategy=permanent` is well-defined — orphan-kill wins (the child is removed, not respawned). 246 + What triggers the kill: the parent's metadata file is gone OR the parent's pid file is gone OR the parent's process isn't alive. What doesn't: the parent's exit code, the parent's `exitedAt` timestamp. Combinator with `strategy=permanent` is well-defined — orphan-kill wins (child removed by `pty gc`; convoy has nothing to respawn on the next tick). 269 247 270 248 Cycles (A→B, B→A) resolve deterministically by name-sorted iteration: whichever name sorts first dies first on the tick where both parents are gone; the loser dies the same tick because its parent (the just-killed winner) is also dead. No cycle detection needed. 271 249
+2 -7
completions/pty.bash
··· 7 7 COMPREPLY=() 8 8 cur="${COMP_WORDS[COMP_CWORD]}" 9 9 prev="${COMP_WORDS[COMP_CWORD-1]}" 10 - commands="run attach a exec peek send events list ls stats restart kill rm remove gc tag tag-multi emit rename up down test help" 10 + commands="run attach a exec peek send events list ls stats restart kill rm remove gc tag tag-multi emit rename test help" 11 11 12 12 # Complete subcommand (first positional). 13 13 if [[ ${COMP_CWORD} -eq 1 ]]; then ··· 86 86 ;; 87 87 gc) 88 88 if [[ "${cur}" == -* ]]; then 89 - COMPREPLY=($(compgen -W "-n --dry-run --idle-days --fast-fail-window --fast-fail-limit --print-launchd-plist --interval" -- "${cur}")) 89 + COMPREPLY=($(compgen -W "-n --dry-run --idle-days --print-launchd-plist --interval" -- "${cur}")) 90 90 fi 91 91 ;; 92 92 tag) ··· 109 109 else 110 110 COMPREPLY=($(compgen -W "${names}" -- "${cur}")) 111 111 fi 112 - ;; 113 - up|down) 114 - # Complete directories (containing pty.toml) or session names from an 115 - # already-loaded toml. Directories are more common; keep it simple. 116 - COMPREPLY=($(compgen -o dirnames -- "${cur}")) 117 112 ;; 118 113 exec) 119 114 # After --, fall through to default (command + args) completion.
+1 -10
completions/pty.fish
··· 62 62 complete -c pty -n __pty_needs_command -a tag-multi -d 'Bulk tag ops across sessions' 63 63 complete -c pty -n __pty_needs_command -a emit -d 'Publish a user.* event' 64 64 complete -c pty -n __pty_needs_command -a rename -d 'Set / show / clear displayName' 65 - complete -c pty -n __pty_needs_command -a up -d 'Start sessions from pty.toml' 66 - complete -c pty -n __pty_needs_command -a down -d 'Stop sessions from pty.toml' 67 65 complete -c pty -n __pty_needs_command -a test -d 'Run the pty test suite (vitest)' 68 66 complete -c pty -n __pty_needs_command -a help -d 'Show usage' 69 67 ··· 132 130 complete -c pty -n '__pty_using_command rm' -a '(__pty_sessions)' -d 'Session' 133 131 complete -c pty -n '__pty_using_command remove' -a '(__pty_sessions)' -d 'Session' 134 132 135 - # ── gc ───────────────────────────────────────────────────────────────── 133 + # ── gc (clean-only post-reboot; convoy owns respawn) ─────────────────── 136 134 complete -c pty -n '__pty_using_command gc' -s n -l dry-run -d 'Preview without changing anything' 137 135 complete -c pty -n '__pty_using_command gc' -l idle-days -x -d 'Reap permanents with no attach in N days' 138 - complete -c pty -n '__pty_using_command gc' -l fast-fail-window -x -d 'Fast-fail window (seconds; default 60)' 139 - complete -c pty -n '__pty_using_command gc' -l fast-fail-limit -x -d 'Consecutive fast fails before flapping (default 3)' 140 136 complete -c pty -n '__pty_using_command gc' -l print-launchd-plist -d 'Emit a launchd plist that runs pty gc' 141 137 complete -c pty -n '__pty_using_command gc' -l interval -x -d 'Plist StartInterval seconds (default 30)' 142 138 ··· 160 156 complete -c pty -n '__pty_using_command rename' -a '(__pty_sessions)' -d 'Session' 161 157 complete -c pty -n '__pty_using_command rename' -l show -d 'Print current displayName' 162 158 complete -c pty -n '__pty_using_command rename' -l clear -d 'Remove displayName' 163 - 164 - # ── up / down ────────────────────────────────────────────────────────── 165 - for verb in up down 166 - complete -c pty -n "__pty_using_command $verb" -a '(__fish_complete_directories)' -d 'Directory containing pty.toml' 167 - end 168 159 169 160 # ── test ─────────────────────────────────────────────────────────────── 170 161 complete -c pty -n '__pty_using_command test' -a 'watch' -d 'Watch mode'
-7
completions/pty.zsh
··· 35 35 'tag-multi:Bulk tag ops across sessions' 36 36 'emit:Publish a user.* event' 37 37 'rename:Set / show / clear displayName' 38 - 'up:Start sessions from pty.toml' 39 - 'down:Stop sessions from pty.toml' 40 38 'test:Run the pty test suite (vitest)' 41 39 'help:Show usage' 42 40 ) ··· 113 111 _arguments \ 114 112 '(-n --dry-run)'{-n,--dry-run}'[Preview without changing anything]' \ 115 113 '--idle-days[Reap permanents with no attach in N days]:days:' \ 116 - '--fast-fail-window[Fast-fail window in seconds (default 60)]:seconds:' \ 117 - '--fast-fail-limit[Consecutive fast fails before flapping (default 3)]:count:' \ 118 114 '--print-launchd-plist[Emit a launchd plist that runs pty gc]' \ 119 115 '--interval[Plist StartInterval seconds (default 30)]:seconds:' 120 116 ;; ··· 142 138 '--show[Print current displayName]' \ 143 139 '--clear[Remove displayName]' \ 144 140 '1:session:_pty_sessions' 145 - ;; 146 - up|down) 147 - _arguments '1:directory:_directories' 148 141 ;; 149 142 run) 150 143 # After --, fall back to normal (command + file) completion
+1 -1
docs/disk-layout.md
··· 74 74 | `session_exec` | `previousCommand, command` | 75 75 | `session_respawn` | — (`pty gc` respawned a `strategy=permanent` session) | 76 76 | `session_abandoned` | `reason: "cwd-gone" \| "idle", idleDays?` — (`pty gc` reaped a live permanent session detected as abandoned) | 77 - | `session_flapping` | `counter, limit, window` — (`pty gc` flipped a permanent session to `strategy.status=flapping` after N consecutive fast-fail respawns; subsequent ticks skip it) | 77 + | `session_flapping` | `counter, limit, window` — emitted by convoy's reconcile loop when it flips a permanent session to `strategy.status=flapping` after N consecutive fast-fail respawns; subsequent ticks skip it. Interface + `EventType.SESSION_FLAPPING` still exported from `@myobie/pty/client` so convoy shares the wire-format. | 78 78 | `display_name_change` | `previous: string\|null, value: string\|null` | 79 79 | `tags_change` | `previous, value` (full snapshots) | 80 80 | `user.<name>` | `data?, text?` — free-form, via `pty emit` |
+1 -1
flake.nix
··· 29 29 30 30 # Generated from package-lock.json. 31 31 # Regenerate with: nix run nixpkgs#prefetch-npm-deps -- package-lock.json 32 - npmDepsHash = "sha256-mer8rhDyD/j+htWDU8F1EH7MrnuS8pS57WdQgGH8cnQ="; 32 + npmDepsHash = "sha256-yzWiYJ7sbxG1ulvmqA4X4ybs0Qtp7xacwy8PA0GCYBA="; 33 33 34 34 # node-pty has native code that needs these at build time 35 35 nativeBuildInputs = with pkgs; [ python3 pkg-config ];
+2 -2
package-lock.json
··· 1 1 { 2 2 "name": "@myobie/pty", 3 - "version": "0.11.0", 3 + "version": "0.12.0", 4 4 "lockfileVersion": 3, 5 5 "requires": true, 6 6 "packages": { 7 7 "": { 8 8 "name": "@myobie/pty", 9 - "version": "0.11.0", 9 + "version": "0.12.0", 10 10 "hasInstallScript": true, 11 11 "license": "MIT", 12 12 "dependencies": {
+1 -1
package.json
··· 1 1 { 2 2 "name": "@myobie/pty", 3 - "version": "0.11.0", 3 + "version": "0.12.0", 4 4 "description": "Persistent terminal sessions with detach/attach, plus a Playwright-style testing library for TUI apps", 5 5 "type": "module", 6 6 "license": "MIT",
+19 -375
src/cli.ts
··· 34 34 readRecentEvents, formatEvent, 35 35 emitUserEvent, 36 36 } from "./events.ts"; 37 - import { readPtyFile, commandWithEnvExports, type PtySessionDef } from "./ptyfile.ts"; 37 + // pty.toml parsing moved to convoy post-reboot; `readPtyFile` + 38 + // `commandWithEnvExports` stay exported from `@myobie/pty/client` so 39 + // convoy shares the parser without vendoring. 38 40 import { extractFilterTags as extractFilterTagsImpl, matchesAllTags, isReservedTagKey } from "./tags.ts"; 39 41 import { parseDuration, formatDuration } from "./duration.ts"; 40 42 ··· 138 140 pty restart <ref> SIGTERM + respawn using stored metadata (prompts if running) 139 141 pty restart -y <ref> Same, no prompt 140 142 pty kill <ref> SIGTERM a running session's daemon 143 + (convoy's reconcile respawns strategy=permanent sessions) 141 144 pty rm <ref> Remove an exited session's metadata (alias: pty remove) 142 - pty gc Reconciliation pass: orphan-kill, abandoned-reap, 143 - permanent-respawn, exited-sweep 145 + pty gc Clean-only reconciliation: orphan-kill (parent= tag), 146 + abandoned-reap (cwd-gone; opt-in idle), sweep exited 147 + non-permanent metadata. Never respawns — convoy owns 148 + respawn post-reboot. 144 149 pty gc --dry-run Preview without changing anything (alias: -n) 145 - pty gc --idle-days N Also reap permanents with no attach in N days 146 - pty gc --fast-fail-window=N Fast-fail window (seconds) for the respawn cap 147 - (default 60; per-session strategy.fast-fail-window wins) 148 - pty gc --fast-fail-limit=N Consecutive fast fails before a permanent is flagged 149 - flapping (default 3; per-session tag wins) 150 + pty gc --idle-days N Also reap permanent sessions with no attach in N days 150 151 pty gc --print-launchd-plist [--interval=N] 151 152 Print a launchd plist that runs 'pty gc' every N seconds 152 153 (default 30); Label + logPath derived from PTY_ROOT 153 - 154 - Multi (pty.toml): 155 - pty up Start every session in ./pty.toml 156 - pty up <dir> Start sessions in <dir>/pty.toml 157 - pty up <name> [<name>...] Start specific sessions from ./pty.toml 158 - pty down Stop every session in ./pty.toml 159 - pty down <dir> Stop sessions in <dir>/pty.toml 160 - pty down <name> [<name>...] Stop specific sessions 161 154 162 155 Global: 163 156 pty --root <path> <subcommand> [...] Pin the state registry for this call (== PTY_ROOT env) ··· 827 820 const printPlist = gcArgs.includes("--print-launchd-plist"); 828 821 let interval = 30; 829 822 let idleDays: number | undefined; 830 - let fastFailWindowSec: number | undefined; 831 - let fastFailLimit: number | undefined; 832 823 const parsePositive = (flag: string, raw: string): number => { 833 824 const v = parseInt(raw, 10); 834 825 if (!Number.isFinite(v) || v <= 0) { ··· 847 838 idleDays = parsePositive("--idle-days", gcArgs[++i]); 848 839 } else if (a.startsWith("--idle-days=")) { 849 840 idleDays = parsePositive("--idle-days", a.slice("--idle-days=".length)); 850 - } else if (a === "--fast-fail-window" && i + 1 < gcArgs.length) { 851 - fastFailWindowSec = parsePositive("--fast-fail-window", gcArgs[++i]); 852 - } else if (a.startsWith("--fast-fail-window=")) { 853 - fastFailWindowSec = parsePositive("--fast-fail-window", a.slice("--fast-fail-window=".length)); 854 - } else if (a === "--fast-fail-limit" && i + 1 < gcArgs.length) { 855 - fastFailLimit = parsePositive("--fast-fail-limit", gcArgs[++i]); 856 - } else if (a.startsWith("--fast-fail-limit=")) { 857 - fastFailLimit = parsePositive("--fast-fail-limit", a.slice("--fast-fail-limit=".length)); 858 841 } 859 842 } 860 843 if (printPlist) { 861 844 printLaunchdPlist(interval); 862 845 break; 863 846 } 864 - await cmdGc(dryRun, idleDays, fastFailWindowSec, fastFailLimit); 847 + await cmdGc(dryRun, idleDays); 865 848 break; 866 849 } 867 850 ··· 941 924 942 925 if (ptyfilePath) { 943 926 console.error(`\nWarning: this session is managed by ${ptyfilePath}`); 944 - console.error("Running 'pty up' will sync tags from the toml and may overwrite this change."); 927 + console.error("Running 'convoy up' will sync tags from the toml and may overwrite this change."); 945 928 console.error("To make it permanent, edit the pty.toml file directly."); 946 929 } 947 930 } catch (e: any) { ··· 961 944 break; 962 945 } 963 946 964 - case "up": { 965 - if (args[1] === "-h" || args[1] === "--help") { 966 - console.log("Usage: pty up [dir] [name...]\n\nStart sessions defined in pty.toml."); 967 - break; 968 - } 969 - // pty up [dir] [name...] 970 - const upArgs = args.slice(1); 971 - let dir: string | undefined; 972 - const names: string[] = []; 973 - 974 - for (const arg of upArgs) { 975 - if (arg.startsWith("-")) break; 976 - if (!dir && names.length === 0 && hasPtyFile(arg)) { 977 - dir = arg; 978 - } else { 979 - names.push(arg); 980 - } 981 - } 982 - 983 - await cmdUp(dir, names); 984 - break; 985 - } 986 - 987 - case "down": { 988 - if (args[1] === "-h" || args[1] === "--help") { 989 - console.log("Usage: pty down [dir] [name...]\n\nStop sessions defined in pty.toml."); 990 - break; 991 - } 992 - // pty down [dir] [name...] 993 - const downArgs = args.slice(1); 994 - let dir: string | undefined; 995 - const names: string[] = []; 996 - 997 - for (const arg of downArgs) { 998 - if (arg.startsWith("-")) break; 999 - if (!dir && names.length === 0 && hasPtyFile(arg)) { 1000 - dir = arg; 1001 - } else { 1002 - names.push(arg); 1003 - } 1004 - } 1005 - 1006 - await cmdDown(dir, names); 1007 - break; 1008 - } 1009 947 1010 948 case "rename": { 1011 949 await cmdRename(args.slice(1)); ··· 1969 1907 console.log(`Session "${name}" removed.`); 1970 1908 } 1971 1909 1972 - async function cmdGc( 1973 - dryRun: boolean, 1974 - idleDays?: number, 1975 - fastFailWindowSec?: number, 1976 - fastFailLimit?: number, 1977 - ): Promise<void> { 1978 - const result = await gc({ dryRun, idleDays, fastFailWindowSec, fastFailLimit }); 1910 + async function cmdGc(dryRun: boolean, idleDays?: number): Promise<void> { 1911 + const result = await gc({ dryRun, idleDays }); 1979 1912 const prunedTags = await pruneOrphanLayoutTags({ dryRun }); 1980 1913 1981 1914 const killedVerb = dryRun ? "Would kill orphan child" : "Killed orphan child"; 1982 1915 const abandonVerb = dryRun ? "Would abandon" : "Abandoned"; 1983 - const respawnVerb = dryRun ? "Would respawn" : "Respawned"; 1984 - const flapVerb = dryRun ? "Would flap" : "Flapping"; 1985 1916 const removeVerb = dryRun ? "Would remove" : "Removed"; 1986 1917 const prunedVerb = dryRun ? "Would prune" : "Pruned"; 1987 1918 ··· 1994 1925 : a.reason; 1995 1926 console.log(`${abandonVerb}: ${a.name} (${detail})`); 1996 1927 } 1997 - for (const r of result.respawned) { 1998 - const note = r.ptyfileReread ? " (pty.toml re-read)" : ""; 1999 - console.log(`${respawnVerb}: ${r.name}${note}`); 2000 - } 2001 - for (const f of result.respawnFailed) { 2002 - console.log(`Respawn failed: ${f.name} — ${f.error}`); 2003 - } 2004 - for (const fl of result.flapped) { 2005 - console.log( 2006 - `${flapVerb}: ${fl.name} (${fl.counter} fast-fails in ${fl.window}s, limit ${fl.limit})`, 2007 - ); 2008 - } 2009 - for (const name of result.flappingSkipped) { 2010 - console.log( 2011 - `Skipped (flapping): ${name} — remove strategy.status tag to retry`, 2012 - ); 2013 - } 2014 1928 for (const name of result.removed) { 2015 1929 console.log(`${removeVerb}: ${name}`); 2016 1930 } ··· 2024 1938 const totalActions = 2025 1939 result.killedOrphanChildren.length + 2026 1940 result.abandoned.length + 2027 - result.respawned.length + 2028 - result.respawnFailed.length + 2029 - result.flapped.length + 2030 - result.flappingSkipped.length + 2031 1941 result.removed.length + 2032 1942 totalTags; 2033 1943 ··· 2042 1952 } 2043 1953 if (result.abandoned.length > 0) { 2044 1954 parts.push(`${result.abandoned.length} abandoned`); 2045 - } 2046 - if (result.respawned.length > 0) { 2047 - parts.push(`${result.respawned.length} respawn${result.respawned.length === 1 ? "" : "s"}`); 2048 - } 2049 - if (result.respawnFailed.length > 0) { 2050 - parts.push(`${result.respawnFailed.length} respawn failure${result.respawnFailed.length === 1 ? "" : "s"}`); 2051 - } 2052 - if (result.flapped.length > 0) { 2053 - parts.push(`${result.flapped.length} flapping`); 2054 - } 2055 - if (result.flappingSkipped.length > 0) { 2056 - parts.push(`${result.flappingSkipped.length} skipped-flapping`); 2057 1955 } 2058 1956 if (result.removed.length > 0) { 2059 1957 parts.push(`${result.removed.length} stale session${result.removed.length === 1 ? "" : "s"}`); ··· 2461 2359 // that isn't part of the session-primitive contract.) 2462 2360 2463 2361 2464 - function hasPtyFile(dir: string): boolean { 2465 - try { 2466 - return fs.statSync(path.join(path.resolve(dir), "pty.toml")).isFile(); 2467 - } catch { 2468 - return false; 2469 - } 2470 - } 2471 - 2472 - async function cmdUp(dir: string | undefined, names: string[]): Promise<void> { 2473 - let ptyFile; 2474 - try { 2475 - ptyFile = readPtyFile(dir); 2476 - } catch (e: any) { 2477 - console.error(e.message); 2478 - process.exit(1); 2479 - } 2480 - 2481 - let sessions = ptyFile.sessions; 2482 - if (names.length > 0) { 2483 - const nameSet = new Set(names); 2484 - const matchesName = (s: PtySessionDef) => nameSet.has(s.displayName) || nameSet.has(s.shortName); 2485 - const unknown = names.filter((n) => !sessions.some((s) => s.displayName === n || s.shortName === n)); 2486 - if (unknown.length > 0) { 2487 - console.error(`Unknown session${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}`); 2488 - console.error(`Available: ${sessions.map((s) => s.shortName).join(", ")}`); 2489 - process.exit(1); 2490 - } 2491 - sessions = sessions.filter(matchesName); 2492 - } 2493 - 2494 - const tomlPath = path.join(ptyFile.dir, "pty.toml"); 2495 - const existing = await listSessions(); 2496 - /** Find an existing session that came from this same (ptyfile, ptyfile.session) 2497 - * pair. The toml-derived displayName is just a label; identity is the tag 2498 - * pair, so renaming a session or hitting a long-name collision doesn't lose 2499 - * the binding. */ 2500 - const findByTags = (shortName: string) => existing.find((s) => 2501 - s.metadata?.tags?.ptyfile === tomlPath && 2502 - s.metadata?.tags?.["ptyfile.session"] === shortName 2503 - ); 2504 - const allRefSet = await allRefs(); 2505 - 2506 - let started = 0; 2507 - let skipped = 0; 2508 - 2509 - for (const sess of sessions) { 2510 - const userTomlKeys = Object.keys(sess.tags ?? {}).sort(); 2511 - const ptyfileTagsValue = userTomlKeys.join(","); 2512 - const tomlTags: Record<string, string> = { 2513 - ...sess.tags, 2514 - ptyfile: tomlPath, 2515 - "ptyfile.session": sess.shortName, 2516 - "ptyfile.tags": ptyfileTagsValue, 2517 - }; 2518 - 2519 - const bound = findByTags(sess.shortName); 2520 - 2521 - if (bound && bound.status === "running") { 2522 - // Sync tags from toml to the running session (including ptyfile metadata). 2523 - // Track which tag keys came from the toml via "ptyfile.tags" so that 2524 - // removing a tag from the toml causes it to be removed here (but 2525 - // manually-added tags — those not in "ptyfile.tags" — are preserved). 2526 - const currentTags = bound.metadata?.tags ?? {}; 2527 - 2528 - const updates: Record<string, string> = {}; 2529 - for (const [k, v] of Object.entries(tomlTags)) { 2530 - if (currentTags[k] !== v) updates[k] = v; 2531 - } 2532 - 2533 - const prevTomlKeys = (currentTags["ptyfile.tags"] ?? "") 2534 - .split(",") 2535 - .map((k) => k.trim()) 2536 - .filter((k) => k.length > 0); 2537 - const newKeySet = new Set(userTomlKeys); 2538 - const removals = prevTomlKeys.filter((k) => !newKeySet.has(k)); 2539 - 2540 - // Manual `pty up` is an operator "reset" signal, same shape as 2541 - // `pty restart` in cmdRestart above. Drop any `pty gc` flapping 2542 - // bookkeeping the session may have accumulated so a re-`pty up` 2543 - // gives the session a clean slate. These keys are gc-owned, never 2544 - // toml-declared, so they aren't in `prevTomlKeys`. 2545 - for (const k of [ 2546 - "strategy.status", 2547 - "strategy.consecutive-fast-fails", 2548 - "strategy.last-respawn-at", 2549 - "strategy.command-hash", 2550 - ]) { 2551 - if (currentTags[k] !== undefined && !removals.includes(k)) removals.push(k); 2552 - } 2553 - 2554 - const label = bound.metadata?.displayName ?? bound.name; 2555 - if (Object.keys(updates).length > 0 || removals.length > 0) { 2556 - try { 2557 - updateTags(bound.name, updates, removals); 2558 - const changedTagUpdates = Object.entries(updates) 2559 - .filter(([k]) => k !== "ptyfile" && k !== "ptyfile.session" && k !== "ptyfile.tags") 2560 - .map(([k, v]) => `${k}=${v}`); 2561 - const changedRemovals = removals.map((k) => `-${k}`); 2562 - const changed = [...changedTagUpdates, ...changedRemovals].join(", "); 2563 - if (changed) { 2564 - console.log(` ● ${label} (already running, updated tags: ${changed})`); 2565 - } else { 2566 - console.log(` ● ${label} (already running)`); 2567 - } 2568 - } catch { 2569 - console.log(` ● ${label} (already running)`); 2570 - } 2571 - } else { 2572 - console.log(` ● ${label} (already running)`); 2573 - } 2574 - skipped++; 2575 - continue; 2576 - } 2577 - 2578 - // Clean up an exited bound session so its slot can be reused. 2579 - if (bound && isGone(bound.status)) { 2580 - cleanupAll(bound.name); 2581 - } 2582 - 2583 - // Pick the on-disk id: honor the pty.toml's `id = "..."` if set, 2584 - // otherwise generate a random one. Either way validate before spawn so 2585 - // long pinned ids fail with a clear up-front error. 2586 - let name: string; 2587 - if (sess.id) { 2588 - try { 2589 - validateName(sess.id); 2590 - } catch (e: any) { 2591 - console.error(` ✗ ${sess.displayName}: ${e.message}`); 2592 - continue; 2593 - } 2594 - if (allRefSet.has(sess.id)) { 2595 - console.error(` ✗ ${sess.displayName}: id "${sess.id}" is already in use (as a name or displayName).`); 2596 - continue; 2597 - } 2598 - name = sess.id; 2599 - allRefSet.add(sess.id); 2600 - } else { 2601 - let candidate: string | null = null; 2602 - for (let attempt = 0; attempt < 8; attempt++) { 2603 - const c = randomSessionName(); 2604 - if (!allRefSet.has(c)) { candidate = c; allRefSet.add(c); break; } 2605 - } 2606 - if (!candidate) { 2607 - console.error(` ✗ ${sess.displayName}: could not generate a unique session id after 8 attempts.`); 2608 - continue; 2609 - } 2610 - name = candidate; 2611 - } 2612 - 2613 - // Validate the toml-derived displayName once. Default `<prefix>-<short>` 2614 - // is always safe; an explicit `display_name` field could be anything. 2615 - try { 2616 - validateDisplayName(sess.displayName); 2617 - } catch (e: any) { 2618 - console.error(` ✗ ${sess.displayName}: ${e.message}`); 2619 - continue; 2620 - } 2621 - 2622 - try { 2623 - await spawnDaemon({ 2624 - name, 2625 - command: "/bin/sh", 2626 - args: ["-c", commandWithEnvExports(sess)], 2627 - displayCommand: sess.command, 2628 - cwd: ptyFile.dir, 2629 - tags: tomlTags, 2630 - displayName: sess.displayName, 2631 - }); 2632 - console.log(` ● ${sess.displayName} (started)`); 2633 - started++; 2634 - } catch (e: any) { 2635 - console.error(` ✗ ${sess.displayName}: ${e.message}`); 2636 - } 2637 - } 2638 - 2639 - if (started === 0 && skipped === sessions.length) { 2640 - console.log("All sessions already running."); 2641 - } else if (started > 0) { 2642 - console.log(`Started ${started} session${started === 1 ? "" : "s"}.`); 2643 - } 2644 - } 2645 - 2646 - async function cmdDown(dir: string | undefined, names: string[]): Promise<void> { 2647 - let ptyFile; 2648 - try { 2649 - ptyFile = readPtyFile(dir); 2650 - } catch (e: any) { 2651 - console.error(e.message); 2652 - process.exit(1); 2653 - } 2654 - 2655 - let sessions = ptyFile.sessions; 2656 - if (names.length > 0) { 2657 - const nameSet = new Set(names); 2658 - sessions = sessions.filter((s) => nameSet.has(s.displayName) || nameSet.has(s.shortName)); 2659 - } 2660 - 2661 - const tomlPath = path.join(ptyFile.dir, "pty.toml"); 2662 - const existing = await listSessions(); 2663 - const findByTags = (shortName: string) => existing.find((s) => 2664 - s.metadata?.tags?.ptyfile === tomlPath && 2665 - s.metadata?.tags?.["ptyfile.session"] === shortName 2666 - ); 2667 - let stopped = 0; 2668 - 2669 - for (const sess of sessions) { 2670 - const existingSession = findByTags(sess.shortName); 2671 - if (!existingSession) continue; 2672 - 2673 - const label = existingSession.metadata?.displayName ?? existingSession.name; 2674 - 2675 - // Strip the `strategy` tag so `pty gc` doesn't respawn the session 2676 - // on its next tick. The `supervisor.status` tag is no longer a thing. 2677 - const wasPermanent = existingSession.metadata?.tags?.strategy === "permanent"; 2678 - if (wasPermanent) { 2679 - try { 2680 - updateTags(existingSession.name, {}, ["strategy"]); 2681 - } catch {} 2682 - } 2683 - 2684 - if (existingSession.status === "running" && existingSession.pid) { 2685 - try { 2686 - process.kill(existingSession.pid, "SIGTERM"); 2687 - console.log(` ○ ${label} (stopped${wasPermanent ? ", removed from supervision" : ""})`); 2688 - stopped++; 2689 - } catch { 2690 - console.error(` ✗ ${label}: failed to stop`); 2691 - } 2692 - cleanupSocket(existingSession.name); 2693 - } else if (isGone(existingSession.status)) { 2694 - cleanupAll(existingSession.name); 2695 - console.log(` ○ ${label} (cleaned up)`); 2696 - stopped++; 2697 - } 2698 - } 2699 - 2700 - if (stopped === 0) { 2701 - console.log("No sessions to stop."); 2702 - } else { 2703 - console.log(`Stopped ${stopped} session${stopped === 1 ? "" : "s"}.`); 2704 - } 2705 - 2706 - // Warn if any stopped sessions are toml-managed 2707 - const anyTomlManaged = sessions.some((sess) => findByTags(sess.shortName)?.metadata?.tags?.ptyfile); 2708 - if (anyTomlManaged && stopped > 0) { 2709 - console.error("\nNote: strategy tags will be restored on the next 'pty up'."); 2710 - } 2711 - } 2712 2362 2713 2363 async function cmdRestart( 2714 2364 name: string, ··· 2880 2530 }); 2881 2531 } 2882 2532 2883 - /** Strip `pty gc`'s flapping bookkeeping from a tag map before a manual 2884 - * restart / respawn. `strategy.status`, `strategy.consecutive-fast-fails`, 2533 + /** Strip convoy's flapping bookkeeping from a tag map before a manual 2534 + * restart. `strategy.status`, `strategy.consecutive-fast-fails`, 2885 2535 * `strategy.last-respawn-at`, and `strategy.command-hash` are all 2886 - * gc-owned — they exist to track auto-respawn state, and an operator 2887 - * action ("please try again") is a signal to reset them. Returns 2888 - * `undefined` when the input is missing/empty so downstream defaults 2889 - * (spawnDaemon skips the `tags` field entirely) apply. */ 2536 + * convoy-owned post-reboot — they track auto-respawn state, and an 2537 + * operator running `pty restart` is a "please try again with fresh 2538 + * state" signal. Returns `undefined` when the input is missing/empty 2539 + * so downstream defaults apply. */ 2890 2540 function clearFlappingBookkeeping( 2891 2541 tags: Record<string, string> | undefined, 2892 2542 ): Record<string, string> | undefined { ··· 2904 2554 2905 2555 function strategyMarker(tags?: Record<string, string>): string { 2906 2556 if (!tags) return ""; 2907 - // Flapping supersedes permanent visually because it's what changed the 2908 - // operator's expectation ("gc stopped respawning this on purpose"). 2909 - // Rendered red so it stands out from the yellow [permanent]. 2910 - if (tags["strategy.status"] === "flapping") { 2911 - return " \x1b[31m[flapping]\x1b[0m"; 2912 - } 2913 2557 if (tags.strategy === "permanent") return " \x1b[33m[permanent]\x1b[0m"; 2914 2558 return ""; 2915 2559 }
+17 -356
src/sessions.ts
··· 405 405 return refs; 406 406 } 407 407 408 - /** Result of a `gc()` reconciliation pass. Five buckets correspond to the 409 - * reconciliation steps: orphan-kill (step 1), abandoned-reap (step 1.5), 410 - * permanent respawn success / failure (step 2), and the sweep of exited 411 - * non-permanent sessions (step 3 — the historic `gc()` behavior). */ 408 + /** Result of a `gc()` reconciliation pass. Three buckets correspond to 409 + * the reconciliation steps: orphan-kill (step 1), abandoned-reap 410 + * (step 1.5), and the sweep of exited non-permanent sessions (step 3 411 + * — the historic `gc()` behavior). Permanent respawn is convoy's job 412 + * post-reboot; `pty gc` is clean-only. */ 412 413 export interface GcResult { 413 414 /** Names of exited/vanished non-permanent sessions whose metadata was 414 415 * removed. Empty under `dryRun: true` callers should treat the same ··· 422 423 * `idleDays` threshold is set (via CLI flag or per-session tag) 423 424 * and `lastAttachAt` is older than that threshold. */ 424 425 abandoned: { name: string; reason: "cwd-gone" | "idle"; idleDays?: number }[]; 425 - /** Permanent sessions respawned this pass. `ptyfileReread` indicates 426 - * whether the spawn used a fresh `pty.toml` read (when the session 427 - * carries `ptyfile` + `ptyfile.session` tags) or its stored metadata. */ 428 - respawned: { name: string; ptyfileReread: boolean }[]; 429 - /** Permanent sessions where respawn was attempted but failed (e.g. the 430 - * binary is on an unmounted volume). Cron interval is the rate limit; 431 - * next tick tries again. */ 432 - respawnFailed: { name: string; error: string }[]; 433 - /** Permanent sessions the fast-fail cap flipped to `flapping` on this 434 - * tick. Each entry records the counter at the moment of flip plus the 435 - * effective `limit`/`window` in play. Sessions already flagged before 436 - * this tick are silently skipped from the respawn loop and do NOT 437 - * appear here — this bucket is transitions only. */ 438 - flapped: { name: string; counter: number; limit: number; window: number }[]; 439 - /** Permanent sessions skipped this tick because they are already 440 - * `strategy.status=flapping`. Distinct from `flapped` (transitions), 441 - * `respawnFailed` (attempted + failed), and `respawned` (attempted + 442 - * succeeded). Consumers can render "N flapping" without having to 443 - * read tags themselves. */ 444 - flappingSkipped: string[]; 445 426 } 446 427 447 428 /** Default fast-fail respawn cap window (seconds). A permanent session ··· 474 455 return h.digest("hex").slice(0, 16); 475 456 } 476 457 477 - /** Reconciliation pass driven by `pty gc`. Stateless: every invocation 478 - * re-derives intent from on-disk metadata. Four steps run in order: 458 + /** Reconciliation pass driven by `pty gc`. Stateless + clean-only: 459 + * every invocation re-derives intent from on-disk metadata and never 460 + * spawns anything. Three steps run in order (permanent respawn moved 461 + * to convoy post-reboot; see notes/lean-pty-core-supervision-spec.md). 479 462 * 480 463 * 1. Orphan-kill: children with a `parent=<name>` tag whose parent's 481 464 * metadata is gone OR whose parent's pid isn't alive get SIGTERM'd 482 - * and `cleanupAll`'d. Runs first so a permanent child whose parent 483 - * has died isn't immediately respawned by step 2. 465 + * and `cleanupAll`'d. 484 466 * 1.5. Abandoned-reap: live `strategy=permanent` sessions whose recorded 485 467 * cwd is gone from disk are SIGTERM'd + `cleanupAll`'d + get a 486 468 * `session_abandoned` event. When `opts.idleDays` is set OR the 487 469 * session carries a `strategy.idle-days=N` tag, sessions whose 488 470 * `lastAttachAt` is older than that threshold are also reaped 489 - * with reason `idle`. Runs before step 2 so a session reaped for 490 - * abandonment isn't immediately respawned by permanent-restart. 491 - * 2. Permanent respawn: every `strategy=permanent` session that's 492 - * exited/vanished is respawned via `spawnDaemon` (lazy-imported to 493 - * avoid the `sessions ↔ spawn` cycle). Sessions with `ptyfile` + 494 - * `ptyfile.session` tags re-read the toml to pick up any edits. 495 - * A fast-fail cap prevents a crash-looping leaf from being 496 - * respawned forever: `strategy.fast-fail-limit` consecutive 497 - * respawns whose leaf exited within `strategy.fast-fail-window` 498 - * seconds flip the session to `strategy.status=flapping` and 499 - * skip it on subsequent ticks. Auto-reset when the stored command 500 - * changes; manual reset via `pty tag <name> --rm strategy.status`. 501 - * 3. Existing sweep: the historic behavior — exited/vanished sessions 502 - * that aren't permanent get `cleanupAll`'d. */ 471 + * with reason `idle`. 472 + * 3. Existing sweep: exited/vanished non-permanent sessions get 473 + * `cleanupAll`'d. Permanent sessions that are gone are left 474 + * in-place for convoy's reconcile loop to notice + respawn. */ 503 475 export async function gc( 504 476 opts: { 505 477 dryRun?: boolean; 506 478 idleDays?: number; 507 - fastFailWindowSec?: number; 508 - fastFailLimit?: number; 509 479 } = {}, 510 480 ): Promise<GcResult> { 511 481 const dryRun = !!opts.dryRun; 512 482 const globalIdleDays = opts.idleDays; 513 - const globalFastFailWindow = opts.fastFailWindowSec; 514 - const globalFastFailLimit = opts.fastFailLimit; 515 483 // First call to `listSessions` is intentionally throwaway — it has a 516 484 // side effect (`cleanupSocket`) on sessions whose daemon SIGKILL'd 517 485 // without writing an exit record, and those sessions are then *missing* ··· 599 567 }); 600 568 } 601 569 602 - // STEP 2: permanent respawn. Re-list since steps 1 and 1.5 may have 603 - // removed some metadata. In dryRun mode we filter out anything step 604 - // 1.5 would have reaped so the preview reflects the same intent. 605 - const afterStep15 = dryRun 606 - ? initial.filter((s) => !abandoned.some((a) => a.name === s.name)) 607 - : await listSessions(); 608 - const respawned: GcResult["respawned"] = []; 609 - const respawnFailed: GcResult["respawnFailed"] = []; 610 - const flapped: GcResult["flapped"] = []; 611 - const flappingSkipped: GcResult["flappingSkipped"] = []; 612 - for (const s of afterStep15) { 613 - if (s.metadata?.tags?.strategy !== "permanent") continue; 614 - if (!isGone(s.status)) continue; 615 - const ptyfileReread = !!s.metadata?.tags?.ptyfile; 616 - 617 - // Fast-fail classifier: was the previous respawn a fast crash? What's 618 - // the running counter? Should we flip to flapping? Runs before any 619 - // spawn so a session at the limit boundary flaps this tick instead 620 - // of respawning one more time. 621 - const decision = classifyFlapping( 622 - s, 623 - new Date(), 624 - globalFastFailWindow, 625 - globalFastFailLimit, 626 - ); 627 - 628 - if (decision.action === "skip-flapping") { 629 - flappingSkipped.push(s.name); 630 - continue; 631 - } 632 - 633 - if (dryRun) { 634 - if (decision.action === "flap-now") { 635 - flapped.push({ 636 - name: s.name, 637 - counter: decision.counter, 638 - limit: decision.effectiveLimit, 639 - window: decision.effectiveWindow, 640 - }); 641 - continue; 642 - } 643 - respawned.push({ name: s.name, ptyfileReread }); 644 - continue; 645 - } 646 - 647 - if (decision.action === "flap-now") { 648 - // Persist the flapping mark to on-disk metadata so subsequent 649 - // ticks see it. We update the metadata file directly instead of 650 - // going through updateTags — the session's daemon is gone, there's 651 - // no live connection to notify, and cleanupAll ordering constraints 652 - // in respawnPermanent don't apply here (we're NOT respawning). 653 - try { 654 - const meta = readMetadata(s.name); 655 - if (meta) { 656 - const merged: Record<string, string> = { 657 - ...(meta.tags ?? {}), 658 - ...decision.newBookkeeping, 659 - }; 660 - writeMetadata(s.name, { ...meta, tags: merged }); 661 - } 662 - } catch { 663 - // Best-effort — if we can't persist the flag now, the next tick 664 - // will recompute the same decision and try again. 665 - } 666 - try { 667 - appendEventSync(s.name, { 668 - session: s.name, 669 - type: "session_flapping", 670 - ts: new Date().toISOString(), 671 - counter: decision.counter, 672 - limit: decision.effectiveLimit, 673 - window: decision.effectiveWindow, 674 - }); 675 - } catch {} 676 - flapped.push({ 677 - name: s.name, 678 - counter: decision.counter, 679 - limit: decision.effectiveLimit, 680 - window: decision.effectiveWindow, 681 - }); 682 - continue; 683 - } 684 - 685 - try { 686 - await respawnPermanent(s.name, s.metadata!, decision.newBookkeeping); 687 - respawned.push({ name: s.name, ptyfileReread }); 688 - } catch (err: any) { 689 - respawnFailed.push({ name: s.name, error: err?.message ?? String(err) }); 690 - } 691 - } 692 - 693 570 // STEP 3: historic sweep. Exited/vanished non-permanent sessions get 694 - // their metadata removed. Permanent sessions are handled by step 2 — 695 - // if their respawn succeeded they're back to `running` and skipped; 696 - // if it failed we leave the metadata around so the next tick can try 697 - // again. 571 + // their metadata removed. Permanent sessions are left in-place — convoy 572 + // owns the respawn decision and reads their tags to compute its 573 + // classifier state. 698 574 const finalList = dryRun ? initial : await listSessions(); 699 575 const removed: string[] = []; 700 576 for (const s of finalList) { ··· 708 584 removed, 709 585 killedOrphanChildren, 710 586 abandoned, 711 - respawned, 712 - respawnFailed, 713 - flapped, 714 - flappingSkipped, 715 587 }; 716 588 } 717 589 ··· 758 630 const ageDays = Math.floor((Date.now() - lastAttachMs) / (1000 * 60 * 60 * 24)); 759 631 if (ageDays < effectiveIdleDays) return null; 760 632 return { reason: "idle", idleDays: ageDays }; 761 - } 762 - 763 - /** Decide whether a `strategy=permanent` session that's exited/vanished 764 - * should be respawned, marked flapping, or silently skipped because 765 - * it's already flapping. Reads three bookkeeping tags from the session: 766 - * - `strategy.last-respawn-at` (ISO ts): when gc last respawned it 767 - * - `strategy.consecutive-fast-fails` (int): running fast-fail counter 768 - * - `strategy.command-hash` (16-char hex): command fingerprint at last 769 - * respawn. If the current fingerprint differs, the operator edited 770 - * the pty.toml (or otherwise changed the command); reset the 771 - * counter and clear any stale `strategy.status=flapping`. 772 - * 773 - * Effective window/limit resolution: 774 - * per-session tag (strategy.fast-fail-window / -limit) 775 - * → global opt (CLI --fast-fail-window / --fast-fail-limit) 776 - * → DEFAULT_FAST_FAIL_WINDOW_SEC / DEFAULT_FAST_FAIL_LIMIT. 777 - * 778 - * Returned `newBookkeeping` MUST be merged onto the session's tags map 779 - * before/instead of respawn. The `flap-now` action never respawns; the 780 - * `respawn` action does; the `skip-flapping` action skips entirely. */ 781 - interface FlappingDecision { 782 - action: "respawn" | "flap-now" | "skip-flapping"; 783 - effectiveWindow: number; 784 - effectiveLimit: number; 785 - /** Fast-fail counter after this tick's classification. Only meaningful 786 - * for `respawn` (stamped on the session) and `flap-now` (the counter 787 - * that crossed the threshold, surfaced in the event payload). */ 788 - counter: number; 789 - /** Tag deltas to persist. Empty for `skip-flapping`. For `respawn`, 790 - * carries the fresh timestamp, counter, and command hash. For 791 - * `flap-now`, adds `strategy.status=flapping` on top. */ 792 - newBookkeeping: Record<string, string>; 793 - } 794 - 795 - function classifyFlapping( 796 - s: SessionInfo, 797 - now: Date, 798 - globalWindowSec: number | undefined, 799 - globalLimit: number | undefined, 800 - ): FlappingDecision { 801 - const tags = s.metadata?.tags ?? {}; 802 - 803 - const tagWindow = parseInt(tags["strategy.fast-fail-window"] ?? "", 10); 804 - const effectiveWindow = Number.isFinite(tagWindow) && tagWindow > 0 805 - ? tagWindow 806 - : (globalWindowSec !== undefined && globalWindowSec > 0 807 - ? globalWindowSec 808 - : DEFAULT_FAST_FAIL_WINDOW_SEC); 809 - 810 - const tagLimit = parseInt(tags["strategy.fast-fail-limit"] ?? "", 10); 811 - const effectiveLimit = Number.isFinite(tagLimit) && tagLimit > 0 812 - ? tagLimit 813 - : (globalLimit !== undefined && globalLimit > 0 814 - ? globalLimit 815 - : DEFAULT_FAST_FAIL_LIMIT); 816 - 817 - const command = s.metadata?.command ?? ""; 818 - const args = s.metadata?.args ?? []; 819 - const currentHash = commandFingerprint(command, args); 820 - const storedHash = tags["strategy.command-hash"]; 821 - const commandChanged = storedHash !== undefined && storedHash !== currentHash; 822 - 823 - // Command change wins over an existing flapping mark: the operator has 824 - // edited the pty.toml (or manually mutated the command), so give it a 825 - // fresh chance. `strategy.status` clears; counter resets to 0. 826 - if (tags["strategy.status"] === "flapping" && !commandChanged) { 827 - return { 828 - action: "skip-flapping", 829 - effectiveWindow, 830 - effectiveLimit, 831 - counter: parseInt(tags["strategy.consecutive-fast-fails"] ?? "0", 10) || 0, 832 - newBookkeeping: {}, 833 - }; 834 - } 835 - 836 - // Was the previous respawn a fast fail? Compare the exit timestamp 837 - // against the last-respawn stamp; anything under `window` seconds is 838 - // fast. If no prior stamp exists (never respawned by gc) or the exit 839 - // is missing (vanished session), treat as slow — the counter resets. 840 - const lastRespawnAt = tags["strategy.last-respawn-at"]; 841 - const exitedAt = s.metadata?.exitedAt; 842 - let liveMs: number | null = null; 843 - if (lastRespawnAt && exitedAt) { 844 - const lr = Date.parse(lastRespawnAt); 845 - const ex = Date.parse(exitedAt); 846 - if (Number.isFinite(lr) && Number.isFinite(ex)) liveMs = ex - lr; 847 - } 848 - const wasFastFail = liveMs !== null && liveMs >= 0 && liveMs < effectiveWindow * 1000; 849 - 850 - const prevCounter = parseInt(tags["strategy.consecutive-fast-fails"] ?? "0", 10) || 0; 851 - const nextCounter = commandChanged ? 0 : (wasFastFail ? prevCounter + 1 : 0); 852 - 853 - if (nextCounter >= effectiveLimit) { 854 - // Threshold crossed. Mark flapping, don't respawn. The counter goes 855 - // into the tags at its final value so subsequent listers can see how 856 - // deep the streak went. 857 - const bookkeeping: Record<string, string> = { 858 - "strategy.status": "flapping", 859 - "strategy.consecutive-fast-fails": String(nextCounter), 860 - "strategy.command-hash": currentHash, 861 - }; 862 - if (lastRespawnAt) bookkeeping["strategy.last-respawn-at"] = lastRespawnAt; 863 - return { 864 - action: "flap-now", 865 - effectiveWindow, 866 - effectiveLimit, 867 - counter: nextCounter, 868 - newBookkeeping: bookkeeping, 869 - }; 870 - } 871 - 872 - // Respawn. Stamp fresh bookkeeping. If we're clearing a stale flap 873 - // mark from a command change, drop `strategy.status` explicitly by 874 - // storing an empty string — updateTags treats that as a remove. 875 - const bookkeeping: Record<string, string> = { 876 - "strategy.last-respawn-at": now.toISOString(), 877 - "strategy.consecutive-fast-fails": String(nextCounter), 878 - "strategy.command-hash": currentHash, 879 - }; 880 - return { 881 - action: "respawn", 882 - effectiveWindow, 883 - effectiveLimit, 884 - counter: nextCounter, 885 - newBookkeeping: bookkeeping, 886 - }; 887 - } 888 - 889 - /** Restart a `strategy=permanent` session whose daemon is gone. If the 890 - * session was toml-managed (`ptyfile` + `ptyfile.session` tags), re-read 891 - * the pty.toml so the new daemon picks up command/env edits since the 892 - * last spawn. On any read error fall back to the stored metadata 893 - * verbatim (last-known-good) so a temporarily-missing toml doesn't 894 - * prevent restart. 895 - * 896 - * `bookkeepingOverlay` (optional) carries pty-internal tags that must 897 - * survive the pty.toml re-read: gc backoff state (`strategy.last-*`, 898 - * `strategy.command-hash`, `strategy.consecutive-fast-fails`). Passed 899 - * by `gc()` STEP-2; ignored by other callers. 900 - * 901 - * Lazy-imports `spawn.ts` so the `sessions.ts ↔ spawn.ts` cycle doesn't 902 - * bite at module-init time. After spawn, appends a `session_respawn` 903 - * event to the session's event log so consumers see the restart. */ 904 - async function respawnPermanent( 905 - name: string, 906 - metadata: SessionMetadata, 907 - bookkeepingOverlay: Record<string, string> = {}, 908 - ): Promise<void> { 909 - let command = metadata.command; 910 - let args = metadata.args; 911 - let displayCommand = metadata.displayCommand; 912 - let cwd = metadata.cwd; 913 - let tags: Record<string, string> | undefined = metadata.tags; 914 - const displayName = metadata.displayName; 915 - 916 - const ptyfilePath = metadata.tags?.ptyfile; 917 - const ptyfileSession = metadata.tags?.["ptyfile.session"]; 918 - if (ptyfilePath && ptyfileSession) { 919 - try { 920 - const { readPtyFile, commandWithEnvExports } = await import("./ptyfile.ts"); 921 - const dir = path.dirname(ptyfilePath); 922 - const ptyFile = readPtyFile(dir); 923 - const sessDef = ptyFile.sessions.find((s) => s.shortName === ptyfileSession); 924 - if (sessDef) { 925 - command = "/bin/sh"; 926 - args = ["-c", commandWithEnvExports(sessDef)]; 927 - displayCommand = sessDef.command; 928 - cwd = ptyFile.dir; 929 - tags = { 930 - ...sessDef.tags, 931 - ptyfile: ptyfilePath, 932 - "ptyfile.session": ptyfileSession, 933 - }; 934 - } 935 - } catch { 936 - // pty.toml unreadable (volume not mounted yet, file deleted, parse 937 - // error). Fall back to stored metadata — better to respawn with 938 - // last-known-good than to give up. 939 - } 940 - } 941 - 942 - // Merge gc's backoff bookkeeping last so it survives the pty.toml 943 - // overlay above. Callers pass an empty overlay when they aren't gc. 944 - // If a command change is clearing a stale flap mark, the caller 945 - // omits `strategy.status` from the overlay; we also clear any 946 - // existing flag on the merged map so a rebuilt tags dict doesn't 947 - // silently carry it forward from the previous metadata. 948 - tags = { ...(tags ?? {}), ...bookkeepingOverlay }; 949 - if (bookkeepingOverlay["strategy.status"] === undefined) { 950 - delete tags["strategy.status"]; 951 - } 952 - 953 - // Wipe stale socket/pid/events before respawn so spawnDaemon doesn't 954 - // trip over leftovers from the dead daemon. Metadata is recreated by 955 - // spawnDaemon. 956 - cleanupAll(name); 957 - 958 - const { spawnDaemon } = await import("./spawn.ts"); 959 - await spawnDaemon({ 960 - name, command, args, displayCommand, cwd, tags, 961 - ...(displayName ? { displayName } : {}), 962 - }); 963 - 964 - // Best-effort event; respawn already succeeded if we got here. 965 - try { 966 - appendEventSync(name, { 967 - session: name, 968 - type: "session_respawn", 969 - ts: new Date().toISOString(), 970 - }); 971 - } catch {} 972 633 } 973 634 974 635 /**
+11 -13
tests/gc-abandoned.test.ts
··· 309 309 }); 310 310 311 311 describe("pty gc — abandoned reap does not disrupt other buckets", () => { 312 - it("still respawns a normal exited permanent session in the same pass", async () => { 313 - // Two sessions: one abandoned (cwd-gone, live), one exited (should 314 - // respawn normally). Both permanent. Same gc pass handles both. 312 + it("reaps a cwd-gone permanent alongside a normal exited permanent (which is left in-place for convoy)", async () => { 313 + // Two sessions: one abandoned (cwd-gone, live), one exited-permanent 314 + // (post-reboot: pty gc leaves it alone; convoy's reconcile loop owns 315 + // respawn). Same gc pass handles both. 315 316 const dir = makeSessionDir(); 316 317 317 318 const abandonName = uniqueName(); 318 319 const abandonCwd = makeCwd(); 319 320 await startDaemon(dir, abandonName, abandonCwd, "sleep", ["60"], { strategy: "permanent" }); 320 321 321 - const respawnName = uniqueName(); 322 - const respawnCwd = makeCwd(); 323 - await startDaemon(dir, respawnName, respawnCwd, "true", [], { strategy: "permanent" }); 322 + const leftAloneName = uniqueName(); 323 + const leftAloneCwd = makeCwd(); 324 + await startDaemon(dir, leftAloneName, leftAloneCwd, "true", [], { strategy: "permanent" }); 324 325 325 326 await new Promise((r) => setTimeout(r, 800)); // let `true` exit 326 327 fs.rmSync(abandonCwd, { recursive: true, force: true }); ··· 328 329 const result = runCli(dir, "gc"); 329 330 expect(result.status).toBe(0); 330 331 expect(result.stdout).toContain(`Abandoned: ${abandonName} (cwd-gone)`); 331 - expect(result.stdout).toContain(`Respawned: ${respawnName}`); 332 - 333 - // Track any respawn pid for cleanup. 334 - try { 335 - const pid = parseInt(fs.readFileSync(path.join(dir, `${respawnName}.pid`), "utf-8").trim(), 10); 336 - if (Number.isFinite(pid)) bgPids.push(pid); 337 - } catch {} 332 + // Post-reboot: pty gc no longer respawns; exited permanent stays 333 + // in-place with its metadata for convoy's reconcile loop to notice. 334 + expect(result.stdout).not.toContain(`Respawned: ${leftAloneName}`); 335 + expect(result.stdout).not.toContain(`Removed: ${leftAloneName}`); 338 336 }, 25000); 339 337 });
-223
tests/gc-flap-clear-badge-root-len.test.ts
··· 1 - // Follow-ups to #56: 2 - // 1. `pty restart` clears strategy.status=flapping + bookkeeping. 3 - // 2. `pty up` clears the same on the "already running, tag-sync" path. 4 - // 3. `pty list` renders `[flapping]` badge (mutually exclusive with [permanent]). 5 - // 4. Fail-loud startup backstop when PTY_ROOT is too long for the socket-path 6 - // kernel limit — errors before any subcommand runs. 7 - 8 - import { describe, it, expect, afterEach, afterAll } from "vitest"; 9 - import * as fs from "node:fs"; 10 - import * as os from "node:os"; 11 - import * as path from "node:path"; 12 - import { fileURLToPath } from "node:url"; 13 - import { spawnSync } from "node:child_process"; 14 - 15 - const __dirname = path.dirname(fileURLToPath(import.meta.url)); 16 - const nodeBin = process.execPath; 17 - const cliPath = path.join(__dirname, "..", "dist", "cli.js"); 18 - 19 - const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-follow-")); 20 - afterAll(() => { 21 - fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 22 - }); 23 - 24 - let sessionDirs: string[] = []; 25 - function makeSessionDir(): string { 26 - const dir = fs.mkdtempSync(path.join(testRoot, "d-")); 27 - sessionDirs.push(dir); 28 - return dir; 29 - } 30 - 31 - let nameCounter = 0; 32 - function uniqueName(): string { 33 - return `fc${++nameCounter}${Math.random().toString(36).slice(2, 5)}`; 34 - } 35 - 36 - function runCli(sessionDir: string, ...args: string[]) { 37 - return spawnSync(nodeBin, [cliPath, ...args], { 38 - env: { ...process.env, PTY_SESSION_DIR: sessionDir }, 39 - encoding: "utf-8", 40 - timeout: 15000, 41 - }); 42 - } 43 - 44 - function readMeta(sessionDir: string, name: string): any { 45 - return JSON.parse(fs.readFileSync(path.join(sessionDir, `${name}.json`), "utf-8")); 46 - } 47 - 48 - /** Write metadata simulating an exited session that gc previously marked 49 - * flapping. Includes all four bookkeeping tags. */ 50 - function writeFlappingExited( 51 - sessionDir: string, 52 - name: string, 53 - extraTags: Record<string, string> = {}, 54 - ): void { 55 - fs.writeFileSync(path.join(sessionDir, `${name}.json`), JSON.stringify({ 56 - command: "sh", args: ["-c", "exit 1"], displayCommand: "sh -c 'exit 1'", 57 - cwd: os.tmpdir(), 58 - createdAt: new Date(Date.now() - 10 * 60_000).toISOString(), 59 - exitedAt: new Date().toISOString(), 60 - exitCode: 1, 61 - tags: { 62 - strategy: "permanent", 63 - "strategy.status": "flapping", 64 - "strategy.consecutive-fast-fails": "3", 65 - "strategy.last-respawn-at": new Date(Date.now() - 5000).toISOString(), 66 - "strategy.command-hash": "0123456789abcdef", 67 - ...extraTags, 68 - }, 69 - })); 70 - const evPath = path.join(sessionDir, `${name}.events.jsonl`); 71 - if (!fs.existsSync(evPath)) fs.writeFileSync(evPath, ""); 72 - } 73 - 74 - afterEach(() => { 75 - for (const dir of sessionDirs) { 76 - try { 77 - for (const e of fs.readdirSync(dir)) { try { fs.unlinkSync(path.join(dir, e)); } catch {} } 78 - } catch {} 79 - } 80 - sessionDirs = []; 81 - }); 82 - 83 - describe("pty restart clears strategy.status=flapping + bookkeeping", () => { 84 - it("restart of a flapping-exited session drops all four gc bookkeeping tags", async () => { 85 - const dir = makeSessionDir(); 86 - const name = uniqueName(); 87 - writeFlappingExited(dir, name, { role: "test" }); 88 - 89 - // Restart it. -y skips the prompt; command is "sh -c 'exit 1'" which 90 - // will exit almost immediately — that's fine, we care about the tag 91 - // snapshot right after spawn. 92 - const r = runCli(dir, "restart", "-y", name); 93 - expect(r.status).toBe(0); 94 - 95 - // Give the daemon a moment to write its metadata. 96 - await new Promise((res) => setTimeout(res, 300)); 97 - 98 - const meta = readMeta(dir, name); 99 - expect(meta.tags.strategy).toBe("permanent"); 100 - expect(meta.tags.role).toBe("test"); 101 - // Bookkeeping tags are gone. 102 - expect(meta.tags["strategy.status"]).toBeUndefined(); 103 - expect(meta.tags["strategy.consecutive-fast-fails"]).toBeUndefined(); 104 - expect(meta.tags["strategy.last-respawn-at"]).toBeUndefined(); 105 - expect(meta.tags["strategy.command-hash"]).toBeUndefined(); 106 - }); 107 - }); 108 - 109 - describe("pty list renders [flapping] badge", () => { 110 - it("running session with strategy.status=flapping shows [flapping] in text output", () => { 111 - const dir = makeSessionDir(); 112 - const name = uniqueName(); 113 - // Simulate a running session (synthetic pid + no exitedAt) with a 114 - // flapping mark. `pty list` classifies status by (pid alive check + 115 - // exited fields); we cheat by using our own pid so isProcessAlive 116 - // returns true — the row renders as running. 117 - fs.writeFileSync(path.join(dir, `${name}.json`), JSON.stringify({ 118 - command: "sh", args: [], displayCommand: "sh", 119 - cwd: os.tmpdir(), 120 - createdAt: new Date().toISOString(), 121 - tags: { 122 - strategy: "permanent", 123 - "strategy.status": "flapping", 124 - }, 125 - })); 126 - fs.writeFileSync(path.join(dir, `${name}.pid`), String(process.pid)); 127 - 128 - const r = runCli(dir, "list"); 129 - expect(r.status).toBe(0); 130 - // ANSI color-code stripped for the assertion; check the literal marker. 131 - // `[flapping]` supersedes `[permanent]` when both would apply. 132 - expect(r.stdout).toContain("[flapping]"); 133 - expect(r.stdout).not.toContain("[permanent]"); 134 - }); 135 - 136 - it("session without strategy.status=flapping still shows [permanent]", () => { 137 - const dir = makeSessionDir(); 138 - const name = uniqueName(); 139 - fs.writeFileSync(path.join(dir, `${name}.json`), JSON.stringify({ 140 - command: "sh", args: [], displayCommand: "sh", 141 - cwd: os.tmpdir(), 142 - createdAt: new Date().toISOString(), 143 - tags: { strategy: "permanent" }, 144 - })); 145 - fs.writeFileSync(path.join(dir, `${name}.pid`), String(process.pid)); 146 - 147 - const r = runCli(dir, "list"); 148 - expect(r.status).toBe(0); 149 - expect(r.stdout).toContain("[permanent]"); 150 - expect(r.stdout).not.toContain("[flapping]"); 151 - }); 152 - }); 153 - 154 - describe("PTY_ROOT length backstop", () => { 155 - it("errors at startup when PTY_ROOT is too deep to fit the sockaddr_un limit", () => { 156 - // Build a 95-byte path — well past the 90-byte usable threshold 157 - // (104 − 14 for `/xxxxxxxx.sock`). Doesn't need to actually exist; 158 - // the check is on byte length, not existence. 159 - const tooLong = "/tmp/" + "a".repeat(95); 160 - expect(Buffer.byteLength(tooLong, "utf-8")).toBeGreaterThan(90); 161 - 162 - const r = spawnSync(nodeBin, [cliPath, "list"], { 163 - env: { ...process.env, PTY_ROOT: tooLong, PTY_ROOT_LEGACY_SILENT: "1" }, 164 - encoding: "utf-8", 165 - timeout: 5000, 166 - }); 167 - expect(r.status).not.toBe(0); 168 - expect(r.stderr).toMatch(/PTY_ROOT is too long/); 169 - expect(r.stderr).toMatch(/104-byte kernel limit/); 170 - // Points the finger at the root, not the name. 171 - expect(r.stderr).toMatch(/Shorten the root/); 172 - }); 173 - 174 - it("errors before any subcommand-specific parsing runs", () => { 175 - // Backstop should fire even on a bogus subcommand — the too-long 176 - // root is caught before dispatch. 177 - const tooLong = "/tmp/" + "b".repeat(100); 178 - const r = spawnSync(nodeBin, [cliPath, "definitely-not-a-real-subcommand"], { 179 - env: { ...process.env, PTY_ROOT: tooLong, PTY_ROOT_LEGACY_SILENT: "1" }, 180 - encoding: "utf-8", 181 - timeout: 5000, 182 - }); 183 - expect(r.status).not.toBe(0); 184 - expect(r.stderr).toMatch(/PTY_ROOT is too long/); 185 - // The "unknown command" path is NOT hit; the root check errors first. 186 - expect(r.stderr).not.toMatch(/Unknown command/); 187 - }); 188 - 189 - it("allows a root right at the usable threshold", () => { 190 - // Build a root at exactly 90 bytes — the maximum that leaves room 191 - // for `/xxxxxxxx.sock` in 104. This should succeed (empty list). 192 - const usable = 104 - ("/".length + 8 + ".sock".length); 193 - const okRoot = "/tmp/" + "c".repeat(usable - "/tmp/".length); 194 - expect(Buffer.byteLength(okRoot, "utf-8")).toBe(usable); 195 - fs.mkdirSync(okRoot, { recursive: true }); 196 - try { 197 - const r = spawnSync(nodeBin, [cliPath, "list", "--json"], { 198 - env: { ...process.env, PTY_ROOT: okRoot, PTY_ROOT_LEGACY_SILENT: "1" }, 199 - encoding: "utf-8", 200 - timeout: 5000, 201 - }); 202 - expect(r.status).toBe(0); 203 - expect(JSON.parse(r.stdout)).toEqual([]); 204 - } finally { 205 - try { fs.rmSync(okRoot, { recursive: true, force: true }); } catch {} 206 - } 207 - }); 208 - 209 - it("--root <shorter> overrides an env that would otherwise fail", () => { 210 - // Env is too long; --root override is fine. The startup check reads 211 - // process.env.PTY_ROOT *after* --root parsing has set it, so the 212 - // override wins. 213 - const tooLongEnv = "/tmp/" + "d".repeat(95); 214 - const shortFlag = fs.mkdtempSync(path.join(testRoot, "shorter-")); 215 - const r = spawnSync(nodeBin, [cliPath, "--root", shortFlag, "list", "--json"], { 216 - env: { ...process.env, PTY_ROOT: tooLongEnv, PTY_ROOT_LEGACY_SILENT: "1" }, 217 - encoding: "utf-8", 218 - timeout: 5000, 219 - }); 220 - expect(r.status).toBe(0); 221 - expect(JSON.parse(r.stdout)).toEqual([]); 222 - }); 223 - });
-320
tests/gc-flapping.test.ts
··· 1 - // #54: fast-fail respawn cap. A crash-looping permanent session gets 2 - // flagged flapping and stopped after `strategy.fast-fail-limit` 3 - // consecutive fast failures within `strategy.fast-fail-window` seconds. 4 - // Auto-reset on command change; manual reset via `pty tag --rm`. 5 - 6 - import { describe, it, expect, afterEach, afterAll } from "vitest"; 7 - import * as fs from "node:fs"; 8 - import * as os from "node:os"; 9 - import * as path from "node:path"; 10 - import { fileURLToPath } from "node:url"; 11 - import { spawnSync } from "node:child_process"; 12 - import { createHash } from "node:crypto"; 13 - 14 - const __dirname = path.dirname(fileURLToPath(import.meta.url)); 15 - const nodeBin = process.execPath; 16 - const cliPath = path.join(__dirname, "..", "dist", "cli.js"); 17 - 18 - const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-flap-")); 19 - afterAll(() => { 20 - fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 21 - }); 22 - 23 - let sessionDirs: string[] = []; 24 - 25 - function makeSessionDir(): string { 26 - const dir = fs.mkdtempSync(path.join(testRoot, "d-")); 27 - sessionDirs.push(dir); 28 - return dir; 29 - } 30 - 31 - let nameCounter = 0; 32 - function uniqueName(): string { 33 - return `fl${++nameCounter}${Math.random().toString(36).slice(2, 5)}`; 34 - } 35 - 36 - function runCli(sessionDir: string, ...args: string[]) { 37 - return spawnSync(nodeBin, [cliPath, ...args], { 38 - env: { ...process.env, PTY_SESSION_DIR: sessionDir }, 39 - encoding: "utf-8", 40 - timeout: 15000, 41 - }); 42 - } 43 - 44 - function readMeta(sessionDir: string, name: string): any { 45 - return JSON.parse(fs.readFileSync(path.join(sessionDir, `${name}.json`), "utf-8")); 46 - } 47 - 48 - function readEvents(sessionDir: string, name: string): any[] { 49 - const filePath = path.join(sessionDir, `${name}.events.jsonl`); 50 - try { 51 - return fs.readFileSync(filePath, "utf-8") 52 - .trimEnd().split("\n").filter((l) => l.length > 0).map((l) => JSON.parse(l)); 53 - } catch { 54 - return []; 55 - } 56 - } 57 - 58 - /** Write a synthetic exited-permanent metadata file. `respawnAt` seeds 59 - * `strategy.last-respawn-at`, `exitedAt` seeds `metadata.exitedAt` — 60 - * their delta drives the fast-fail classifier. */ 61 - function writeExitedPermanent( 62 - sessionDir: string, 63 - name: string, 64 - opts: { 65 - command?: string; 66 - args?: string[]; 67 - tags?: Record<string, string>; 68 - lastRespawnAt?: string; 69 - exitedAt?: string; 70 - counter?: number; 71 - commandHash?: string; 72 - status?: string; 73 - }, 74 - ): void { 75 - const command = opts.command ?? "sh"; 76 - const args = opts.args ?? ["-c", "exit 1"]; 77 - const tags: Record<string, string> = { 78 - strategy: "permanent", 79 - ...(opts.tags ?? {}), 80 - }; 81 - if (opts.lastRespawnAt !== undefined) tags["strategy.last-respawn-at"] = opts.lastRespawnAt; 82 - if (opts.counter !== undefined) tags["strategy.consecutive-fast-fails"] = String(opts.counter); 83 - if (opts.commandHash !== undefined) tags["strategy.command-hash"] = opts.commandHash; 84 - if (opts.status !== undefined) tags["strategy.status"] = opts.status; 85 - 86 - fs.writeFileSync(path.join(sessionDir, `${name}.json`), JSON.stringify({ 87 - command, args, displayCommand: command, 88 - cwd: os.tmpdir(), 89 - createdAt: new Date(Date.now() - 10 * 60_000).toISOString(), 90 - exitedAt: opts.exitedAt ?? new Date().toISOString(), 91 - exitCode: 1, 92 - tags, 93 - })); 94 - // Also write a stub events file so appendEventSync doesn't create 95 - // orphaned JSONL. Some listSessions paths key off it. 96 - const evPath = path.join(sessionDir, `${name}.events.jsonl`); 97 - if (!fs.existsSync(evPath)) fs.writeFileSync(evPath, ""); 98 - } 99 - 100 - function commandHash(command: string, args: string[]): string { 101 - const h = createHash("sha256"); 102 - h.update(command); 103 - h.update("\0"); 104 - h.update(args.join("\0")); 105 - return h.digest("hex").slice(0, 16); 106 - } 107 - 108 - afterEach(() => { 109 - for (const dir of sessionDirs) { 110 - try { 111 - for (const e of fs.readdirSync(dir)) { try { fs.unlinkSync(path.join(dir, e)); } catch {} } 112 - } catch {} 113 - } 114 - sessionDirs = []; 115 - }); 116 - 117 - describe("gc fast-fail respawn cap", () => { 118 - it("dry-run: below-limit fast fails preview as respawn (no state mutation)", () => { 119 - const dir = makeSessionDir(); 120 - const name = uniqueName(); 121 - // Session was respawned 5s ago, exited 1s later → fast fail (1s < 60s). 122 - // Prior counter 1 → this tick would make it 2, still below limit 3. 123 - const last = new Date(Date.now() - 5000).toISOString(); 124 - const exit = new Date(Date.now() - 4000).toISOString(); 125 - writeExitedPermanent(dir, name, { 126 - lastRespawnAt: last, exitedAt: exit, counter: 1, 127 - commandHash: commandHash("sh", ["-c", "exit 1"]), 128 - }); 129 - 130 - const r = runCli(dir, "gc", "--dry-run"); 131 - expect(r.status).toBe(0); 132 - expect(r.stdout).toMatch(new RegExp(`Would respawn: ${name}`)); 133 - expect(r.stdout).not.toContain("Would flap"); 134 - // Dry-run must not mutate tags on disk. 135 - const meta = readMeta(dir, name); 136 - expect(meta.tags["strategy.consecutive-fast-fails"]).toBe("1"); 137 - expect(meta.tags["strategy.status"]).toBeUndefined(); 138 - }); 139 - 140 - it("dry-run: at-limit tick previews Would flap and no respawn", () => { 141 - const dir = makeSessionDir(); 142 - const name = uniqueName(); 143 - const last = new Date(Date.now() - 5000).toISOString(); 144 - const exit = new Date(Date.now() - 4000).toISOString(); 145 - // Prior counter 2 → this tick makes it 3, hits default limit → flap. 146 - writeExitedPermanent(dir, name, { 147 - lastRespawnAt: last, exitedAt: exit, counter: 2, 148 - commandHash: commandHash("sh", ["-c", "exit 1"]), 149 - }); 150 - 151 - const r = runCli(dir, "gc", "--dry-run"); 152 - expect(r.status).toBe(0); 153 - expect(r.stdout).toMatch(new RegExp(`Would flap: ${name} \\(3 fast-fails in 60s, limit 3\\)`)); 154 - expect(r.stdout).not.toMatch(new RegExp(`Would respawn: ${name}`)); 155 - 156 - // No mutation on dry-run. 157 - const meta = readMeta(dir, name); 158 - expect(meta.tags["strategy.status"]).toBeUndefined(); 159 - expect(meta.tags["strategy.consecutive-fast-fails"]).toBe("2"); 160 - }); 161 - 162 - it("at-limit tick persists strategy.status=flapping and emits session_flapping", () => { 163 - const dir = makeSessionDir(); 164 - const name = uniqueName(); 165 - const last = new Date(Date.now() - 5000).toISOString(); 166 - const exit = new Date(Date.now() - 4000).toISOString(); 167 - writeExitedPermanent(dir, name, { 168 - lastRespawnAt: last, exitedAt: exit, counter: 2, 169 - commandHash: commandHash("sh", ["-c", "exit 1"]), 170 - }); 171 - 172 - const r = runCli(dir, "gc"); 173 - expect(r.status).toBe(0); 174 - expect(r.stdout).toContain(`Flapping: ${name} (3 fast-fails in 60s, limit 3)`); 175 - 176 - const meta = readMeta(dir, name); 177 - expect(meta.tags["strategy.status"]).toBe("flapping"); 178 - expect(meta.tags["strategy.consecutive-fast-fails"]).toBe("3"); 179 - 180 - const events = readEvents(dir, name); 181 - const flap = events.find((e) => e.type === "session_flapping"); 182 - expect(flap).toBeDefined(); 183 - expect(flap.counter).toBe(3); 184 - expect(flap.limit).toBe(3); 185 - expect(flap.window).toBe(60); 186 - }); 187 - 188 - it("already-flapping session is silently skipped on subsequent tick", () => { 189 - const dir = makeSessionDir(); 190 - const name = uniqueName(); 191 - writeExitedPermanent(dir, name, { 192 - status: "flapping", 193 - counter: 3, 194 - lastRespawnAt: new Date(Date.now() - 60_000).toISOString(), 195 - exitedAt: new Date(Date.now() - 55_000).toISOString(), 196 - commandHash: commandHash("sh", ["-c", "exit 1"]), 197 - }); 198 - 199 - const r = runCli(dir, "gc"); 200 - expect(r.status).toBe(0); 201 - expect(r.stdout).toContain(`Skipped (flapping): ${name}`); 202 - expect(r.stdout).not.toContain(`Respawned: ${name}`); 203 - 204 - // No new events beyond what was already there. 205 - const events = readEvents(dir, name); 206 - expect(events.filter((e) => e.type === "session_flapping").length).toBe(0); 207 - }); 208 - 209 - it("slow-fail (past window) resets the counter to 0", () => { 210 - const dir = makeSessionDir(); 211 - const name = uniqueName(); 212 - // Last respawn 10 minutes ago, exited 9 minutes ago → 60s live, past 213 - // the default 60s window (using an exit ~60m after respawn). 214 - const last = new Date(Date.now() - 10 * 60_000).toISOString(); 215 - const exit = new Date(Date.now() - 5 * 60_000).toISOString(); 216 - writeExitedPermanent(dir, name, { 217 - lastRespawnAt: last, exitedAt: exit, counter: 2, 218 - commandHash: commandHash("sh", ["-c", "exit 1"]), 219 - }); 220 - 221 - const r = runCli(dir, "gc", "--dry-run"); 222 - expect(r.status).toBe(0); 223 - expect(r.stdout).toMatch(new RegExp(`Would respawn: ${name}`)); 224 - expect(r.stdout).not.toContain("Would flap"); 225 - // (Counter reset is verified via the "no flap at same prior counter" 226 - // outcome — a fast fail at prior=2 would have flapped; slow-fail 227 - // continues to respawn.) 228 - }); 229 - 230 - it("command-hash change auto-resets counter and clears flapping mark", () => { 231 - const dir = makeSessionDir(); 232 - const name = uniqueName(); 233 - // Session is currently flagged flapping under an old command hash. 234 - // Metadata now reports a DIFFERENT command (operator edited pty.toml). 235 - // The classifier should notice the divergence and both reset the 236 - // counter and clear the flapping mark, letting gc respawn. 237 - const oldHash = commandHash("sh", ["-c", "old-command"]); 238 - writeExitedPermanent(dir, name, { 239 - command: "sh", args: ["-c", "exit 1"], // new command 240 - status: "flapping", counter: 3, 241 - lastRespawnAt: new Date(Date.now() - 5000).toISOString(), 242 - exitedAt: new Date(Date.now() - 4000).toISOString(), 243 - commandHash: oldHash, // stale hash 244 - }); 245 - 246 - const r = runCli(dir, "gc", "--dry-run"); 247 - expect(r.status).toBe(0); 248 - expect(r.stdout).toMatch(new RegExp(`Would respawn: ${name}`)); 249 - expect(r.stdout).not.toContain("Skipped (flapping)"); 250 - expect(r.stdout).not.toContain("Would flap"); 251 - }); 252 - 253 - it("per-session strategy.fast-fail-limit overrides the default", () => { 254 - const dir = makeSessionDir(); 255 - const name = uniqueName(); 256 - // Prior counter 1 + fast fail → this tick makes it 2. Default limit 257 - // 3 would let it respawn; per-session limit 2 flaps instead. 258 - writeExitedPermanent(dir, name, { 259 - lastRespawnAt: new Date(Date.now() - 5000).toISOString(), 260 - exitedAt: new Date(Date.now() - 4000).toISOString(), 261 - counter: 1, 262 - commandHash: commandHash("sh", ["-c", "exit 1"]), 263 - tags: { "strategy.fast-fail-limit": "2" }, 264 - }); 265 - 266 - const r = runCli(dir, "gc", "--dry-run"); 267 - expect(r.status).toBe(0); 268 - expect(r.stdout).toMatch(new RegExp(`Would flap: ${name} \\(2 fast-fails in 60s, limit 2\\)`)); 269 - }); 270 - 271 - it("--fast-fail-limit CLI flag applies to sessions without a per-session tag", () => { 272 - const dir = makeSessionDir(); 273 - const name = uniqueName(); 274 - // Prior counter 4 + fast fail → 5. CLI flag lifts limit to 10 → still respawn. 275 - writeExitedPermanent(dir, name, { 276 - lastRespawnAt: new Date(Date.now() - 5000).toISOString(), 277 - exitedAt: new Date(Date.now() - 4000).toISOString(), 278 - counter: 4, 279 - commandHash: commandHash("sh", ["-c", "exit 1"]), 280 - }); 281 - 282 - const r = runCli(dir, "gc", "--dry-run", "--fast-fail-limit=10"); 283 - expect(r.status).toBe(0); 284 - expect(r.stdout).toMatch(new RegExp(`Would respawn: ${name}`)); 285 - expect(r.stdout).not.toContain("Would flap"); 286 - }); 287 - 288 - it("per-session strategy.fast-fail-window overrides the default", () => { 289 - const dir = makeSessionDir(); 290 - const name = uniqueName(); 291 - // Live time = 30s. Default window 60s → fast fail. Per-session window 292 - // 10s → slow fail (30 > 10) → counter resets → respawn, no flap. 293 - writeExitedPermanent(dir, name, { 294 - lastRespawnAt: new Date(Date.now() - 30_000).toISOString(), 295 - exitedAt: new Date(Date.now() - 0).toISOString(), 296 - counter: 2, 297 - commandHash: commandHash("sh", ["-c", "exit 1"]), 298 - tags: { "strategy.fast-fail-window": "10" }, 299 - }); 300 - 301 - const r = runCli(dir, "gc", "--dry-run"); 302 - expect(r.status).toBe(0); 303 - expect(r.stdout).toMatch(new RegExp(`Would respawn: ${name}`)); 304 - expect(r.stdout).not.toContain("Would flap"); 305 - }); 306 - 307 - it("no prior last-respawn-at (first respawn ever) doesn't count as fast fail", () => { 308 - const dir = makeSessionDir(); 309 - const name = uniqueName(); 310 - // Session exited fresh — no last-respawn-at tag yet. Counter absent. 311 - writeExitedPermanent(dir, name, { 312 - exitedAt: new Date().toISOString(), 313 - }); 314 - 315 - const r = runCli(dir, "gc", "--dry-run"); 316 - expect(r.status).toBe(0); 317 - expect(r.stdout).toMatch(new RegExp(`Would respawn: ${name}`)); 318 - expect(r.stdout).not.toContain("Would flap"); 319 - }); 320 - });
+5 -5
tests/gc-parent-child.test.ts
··· 158 158 expect(result.stdout).toContain(`Killed orphan child: ${b}`); 159 159 }, 15000); 160 160 161 - it("combined parent= AND strategy=permanent: child is killed, not respawned", async () => { 162 - // Orphan-kill (step 1) runs BEFORE permanent respawn (step 2), so a 163 - // child with both tags whose parent has died should be removed — 164 - // not respawned as a permanent. 161 + it("combined parent= AND strategy=permanent: child is killed (orphan-kill), metadata removed", async () => { 162 + // Orphan-kill (step 1) removes a child whose parent has died. Post- 163 + // reboot pty gc doesn't respawn permanents anyway (convoy owns 164 + // respawn), so the child is simply gone — its metadata is removed 165 + // by cleanupAll and convoy sees no session to respawn. 165 166 const dir = makeSessionDir(); 166 167 const parent = uniqueName("par"); 167 168 const child = uniqueName("ch"); ··· 175 176 const result = runCli(dir, "gc"); 176 177 expect(result.status).toBe(0); 177 178 expect(result.stdout).toContain(`Killed orphan child: ${child}`); 178 - expect(result.stdout).not.toContain(`Respawned: ${child}`); 179 179 expect(fs.existsSync(path.join(dir, `${child}.json`))).toBe(false); 180 180 }, 15000); 181 181
-267
tests/gc-permanent.test.ts
··· 1 - import { describe, it, expect, afterEach, afterAll } from "vitest"; 2 - import * as fs from "node:fs"; 3 - import * as os from "node:os"; 4 - import * as path from "node:path"; 5 - import { fileURLToPath } from "node:url"; 6 - import { spawn, spawnSync } from "node:child_process"; 7 - 8 - const __dirname = path.dirname(fileURLToPath(import.meta.url)); 9 - const nodeBin = process.execPath; 10 - const cliPath = path.join(__dirname, "..", "dist", "cli.js"); 11 - const serverModule = path.join(__dirname, "..", "dist", "server.js"); 12 - 13 - const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-gcp-")); 14 - afterAll(() => { 15 - fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 16 - }); 17 - 18 - let bgPids: number[] = []; 19 - let sessionDirs: string[] = []; 20 - 21 - function makeSessionDir(): string { 22 - const dir = fs.mkdtempSync(path.join(testRoot, "d-")); 23 - sessionDirs.push(dir); 24 - return dir; 25 - } 26 - 27 - let nameCounter = 0; 28 - function uniqueName(): string { 29 - // Short — socket paths must fit under SUN_PATH_MAX (104 bytes). 30 - return `gp${++nameCounter}${Math.random().toString(36).slice(2, 5)}`; 31 - } 32 - 33 - async function startDaemon( 34 - sessionDir: string, 35 - name: string, 36 - command: string, 37 - args: string[] = [], 38 - tags?: Record<string, string>, 39 - ): Promise<number> { 40 - const config = JSON.stringify({ 41 - name, command, args, displayCommand: command, 42 - cwd: os.tmpdir(), rows: 24, cols: 80, tags, 43 - }); 44 - const child = spawn(nodeBin, [serverModule], { 45 - detached: true, 46 - stdio: ["ignore", "ignore", "pipe"], 47 - env: { ...process.env, PTY_SERVER_CONFIG: config, PTY_SESSION_DIR: sessionDir }, 48 - }); 49 - let stderr = ""; 50 - child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); }); 51 - let exitCode: number | null = null; 52 - child.on("exit", (code) => { exitCode = code; }); 53 - (child.stderr as any)?.unref?.(); 54 - child.unref(); 55 - 56 - const socketPath = path.join(sessionDir, `${name}.sock`); 57 - const start = Date.now(); 58 - while (Date.now() - start < 5000) { 59 - if (exitCode !== null) throw new Error(`Daemon exited: ${stderr}`); 60 - try { 61 - fs.statSync(socketPath); 62 - await new Promise((r) => setTimeout(r, 100)); 63 - bgPids.push(child.pid!); 64 - return child.pid!; 65 - } catch {} 66 - await new Promise((r) => setTimeout(r, 50)); 67 - } 68 - throw new Error("Timeout waiting for daemon"); 69 - } 70 - 71 - function runCli(sessionDir: string, ...args: string[]) { 72 - return spawnSync(nodeBin, [cliPath, ...args], { 73 - env: { ...process.env, PTY_SESSION_DIR: sessionDir }, 74 - encoding: "utf-8", 75 - timeout: 15000, 76 - }); 77 - } 78 - 79 - function readMeta(sessionDir: string, name: string) { 80 - return JSON.parse(fs.readFileSync(path.join(sessionDir, `${name}.json`), "utf-8")); 81 - } 82 - 83 - function readEvents(sessionDir: string, name: string): any[] { 84 - const filePath = path.join(sessionDir, `${name}.events.jsonl`); 85 - try { 86 - const content = fs.readFileSync(filePath, "utf-8"); 87 - return content.trimEnd().split("\n").filter((l) => l.length > 0).map((l) => JSON.parse(l)); 88 - } catch { 89 - return []; 90 - } 91 - } 92 - 93 - afterEach(() => { 94 - for (const pid of bgPids) { try { process.kill(pid, "SIGTERM"); } catch {} } 95 - bgPids = []; 96 - for (const dir of sessionDirs) { 97 - try { 98 - for (const e of fs.readdirSync(dir)) { try { fs.unlinkSync(path.join(dir, e)); } catch {} } 99 - } catch {} 100 - } 101 - sessionDirs = []; 102 - }); 103 - 104 - describe("pty gc — strategy=permanent respawn", () => { 105 - it("respawns an exited permanent session", async () => { 106 - const dir = makeSessionDir(); 107 - const name = uniqueName(); 108 - // First daemon runs `true` and exits immediately — gives us the 109 - // exited-permanent setup gc should react to. 110 - const firstPid = await startDaemon(dir, name, "true", [], { strategy: "permanent" }); 111 - await new Promise((r) => setTimeout(r, 800)); 112 - 113 - const before = readMeta(dir, name); 114 - expect(before.exitedAt).toBeTruthy(); 115 - 116 - const result = runCli(dir, "gc"); 117 - expect(result.status).toBe(0); 118 - expect(result.stdout).toContain(`Respawned: ${name}`); 119 - 120 - // session_respawn event was written before the respawned daemon's 121 - // own session_start, so the event log contains both. Verify the 122 - // respawn event regardless of whether the new `true` daemon has 123 - // also already exited by the time we read. 124 - await new Promise((r) => setTimeout(r, 200)); 125 - const events = readEvents(dir, name); 126 - expect(events.some((e) => e.type === "session_respawn")).toBe(true); 127 - 128 - // The new daemon has a different pid than the first one (proves a 129 - // real respawn happened, not just a no-op). 130 - try { 131 - const pidStr = fs.readFileSync(path.join(dir, `${name}.pid`), "utf-8").trim(); 132 - const pid = parseInt(pidStr, 10); 133 - if (Number.isFinite(pid)) { 134 - bgPids.push(pid); 135 - expect(pid).not.toBe(firstPid); 136 - } 137 - } catch { 138 - // pid file may already be cleaned by the new daemon exiting — that 139 - // just confirms a fresh daemon ran. 140 - } 141 - }, 20000); 142 - 143 - it("does NOT respawn an exited session without strategy=permanent", async () => { 144 - const dir = makeSessionDir(); 145 - const name = uniqueName(); 146 - await startDaemon(dir, name, "true"); 147 - await new Promise((r) => setTimeout(r, 800)); 148 - 149 - const result = runCli(dir, "gc"); 150 - expect(result.status).toBe(0); 151 - expect(result.stdout).not.toContain(`Respawned: ${name}`); 152 - expect(result.stdout).toContain(`Removed: ${name}`); 153 - // Metadata gone (was reaped by step 3). 154 - expect(fs.existsSync(path.join(dir, `${name}.json`))).toBe(false); 155 - }, 15000); 156 - 157 - it("--dry-run previews respawn without spawning anything", async () => { 158 - const dir = makeSessionDir(); 159 - const name = uniqueName(); 160 - await startDaemon(dir, name, "true", [], { strategy: "permanent" }); 161 - await new Promise((r) => setTimeout(r, 800)); 162 - 163 - const dry = runCli(dir, "gc", "--dry-run"); 164 - expect(dry.status).toBe(0); 165 - expect(dry.stdout).toContain(`Would respawn: ${name}`); 166 - expect(dry.stdout).toContain("Dry run"); 167 - 168 - // Still exited — no actual respawn happened. Metadata's exitedAt 169 - // should still be present. 170 - const meta = readMeta(dir, name); 171 - expect(meta.exitedAt).toBeTruthy(); 172 - expect(fs.existsSync(path.join(dir, `${name}.pid`))).toBe(false); 173 - }, 15000); 174 - 175 - it("respawn does not loop within one invocation", async () => { 176 - // If a permanent session's command exits immediately (`true`), one 177 - // gc invocation should respawn it exactly once — not loop until the 178 - // process settles or stack-overflow on a sub-second exit. 179 - const dir = makeSessionDir(); 180 - const name = uniqueName(); 181 - await startDaemon(dir, name, "true", [], { strategy: "permanent" }); 182 - await new Promise((r) => setTimeout(r, 800)); 183 - 184 - const result = runCli(dir, "gc"); 185 - expect(result.status).toBe(0); 186 - // Exactly one "Respawned:" line, regardless of whether the new 187 - // daemon has also exited by the time gc returns. 188 - const respawnLines = (result.stdout.match(/^Respawned: /gm) || []).length; 189 - expect(respawnLines).toBe(1); 190 - 191 - // Track whatever pid we ended up with so afterEach can clean up. 192 - try { 193 - const pid = parseInt(fs.readFileSync(path.join(dir, `${name}.pid`), "utf-8").trim(), 10); 194 - if (Number.isFinite(pid)) bgPids.push(pid); 195 - } catch {} 196 - }, 15000); 197 - 198 - it("pty.toml respawn re-reads the toml so command edits take effect", async () => { 199 - // Seed a pty.toml in a tmp dir and use `pty up` to spawn a permanent 200 - // session referencing it. 201 - const dir = makeSessionDir(); 202 - const projectDir = fs.mkdtempSync(path.join(testRoot, "proj-")); 203 - sessionDirs.push(projectDir); 204 - const tomlPath = path.join(projectDir, "pty.toml"); 205 - const v1Marker = `/tmp/pty-gc-permv1-${Date.now()}.flag`; 206 - const v2Marker = `/tmp/pty-gc-permv2-${Date.now()}.flag`; 207 - // v1: write v1Marker and exit. 208 - fs.writeFileSync(tomlPath, `[sessions.perm] 209 - command = "touch ${v1Marker}" 210 - tags = { strategy = "permanent" } 211 - `); 212 - 213 - const projectShort = path.basename(projectDir); 214 - void projectShort; 215 - 216 - const up = runCli(dir, "up", projectDir); 217 - expect(up.status).toBe(0); 218 - // Under the decoupled name/displayName model, the session's on-disk 219 - // name is a random id; the toml-derived "perm" is the displayName. 220 - // Resolve the actual on-disk name via `pty list --json`. 221 - const listOut = runCli(dir, "list", "--json").stdout; 222 - const sessions = JSON.parse(listOut); 223 - const found = sessions.find((s: any) => s.displayName === "perm"); 224 - expect(found).toBeDefined(); 225 - const sessionName: string = found.name; 226 - 227 - // Wait for first run to complete (it touches v1Marker and exits). 228 - const start = Date.now(); 229 - while (Date.now() - start < 5000) { 230 - if (fs.existsSync(v1Marker)) break; 231 - await new Promise((r) => setTimeout(r, 100)); 232 - } 233 - expect(fs.existsSync(v1Marker)).toBe(true); 234 - fs.unlinkSync(v1Marker); 235 - 236 - // Now wait for the daemon to write its exit record. 237 - await new Promise((r) => setTimeout(r, 800)); 238 - const meta = readMeta(dir, sessionName); 239 - expect(meta.exitedAt).toBeTruthy(); 240 - expect(meta.tags?.ptyfile).toBe(tomlPath); 241 - 242 - // Edit the toml to write v2Marker instead. 243 - fs.writeFileSync(tomlPath, `[sessions.perm] 244 - command = "touch ${v2Marker}" 245 - tags = { strategy = "permanent" } 246 - `); 247 - 248 - const result = runCli(dir, "gc"); 249 - expect(result.status).toBe(0); 250 - expect(result.stdout).toContain(`Respawned: ${sessionName} (pty.toml re-read)`); 251 - 252 - // The respawned session should write v2Marker. 253 - const start2 = Date.now(); 254 - while (Date.now() - start2 < 5000) { 255 - if (fs.existsSync(v2Marker)) break; 256 - await new Promise((r) => setTimeout(r, 100)); 257 - } 258 - expect(fs.existsSync(v2Marker)).toBe(true); 259 - try { fs.unlinkSync(v2Marker); } catch {} 260 - 261 - // Track the new pid for teardown. 262 - try { 263 - const pid = parseInt(fs.readFileSync(path.join(dir, `${sessionName}.pid`), "utf-8").trim(), 10); 264 - if (Number.isFinite(pid)) bgPids.push(pid); 265 - } catch {} 266 - }, 25000); 267 - });
+89
tests/pty-root-length-backstop.test.ts
··· 1 + // Fail-loud startup backstop when PTY_ROOT is too long for the socket-path 2 + // kernel limit — errors before any subcommand runs. 3 + 4 + import { describe, it, expect, afterAll } from "vitest"; 5 + import * as fs from "node:fs"; 6 + import * as os from "node:os"; 7 + import * as path from "node:path"; 8 + import { fileURLToPath } from "node:url"; 9 + import { spawnSync } from "node:child_process"; 10 + 11 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 12 + const nodeBin = process.execPath; 13 + const cliPath = path.join(__dirname, "..", "dist", "cli.js"); 14 + 15 + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-root-len-")); 16 + afterAll(() => { 17 + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 18 + }); 19 + 20 + describe("PTY_ROOT length backstop", () => { 21 + it("errors at startup when PTY_ROOT is too deep to fit the sockaddr_un limit", () => { 22 + // Build a 95-byte path — well past the 90-byte usable threshold 23 + // (104 − 14 for `/xxxxxxxx.sock`). Doesn't need to actually exist; 24 + // the check is on byte length, not existence. 25 + const tooLong = "/tmp/" + "a".repeat(95); 26 + expect(Buffer.byteLength(tooLong, "utf-8")).toBeGreaterThan(90); 27 + 28 + const r = spawnSync(nodeBin, [cliPath, "list"], { 29 + env: { ...process.env, PTY_ROOT: tooLong, PTY_ROOT_LEGACY_SILENT: "1" }, 30 + encoding: "utf-8", 31 + timeout: 5000, 32 + }); 33 + expect(r.status).not.toBe(0); 34 + expect(r.stderr).toMatch(/PTY_ROOT is too long/); 35 + expect(r.stderr).toMatch(/104-byte kernel limit/); 36 + // Points the finger at the root, not the name. 37 + expect(r.stderr).toMatch(/Shorten the root/); 38 + }); 39 + 40 + it("errors before any subcommand-specific parsing runs", () => { 41 + // Backstop should fire even on a bogus subcommand — the too-long 42 + // root is caught before dispatch. 43 + const tooLong = "/tmp/" + "b".repeat(100); 44 + const r = spawnSync(nodeBin, [cliPath, "definitely-not-a-real-subcommand"], { 45 + env: { ...process.env, PTY_ROOT: tooLong, PTY_ROOT_LEGACY_SILENT: "1" }, 46 + encoding: "utf-8", 47 + timeout: 5000, 48 + }); 49 + expect(r.status).not.toBe(0); 50 + expect(r.stderr).toMatch(/PTY_ROOT is too long/); 51 + // The "unknown command" path is NOT hit; the root check errors first. 52 + expect(r.stderr).not.toMatch(/Unknown command/); 53 + }); 54 + 55 + it("allows a root right at the usable threshold", () => { 56 + // Build a root at exactly 90 bytes — the maximum that leaves room 57 + // for `/xxxxxxxx.sock` in 104. This should succeed (empty list). 58 + const usable = 104 - ("/".length + 8 + ".sock".length); 59 + const okRoot = "/tmp/" + "c".repeat(usable - "/tmp/".length); 60 + expect(Buffer.byteLength(okRoot, "utf-8")).toBe(usable); 61 + fs.mkdirSync(okRoot, { recursive: true }); 62 + try { 63 + const r = spawnSync(nodeBin, [cliPath, "list", "--json"], { 64 + env: { ...process.env, PTY_ROOT: okRoot, PTY_ROOT_LEGACY_SILENT: "1" }, 65 + encoding: "utf-8", 66 + timeout: 5000, 67 + }); 68 + expect(r.status).toBe(0); 69 + expect(JSON.parse(r.stdout)).toEqual([]); 70 + } finally { 71 + try { fs.rmSync(okRoot, { recursive: true, force: true }); } catch {} 72 + } 73 + }); 74 + 75 + it("--root <shorter> overrides an env that would otherwise fail", () => { 76 + // Env is too long; --root override is fine. The startup check reads 77 + // process.env.PTY_ROOT *after* --root parsing has set it, so the 78 + // override wins. 79 + const tooLongEnv = "/tmp/" + "d".repeat(95); 80 + const shortFlag = fs.mkdtempSync(path.join(testRoot, "shorter-")); 81 + const r = spawnSync(nodeBin, [cliPath, "--root", shortFlag, "list", "--json"], { 82 + env: { ...process.env, PTY_ROOT: tooLongEnv, PTY_ROOT_LEGACY_SILENT: "1" }, 83 + encoding: "utf-8", 84 + timeout: 5000, 85 + }); 86 + expect(r.status).toBe(0); 87 + expect(JSON.parse(r.stdout)).toEqual([]); 88 + }); 89 + });
-557
tests/up-down.test.ts
··· 1 - import { describe, it, expect, afterEach, afterAll } from "vitest"; 2 - import * as fs from "node:fs"; 3 - import * as os from "node:os"; 4 - import * as path from "node:path"; 5 - import { fileURLToPath } from "node:url"; 6 - import { spawnSync } from "node:child_process"; 7 - 8 - const __dirname = path.dirname(fileURLToPath(import.meta.url)); 9 - const nodeBin = process.execPath; 10 - const cliPath = path.join(__dirname, "..", "dist", "cli.js"); 11 - 12 - const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-updown-")); 13 - afterAll(() => { 14 - fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 15 - }); 16 - 17 - let sessionDirs: string[] = []; 18 - 19 - function makeProjectDir(): string { 20 - const dir = fs.mkdtempSync(path.join(testRoot, "proj-")); 21 - sessionDirs.push(dir); 22 - return dir; 23 - } 24 - 25 - function makeSessionDir(): string { 26 - const dir = fs.mkdtempSync(path.join(testRoot, "sd-")); 27 - sessionDirs.push(dir); 28 - return dir; 29 - } 30 - 31 - function writePtyToml(dir: string, content: string): void { 32 - fs.writeFileSync(path.join(dir, "pty.toml"), content); 33 - } 34 - 35 - function runCli(sessionDir: string, ...args: string[]): { status: number | null; stdout: string; stderr: string } { 36 - const result = spawnSync(nodeBin, [cliPath, ...args], { 37 - cwd: os.tmpdir(), 38 - env: { ...process.env, PTY_SESSION_DIR: sessionDir }, 39 - encoding: "utf-8", 40 - timeout: 15000, 41 - }); 42 - return { status: result.status, stdout: result.stdout, stderr: result.stderr }; 43 - } 44 - 45 - function listJson(sessionDir: string): any[] { 46 - const result = runCli(sessionDir, "list", "--json"); 47 - return JSON.parse(result.stdout); 48 - } 49 - 50 - afterEach(() => { 51 - // Kill any sessions we may have started 52 - for (const dir of sessionDirs) { 53 - if (!fs.existsSync(dir)) continue; 54 - try { 55 - const entries = fs.readdirSync(dir); 56 - for (const e of entries) { 57 - if (e.endsWith(".pid")) { 58 - const pidStr = fs.readFileSync(path.join(dir, e), "utf-8").trim(); 59 - const pid = parseInt(pidStr, 10); 60 - if (!isNaN(pid)) { 61 - try { process.kill(pid, "SIGTERM"); } catch {} 62 - } 63 - } 64 - } 65 - // Clean up files 66 - for (const e of entries) { 67 - try { fs.unlinkSync(path.join(dir, e)); } catch {} 68 - } 69 - } catch {} 70 - } 71 - sessionDirs = []; 72 - }); 73 - 74 - describe("pty up", () => { 75 - it("starts all sessions from pty.toml", () => { 76 - const projDir = makeProjectDir(); 77 - const sessDir = makeSessionDir(); 78 - writePtyToml(projDir, ` 79 - [sessions.one] 80 - command = "cat" 81 - 82 - [sessions.two] 83 - command = "cat" 84 - `); 85 - 86 - const result = runCli(sessDir, "up", projDir); 87 - 88 - expect(result.status).toBe(0); 89 - expect(result.stdout).toContain("one (started)"); 90 - expect(result.stdout).toContain("two (started)"); 91 - expect(result.stdout).toContain("Started 2 sessions"); 92 - 93 - const sessions = listJson(sessDir); 94 - expect(sessions.filter((s: any) => s.status === "running")).toHaveLength(2); 95 - }, 15000); 96 - 97 - it("starts only named sessions", () => { 98 - const projDir = makeProjectDir(); 99 - const sessDir = makeSessionDir(); 100 - writePtyToml(projDir, ` 101 - [sessions.web] 102 - command = "cat" 103 - 104 - [sessions.worker] 105 - command = "cat" 106 - 107 - [sessions.db] 108 - command = "cat" 109 - `); 110 - 111 - const result = runCli(sessDir, "up", projDir, "web", "db"); 112 - 113 - expect(result.status).toBe(0); 114 - expect(result.stdout).toContain("web (started)"); 115 - expect(result.stdout).toContain("db (started)"); 116 - expect(result.stdout).not.toContain("worker"); 117 - 118 - const sessions = listJson(sessDir); 119 - const running = sessions.filter((s: any) => s.status === "running"); 120 - expect(running).toHaveLength(2); 121 - expect(running.map((s: any) => s.displayName).sort()).toEqual(["db", "web"]); 122 - }, 15000); 123 - 124 - it("propagates env from pty.toml into the spawned session", async () => { 125 - const projDir = makeProjectDir(); 126 - const sessDir = makeSessionDir(); 127 - const outFile = path.join(projDir, "envcheck.out"); 128 - writePtyToml(projDir, ` 129 - [sessions.envprobe] 130 - command = "echo \\"$MY_VAR|$ANOTHER\\" > '${outFile}'; cat" 131 - 132 - [sessions.envprobe.env] 133 - MY_VAR = "hello" 134 - ANOTHER = "world" 135 - `); 136 - 137 - const result = runCli(sessDir, "up", projDir); 138 - expect(result.status).toBe(0); 139 - expect(result.stdout).toContain("envprobe (started)"); 140 - 141 - // `cat` keeps the session alive after the redirect; wait for the file. 142 - const deadline = Date.now() + 3000; 143 - while (Date.now() < deadline && !fs.existsSync(outFile)) { 144 - await new Promise((r) => setTimeout(r, 50)); 145 - } 146 - expect(fs.existsSync(outFile)).toBe(true); 147 - expect(fs.readFileSync(outFile, "utf-8").trim()).toBe("hello|world"); 148 - }, 15000); 149 - 150 - it("skips already running sessions", () => { 151 - const projDir = makeProjectDir(); 152 - const sessDir = makeSessionDir(); 153 - writePtyToml(projDir, ` 154 - [sessions.mycat] 155 - command = "cat" 156 - `); 157 - 158 - // Start once 159 - runCli(sessDir, "up", projDir); 160 - 161 - // Start again 162 - const result = runCli(sessDir, "up", projDir); 163 - expect(result.stdout).toContain("mycat (already running)"); 164 - expect(result.stdout).toContain("All sessions already running"); 165 - }, 15000); 166 - 167 - it("syncs tags to already-running sessions on pty up", () => { 168 - const projDir = makeProjectDir(); 169 - const sessDir = makeSessionDir(); 170 - 171 - // Start with no tags 172 - writePtyToml(projDir, ` 173 - [sessions.syncme] 174 - command = "cat" 175 - `); 176 - runCli(sessDir, "up", projDir); 177 - 178 - // Update toml to add tags 179 - writePtyToml(projDir, ` 180 - [sessions.syncme] 181 - command = "cat" 182 - tags = { strategy = "permanent", role = "server" } 183 - `); 184 - 185 - const result = runCli(sessDir, "up", projDir); 186 - expect(result.stdout).toContain("updated tags: strategy=permanent, role=server"); 187 - 188 - // Verify tags were applied 189 - const sessions = listJson(sessDir); 190 - const session = sessions.find((s: any) => s.displayName === "syncme"); 191 - expect(session.tags.strategy).toBe("permanent"); 192 - expect(session.tags.role).toBe("server"); 193 - expect(session.tags.ptyfile).toBeDefined(); 194 - }, 15000); 195 - 196 - it("does not remove manually-added tags on pty up", () => { 197 - const projDir = makeProjectDir(); 198 - const sessDir = makeSessionDir(); 199 - writePtyToml(projDir, ` 200 - [sessions.manual] 201 - command = "cat" 202 - tags = { role = "server" } 203 - `); 204 - 205 - runCli(sessDir, "up", projDir); 206 - 207 - // Manually add an extra tag 208 - runCli(sessDir, "tag", "manual", "custom=yes"); 209 - 210 - // Run pty up again — should NOT remove the custom tag 211 - runCli(sessDir, "up", projDir); 212 - 213 - const sessions = listJson(sessDir); 214 - const session = sessions.find((s: any) => s.displayName === "manual"); 215 - expect(session.tags.role).toBe("server"); 216 - expect(session.tags.custom).toBe("yes"); 217 - }, 15000); 218 - 219 - it("removes tags that were removed from pty.toml", () => { 220 - const projDir = makeProjectDir(); 221 - const sessDir = makeSessionDir(); 222 - 223 - writePtyToml(projDir, ` 224 - [sessions.remover] 225 - command = "cat" 226 - tags = { role = "server", env = "dev" } 227 - `); 228 - runCli(sessDir, "up", projDir); 229 - 230 - let sessions = listJson(sessDir); 231 - let session = sessions.find((s: any) => s.displayName === "remover"); 232 - expect(session.tags.role).toBe("server"); 233 - expect(session.tags.env).toBe("dev"); 234 - 235 - // Remove env from the toml 236 - writePtyToml(projDir, ` 237 - [sessions.remover] 238 - command = "cat" 239 - tags = { role = "server" } 240 - `); 241 - const result = runCli(sessDir, "up", projDir); 242 - expect(result.stdout).toContain("-env"); 243 - 244 - sessions = listJson(sessDir); 245 - session = sessions.find((s: any) => s.displayName === "remover"); 246 - expect(session.tags.role).toBe("server"); 247 - expect(session.tags.env).toBeUndefined(); 248 - // Metadata tags should still be present 249 - expect(session.tags.ptyfile).toBeDefined(); 250 - expect(session.tags["ptyfile.session"]).toBe("remover"); 251 - }, 20000); 252 - 253 - it("removes all toml tags when the tags table is deleted", () => { 254 - const projDir = makeProjectDir(); 255 - const sessDir = makeSessionDir(); 256 - 257 - writePtyToml(projDir, ` 258 - [sessions.cleared] 259 - command = "cat" 260 - tags = { role = "server", env = "dev" } 261 - `); 262 - runCli(sessDir, "up", projDir); 263 - 264 - // Remove the tags table entirely 265 - writePtyToml(projDir, ` 266 - [sessions.cleared] 267 - command = "cat" 268 - `); 269 - const result = runCli(sessDir, "up", projDir); 270 - expect(result.stdout).toContain("-env"); 271 - expect(result.stdout).toContain("-role"); 272 - 273 - const sessions = listJson(sessDir); 274 - const session = sessions.find((s: any) => s.displayName === "cleared"); 275 - expect(session.tags.role).toBeUndefined(); 276 - expect(session.tags.env).toBeUndefined(); 277 - expect(session.tags.ptyfile).toBeDefined(); 278 - }, 20000); 279 - 280 - it("preserves manually-added tags when toml tags are removed", () => { 281 - const projDir = makeProjectDir(); 282 - const sessDir = makeSessionDir(); 283 - 284 - writePtyToml(projDir, ` 285 - [sessions.mixer] 286 - command = "cat" 287 - tags = { role = "server" } 288 - `); 289 - runCli(sessDir, "up", projDir); 290 - 291 - // Add a manual tag 292 - runCli(sessDir, "tag", "mixer", "custom=yes"); 293 - 294 - // Remove the toml tag 295 - writePtyToml(projDir, ` 296 - [sessions.mixer] 297 - command = "cat" 298 - `); 299 - runCli(sessDir, "up", projDir); 300 - 301 - const sessions = listJson(sessDir); 302 - const session = sessions.find((s: any) => s.displayName === "mixer"); 303 - expect(session.tags.role).toBeUndefined(); 304 - expect(session.tags.custom).toBe("yes"); 305 - }, 20000); 306 - 307 - it("replaces a toml tag's value (not remove+re-add)", () => { 308 - const projDir = makeProjectDir(); 309 - const sessDir = makeSessionDir(); 310 - 311 - writePtyToml(projDir, ` 312 - [sessions.mover] 313 - command = "cat" 314 - tags = { env = "dev" } 315 - `); 316 - runCli(sessDir, "up", projDir); 317 - 318 - writePtyToml(projDir, ` 319 - [sessions.mover] 320 - command = "cat" 321 - tags = { env = "prod" } 322 - `); 323 - const result = runCli(sessDir, "up", projDir); 324 - expect(result.stdout).toContain("env=prod"); 325 - expect(result.stdout).not.toContain("-env"); 326 - 327 - const sessions = listJson(sessDir); 328 - const session = sessions.find((s: any) => s.displayName === "mover"); 329 - expect(session.tags.env).toBe("prod"); 330 - }, 20000); 331 - 332 - it("no output for already-running sessions with matching tags", () => { 333 - const projDir = makeProjectDir(); 334 - const sessDir = makeSessionDir(); 335 - writePtyToml(projDir, ` 336 - [sessions.unchanged] 337 - command = "cat" 338 - tags = { role = "server" } 339 - `); 340 - 341 - runCli(sessDir, "up", projDir); 342 - 343 - // Run again — tags match, no update message 344 - const result = runCli(sessDir, "up", projDir); 345 - expect(result.stdout).toContain("unchanged (already running)"); 346 - expect(result.stdout).not.toContain("updated tags"); 347 - }, 15000); 348 - 349 - it("propagates tags from pty.toml", () => { 350 - const projDir = makeProjectDir(); 351 - const sessDir = makeSessionDir(); 352 - writePtyToml(projDir, ` 353 - [sessions.tagged] 354 - command = "cat" 355 - tags = { role = "server", env = "dev" } 356 - `); 357 - 358 - runCli(sessDir, "up", projDir); 359 - 360 - const sessions = listJson(sessDir); 361 - const session = sessions.find((s: any) => s.displayName === "tagged"); 362 - expect(session).toBeDefined(); 363 - expect(session.tags.role).toBe("server"); 364 - expect(session.tags.env).toBe("dev"); 365 - expect(session.tags.ptyfile).toBeDefined(); 366 - }, 15000); 367 - 368 - it("sets cwd to the project directory", () => { 369 - const projDir = makeProjectDir(); 370 - const sessDir = makeSessionDir(); 371 - writePtyToml(projDir, ` 372 - [sessions.checkdir] 373 - command = "cat" 374 - `); 375 - 376 - runCli(sessDir, "up", projDir); 377 - 378 - const sessions = listJson(sessDir); 379 - const session = sessions.find((s: any) => s.displayName === "checkdir"); 380 - expect(session).toBeDefined(); 381 - expect(session.cwd).toBe(projDir); 382 - }, 15000); 383 - 384 - it("uses prefix for session names", () => { 385 - const projDir = makeProjectDir(); 386 - const sessDir = makeSessionDir(); 387 - writePtyToml(projDir, ` 388 - prefix = "myapp" 389 - 390 - [sessions.web] 391 - command = "cat" 392 - 393 - [sessions.worker] 394 - command = "cat" 395 - `); 396 - 397 - const result = runCli(sessDir, "up", projDir); 398 - 399 - expect(result.status).toBe(0); 400 - expect(result.stdout).toContain("myapp-web (started)"); 401 - expect(result.stdout).toContain("myapp-worker (started)"); 402 - 403 - const sessions = listJson(sessDir); 404 - const names = sessions.filter((s: any) => s.status === "running").map((s: any) => s.displayName); 405 - expect(names.sort()).toEqual(["myapp-web", "myapp-worker"]); 406 - }, 15000); 407 - 408 - it("filters by short name when prefix is set", () => { 409 - const projDir = makeProjectDir(); 410 - const sessDir = makeSessionDir(); 411 - writePtyToml(projDir, ` 412 - prefix = "myapp" 413 - 414 - [sessions.web] 415 - command = "cat" 416 - 417 - [sessions.worker] 418 - command = "cat" 419 - `); 420 - 421 - const result = runCli(sessDir, "up", projDir, "web"); 422 - 423 - expect(result.status).toBe(0); 424 - expect(result.stdout).toContain("myapp-web (started)"); 425 - expect(result.stdout).not.toContain("worker"); 426 - 427 - const sessions = listJson(sessDir); 428 - const running = sessions.filter((s: any) => s.status === "running"); 429 - expect(running).toHaveLength(1); 430 - expect(running[0].displayName).toBe("myapp-web"); 431 - }, 15000); 432 - 433 - it("sets ptyfile tags on created sessions", () => { 434 - const projDir = makeProjectDir(); 435 - const sessDir = makeSessionDir(); 436 - writePtyToml(projDir, ` 437 - [sessions.tracked] 438 - command = "cat" 439 - `); 440 - 441 - runCli(sessDir, "up", projDir); 442 - 443 - const sessions = listJson(sessDir); 444 - const session = sessions.find((s: any) => s.displayName === "tracked"); 445 - expect(session).toBeDefined(); 446 - expect(session.tags.ptyfile).toBe(projDir + "/pty.toml"); 447 - expect(session.tags["ptyfile.session"]).toBe("tracked"); 448 - }, 15000); 449 - 450 - it("errors on unknown session name", () => { 451 - const projDir = makeProjectDir(); 452 - const sessDir = makeSessionDir(); 453 - writePtyToml(projDir, ` 454 - [sessions.real] 455 - command = "cat" 456 - `); 457 - 458 - const result = runCli(sessDir, "up", projDir, "fake"); 459 - expect(result.status).not.toBe(0); 460 - expect(result.stderr).toContain("Unknown session: fake"); 461 - expect(result.stderr).toContain("Available: real"); 462 - }, 15000); 463 - 464 - it("errors when no pty.toml exists", () => { 465 - const projDir = makeProjectDir(); 466 - const sessDir = makeSessionDir(); 467 - 468 - const result = runCli(sessDir, "up", projDir); 469 - expect(result.status).not.toBe(0); 470 - expect(result.stderr).toContain("No pty.toml found"); 471 - }, 15000); 472 - 473 - it("errors on pty.toml with no sessions", () => { 474 - const projDir = makeProjectDir(); 475 - const sessDir = makeSessionDir(); 476 - writePtyToml(projDir, `# empty config\n`); 477 - 478 - const result = runCli(sessDir, "up", projDir); 479 - expect(result.status).not.toBe(0); 480 - expect(result.stderr).toContain("No sessions defined"); 481 - }, 15000); 482 - 483 - it("errors on session without a command", () => { 484 - const projDir = makeProjectDir(); 485 - const sessDir = makeSessionDir(); 486 - writePtyToml(projDir, ` 487 - [sessions.bad] 488 - tags = { foo = "bar" } 489 - `); 490 - 491 - const result = runCli(sessDir, "up", projDir); 492 - expect(result.status).not.toBe(0); 493 - expect(result.stderr).toContain('missing a "command" field'); 494 - }, 15000); 495 - }); 496 - 497 - describe("pty down", () => { 498 - it("stops all running sessions from pty.toml", () => { 499 - const projDir = makeProjectDir(); 500 - const sessDir = makeSessionDir(); 501 - writePtyToml(projDir, ` 502 - [sessions.alpha] 503 - command = "cat" 504 - 505 - [sessions.beta] 506 - command = "cat" 507 - `); 508 - 509 - // Start them 510 - runCli(sessDir, "up", projDir); 511 - let sessions = listJson(sessDir); 512 - expect(sessions.filter((s: any) => s.status === "running")).toHaveLength(2); 513 - 514 - // Stop them 515 - const result = runCli(sessDir, "down", projDir); 516 - expect(result.status).toBe(0); 517 - expect(result.stdout).toContain("alpha (stopped)"); 518 - expect(result.stdout).toContain("beta (stopped)"); 519 - expect(result.stdout).toContain("Stopped 2 sessions"); 520 - }, 15000); 521 - 522 - it("stops only named sessions", () => { 523 - const projDir = makeProjectDir(); 524 - const sessDir = makeSessionDir(); 525 - writePtyToml(projDir, ` 526 - [sessions.keep] 527 - command = "cat" 528 - 529 - [sessions.stop] 530 - command = "cat" 531 - `); 532 - 533 - runCli(sessDir, "up", projDir); 534 - 535 - const result = runCli(sessDir, "down", projDir, "stop"); 536 - expect(result.stdout).toContain("stop (stopped)"); 537 - expect(result.stdout).not.toContain("keep"); 538 - 539 - // "keep" should still be running 540 - const sessions = listJson(sessDir); 541 - const running = sessions.filter((s: any) => s.status === "running"); 542 - expect(running).toHaveLength(1); 543 - expect(running[0].displayName).toBe("keep"); 544 - }, 15000); 545 - 546 - it("reports when nothing to stop", () => { 547 - const projDir = makeProjectDir(); 548 - const sessDir = makeSessionDir(); 549 - writePtyToml(projDir, ` 550 - [sessions.ghost] 551 - command = "cat" 552 - `); 553 - 554 - const result = runCli(sessDir, "down", projDir); 555 - expect(result.stdout).toContain("No sessions to stop"); 556 - }, 15000); 557 - });
-180
tests/up-name-decouple.test.ts
··· 1 - import { describe, it, expect, afterEach, afterAll } from "vitest"; 2 - import * as fs from "node:fs"; 3 - import * as os from "node:os"; 4 - import * as path from "node:path"; 5 - import { fileURLToPath } from "node:url"; 6 - import { spawnSync } from "node:child_process"; 7 - 8 - const __dirname = path.dirname(fileURLToPath(import.meta.url)); 9 - const nodeBin = process.execPath; 10 - const cliPath = path.join(__dirname, "..", "dist", "cli.js"); 11 - 12 - const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-updc-")); 13 - afterAll(() => { 14 - fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 15 - }); 16 - 17 - const sessionDirs: string[] = []; 18 - 19 - function makeProjectDir(): string { 20 - const dir = fs.mkdtempSync(path.join(testRoot, "p-")); 21 - return dir; 22 - } 23 - 24 - function makeSessionDir(): string { 25 - const dir = fs.mkdtempSync(path.join(testRoot, "s-")); 26 - sessionDirs.push(dir); 27 - return dir; 28 - } 29 - 30 - function writePtyToml(dir: string, content: string): void { 31 - fs.writeFileSync(path.join(dir, "pty.toml"), content); 32 - } 33 - 34 - function runCli(sessionDir: string, ...args: string[]): { status: number | null; stdout: string; stderr: string } { 35 - const r = spawnSync(nodeBin, [cliPath, ...args], { 36 - cwd: os.tmpdir(), 37 - env: { ...process.env, PTY_SESSION_DIR: sessionDir }, 38 - encoding: "utf-8", 39 - timeout: 15000, 40 - }); 41 - return { status: r.status, stdout: r.stdout, stderr: r.stderr }; 42 - } 43 - 44 - function listJson(sessionDir: string): any[] { 45 - const r = runCli(sessionDir, "list", "--json"); 46 - if (!r.stdout.trim()) return []; 47 - return JSON.parse(r.stdout); 48 - } 49 - 50 - afterEach(() => { 51 - for (const dir of sessionDirs) { 52 - if (!fs.existsSync(dir)) continue; 53 - try { 54 - const entries = fs.readdirSync(dir); 55 - for (const e of entries) { 56 - if (e.endsWith(".pid")) { 57 - const pid = parseInt(fs.readFileSync(path.join(dir, e), "utf-8").trim(), 10); 58 - if (!isNaN(pid)) { try { process.kill(pid, "SIGTERM"); } catch {} } 59 - } 60 - } 61 - for (const e of entries) { try { fs.unlinkSync(path.join(dir, e)); } catch {} } 62 - } catch {} 63 - } 64 - sessionDirs.length = 0; 65 - }); 66 - 67 - describe("pty up: on-disk name decoupled from display label", () => { 68 - it("spawns sessions with a random short id; displayName carries the toml-derived label", () => { 69 - const projDir = makeProjectDir(); 70 - const sessDir = makeSessionDir(); 71 - writePtyToml(projDir, ` 72 - prefix = "myapp" 73 - 74 - [sessions.web] 75 - command = "cat" 76 - `); 77 - 78 - runCli(sessDir, "up", projDir); 79 - const sessions = listJson(sessDir); 80 - expect(sessions).toHaveLength(1); 81 - const s = sessions[0]; 82 - // On-disk name is a short random base32-ish id, NOT "myapp-web". 83 - expect(s.name).toMatch(/^[a-z0-9]{6,12}$/); 84 - expect(s.name).not.toBe("myapp-web"); 85 - // Display label is the prefix-shortName combo. 86 - expect(s.displayName).toBe("myapp-web"); 87 - expect(s.tags["ptyfile.session"]).toBe("web"); 88 - }, 15000); 89 - 90 - it("supports a long prefix that would have exceeded the sock path limit under the old model", () => { 91 - const projDir = makeProjectDir(); 92 - const sessDir = makeSessionDir(); 93 - // 90-char prefix + "-web" + ".sock" + session-dir would blow past 104. 94 - const longPrefix = "p".repeat(90); 95 - writePtyToml(projDir, ` 96 - prefix = "${longPrefix}" 97 - 98 - [sessions.web] 99 - command = "cat" 100 - `); 101 - 102 - const result = runCli(sessDir, "up", projDir); 103 - expect(result.status).toBe(0); 104 - const sessions = listJson(sessDir); 105 - expect(sessions).toHaveLength(1); 106 - expect(sessions[0].displayName).toBe(`${longPrefix}-web`); 107 - // On-disk name is short. 108 - expect(sessions[0].name.length).toBeLessThan(20); 109 - }, 15000); 110 - 111 - it("re-running pty up matches existing sessions by (ptyfile, ptyfile.session) tag pair, not by name", () => { 112 - const projDir = makeProjectDir(); 113 - const sessDir = makeSessionDir(); 114 - writePtyToml(projDir, ` 115 - [sessions.svc] 116 - command = "cat" 117 - `); 118 - 119 - runCli(sessDir, "up", projDir); 120 - const firstId = listJson(sessDir).find((s: any) => s.displayName === "svc")!.name; 121 - 122 - // Re-run pty up; should detect already-running and NOT spawn a second. 123 - const second = runCli(sessDir, "up", projDir); 124 - expect(second.stdout).toContain("svc (already running)"); 125 - const sessions = listJson(sessDir).filter((s: any) => s.displayName === "svc"); 126 - expect(sessions).toHaveLength(1); 127 - expect(sessions[0].name).toBe(firstId); 128 - }, 15000); 129 - 130 - it("honors pty.toml `id = \"...\"` to pin the on-disk identifier", () => { 131 - const projDir = makeProjectDir(); 132 - const sessDir = makeSessionDir(); 133 - writePtyToml(projDir, ` 134 - [sessions.svc] 135 - id = "pinned" 136 - command = "cat" 137 - `); 138 - 139 - runCli(sessDir, "up", projDir); 140 - const sessions = listJson(sessDir); 141 - expect(sessions).toHaveLength(1); 142 - expect(sessions[0].name).toBe("pinned"); 143 - expect(sessions[0].displayName).toBe("svc"); 144 - }, 15000); 145 - 146 - it("honors pty.toml `display_name = \"...\"` to override the default label", () => { 147 - const projDir = makeProjectDir(); 148 - const sessDir = makeSessionDir(); 149 - writePtyToml(projDir, ` 150 - prefix = "myapp" 151 - 152 - [sessions.web] 153 - display_name = "My Web Server" 154 - command = "cat" 155 - `); 156 - 157 - runCli(sessDir, "up", projDir); 158 - const sessions = listJson(sessDir); 159 - expect(sessions).toHaveLength(1); 160 - expect(sessions[0].displayName).toBe("My Web Server"); 161 - expect(sessions[0].name).toMatch(/^[a-z0-9]{6,12}$/); 162 - }, 15000); 163 - 164 - it("operations (kill, peek, send) resolve by displayName for toml-spawned sessions", () => { 165 - const projDir = makeProjectDir(); 166 - const sessDir = makeSessionDir(); 167 - writePtyToml(projDir, ` 168 - [sessions.svc] 169 - command = "cat" 170 - `); 171 - 172 - runCli(sessDir, "up", projDir); 173 - // Resolve by displayName 174 - const killResult = runCli(sessDir, "kill", "svc"); 175 - expect(killResult.status).toBe(0); 176 - // No running sessions left 177 - const sessions = listJson(sessDir).filter((s: any) => s.status === "running"); 178 - expect(sessions).toHaveLength(0); 179 - }, 15000); 180 - });