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
40 changes: 39 additions & 1 deletion packages/process-daemon/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,44 @@ when it changes; an OSC change hidden behind a user title publishes nothing.

`list` returns terminals in creation order.

## Pipe Processes

`Create` takes `mode: "pty" | "pipe"` (default `"pty"`). A pipe process runs
any command without a terminal; it is Unix-only for now, and only daemons that
list the `pipe` feature accept it. Older daemons ignore `mode` and would start
a PTY instead, so clients check `features` first.

- `title`, `cols` and `rows` are optional for pipe processes; the title
defaults to the program's file name and the size is reported as `0x0`.
- The child gets its own process group, an empty signal mask with default
actions (the daemon blocks SIGTERM and SIGINT, and std keeps the mask across
exec), stdin on `/dev/null`, and one pipe for
both stdout and stderr, so their bytes stay in order. No `TERM` or
`COLORTERM` is added, and nothing parses the output: there is no emulator,
no terminal replies, no OSC title and no foreground process.
- Output goes through the same raw replay and subscriptions as a PTY, with the
same absolute offsets. A pipe has no controller or observer distinction for
writing, since it accepts no input.
- `write`, `resize`, `control`, `input`, `snapshot`, `read_rows` and `rename`
fail for pipe processes. `replay`, `subscribe` and `terminate`
work.
- Exit is published once both the exit status and the end of output are
known. A descendant that keeps the pipe open (`cmd &`) delays that by at
most one second after the child exits; output after that is not read.
- `Exited` (the lifecycle and the subscription response) carries `signal`,
the signal's name such as `"SIGTERM"`, when a pipe process was killed by
one; `exit_code` is then null. The field is left out when there is no
signal, so PTY exits serialize as before.
- An exited pipe process stays listed, as a zombie, until `terminate`. The
zombie keeps its PID and process group ID reserved, so `terminate` can kill
any descendants left in the group without reaching an unrelated process.
`terminate` sends SIGKILL to the group and to the child, reaps it, and never
waits on a descendant that left the group and still holds the pipe.

`List` takes `mode` too. Without it, `list` returns PTYs only, so servers that
predate pipe processes never see them. Clients leave `mode` out of the request
when it is unset: older daemons reject a `list` with any extra field.

## Failure Boundary

Losing the owning server connection stops `opencode-pty` and its terminals.
Expand All @@ -131,7 +169,7 @@ subscriptions, title updates, rename, controller takeover, terminate, and destru
shutdown, ownership acquisition, and restart handoff preparation.

`Pong` and the registration carry `features`, the daemon's additive
capabilities (currently `["rename"]`). Additive changes, such as a new request
capabilities (currently `["rename", "pipe"]` on Unix and `["rename"]` on Windows). Additive changes, such as a new request
or field older clients can ignore, extend `features` and keep the protocol
version; clients check `features` before sending a new request, since a daemon
that cannot parse one drops the connection. Only breaking changes bump
Expand Down
16 changes: 12 additions & 4 deletions packages/process-daemon/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use crate::protocol::{
AttachmentRole, Envelope, SubscriptionEvent, read_frame, read_subscription_event, write_frame,
};
use crate::protocol::{PROTOCOL_VERSION, Request, Response};
use crate::service::{CreateTerminal, TerminalId, TerminalInfo, TerminalRows};
use crate::service::{CreateTerminal, ProcessMode, TerminalId, TerminalInfo, TerminalRows};
#[cfg(unix)]
use crate::transport::Connection;

Expand Down Expand Up @@ -165,14 +165,15 @@ impl TerminalClient {
env: request.env,
cols: request.cols,
rows: request.rows,
mode: request.mode,
})? {
Response::Created { terminal } => Ok(terminal),
response => unexpected(response),
}
}

