Implement TTY/PTY support for interactive processes #58

Open
opened 2026-03-13 12:21:53 +00:00 by timur · 0 comments
Owner

Context

Issue #26 requests TTY/PTY support for zinit — the ability to spawn interactive processes (shells, TUI apps) under supervision and attach to them via CLI or web UI. A previous implementation on development_tty (now archived) cannot be merged due to massive architectural divergence (81 commits behind, every file rewritten). This is a clean reimplementation on the current job-based architecture.

Design Decisions

  • PTY library: Use nix::pty::openpty (already in workspace, just add "pty" feature) instead of portable-pty (10+ new transitive deps)
  • Attach by job ID at protocol level (services can have multiple jobs; CLI provides convenience lookup by service name)
  • PTY handles stored in-memory in a new PtyRegistry (file descriptors aren't serializable to SQLite)
  • WebSocket protocol: binary frames for raw I/O, text frames for JSON control ({"resize":{"cols":N,"rows":M}})
  • New dependencies: only tokio-tungstenite (for UI proxy + CLI client); axum's ws feature handles the server side

Phase 1: PTY Infrastructure (zinit_lib + zinit_server core)

1.1 Add tty: bool to ActionSpec

  • crates/zinit_lib/src/db/actions/model.rs — Add #[serde(default)] pub tty: bool to ActionSpec
  • crates/zinit_server/openrpc.json — Add "tty": { "type": "boolean", "default": false } to ActionSpec schema

1.2 Enable nix pty feature

  • Cargo.toml (workspace) — Add "pty" to nix features list

1.3 Create PtyRegistry

  • NEW: crates/zinit_server/src/supervisor/pty.rs
    • PtyHandle struct: master_fd: OwnedFd, writer: Arc<Mutex<std::fs::File>>, broadcast_tx: broadcast::Sender<Vec<u8>>
    • PtyRegistry struct: Mutex<HashMap<u32, Arc<PtyHandle>>> with insert/get/remove/resize methods
    • resize() uses libc::ioctl(TIOCSWINSZ)
  • crates/zinit_server/src/supervisor/mod.rs — Add pub mod pty;, add pty_registry: Arc<PtyRegistry> to Supervisor, pass to run_job

1.4 PTY-aware executor

  • crates/zinit_server/src/supervisor/executor.rs — Key changes:
    • run_job(db, job_id, pty_registry) — accept registry
    • When job.spec.tty == true, branch to PTY path:
      1. nix::pty::openpty(None, None) for master/slave fd pair
      2. Use std::process::Command (need pre_exec) with:
        • pre_exec: setsid(), ioctl(TIOCSCTTY), dup2 slave to 0/1/2, close master
      3. Auto-set TERM=xterm-256color if not in env
      4. Register PtyHandle in registry
      5. Spawn tokio task to read master fd → broadcast + DB logs (line-buffer for logs, raw bytes for broadcast)
      6. Await child exit via spawn_blocking(waitpid)
      7. Remove from registry on exit
    • Regular path stays unchanged (just refactored into helper)

Phase 2: WebSocket Endpoint on zinit_server

2.1 Enable axum ws feature

  • Cargo.toml (workspace) — Add "ws" to axum features

2.2 Add WebSocket route + handler

  • crates/zinit_server/src/web.rs:
    • Add pty_registry: Arc<PtyRegistry> to WebState
    • Add route: .route("/api/jobs/{id}/pty", get(pty_ws_handler))
    • pty_ws_handler: lookup PTY in registry, return 404 if not found, upgrade to WebSocket
    • handle_pty_session: tokio::select! between:
      • broadcast_rx.recv() → send binary frame to browser
      • ws_rx.next() → binary data goes to PTY writer, text data parsed for resize JSON
  • crates/zinit_server/src/main.rs — Pass pty_registry clone to run_web_server

Phase 3: UI (zinit_ui proxy + xterm.js)

3.1 Add WebSocket proxy

  • Cargo.toml (workspace) — Add tokio-tungstenite = "0.26"
  • crates/zinit_ui/Cargo.toml — Add tokio-tungstenite, axum ws feature
  • crates/zinit_ui/src/routes.rs — Add /api/jobs/{id}/pty route that:
    1. Accepts browser WebSocket upgrade
    2. Connects to zinit_server socket as WebSocket client via tokio_tungstenite::client_async
    3. Bidirectional forward loop

3.2 Add xterm.js terminal tab

  • crates/zinit_ui/templates/index.html (or appropriate template):
    • Add xterm.js 5.3.0 + fit addon CDN imports
    • Add "Terminal" tab in job detail view
    • JS: attachToJob(id) opens WebSocket, pipes binary data to/from Terminal
    • JS: resize handler sends {"resize":{...}} on window resize

Phase 4: CLI Attach Command

4.1 Add Attach command

  • crates/zinit/Cargo.toml — Add tokio-tungstenite dependency
  • crates/zinit/src/cli/args.rs — Add Attach { id: Option<u32>, name: Option<String>, detach_key: String } to Commands enum
  • NEW: crates/zinit/src/cli/attach.rs:
    • If --name given, RPC lookup to find the running TTY job for that service
    • Connect to zinit_server Unix socket, WebSocket handshake via tokio_tungstenite::client_async
    • crossterm::terminal::enable_raw_mode() with Drop guard for cleanup
    • run_attach_loop: tokio::select! between crossterm::event::EventStream (keyboard → WS binary) and WS recv (binary → stdout)
    • key_event_to_bytes() helper for terminal escape sequences (reuse pattern from archived branch)
    • Resize events sent as JSON text frames
  • crates/zinit/src/main.rs — Wire Commands::Attach to attach::cmd_attach

Phase 5: Cleanup + Integration

  • PTY cleanup in graceful_stop_job and cancel_job — remove from registry after process death
  • Integration test: create job with tty: true, verify spawn, attach via WS, send/recv, detach
  • ActionSpec serde test for tty field round-trip

Files Changed Summary

File Type What
Cargo.toml (workspace) Modify nix pty feature, axum ws feature, add tokio-tungstenite
crates/zinit_lib/src/db/actions/model.rs Modify tty: bool on ActionSpec
crates/zinit_server/openrpc.json Modify tty in ActionSpec schema
crates/zinit_server/src/supervisor/pty.rs New PtyRegistry + PtyHandle
crates/zinit_server/src/supervisor/mod.rs Modify Add pty module, registry on Supervisor
crates/zinit_server/src/supervisor/executor.rs Modify PTY spawn path
crates/zinit_server/src/web.rs Modify WebSocket route + handler
crates/zinit_server/src/main.rs Modify Pass pty_registry
crates/zinit_ui/Cargo.toml Modify Add deps
crates/zinit_ui/src/routes.rs Modify WS proxy route
crates/zinit_ui/templates/ Modify xterm.js terminal tab
crates/zinit/Cargo.toml Modify Add deps
crates/zinit/src/cli/args.rs Modify Attach command
crates/zinit/src/cli/attach.rs New Attach implementation
crates/zinit/src/main.rs Modify Wire Attach command

Verification

  1. cargo build --workspace — compiles
  2. Create a test service TOML with tty = true and exec = "/bin/bash"
  3. Start zinit_server, create a job via RPC with tty enabled
  4. zinit attach --id <job_id> — verify interactive shell works, keystrokes flow, output displays
  5. Open UI terminal tab — verify xterm.js connects and works
  6. Test resize: resize terminal window, verify child sees new dimensions
  7. Test cleanup: kill the job, verify WebSocket closes gracefully and registry is cleaned
  8. Run cargo test --workspace
## Context Issue #26 requests TTY/PTY support for zinit — the ability to spawn interactive processes (shells, TUI apps) under supervision and attach to them via CLI or web UI. A previous implementation on `development_tty` (now archived) cannot be merged due to massive architectural divergence (81 commits behind, every file rewritten). This is a clean reimplementation on the current job-based architecture. ## Design Decisions - **PTY library**: Use `nix::pty::openpty` (already in workspace, just add `"pty"` feature) instead of `portable-pty` (10+ new transitive deps) - **Attach by job ID** at protocol level (services can have multiple jobs; CLI provides convenience lookup by service name) - **PTY handles stored in-memory** in a new `PtyRegistry` (file descriptors aren't serializable to SQLite) - **WebSocket protocol**: binary frames for raw I/O, text frames for JSON control (`{"resize":{"cols":N,"rows":M}}`) - **New dependencies**: only `tokio-tungstenite` (for UI proxy + CLI client); axum's `ws` feature handles the server side --- ## Phase 1: PTY Infrastructure (zinit_lib + zinit_server core) ### 1.1 Add `tty: bool` to ActionSpec - **`crates/zinit_lib/src/db/actions/model.rs`** — Add `#[serde(default)] pub tty: bool` to `ActionSpec` - **`crates/zinit_server/openrpc.json`** — Add `"tty": { "type": "boolean", "default": false }` to ActionSpec schema ### 1.2 Enable nix pty feature - **`Cargo.toml`** (workspace) — Add `"pty"` to nix features list ### 1.3 Create PtyRegistry - **NEW: `crates/zinit_server/src/supervisor/pty.rs`** - `PtyHandle` struct: `master_fd: OwnedFd`, `writer: Arc<Mutex<std::fs::File>>`, `broadcast_tx: broadcast::Sender<Vec<u8>>` - `PtyRegistry` struct: `Mutex<HashMap<u32, Arc<PtyHandle>>>` with `insert/get/remove/resize` methods - `resize()` uses `libc::ioctl(TIOCSWINSZ)` - **`crates/zinit_server/src/supervisor/mod.rs`** — Add `pub mod pty;`, add `pty_registry: Arc<PtyRegistry>` to `Supervisor`, pass to `run_job` ### 1.4 PTY-aware executor - **`crates/zinit_server/src/supervisor/executor.rs`** — Key changes: - `run_job(db, job_id, pty_registry)` — accept registry - When `job.spec.tty == true`, branch to PTY path: 1. `nix::pty::openpty(None, None)` for master/slave fd pair 2. Use `std::process::Command` (need `pre_exec`) with: - `pre_exec`: `setsid()`, `ioctl(TIOCSCTTY)`, `dup2` slave to 0/1/2, close master 3. Auto-set `TERM=xterm-256color` if not in env 4. Register `PtyHandle` in registry 5. Spawn tokio task to read master fd → broadcast + DB logs (line-buffer for logs, raw bytes for broadcast) 6. Await child exit via `spawn_blocking(waitpid)` 7. Remove from registry on exit - Regular path stays unchanged (just refactored into helper) --- ## Phase 2: WebSocket Endpoint on zinit_server ### 2.1 Enable axum ws feature - **`Cargo.toml`** (workspace) — Add `"ws"` to axum features ### 2.2 Add WebSocket route + handler - **`crates/zinit_server/src/web.rs`**: - Add `pty_registry: Arc<PtyRegistry>` to `WebState` - Add route: `.route("/api/jobs/{id}/pty", get(pty_ws_handler))` - `pty_ws_handler`: lookup PTY in registry, return 404 if not found, upgrade to WebSocket - `handle_pty_session`: `tokio::select!` between: - `broadcast_rx.recv()` → send binary frame to browser - `ws_rx.next()` → binary data goes to PTY writer, text data parsed for resize JSON - **`crates/zinit_server/src/main.rs`** — Pass `pty_registry` clone to `run_web_server` --- ## Phase 3: UI (zinit_ui proxy + xterm.js) ### 3.1 Add WebSocket proxy - **`Cargo.toml`** (workspace) — Add `tokio-tungstenite = "0.26"` - **`crates/zinit_ui/Cargo.toml`** — Add `tokio-tungstenite`, axum `ws` feature - **`crates/zinit_ui/src/routes.rs`** — Add `/api/jobs/{id}/pty` route that: 1. Accepts browser WebSocket upgrade 2. Connects to zinit_server socket as WebSocket client via `tokio_tungstenite::client_async` 3. Bidirectional forward loop ### 3.2 Add xterm.js terminal tab - **`crates/zinit_ui/templates/index.html`** (or appropriate template): - Add xterm.js 5.3.0 + fit addon CDN imports - Add "Terminal" tab in job detail view - JS: `attachToJob(id)` opens WebSocket, pipes binary data to/from Terminal - JS: resize handler sends `{"resize":{...}}` on window resize --- ## Phase 4: CLI Attach Command ### 4.1 Add Attach command - **`crates/zinit/Cargo.toml`** — Add `tokio-tungstenite` dependency - **`crates/zinit/src/cli/args.rs`** — Add `Attach { id: Option<u32>, name: Option<String>, detach_key: String }` to Commands enum - **NEW: `crates/zinit/src/cli/attach.rs`**: - If `--name` given, RPC lookup to find the running TTY job for that service - Connect to zinit_server Unix socket, WebSocket handshake via `tokio_tungstenite::client_async` - `crossterm::terminal::enable_raw_mode()` with Drop guard for cleanup - `run_attach_loop`: `tokio::select!` between `crossterm::event::EventStream` (keyboard → WS binary) and WS recv (binary → stdout) - `key_event_to_bytes()` helper for terminal escape sequences (reuse pattern from archived branch) - Resize events sent as JSON text frames - **`crates/zinit/src/main.rs`** — Wire `Commands::Attach` to `attach::cmd_attach` --- ## Phase 5: Cleanup + Integration - PTY cleanup in `graceful_stop_job` and `cancel_job` — remove from registry after process death - Integration test: create job with `tty: true`, verify spawn, attach via WS, send/recv, detach - ActionSpec serde test for `tty` field round-trip --- ## Files Changed Summary | File | Type | What | |------|------|------| | `Cargo.toml` (workspace) | Modify | nix `pty` feature, axum `ws` feature, add `tokio-tungstenite` | | `crates/zinit_lib/src/db/actions/model.rs` | Modify | `tty: bool` on ActionSpec | | `crates/zinit_server/openrpc.json` | Modify | `tty` in ActionSpec schema | | `crates/zinit_server/src/supervisor/pty.rs` | **New** | PtyRegistry + PtyHandle | | `crates/zinit_server/src/supervisor/mod.rs` | Modify | Add pty module, registry on Supervisor | | `crates/zinit_server/src/supervisor/executor.rs` | Modify | PTY spawn path | | `crates/zinit_server/src/web.rs` | Modify | WebSocket route + handler | | `crates/zinit_server/src/main.rs` | Modify | Pass pty_registry | | `crates/zinit_ui/Cargo.toml` | Modify | Add deps | | `crates/zinit_ui/src/routes.rs` | Modify | WS proxy route | | `crates/zinit_ui/templates/` | Modify | xterm.js terminal tab | | `crates/zinit/Cargo.toml` | Modify | Add deps | | `crates/zinit/src/cli/args.rs` | Modify | Attach command | | `crates/zinit/src/cli/attach.rs` | **New** | Attach implementation | | `crates/zinit/src/main.rs` | Modify | Wire Attach command | --- ## Verification 1. `cargo build --workspace` — compiles 2. Create a test service TOML with `tty = true` and `exec = "/bin/bash"` 3. Start zinit_server, create a job via RPC with tty enabled 4. `zinit attach --id <job_id>` — verify interactive shell works, keystrokes flow, output displays 5. Open UI terminal tab — verify xterm.js connects and works 6. Test resize: resize terminal window, verify child sees new dimensions 7. Test cleanup: kill the job, verify WebSocket closes gracefully and registry is cleaned 8. Run `cargo test --workspace`
Commenting is not possible because the repository is archived.
No labels
No milestone
No project
No assignees
1 participant
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
geomind_code/zinit_archive2#58
No description provided.