Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@

## Unreleased

### Storage format

- Supporting live daemons now advertise a `recovery` capability in session
metadata. `pty recover <name> --snapshot <file>` uses that captured
capability to authenticate a signal-free listener/registry rebind after an
external unlink. Recovery preserves the daemon, PTY child, existing clients,
generation, and launch identity; stale, tampered, legacy, or foreign-path
attempts fail closed without relaunching. A retained signed metadata revision
prevents older snapshots from rolling back later tags, display names, attach
state, or lifecycle metadata. Recovery locks are resumable only by the same
authenticated daemon identity after an interrupted CLI, and both the root and
`.recovery` directory are identity/permission checked immediately before
authenticated request exchange. Metadata mutations advance their signed
recovery revision before publishing the new metadata, so an unlink during
publication can deny recovery but cannot authorize an older snapshot.

### Restart-durable environment removals

- `pty run --unset-env KEY` and programmatic `unsetEnv` persist inherited
Expand Down Expand Up @@ -98,7 +114,6 @@ notification because pty does not journal a cross-file transaction.
semantics. An attached client that switches to readonly via `PEEK` now
relinquishes its requested geometry, re-negotiating the effective size when
necessary.

### Read-only session listing

- `listSessions()` and `pty list` are now strictly observational: they no
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ pty emit myserver user.note --text "checkpoint reached" # with a text payloa

pty restart myserver # restart an exited session (must have been preserved)
pty kill myserver # terminate a running session
pty --root /state/pty recover myserver --snapshot ./myserver.json # rebind a live supporting daemon after external unlink
pty rm myserver # remove an exited session's metadata
pty gc # reconcile: kill orphan children, respawn permanents, sweep vanished
pty gc --dry-run # preview what gc would do without changing anything
Expand Down Expand Up @@ -198,6 +199,21 @@ Two per-session flags override the configured default either way:
against the dead metadata to respawn them). `pty kill` also preserves — it is
stop-and-keep, deliberately distinct from `pty rm`.

If an external cleanup unlinks a live session's socket, pid, and metadata
paths, do not rerun its launch command: that can create a second provider.
New daemons advertise a recovery capability in metadata when their selected
root is private to the daemon user. Capture that complete metadata before the
unlink, then use `pty recover <name> --snapshot <file>`
against the same `PTY_ROOT`. Recovery authenticates the original daemon and
rebinds its listener without signaling, restarting, or disconnecting existing
clients. Missing, legacy, stale, tampered, wrong-root, and occupied-path
snapshots fail closed.
Recovery also rejects a snapshot captured before any later metadata mutation,
and rechecks that both the root and its recovery directory are still private
before exchanging authenticated state. An interrupted recover command can be
resumed with the same valid snapshot; it never probes, signals, or relaunches
the supporting daemon.

