diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f012a1..b06afac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,32 @@ ## Unreleased +### Generation-owned startup readiness + +- `spawnDaemon` and `pty run` can arm a daemon-owned startup lease. PTY + publishes one lifecycle tag containing the opaque generation, host boot + identity, and absolute monotonic deadline before detached spawn returns. + Caller exit cannot disarm or extend the deadline. Only a current-generation + live-daemon compare-and-set away from the exact starting value disarms it. + At expiry, complete frozen child containment records terminal cause + `deadline` before exact teardown. An unavailable or partial process-table + observation records `teardown-unavailable` instead, best-effort signals + observed exact identities plus the root process group, and preserves exit + evidence without claiming exact containment. Startup-lease generations retain + their terminal lifecycle and exit evidence through natural child/daemon exit + for generation-fenced consumption. +- The public client and machine CLI now expose typed accepted-socket ownership + for an exact held TCP 4-tuple and generation. Linux uses complete procfs + socket/descriptor tables; package production builds one universal Darwin + `libproc` helper for arm64 and x86_64, while source installs may fall back to + a best-effort local build without making a compiler an install requirement. + Inspection runs off the daemon event loop. + Results distinguish `Owned`, `NotOwned`, and `Unavailable`; incomplete, + scoped, or changing observations fail closed. +- `compareAndSetLifecycle` / `pty readiness cas` provide the matching + generation-fenced one-tag CAS so stale readiness or terminal completions + cannot overwrite a replacement generation. + ### `pty metadata patch` waits out the attach window - An attached child starts while `pty run` still holds the per-session diff --git a/completions/pty.bash b/completions/pty.bash index 26abf02..073b444 100644 --- a/completions/pty.bash +++ b/completions/pty.bash @@ -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 recover rm remove gc tag tag-multi emit rename metadata evidence 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 readiness evidence up down test remote-serve" if [[ ${COMP_CWORD} -eq 1 ]]; then if [[ "${cur}" == -* ]]; then @@ -24,7 +24,7 @@ _pty() { case "${COMP_WORDS[1]}" in run) - COMPREPLY=($(compgen -W "-d --detach -a --attach -e --ephemeral --id --name --no-display-name --tag --env --unset-env --cwd --isolate-env --force" -- "${cur}")) + COMPREPLY=($(compgen -W "-d --detach -a --attach -e --ephemeral --id --name --no-display-name --tag --env --unset-env --cwd --isolate-env --force --startup-timeout-ms --lifecycle-tag" -- "${cur}")) ;; attach|a) if [[ "${prev}" == "--attach-stream-fd-v1" ]]; then @@ -128,6 +128,30 @@ _pty() { metadata) COMPREPLY=($(compgen -W "--id" -- "${cur}")) ;; + readiness) + if [[ ${COMP_CWORD} -eq 2 ]]; then + COMPREPLY=($(compgen -W "ownership cas" -- "${cur}")) + return + fi + case "${COMP_WORDS[2]}" in + ownership) + if [[ "${prev}" == "--id" ]]; then + return + fi + if [[ "${cur}" == -* ]]; then + COMPREPLY=($(compgen -W "--id" -- "${cur}")) + fi + ;; + cas) + if [[ "${prev}" == "--id" ]]; then + return + fi + if [[ "${cur}" == -* ]]; then + COMPREPLY=($(compgen -W "--id" -- "${cur}")) + fi + ;; + esac + ;; evidence) if [[ ${COMP_CWORD} -eq 2 ]]; then COMPREPLY=($(compgen -W "snapshot remove" -- "${cur}")) diff --git a/completions/pty.fish b/completions/pty.fish index daf0979..28d490e 100644 --- a/completions/pty.fish +++ b/completions/pty.fish @@ -72,6 +72,7 @@ complete -c pty -n __pty_needs_command -a tag-multi -d 'Bulk tag ops across sess complete -c pty -n __pty_needs_command -a emit -d 'Publish a user.* event' complete -c pty -n __pty_needs_command -a rename -d 'Set / show / clear displayName' complete -c pty -n __pty_needs_command -a metadata -d 'Atomically patch presentation metadata by stable id' +complete -c pty -n __pty_needs_command -a readiness -d 'Exact socket ownership and lifecycle compare-and-set' complete -c pty -n __pty_needs_command -a evidence -d 'Read or remove exact-generation retained exit evidence' complete -c pty -n __pty_needs_command -a up -d 'Start sessions from pty.toml' complete -c pty -n __pty_needs_command -a down -d 'Stop sessions from pty.toml' @@ -89,6 +90,8 @@ complete -c pty -n '__pty_using_command run' -l unset-env -d 'Remove inherited e complete -c pty -n '__pty_using_command run' -l cwd -d 'Working directory' complete -c pty -n '__pty_using_command run' -l isolate-env -d 'Scrub env to a safe allow-list' complete -c pty -n '__pty_using_command run' -l force -d 'Create even from inside another pty' +complete -c pty -n '__pty_using_command run' -l startup-timeout-ms -x -d 'Daemon-owned startup deadline in milliseconds' +complete -c pty -n '__pty_using_command run' -l lifecycle-tag -x -d 'Generation-fenced lifecycle tag key' complete -c pty -n '__pty_using_command attach a' -l auto-restart -s r -d 'Auto-restart if the session is exited' complete -c pty -n '__pty_using_command attach a' -l no-restart -d 'Attach only; never prompt or restart an exited session' complete -c pty -n '__pty_using_command attach a' -l force -d 'Attach even from inside another pty' @@ -154,6 +157,10 @@ complete -c pty -n '__pty_using_command rename' -l clear -d 'Remove displayName' complete -c pty -n '__pty_using_command rename' -a '(__pty_sessions)' -d 'Session' complete -c pty -n '__pty_using_command metadata' -l id -d 'Exact stable session id' complete -c pty -n '__pty_using_command metadata' -x -a 'patch' -d 'Value' +complete -c pty -n '__pty_needs_subcommand readiness' -a ownership -d 'Prove accepted socket ownership for one held connection' +complete -c pty -n '__pty_using_subcommand readiness ownership' -l id -x -d 'Exact stable session id' +complete -c pty -n '__pty_needs_subcommand readiness' -a cas -d 'Compare-and-set one lifecycle tag value' +complete -c pty -n '__pty_using_subcommand readiness cas' -l id -x -d 'Exact stable session id' complete -c pty -n '__pty_needs_subcommand evidence' -a snapshot -d 'Read retained evidence for one exact generation' complete -c pty -n '__pty_using_subcommand evidence snapshot' -l id -x -d 'Exact stable session id' complete -c pty -n '__pty_needs_subcommand evidence' -a remove -d 'Remove only the matching terminal generation' diff --git a/completions/pty.zsh b/completions/pty.zsh index af8df55..eebd537 100644 --- a/completions/pty.zsh +++ b/completions/pty.zsh @@ -35,6 +35,7 @@ _pty() { 'emit:Publish a user.* event' 'rename:Set / show / clear displayName' 'metadata:Atomically patch presentation metadata by stable id' + 'readiness:Exact socket ownership and lifecycle compare-and-set' 'evidence:Read or remove exact-generation retained exit evidence' 'up:Start sessions from pty.toml' 'down:Stop sessions from pty.toml' @@ -68,7 +69,9 @@ _pty() { '--unset-env[Remove inherited environment key (repeatable)]' \ '--cwd[Working directory]' \ '--isolate-env[Scrub env to a safe allow-list]' \ - '--force[Create even from inside another pty]' + '--force[Create even from inside another pty]' \ + '--startup-timeout-ms[Daemon-owned startup deadline in milliseconds]:milliseconds:' \ + '--lifecycle-tag[Generation-fenced lifecycle tag key]:key:' ;; attach|a) _arguments \ @@ -186,6 +189,22 @@ _pty() { '--id[Exact stable session id]' \ '1:mode:(patch)' ;; + readiness) + if (( CURRENT == 3 )); then + _values 'operation' ownership cas + else + case ${words[2]} in + ownership) + _arguments \ + '--id[Exact stable session id]:id:' + ;; + cas) + _arguments \ + '--id[Exact stable session id]:id:' + ;; + esac + fi + ;; evidence) if (( CURRENT == 3 )); then _values 'operation' snapshot remove diff --git a/docs/client.md b/docs/client.md index 4030aa3..da96467 100644 --- a/docs/client.md +++ b/docs/client.md @@ -58,6 +58,63 @@ Invalid arguments and operational failures exit nonzero with a diagnostic on stderr. A reconciler should durably consume the snapshot before passing its opaque generation to `remove`; a mismatch must leave the replacement intact. +### Accepted-socket ownership and lifecycle CAS + +`queryAcceptedSocketOwnership(name, request)` asks the live PTY daemon to prove +that the accepted side of one exact held TCP connection belongs to the current +child process or one of its descendants: + +```typescript +const ownership = await queryAcceptedSocketOwnership("dev.web", { + expectedGeneration: generation, + connection: { + localAddress: socket.localAddress!, + localPort: socket.localPort!, + remoteAddress: socket.remoteAddress!, + remotePort: socket.remotePort!, + }, +}); +// { _tag: "Owned", pid } | { _tag: "NotOwned" } | +// { _tag: "Unavailable", reason } +``` + +The caller must keep that exact connection open while querying. PTY reverses +the client-observed 4-tuple to identify the server-side `ESTABLISHED` socket, +then binds it to the exact current child identity and descendant tree. Inspection +runs in a worker so procfs, `ps`, or `libproc` latency cannot stall the daemon's +startup deadline timer. Linux uses procfs. Published packages include PTY's +universal arm64/x86_64 Darwin helper; source installs may use a best-effort +local build, and helper absence never fails package installation. Unsupported +platforms, scoped IPv6 tuples, generation mismatch, process-tree or descriptor +churn, unreadable or partial descriptor/socket tables, and helper failure return +`Unavailable`; none are +treated as `NotOwned`. + +`compareAndSetLifecycle(name, request)` compares and replaces one exact tag +value through the live daemon: + +```typescript +const result = await compareAndSetLifecycle("dev.web", { + expectedGeneration: generation, + tag: "run.lifecycle", + expectedValue: startingValue, + value: JSON.stringify({ _tag: "ready", generation }), +}); +``` + +Results are tagged: `Changed`, `Unchanged`, `ValueMismatch`, `Missing`, +`GenerationMismatch`, `Busy`, `DeadlineExpired`, `Terminal`, or +`InvalidRequest`. A replacement generation cannot be changed by a stale +completion. + +The shell boundary reads the same typed request as one JSON object on stdin and +emits exactly one tagged JSON result: + +```sh +pty readiness ownership --id dev.web < ownership.json +pty readiness cas --id dev.web < transition.json +``` + ### `validateName(name: string): void` Throws if the name is invalid. Names must match `[a-zA-Z0-9._-]`, cannot be @@ -224,6 +281,10 @@ interface SpawnDaemonOptions { extraEnv?: Record; // explicit assignments applied last unsetEnv?: string[]; // inherited keys removed before assignments env?: Record; // exact child env; mutually exclusive with the above + startupLease?: { + timeoutMs: number; // positive startup budget, converted once + lifecycleTag: string; // exact tag carrying lifecycle JSON + }; } ``` @@ -232,6 +293,24 @@ interface SpawnDaemonOptions { `xterm-256color`; naming either key in `unsetEnv` does not suppress those invariants. An explicit `extraEnv.TERM` value is preserved. +When `startupLease` is present, the daemon atomically publishes the lifecycle +tag as `{\"_tag\":\"starting\",\"generation\":\"…\",\"bootId\":\"…\",\"deadlineMonotonicNs\":\"…\"}` +before `spawnDaemon()` resolves. The deadline is absolute in the named host-boot +monotonic domain. Initiating caller exit cannot disarm or extend it. Only a +successful live-daemon CAS away from that exact starting value disarms the +timer. At expiry PTY first freezes and proves complete child containment. A +complete observation records terminal cause `deadline` and terminates the exact +child tree. An unavailable or partial observation instead records terminal +cause `teardown-unavailable`, then best-effort signals every exact identity it +did observe plus the root process group; it never claims exact teardown when +containment is unknown. Both outcomes retain normal exit evidence. Child exit +before readiness similarly records terminal cause `exit`. Startup-lease +generations bypass ordinary daemon exit reaping so their terminal +lifecycle and exit evidence survive child/daemon shutdown for generation-fenced +consumption. A terminal value written by a successful current-generation +CAS is retained through the resulting child and daemon shutdown rather than +replaced by `exit`. + ### `resolveCommand(cmd: string): string` Resolve a command name to an absolute path (like `which`). Throws if not found. diff --git a/native/pty-socket-owner-darwin.c b/native/pty-socket-owner-darwin.c new file mode 100644 index 0000000..af5e0e6 --- /dev/null +++ b/native/pty-socket-owner-darwin.c @@ -0,0 +1,164 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* + * Private, release-sensitive Darwin backend for PTY accepted-socket ownership. + * + * argv: accepted-local-address accepted-local-port accepted-foreign-address + * accepted-foreign-port pid... + * stdout: "owned PID", "not-owned", or "unavailable". + * + * Every requested PID must yield a complete descriptor table. A permission + * failure, short libproc record, invalid argument, or allocation failure is + * Unavailable rather than a false NotOwned. + */ + +static int parse_port(const char *text, uint16_t *out) { + char *end = NULL; + errno = 0; + long value = strtol(text, &end, 10); + if (errno != 0 || end == text || *end != '\0' || value <= 0 || value > 65535) { + return 0; + } + *out = (uint16_t)value; + return 1; +} + +static int address_matches( + const struct in_sockinfo *info, + int family, + const void *local_address, + const void *foreign_address +) { + if (family == AF_INET) { + if ((info->insi_vflag & INI_IPV4) == 0) return 0; + return memcmp( + &info->insi_laddr.ina_46.i46a_addr4, + local_address, + sizeof(struct in_addr) + ) == 0 && memcmp( + &info->insi_faddr.ina_46.i46a_addr4, + foreign_address, + sizeof(struct in_addr) + ) == 0; + } + if (family == AF_INET6) { + if ((info->insi_vflag & INI_IPV6) == 0) return 0; + return memcmp( + &info->insi_laddr.ina_6, + local_address, + sizeof(struct in6_addr) + ) == 0 && memcmp( + &info->insi_faddr.ina_6, + foreign_address, + sizeof(struct in6_addr) + ) == 0; + } + return 0; +} + +int main(int argc, char **argv) { + if (argc < 6) { + puts("unavailable"); + return 0; + } + + int family = strchr(argv[1], ':') == NULL ? AF_INET : AF_INET6; + if ((strchr(argv[3], ':') == NULL ? AF_INET : AF_INET6) != family) { + puts("unavailable"); + return 0; + } + + struct in6_addr local6; + struct in6_addr foreign6; + struct in_addr local4; + struct in_addr foreign4; + void *local_address = family == AF_INET ? (void *)&local4 : (void *)&local6; + void *foreign_address = family == AF_INET ? (void *)&foreign4 : (void *)&foreign6; + if (inet_pton(family, argv[1], local_address) != 1 || + inet_pton(family, argv[3], foreign_address) != 1) { + puts("unavailable"); + return 0; + } + + uint16_t local_port; + uint16_t foreign_port; + if (!parse_port(argv[2], &local_port) || !parse_port(argv[4], &foreign_port)) { + puts("unavailable"); + return 0; + } + + for (int arg = 5; arg < argc; arg++) { + char *end = NULL; + errno = 0; + long parsed_pid = strtol(argv[arg], &end, 10); + if (errno != 0 || end == argv[arg] || *end != '\0' || parsed_pid <= 0 || parsed_pid > INT32_MAX) { + puts("unavailable"); + return 0; + } + int pid = (int)parsed_pid; + int bytes = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, NULL, 0); + if (bytes <= 0 || bytes % (int)sizeof(struct proc_fdinfo) != 0) { + puts("unavailable"); + return 0; + } + int capacity = bytes + 32 * (int)sizeof(struct proc_fdinfo); + struct proc_fdinfo *fds = calloc(1, (size_t)capacity); + if (fds == NULL) { + puts("unavailable"); + return 0; + } + int read_bytes = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, fds, capacity); + if (read_bytes <= 0 || read_bytes >= capacity || read_bytes % (int)sizeof(*fds) != 0) { + free(fds); + puts("unavailable"); + return 0; + } + + int count = read_bytes / (int)sizeof(*fds); + for (int index = 0; index < count; index++) { + if (fds[index].proc_fdtype != PROX_FDTYPE_SOCKET) continue; + struct socket_fdinfo socket_info; + int socket_bytes = proc_pidfdinfo( + pid, + fds[index].proc_fd, + PROC_PIDFDSOCKETINFO, + &socket_info, + (int)sizeof(socket_info) + ); + if (socket_bytes != (int)sizeof(socket_info)) { + free(fds); + puts("unavailable"); + return 0; + } + if (socket_info.psi.soi_kind != SOCKINFO_TCP || + socket_info.psi.soi_protocol != IPPROTO_TCP || + socket_info.psi.soi_family != family || + socket_info.psi.soi_proto.pri_tcp.tcpsi_state != TSI_S_ESTABLISHED) { + continue; + } + const struct in_sockinfo *info = &socket_info.psi.soi_proto.pri_tcp.tcpsi_ini; + if (ntohs((uint16_t)info->insi_lport) != local_port || + ntohs((uint16_t)info->insi_fport) != foreign_port) { + continue; + } + if (address_matches(info, family, local_address, foreign_address)) { + free(fds); + printf("owned %d\n", pid); + return 0; + } + } + free(fds); + } + + puts("not-owned"); + return 0; +} diff --git a/package.json b/package.json index f8e0988..54162e5 100644 --- a/package.json +++ b/package.json @@ -21,9 +21,11 @@ "files": [ "bin/", "dist/", + "native/", "completions/", "docs/", "scripts/postinstall.js", + "scripts/build-darwin-socket-owner.js", "README.md", "LICENSE" ], @@ -61,6 +63,8 @@ "build": "tsc -p tsconfig.build.json", "prepublishOnly": "npm run build", "postinstall": "node scripts/postinstall.js", + "prepack": "node scripts/build-darwin-socket-owner.js", + "postpack": "node scripts/build-darwin-socket-owner.js --clean", "prepare": "git config core.hooksPath githooks 2>/dev/null || true; sh scripts/update-nix-hash.sh 2>/dev/null || true", "install-completions": "sh scripts/install-completions.sh", "typecheck": "tsc", diff --git a/scripts/build-darwin-socket-owner.js b/scripts/build-darwin-socket-owner.js new file mode 100644 index 0000000..7fbd359 --- /dev/null +++ b/scripts/build-darwin-socket-owner.js @@ -0,0 +1,54 @@ +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); + +/** Build the universal helper that is included by npm pack. Package production + * fails closed off Darwin or when either architecture cannot be compiled; an + * install never needs a compiler to use the resulting package. */ +export function buildDarwinSocketOwner({ + platform = process.platform, + root = path.resolve(scriptDir, ".."), + run = spawnSync, +} = {}) { + if (platform !== "darwin") { + throw new Error("Darwin socket-ownership packages must be produced on Darwin"); + } + const source = path.join(root, "native", "pty-socket-owner-darwin.c"); + const output = path.join(root, "bin", "pty-socket-owner-darwin"); + const temporary = `${output}.tmp-${process.pid}`; + const result = run("cc", [ + "-O2", + "-Wall", + "-Wextra", + "-arch", + "arm64", + "-arch", + "x86_64", + source, + "-o", + temporary, + ], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.error || result.status !== 0 || !fs.existsSync(temporary)) { + try { fs.rmSync(temporary, { force: true }); } catch {} + const detail = result.error?.message ?? result.stderr?.trim() ?? `cc exited ${result.status}`; + throw new Error(`Failed to build universal Darwin socket-ownership helper: ${detail}`); + } + fs.renameSync(temporary, output); + fs.chmodSync(output, 0o755); + return output; +} + +export function cleanDarwinSocketOwner(root = path.resolve(scriptDir, "..")) { + fs.rmSync(path.join(root, "bin", "pty-socket-owner-darwin"), { force: true }); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + if (process.argv.includes("--clean")) cleanDarwinSocketOwner(); + else buildDarwinSocketOwner(); +} diff --git a/scripts/postinstall.js b/scripts/postinstall.js index 5f27e96..2667208 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -8,17 +8,18 @@ // The root-cause fix belongs in microsoft/node-pty; once that ships, this // script can be removed entirely. +import { spawnSync } from "node:child_process"; import * as fs from "node:fs"; import * as path from "node:path"; import { createRequire } from "node:module"; -import { pathToFileURL } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; if (process.platform === "win32") process.exit(0); // Resolve from this package's root (package.json sibling), not from the // script file, so that node-pty is discoverable regardless of whether // node_modules is nested (npm/yarn/bun) or a sibling link (pnpm GVS). -const pkgDir = path.dirname(new URL(import.meta.url).pathname); +const pkgDir = path.dirname(fileURLToPath(import.meta.url)); const requireFromPkg = createRequire( pathToFileURL(path.join(pkgDir, "..", "package.json")), ); @@ -28,19 +29,71 @@ try { nodePtyDir = path.dirname(requireFromPkg.resolve("node-pty/package.json")); } catch { console.warn("[@compoundingtech/pty] node-pty not found; skipping spawn-helper chmod"); - process.exit(0); } -const helper = path.join( - nodePtyDir, - "prebuilds", - `${process.platform}-${process.arch}`, - "spawn-helper", -); +if (nodePtyDir) { + const helper = path.join( + nodePtyDir, + "prebuilds", + `${process.platform}-${process.arch}`, + "spawn-helper", + ); + try { + fs.chmodSync(helper, 0o755); + } catch { + // Acceptable when there's no prebuild for this arch and node-pty was + // built from source — node-gyp produces the binary executable already. + } +} -try { - fs.chmodSync(helper, 0o755); -} catch { - // Acceptable when there's no prebuild for this arch and node-pty was - // built from source — node-gyp produces the binary executable already. +export function installDarwinSocketOwner({ + platform = process.platform, + root = path.resolve(pkgDir, ".."), + run = spawnSync, + warn = console.warn, +} = {}) { + if (platform !== "darwin") return "not-applicable"; + const source = path.join(root, "native", "pty-socket-owner-darwin.c"); + const output = path.join(root, "bin", `pty-socket-owner-darwin-${process.arch}`); + const packaged = [ + output, + path.join(root, "bin", "pty-socket-owner-darwin"), + ].find((candidate) => fs.existsSync(candidate)); + if (packaged) { + try { + fs.chmodSync(packaged, 0o755); + return "available"; + } catch (error) { + warn(`[@compoundingtech/pty] Darwin socket-ownership helper unavailable: ${ + error instanceof Error ? error.message : String(error) + }`); + return "unavailable"; + } + } + const temporary = `${output}.tmp-${process.pid}`; + const result = run("cc", ["-O2", "-Wall", "-Wextra", source, "-o", temporary], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (!result.error && result.status === 0) { + try { + fs.renameSync(temporary, output); + fs.chmodSync(output, 0o755); + return "available"; + } catch (error) { + try { fs.rmSync(temporary, { force: true }); } catch {} + warn(`[@compoundingtech/pty] Darwin socket-ownership helper unavailable: ${ + error instanceof Error ? error.message : String(error) + }`); + return "unavailable"; + } + } + try { fs.rmSync(temporary, { force: true }); } catch {} + const detail = result.error?.message ?? result.stderr?.trim() ?? "unknown compiler failure"; + warn(`[@compoundingtech/pty] Darwin socket-ownership helper unavailable: ${detail}`); + return "unavailable"; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + installDarwinSocketOwner(); } diff --git a/src/cli.ts b/src/cli.ts index ecd3c50..2e18a30 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,8 +4,10 @@ import * as path from "node:path"; import * as readline from "node:readline/promises"; import { spawnSync, execFileSync } from "node:child_process"; import { randomBytes } from "node:crypto"; -import { attach, peek, send, queryStats, resolveSeqDelayMs, validateAttachStreamFdV1, type StatsResult, - isSessionAlive, +import { + attach, peek, send, queryStats, resolveSeqDelayMs, validateAttachStreamFdV1, + isSessionAlive, queryAcceptedSocketOwnership, compareAndSetLifecycle, + type StatsResult, } from "./client.ts"; import { printVersion } from "./version.ts"; import { parseSeqValue } from "./keys.ts"; @@ -79,6 +81,10 @@ import { verifyRecoveryResult, type RecoveryResult, } from "./recovery.ts"; +import { + decodeAcceptedSocketOwnershipRequest, + decodeLifecycleCompareAndSetRequest, +} from "./protocol.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` @@ -135,6 +141,10 @@ Flags: --tag keep=true Exempt from reaping: keep metadata/logs after exit --cwd Working directory for the command --isolate-env Scrub the child env to a safe allow-list (for remote-reachable sessions) + --startup-timeout-ms + Arm a daemon-owned hard startup lease + --lifecycle-tag + Tag carrying the generation-fenced lifecycle value --force Create even from inside another pty session (bypass the nesting guard) Examples: @@ -434,6 +444,22 @@ Examples: printf '%s' '{"displayName":"Worker","tags":{"role":"worker"}}' | pty metadata patch --id a1b2c3d4 printf '%s' '{"displayName":null,"tags":{"temporary":null}}' | pty metadata patch --id a1b2c3d4`, + readiness: `Usage: pty readiness ownership --id + pty readiness cas --id + +Machine-only readiness control for one exact live daemon generation. Both +operations read one JSON object from stdin and emit one tagged JSON result. + +ownership input: + {"expectedGeneration":"","connection":{"localAddress":"127.0.0.1","localPort":51000,"remoteAddress":"127.0.0.1","remotePort":3000}} + +cas input: + {"expectedGeneration":"","tag":"run.lifecycle","expectedValue":"","value":""} + +Examples: + pty readiness ownership --id a1b2c3d4 < ownership.json + pty readiness cas --id a1b2c3d4 < transition.json`, + evidence: `Usage: pty evidence snapshot --id pty evidence remove --id --expected-generation @@ -472,6 +498,23 @@ Examples: pty test -t "peek"`, }; +const READINESS_LEAF_HELP = { + ownership: `Usage: pty readiness ownership --id + +Read one exact-generation held TCP 4-tuple from stdin and emit one tagged +Owned, NotOwned, or Unavailable JSON result. + +Example: + pty readiness ownership --id a1b2c3d4 < ownership.json`, + cas: `Usage: pty readiness cas --id + +Read one exact-generation, exact-value lifecycle tag compare-and-set from stdin +and emit one tagged JSON result. + +Example: + pty readiness cas --id a1b2c3d4 < transition.json`, +} as const; + const EVIDENCE_LEAF_HELP = { snapshot: `Usage: pty evidence snapshot --id @@ -520,6 +563,8 @@ Create sessions: pty run --cwd /path -- Run in a specific directory pty run --isolate-env -- Scrub the child env to a safe allow-list (intended for remote-reachable sessions) + pty run --startup-timeout-ms N --lifecycle-tag key ... + Arm a daemon-owned hard startup lease pty run --force -- Create even from inside another pty session (nested) Attach & interact: @@ -562,6 +607,8 @@ Observe: Modify: pty metadata patch --id Atomically merge displayName/tags from JSON stdin pty evidence snapshot --id Read exact-generation retained exit evidence as JSON + pty readiness ownership --id Prove exact held TCP connection ownership + pty readiness cas --id Generation-fenced lifecycle tag CAS pty rename