pub fn list(&self) -> Result<Vec<TerminalInfo>> {
match self.request(Request::List)? {
match self.request(Request::List { mode: None })? {
Response::Terminals { terminals } => Ok(terminals),
response => unexpected(response),
}
Expand Down Expand Up @@ -524,8 +525,13 @@ fn watch(directory: &Path, id: TerminalId) -> Result<()> {
io::stdout().flush()?;
}
SubscriptionEvent::Response(response) => match *response {
Response::Exited { exit_code, .. } => {
eprintln!("\nterminal exited: {exit_code:?}");
Response::Exited {
exit_code, signal, ..
} => {
match signal {
Some(signal) => eprintln!("\nterminal killed by {signal}"),
None => eprintln!("\nterminal exited: {exit_code:?}"),
}
return Ok(());
}
Response::Resized { cols, rows, .. } => {
Expand Down Expand Up @@ -588,6 +594,7 @@ fn play(directory: &Path) -> Result<()> {
cwd: env::current_dir()?,
cols: 100,
rows: 30,
mode: ProcessMode::Pty,
}
};
let info = client.create(request)?;
Expand Down Expand Up @@ -665,6 +672,7 @@ fn play(directory: &Path) -> Result<()> {
cwd: env::current_dir()?,
cols: 100,
rows: 30,
mode: ProcessMode::Pty,
})?;
active = Some(info.id);
thread::sleep(Duration::from_millis(200));
Expand Down
8 changes: 6 additions & 2 deletions packages/process-daemon/src/daemon/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -503,9 +503,11 @@ fn stream_subscription(
},
StreamEvent::Exited {
exit_code,
signal,
final_offset,
} => Response::Exited {
exit_code,
signal,
final_offset,
},
StreamEvent::ControllerChanged {
Expand Down Expand Up @@ -556,6 +558,7 @@ fn dispatch(
env,
cols,
rows,
mode,
} => Response::Created {
terminal: service.create(CreateTerminal {
program,
Expand All @@ -566,10 +569,11 @@ fn dispatch(
env,
cols,
rows,
mode,
})?,
},
Request::List => Response::Terminals {
terminals: service.list()?,
Request::List { mode } => Response::Terminals {
terminals: service.list_mode(mode.unwrap_or_default())?,
},
Request::Write {
id,
Expand Down
103 changes: 101 additions & 2 deletions packages/process-daemon/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@ use std::path::PathBuf;
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};

use crate::service::{TerminalId, TerminalInfo};
use crate::service::{ProcessMode, TerminalId, TerminalInfo};

pub const PROTOCOL_VERSION: u32 = 7;
/// Additive capabilities of this daemon. Breaking changes bump `PROTOCOL_VERSION`.
/// `pipe` (non-PTY processes) is Unix-only for now.
#[cfg(unix)]
pub const FEATURES: &[&str] = &["rename", "pipe"];
#[cfg(not(unix))]
pub const FEATURES: &[&str] = &["rename"];

pub fn features() -> Vec<String> {
Expand Down Expand Up @@ -38,17 +42,31 @@ pub enum Request {
ticket: Option<String>,
},
PrepareHandoff,
/// Old daemons ignore `mode` and would start a PTY, so clients send
/// `"pipe"` only to a daemon that lists the `pipe` feature.
Create {
program: String,
args: Vec<String>,
cwd: PathBuf,
#[serde(default)]
title: String,
group_id: String,
env: std::collections::HashMap<String, String>,
/// Terminal size; required for `pty`, ignored for `pipe`.
#[serde(default)]
cols: u16,
#[serde(default)]
rows: u16,
#[serde(default, skip_serializing_if = "ProcessMode::is_pty")]
mode: ProcessMode,
},
/// Without `mode`, lists PTYs only, so older servers never see pipe processes.
/// `mode` is left out of the JSON when unset: older daemons reject a `list`
/// with any extra key.
List {
#[serde(default, skip_serializing_if = "Option::is_none")]
mode: Option<ProcessMode>,
},
List,
Write {
id: TerminalId,
attachment_id: Option<String>,
Expand Down Expand Up @@ -161,6 +179,9 @@ pub enum Response {
},
Exited {
exit_code: Option<u32>,
/// Signal name such as `SIGTERM` when a pipe process was killed by one.
#[serde(default, skip_serializing_if = "Option::is_none")]
signal: Option<String>,
final_offset: u64,
},
ControllerChanged {
Expand Down Expand Up @@ -335,4 +356,82 @@ mod tests {
serde_json::Value::Null
);
}

#[test]
fn pipe_fields_keep_the_wire_compatible_with_older_peers() {
use crate::service::{ProcessMode, TerminalLifecycle};

// An older daemon rejects `list` with any extra key, and an older
// client sends a bare `list` and a `create` with no mode.
assert_eq!(
serde_json::to_value(Request::List { mode: None }).unwrap(),
serde_json::json!({"op": "list"})
);
assert!(matches!(
serde_json::from_str::<Request>(r#"{"op":"list"}"#).unwrap(),
Request::List { mode: None }
));
assert!(matches!(
serde_json::from_str::<Request>(r#"{"op":"list","mode":"pipe"}"#).unwrap(),
Request::List {
mode: Some(ProcessMode::Pipe)
}
));
let create = serde_json::json!({
"op": "create", "program": "sh", "args": [], "cwd": "/",
"title": "t", "group_id": "g", "env": {}, "cols": 80, "rows": 24,
});
assert!(matches!(
serde_json::from_value::<Request>(create.clone()).unwrap(),
Request::Create {
mode: ProcessMode::Pty,
cols: 80,
..
}
));
// A PTY create serializes exactly as before.
assert_eq!(
serde_json::to_value(serde_json::from_value::<Request>(create.clone()).unwrap())
.unwrap(),
create
);
assert!(matches!(
serde_json::from_str::<Request>(
r#"{"op":"create","program":"sh","args":[],"cwd":"/","group_id":"g","env":{},"mode":"pipe"}"#
)
.unwrap(),
Request::Create {
mode: ProcessMode::Pipe,
cols: 0,
rows: 0,
..
}
));
// Exits without a signal look exactly as before.
assert_eq!(
serde_json::to_value(Response::Exited {
exit_code: Some(1),
signal: None,
final_offset: 2,
})
.unwrap(),
serde_json::json!({"type": "exited", "exit_code": 1, "final_offset": 2})
);
assert_eq!(
serde_json::to_value(TerminalLifecycle::Exited {
exit_code: Some(1),
signal: None,
})
.unwrap(),
serde_json::json!({"status": "exited", "exit_code": 1})
);
assert_eq!(
serde_json::from_str::<TerminalLifecycle>(r#"{"status":"exited","exit_code":null}"#)
.unwrap(),
TerminalLifecycle::Exited {
exit_code: None,
signal: None,
}
);
}
}
Loading
Loading