```sh
pty run -d -- npm test # shipped default: reaped when it finishes
PTY_REAP_ON_EXIT=false pty run -d -- npm test # preserved: peekable until gc sweeps it
Expand Down
5 changes: 4 additions & 1 deletion completions/pty.bash
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ _pty() {
COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
commands="run attach a exec peek send events list ls stats restart kill rm remove gc tag tag-multi emit rename metadata up down test remote-serve"
commands="run attach a exec peek send events list ls stats restart kill recover rm remove gc tag tag-multi emit rename metadata up down test remote-serve"

if [[ ${COMP_CWORD} -eq 1 ]]; then
if [[ "${cur}" == -* ]]; then
Expand Down Expand Up @@ -84,6 +84,9 @@ _pty() {
COMPREPLY=($(compgen -W "${names}" -- "${cur}"))
fi
;;
recover)
COMPREPLY=($(compgen -W "--snapshot" -- "${cur}"))
;;
rm|remove)
if [[ "${cur}" == -* ]]; then
COMPREPLY=($(compgen -W "" -- "${cur}"))
Expand Down
2 changes: 2 additions & 0 deletions completions/pty.fish
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ complete -c pty -n __pty_needs_command -a ls -d 'List sessions'
complete -c pty -n __pty_needs_command -a stats -d 'Live CPU / memory / PIDs'
complete -c pty -n __pty_needs_command -a restart -d 'SIGTERM + respawn'
complete -c pty -n __pty_needs_command -a kill -d 'SIGTERM a running session'
complete -c pty -n __pty_needs_command -a recover -d 'Rebind a supporting live daemon after registry unlink'
complete -c pty -n __pty_needs_command -a rm -d 'Remove exited metadata'
complete -c pty -n __pty_needs_command -a remove -d 'Remove exited metadata'
complete -c pty -n __pty_needs_command -a gc -d 'Reconciliation pass'
Expand Down Expand Up @@ -117,6 +118,7 @@ complete -c pty -n '__pty_using_command restart' -l yes -s y -d 'Skip confirmati
complete -c pty -n '__pty_using_command restart' -l force -d 'Attach after restart even from inside another pty'
complete -c pty -n '__pty_using_command restart' -a '(__pty_sessions)' -d 'Session'
complete -c pty -n '__pty_using_command kill' -a '(__pty_sessions)' -d 'Session'
complete -c pty -n '__pty_using_command recover' -l snapshot -d 'Captured capability-bearing metadata file'
complete -c pty -n '__pty_using_command rm remove' -a '(__pty_sessions)' -d 'Session'
complete -c pty -n '__pty_using_command gc' -l dry-run -s n -d 'Preview without changing anything'
complete -c pty -n '__pty_using_command gc' -l idle-days -d 'Reap permanents with no attach in N days'
Expand Down
5 changes: 5 additions & 0 deletions completions/pty.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ _pty() {
'stats:Live CPU / memory / PIDs'
'restart:SIGTERM + respawn'
'kill:SIGTERM a running session'
'recover:Rebind a supporting live daemon after registry unlink'
'rm:Remove exited metadata'
'remove:Alias for rm'
'gc:Reconciliation pass'
Expand Down Expand Up @@ -135,6 +136,10 @@ _pty() {
_arguments \
'1:session:_pty_sessions'
;;
recover)
_arguments \
'--snapshot[Captured capability-bearing metadata file]'
;;
rm|remove)
_arguments \
'1:session:_pty_sessions'
Expand Down
24 changes: 24 additions & 0 deletions docs/disk-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ For non-Node tools that want to read pty's state without paying Node startup. Th
| `<name>.sock` | daemon IPC socket (Unix) | 2 |
| `<name>.pid` | daemon pid (decimal) | 2 |
| `<name>.lock` | creation-race lock | 2 |
| `.recovery/` | authenticated request/result exchange for supported live daemons | 2 |
| `<name>.events.lock` | event append/retention lock | 2 |
| `theme` | last-selected TUI theme | 2 |
| `gc.log` | stdout/stderr of `pty gc` when run by launchd/cron (only present after auto-running gc is installed) | 2 |
Expand All @@ -36,6 +37,17 @@ Pretty-printed JSON. Source of truth: `SessionMetadata` in `src/sessions.ts`.
{
generation?: string; // opaque daemon generation; guards cleanup ownership
daemonPid?: number; // daemon owning this generation, retained after child exit
recovery?: { // signal-free live-registry recovery capability
protocol: 1;
secret: string; // opaque request-authentication key
processStartToken: string;
launchIdentity: string;
rootDevice: number;
rootInode: number;
recoveryDirDevice: number;
recoveryDirInode: number;
metadataRevision: string; // exact revision bound to retained recovery state
};
command: string; // resolved binary path
args: string[];
displayCommand: string; // command as the user typed it
Expand Down Expand Up @@ -67,6 +79,18 @@ the historical ambient-inheritance behavior.
removes files still owned by its generation, and `pty rm` waits for that
daemon to finish deferred shutdown before it reports success. Readers should
treat the generation token as opaque.
- `recovery` is present only when the daemon can prove its OS process-start
identity and the selected root is owned by the daemon user with no
group/other permissions. A snapshot containing this capability can authenticate
`pty recover` after the socket, pid, and metadata paths are externally
unlinked. The root is mode `0700`; treat the embedded secret as opaque and
do not publish snapshots. The root and `.recovery` directory identities and
permissions are revalidated before recovery state is exchanged. A signed
retained revision rejects older snapshots after tags, display name, attach
state, or other metadata changes. The signed revision advances before changed
metadata is renamed into place: a partial publication may disable recovery,
but never re-authorizes the previous snapshot. Successful recovery rotates
the secret.
- `displayName` is mutable presentation metadata and is not unique. Stable
identity remains the `<name>` filename stem; consumers must not use
`displayName` as a durable key.
Expand Down
184 changes: 184 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ import {
validateName,
validateDisplayName,
acquireLock,
acquireRecoveryLock,
isLockOwnedByPid,
releaseLock,
releaseRecoveryLock,
updateTags,
setDisplayName,
patchMetadataById,
Expand All @@ -34,6 +36,9 @@ import {
writeMetadata,
atomicWriteFileSync,
getSessionDir,
getSocketPath,
getPidPath,
getMetadataPath,
DEFAULT_SESSION_DIR,
type SessionInfo,
type SessionMetadata,
Expand All @@ -48,6 +53,20 @@ import { readPtyFile, type PtySessionDef } from "./ptyfile.ts";
import { extractFilterTags as extractFilterTagsImpl, matchesAllTags, isReservedTagKey } from "./tags.ts";
import { parseDuration, formatDuration } from "./duration.ts";
import { serveRemoteControl, runRemoteServeStdio, fetchRemoteList, dialAndRoute, RouteRefusedError, PTY_REMOTE_ALPN, FABRIC_BIN } from "./remote.ts";
import {
RECOVERY_PROTOCOL,
assertPrivateRecoveryPaths,
atomicWritePrivate,
readBoundedJson,
readProcessStartToken,
recoveryLockContents,
recoveryLockIdentity,
recoveryRequestPath,
recoveryResultPath,
signRecoveryRequest,
verifyRecoveryResult,
type RecoveryResult,
} from "./recovery.ts";

// Name this process so it shows up meaningfully in ps/top/htop/btm instead of
// "MainThread" (V8's default main-thread name under Node 24+). `process.title`
Expand Down Expand Up @@ -275,6 +294,17 @@ SIGTERM a running session's daemon. Metadata is kept — restart or \`pty rm\` i
Examples:
pty kill myserver`,

