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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ nightcrow update # reinstall the binary; restart the session afterwards

For foreground operation, use `nightcrow`; `nightcrow -d` starts the session in the background and writes its output to `~/.nightcrow/daemon.out`. See [Getting started](docs/getting-started.md) for installation variants, startup panes, disconnects, updates, and build verification.

To inspect a running daemon without attaching, run `nightcrow status [--socket PATH]`. It performs a read-only one-shot query and reports the PID, version, start time, uptime, endpoint, attached clients, repositories, and panes. It exits non-zero when no daemon is running.
To inspect a running daemon without attaching, run `nightcrow status [--socket PATH]`. It performs a read-only one-shot query and reports the PID, version, start time, uptime, web and attach endpoints, attached clients, repositories, and panes. It exits non-zero when no daemon is running.

## Features

Expand Down
9 changes: 7 additions & 2 deletions src/application/session_terminals_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,13 @@ fn attached(dir: &tempfile::TempDir, repos: &[String]) -> (DaemonSocket, DaemonC
let listener = socket.listener().try_clone().expect("clones");
let state = crate::test_util::session_state(repos, dir.path());
let (shutdown_tx, _shutdown_rx) = std::sync::mpsc::sync_channel(1);
let session =
crate::daemon::serve::start(state, socket.path(), shutdown_tx).expect("starts the watcher");
let session = crate::daemon::serve::start(
state,
socket.path(),
"127.0.0.1:4321".parse().unwrap(),
shutdown_tx,
)
.expect("starts the watcher");
std::thread::spawn(move || crate::daemon::serve::serve(listener, session));
let client = DaemonClient::connect(socket.path()).expect("attaches");
(socket, client)
Expand Down
7 changes: 6 additions & 1 deletion src/cli/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,12 @@ pub(crate) fn run_daemon(
.listener()
.try_clone()
.context("cloning the daemon listener")?;
let session = crate::daemon::serve::start(server.session_state(), socket.path(), shutdown_tx)?;
let session = crate::daemon::serve::start(
server.session_state(),
socket.path(),
server.addr(),
shutdown_tx,
)?;
std::thread::Builder::new()
.name("nightcrow-daemon-accept".into())
.spawn(move || crate::daemon::serve::serve(listener, session))
Expand Down
7 changes: 5 additions & 2 deletions src/cli/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,13 @@ fn validate_status(status: &DaemonStatus) -> Result<()> {
if status.pid == 0 {
bail!("protocol error: malformed status response: PID is zero");
}
if let Ok(endpoint) = &status.endpoint
if status.web_endpoint.is_empty() {
bail!("protocol error: malformed status response: web endpoint is empty");
}
if let Ok(endpoint) = &status.attach_endpoint
&& endpoint.is_empty()
{
bail!("protocol error: malformed status response: endpoint is empty");
bail!("protocol error: malformed status response: attach endpoint is empty");
}
let mut client_ids = status.attached_clients.clone();
client_ids.sort_unstable();
Expand Down
13 changes: 12 additions & 1 deletion src/cli/status_render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,18 @@ pub(super) fn render_status(status: &DaemonStatus) -> String {
)
.unwrap();
writeln!(output, "Uptime: {}", format_uptime(status.uptime_ms)).unwrap();
writeln!(output, "Endpoint: {}", format_endpoint(&status.endpoint)).unwrap();
writeln!(
output,
"Web endpoint: {}",
display_text(&status.web_endpoint)
)
.unwrap();
writeln!(
output,
"Attach endpoint: {}",
format_endpoint(&status.attach_endpoint)
)
.unwrap();

let mut clients = status.attached_clients.clone();
clients.sort_unstable();
Expand Down
14 changes: 9 additions & 5 deletions src/cli/status_render_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ fn status() -> DaemonStatus {
version: version(),
started_at_unix_ms: Ok(1_735_689_723_004),
uptime_ms: 90_061_000,
endpoint: Ok("custom.sock".into()),
web_endpoint: "http://127.0.0.1:4321/".into(),
attach_endpoint: Ok("custom.sock".into()),
attached_clients: vec![9, 2],
repositories: vec![
RepositoryStatus {
Expand All @@ -35,6 +36,8 @@ fn status_output_sorts_ids_and_repositories_and_names_empty_values() {
assert!(output.contains("Status: running"));
assert!(output.contains("Started at: 2025-01-01T00:02:03.004Z"));
assert!(output.contains("Uptime: 1d 1h 1m 1s"));
assert!(output.contains("Web endpoint: http://127.0.0.1:4321/"));
assert!(output.contains("Attach endpoint: custom.sock"));
assert!(output.contains("Attached client IDs: 2, 9"));
assert!(output.find("Repository: a") < output.find("Repository: b"));
assert!(output.contains(" Pane IDs: 3, 8"));
Expand All @@ -60,21 +63,22 @@ fn status_output_explains_empty_repository_set() {
}

#[test]
fn status_output_explains_unavailable_endpoint() {
fn status_output_explains_unavailable_attach_endpoint() {
let mut status = status();
status.endpoint = Err(StatusUnavailable {
status.attach_endpoint = Err(StatusUnavailable {
reason: StatusUnavailableReason::EndpointNotUnicode,
});
assert!(
render_status(&status)
.contains("Endpoint: unavailable (endpoint path is not valid Unicode)")
.contains("Attach endpoint: unavailable (endpoint path is not valid Unicode)")
);
}

#[test]
fn status_output_escapes_control_characters_and_preserves_unicode() {
let mut status = status();
status.endpoint = Ok("sock\u{1b}]0;evil\u{7}\n\u{9b}".into());
status.web_endpoint = "http://sock\u{1b}]0;evil\u{7}\n\u{9b}/".into();
status.attach_endpoint = Ok("sock\u{1b}]0;evil\u{7}\n\u{9b}".into());
status.repositories[0].id = "repo-한글\u{1b}".into();
status.repositories[0].path = "C:\\work\n\u{80}".into();
let output = render_status(&status);
Expand Down
44 changes: 42 additions & 2 deletions src/cli/status_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ fn a_version_mismatch_is_reported_as_a_version_error() {
version: "old".into(),
started_at_unix_ms: Ok(0),
uptime_ms: 0,
endpoint: Ok("sock".into()),
web_endpoint: "http://127.0.0.1:4321/".into(),
attach_endpoint: Ok("sock".into()),
repositories: vec![],
attached_clients: vec![],
};
Expand All @@ -77,7 +78,8 @@ fn malformed_status_facts_are_rejected_before_rendering() {
version: version(),
started_at_unix_ms: Ok(0),
uptime_ms: 0,
endpoint: Ok("sock".into()),
web_endpoint: "http://127.0.0.1:4321/".into(),
attach_endpoint: Ok("sock".into()),
repositories: vec![RepositoryStatus {
id: "repo".into(),
path: "/repo".into(),
Expand All @@ -89,3 +91,41 @@ fn malformed_status_facts_are_rejected_before_rendering() {
let error = validate_status(&status).unwrap_err();
assert!(error.to_string().contains("malformed status"), "{error:#}");
}

#[test]
fn an_empty_web_endpoint_is_rejected_before_rendering() {
let status = DaemonStatus {
pid: 1,
version: version(),
started_at_unix_ms: Ok(0),
uptime_ms: 0,
web_endpoint: String::new(),
attach_endpoint: Ok("sock".into()),
repositories: vec![],
attached_clients: vec![],
};
let error = validate_status(&status).unwrap_err();
assert!(
error.to_string().contains("web endpoint is empty"),
"{error:#}"
);
}

#[test]
fn an_empty_attach_endpoint_is_rejected_before_rendering() {
let status = DaemonStatus {
pid: 1,
version: version(),
started_at_unix_ms: Ok(0),
uptime_ms: 0,
web_endpoint: "http://127.0.0.1:4321/".into(),
attach_endpoint: Ok(String::new()),
repositories: vec![],
attached_clients: vec![],
};
let error = validate_status(&status).unwrap_err();
assert!(
error.to_string().contains("attach endpoint is empty"),
"{error:#}"
);
}
9 changes: 7 additions & 2 deletions src/daemon/client_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,13 @@ fn daemon(dir: &tempfile::TempDir, repos: &[String]) -> TestDaemon {
},
));
let (shutdown_tx, _shutdown_rx) = std::sync::mpsc::sync_channel(1);
let session =
crate::daemon::serve::start(state, socket.path(), shutdown_tx).expect("starts the watcher");
let session = crate::daemon::serve::start(
state,
socket.path(),
"127.0.0.1:4321".parse().unwrap(),
shutdown_tx,
)
.expect("starts the watcher");
std::thread::spawn(move || crate::daemon::serve::serve(listener, session));
TestDaemon { socket }
}
Expand Down
51 changes: 50 additions & 1 deletion src/daemon/one_shot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,40 @@ use super::frame::{Frame, FrameKind, read_frame, write_frame};
use super::protocol::{ClientMessage, ServerMessage};
use super::transport::UnixStream;
use anyhow::{Context, Result, bail};
use serde::Deserialize;
use std::io::Write;
use std::path::Path;
use std::time::Duration;

/// The status shape emitted before the web and attach endpoints were split.
/// It is intentionally private and only used to turn a precise, same-version
/// compatibility failure into an actionable error at the one-shot boundary.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct LegacyStatusResponse {
#[serde(rename = "type")]
message_type: String,
status: LegacyDaemonStatus,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct LegacyDaemonStatus {
#[serde(rename = "pid")]
_pid: u32,
version: String,
#[serde(rename = "started_at_unix_ms")]
_started_at_unix_ms: Result<u64, super::protocol::StatusUnavailable>,
#[serde(rename = "uptime_ms")]
_uptime_ms: u64,
#[serde(rename = "endpoint")]
_endpoint: Result<String, super::protocol::StatusUnavailable>,
#[serde(rename = "repositories")]
_repositories: Vec<super::protocol::RepositoryStatus>,
#[serde(rename = "attached_clients")]
_attached_clients: Vec<u64>,
}

pub(crate) fn connect(path: &Path) -> std::io::Result<UnixStream> {
UnixStream::connect(path)
}
Expand Down Expand Up @@ -44,7 +74,26 @@ pub(crate) fn request(
frame.kind
);
}
serde_json::from_slice(&frame.payload).context("protocol error: malformed daemon response JSON")
match serde_json::from_slice(&frame.payload) {
Ok(response) => Ok(response),
Err(error) => {
if matches!(request, ClientMessage::Status {})
&& is_legacy_status_response(&frame.payload)
{
bail!(
"protocol incompatibility: daemon status response uses the legacy endpoint field; restart the daemon after updating nightcrow"
);
}
Err(error).context("protocol error: malformed daemon response JSON")
}
}
}

fn is_legacy_status_response(payload: &[u8]) -> bool {
let Ok(response) = serde_json::from_slice::<LegacyStatusResponse>(payload) else {
return false;
};
response.message_type == "status" && response.status.version == super::protocol::version()
}

#[cfg(test)]
Expand Down
71 changes: 70 additions & 1 deletion src/daemon/one_shot_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ fn one_shot_request_uses_the_configured_endpoint_and_reads_one_typed_response()
version: "test".into(),
started_at_unix_ms: Ok(1),
uptime_ms: 2,
endpoint: Ok("status.sock".into()),
web_endpoint: "http://127.0.0.1:4321/".into(),
attach_endpoint: Ok("status.sock".into()),
repositories: vec![],
attached_clients: vec![],
};
Expand All @@ -43,6 +44,74 @@ fn one_shot_request_uses_the_configured_endpoint_and_reads_one_typed_response()
server.join().unwrap();
}

#[test]
fn a_legacy_status_response_reports_that_the_daemon_must_be_restarted() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("status.sock");
let socket = DaemonSocket::bind(&path).unwrap();
let listener = socket.listener().try_clone().unwrap();
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let _ = read_frame(&mut stream).unwrap();
let response = format!(
r#"{{"type":"status","status":{{"pid":7,"version":"{}","started_at_unix_ms":{{"Ok":1}},"uptime_ms":2,"endpoint":{{"Ok":"status.sock"}},"repositories":[],"attached_clients":[]}}}}"#,
crate::daemon::protocol::version()
);
write_frame(&mut stream, &Frame::control(response.into_bytes())).unwrap();
stream.flush().unwrap();
});

let error = request(
&path,
&ClientMessage::Status {},
std::time::Duration::from_secs(1),
)
.unwrap_err();

assert!(
error.to_string().contains("protocol incompatibility"),
"{error:#}"
);
assert!(error.to_string().contains("legacy endpoint"), "{error:#}");
assert!(
error.to_string().contains("restart the daemon"),
"{error:#}"
);
server.join().unwrap();
}

#[test]
fn an_incomplete_legacy_status_marker_remains_a_malformed_response() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("status.sock");
let socket = DaemonSocket::bind(&path).unwrap();
let listener = socket.listener().try_clone().unwrap();
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let _ = read_frame(&mut stream).unwrap();
let response = format!(
r#"{{"type":"status","status":{{"pid":7,"version":"{}","started_at_unix_ms":{{"Ok":1}},"uptime_ms":2,"endpoint":7,"repositories":[],"attached_clients":[]}}}}"#,
crate::daemon::protocol::version()
);
write_frame(&mut stream, &Frame::control(response.into_bytes())).unwrap();
stream.flush().unwrap();
});

let error = request(
&path,
&ClientMessage::Status {},
std::time::Duration::from_secs(1),
)
.unwrap_err();

assert!(
error.to_string().contains("malformed daemon response JSON"),
"{error:#}"
);
assert!(!error.to_string().contains("protocol incompatibility"));
server.join().unwrap();
}

#[test]
fn a_terminal_response_is_a_wire_error() {
let dir = tempfile::TempDir::new().unwrap();
Expand Down
7 changes: 5 additions & 2 deletions src/daemon/protocol/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ pub struct DaemonStatus {
pub version: String,
pub started_at_unix_ms: Result<u64, StatusUnavailable>,
pub uptime_ms: u64,
/// The socket endpoint, or why it could not be represented as text.
pub endpoint: Result<String, StatusUnavailable>,
/// The HTTP endpoint the viewer listener bound at runtime.
pub web_endpoint: String,
/// The attach socket endpoint, or why its path could not be represented as
/// text.
pub attach_endpoint: Result<String, StatusUnavailable>,
pub repositories: Vec<RepositoryStatus>,
/// Attach protocol client ids only. Terminal-hub connection ids are a
/// different namespace and deliberately do not appear here.
Expand Down
Loading