recover: `Usage: pty recover <name> --snapshot <metadata.json>

Ask the original supporting daemon to republish an externally unlinked socket
and registry without signaling or restarting its PTY child.

The snapshot must have been captured from the same selected PTY_ROOT before
the registry was unlinked and must advertise a recovery capability.

Example:
pty --root /state/pty recover myserver --snapshot ./myserver.json`,

rm: `Usage: pty rm <ref>

Remove an exited session's files (socket/pid/json/events) (alias: pty remove).
Expand Down Expand Up @@ -494,6 +524,7 @@ Lifecycle:
pty restart <ref> SIGTERM + respawn using stored metadata (prompts if running)
pty restart -y <ref> Same, no prompt
pty kill <ref> SIGTERM a running session's daemon
pty recover <name> --snapshot <file> Rebind a supporting live daemon after registry unlink
pty rm <ref> Remove an exited session's metadata (alias: pty remove)
pty gc Reconciliation pass: orphan-kill, abandoned-reap,
permanent-respawn, exited-sweep
Expand Down Expand Up @@ -1324,6 +1355,23 @@ async function main(): Promise<void> {
break;
}

case "recover": {
const recoverName = args[1];
const snapshotIndex = args.indexOf("--snapshot");
const snapshotPath = snapshotIndex >= 0 ? args[snapshotIndex + 1] : null;
if (!recoverName || !snapshotPath) {
console.error("Usage: pty recover <name> --snapshot <metadata.json>");
process.exit(1);
}
try {
await cmdRecover(recoverName, snapshotPath);
} catch (error) {
console.error(`pty recover: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
break;
}

case "gc": {
const gcArgs = args.slice(1);
const dryRun = gcArgs.some((a) => a === "--dry-run" || a === "-n");
Expand Down Expand Up @@ -2572,6 +2620,142 @@ async function cmdKill(name: string): Promise<void> {
}
}

async function cmdRecover(name: string, snapshotPath: string): Promise<void> {
validateName(name);
const root = path.resolve(getSessionDir());
const snapshot = readBoundedJson<SessionMetadata>(path.resolve(snapshotPath));
const capability = snapshot.recovery;
if (
capability?.protocol !== RECOVERY_PROTOCOL ||
typeof capability.secret !== "string" ||
typeof capability.metadataRevision !== "string" ||
capability.metadataRevision.length === 0 ||
!snapshot.generation ||
!snapshot.daemonPid
) {
throw new Error("snapshot does not advertise supported recovery");
}
assertPrivateRecoveryPaths(root, capability);
if (readProcessStartToken(snapshot.daemonPid) !== capability.processStartToken) {
throw new Error("daemon PID/start identity no longer matches the snapshot");
}
const lockIdentity = recoveryLockIdentity({
name,
daemonPid: snapshot.daemonPid,
processStartToken: capability.processStartToken,
rootDevice: capability.rootDevice,
rootInode: capability.rootInode,
recoveryDirDevice: capability.recoveryDirDevice,
recoveryDirInode: capability.recoveryDirInode,
});
const lockContents = recoveryLockContents(snapshot.daemonPid, lockIdentity);
if (!acquireRecoveryLock(name, lockContents)) {
throw new Error(`session "${name}" is being created by another process`);
}

const requestPath = recoveryRequestPath(root, name);
const resultPath = recoveryResultPath(root, name);
try {
const targets = [
getSocketPath(name),
getPidPath(name),
getMetadataPath(name),
];
if (targets.some((target) => fs.existsSync(target))) {
const current = readMetadata(name);
if (
!targets.every((target) => fs.existsSync(target)) ||
!current ||
current.daemonPid !== snapshot.daemonPid ||
current.generation !== snapshot.generation ||
current.recovery?.processStartToken !== capability.processStartToken ||
current.recovery.launchIdentity !== capability.launchIdentity
) {
throw new Error("recovery target is no longer empty");
}
const stats = await queryStats(name);
if (stats.daemon.pid !== snapshot.daemonPid) {
throw new Error("republished socket reached a different daemon");
}
console.log(`Session "${name}" registry recovered without restart.`);
return;
}
assertPrivateRecoveryPaths(root, capability);
try { fs.unlinkSync(resultPath); } catch {}
const nonce = randomBytes(16).toString("hex");
const request = signRecoveryRequest(capability.secret, {
protocol: RECOVERY_PROTOCOL,
name,
daemonPid: snapshot.daemonPid,
generation: snapshot.generation,
processStartToken: capability.processStartToken,
launchIdentity: capability.launchIdentity,
rootDevice: capability.rootDevice,
rootInode: capability.rootInode,
lockIdentity,
nonce,
metadata: snapshot,
});
assertPrivateRecoveryPaths(root, capability);
atomicWritePrivate(requestPath, request);

const deadline = Date.now() + 7000;
let nextNotify = Date.now() + 250;
let result: RecoveryResult | null = null;
while (Date.now() < deadline) {
try {
const candidate = readBoundedJson<RecoveryResult>(resultPath);
if (candidate.nonce === nonce) {
result = candidate;
break;
}
} catch {}
if (Date.now() >= nextNotify) {
assertPrivateRecoveryPaths(root, capability);
atomicWritePrivate(requestPath, request);
nextNotify = Date.now() + 250;
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
if (!result) throw new Error("supporting daemon did not answer recovery request");
if (!verifyRecoveryResult(capability.secret, result)) {
throw new Error("daemon recovery response authentication failed");
}
if (!result.ok) throw new Error(result.error ?? "daemon refused recovery");
if (
result.daemonPid !== snapshot.daemonPid ||
result.generation !== snapshot.generation ||
result.processStartToken !== capability.processStartToken ||
result.launchIdentity !== capability.launchIdentity
) {
throw new Error("daemon recovery response changed identity");
}

const current = readMetadata(name);
if (
!current ||
current.daemonPid !== snapshot.daemonPid ||
current.generation !== snapshot.generation ||
current.recovery?.processStartToken !== capability.processStartToken ||
current.recovery.launchIdentity !== capability.launchIdentity
) {
throw new Error("republished metadata changed identity");
}
const stats = await queryStats(name);
if (stats.daemon.pid !== snapshot.daemonPid) {
throw new Error("republished socket reached a different daemon");
}
console.log(`Session "${name}" registry recovered without restart.`);
} finally {
try {
assertPrivateRecoveryPaths(root, capability);
try { fs.unlinkSync(requestPath); } catch {}
try { fs.unlinkSync(resultPath); } catch {}
} catch {}
releaseRecoveryLock(name, lockContents);
}
}

function renameUsage(): void {
// Single source of truth: the same text `pty rename --help` prints, to stderr
// for the error paths.
Expand Down
Loading
Loading