Skip to content
Closed
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
80 changes: 80 additions & 0 deletions crates/subc-control/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ pub mod ops {
pub const SUPERVISOR_SET_ENABLED: &str = "supervisor.set_enabled";
pub const SUPERVISOR_HEALTH_PROBE: &str = "supervisor.health_probe";
pub const SUPERVISOR_HEALTH: &str = "supervisor.health";
pub const SUPERVISOR_STDERR_TAIL: &str = "supervisor.stderr_tail";
}

/// Client-originated channel-0 control RPC body.
Expand Down Expand Up @@ -127,6 +128,21 @@ pub enum ClientControlRequest {
SupervisorHealthProbe { module_id: String },
#[serde(rename = "supervisor.health")]
SupervisorHealth {},
/// Retained stderr for one module.
///
/// A separate op rather than a field on `supervisor.list`: the tail is
/// kilobytes per module and `list` renders every module, so carrying it in
/// the snapshot would charge every status read for a payload almost no
/// caller wants. Caps ride on the REQUEST so a caller wanting twenty lines
/// and one wanting the whole ring need no separate fields anywhere.
#[serde(rename = "supervisor.stderr_tail")]
SupervisorStderrTail {
module_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
max_lines: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
max_bytes: Option<u32>,
},
}

/// subc's channel-0 response body for client control RPCs.
Expand Down Expand Up @@ -186,6 +202,70 @@ pub enum ClientControlResponse {
generation: u64,
modules: Vec<SupervisorHealthEntry>,
},
#[serde(rename = "supervisor.stderr_tail")]
SupervisorStderrTail {
module_id: String,
#[serde(flatten)]
tail: StderrTail,
},
}

/// A module's retained stderr, oldest entry first.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct StderrTail {
pub capture: StderrCaptureState,
pub entries: Vec<StderrTailEntry>,
/// Lines not present above: evicted by the ring, or held back by this
/// request's own caps.
///
/// Non-zero means the first entry is not the first line the module wrote. A
/// reader hunting a cause needs that, or an absent explanation reads as a
/// module that never gave one.
///
/// Zero is skipped so the common complete-tail case stays compact.
#[serde(default, skip_serializing_if = "is_zero_u64")]
pub dropped_lines: u64,
}

/// Whether stderr is being captured for a module, and if not, why not.
///
/// A typed state rather than an empty-tail convention. "The module printed
/// nothing before dying" and "nobody was capturing" send an operator in opposite
/// directions, and rendering them alike is the defect this op exists to fix --
/// the same shape as a `detail -` that means both no-detail and never-probed.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum StderrCaptureState {
/// A reader is attached, or was attached and saw clean EOF. An empty
/// `entries` under this state means the module genuinely wrote nothing.
Captured,
/// Retained entries are valid, but the stderr reader ended before clean EOF.
Incomplete { reason: String },
/// No reader was attached. `entries` says nothing about what the module wrote.
NotCaptured { reason: String },
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum StderrTailEntry {
Line {
text: String,
/// The line was cut at the per-line cap and `text` is a prefix.
///
/// Carried as a field rather than left to a marker in `text` so a
/// consumer can branch on it without string matching.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
truncated: bool,
},
/// The supervisor spawned a new process. Entries after this came from it.
///
/// In-band because position is the information: which side of the restart a
/// line falls on is unanswerable from a count.
ProcessStart,
}

fn is_zero_u64(value: &u64) -> bool {
*value == 0
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
"supervisor.rescan",
"supervisor.set_enabled",
"supervisor.health_probe",
"supervisor.health"
"supervisor.health",
"supervisor.stderr_tail"
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"supervisor.rescan",
"supervisor.set_enabled",
"supervisor.health_probe",
"supervisor.health"
"supervisor.health",
"supervisor.stderr_tail"
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"supervisor.rescan",
"supervisor.set_enabled",
"supervisor.health_probe",
"supervisor.health"
"supervisor.health",
"supervisor.stderr_tail"
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"capture": {
"state": "captured"
},
"dropped_lines": 12,
"entries": [
{
"kind": "line",
"text": "config error: missing top-level `storage`"
},
{
"kind": "process_start"
},
{
"kind": "line",
"text": "config error: missing top-level `stor",
"truncated": true
}
],
"module_id": "aft-tools",
"op": "supervisor.stderr_tail"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"capture": {
"reason": "stderr read failed: reader failed",
"state": "incomplete"
},
"entries": [
{
"kind": "line",
"text": "config error: missing top-level `storage`"
}
],
"module_id": "aft-tools",
"op": "supervisor.stderr_tail"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"capture": {
"reason": "stderr pipe was not available on spawn",
"state": "not_captured"
},
"entries": [],
"module_id": "aft-tools",
"op": "supervisor.stderr_tail"
}
57 changes: 56 additions & 1 deletion crates/subc-control/tests/golden_json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ use serde::{de::DeserializeOwned, Serialize};
use serde_json::Value;
use subc_control::{
CatalogEntry, ClientControlRequest, ClientControlResponse, ConsumerIdentity, PollKind,
SupervisorEntry, SupervisorHealthEntry, SupervisorHealthStatus, SupervisorRescanResult,
StderrCaptureState, StderrTail, StderrTailEntry, SupervisorEntry, SupervisorHealthEntry,
SupervisorHealthStatus, SupervisorRescanResult,
};
use subc_protocol::{
manifest::{
Expand Down Expand Up @@ -264,6 +265,59 @@ fn client_control_responses() -> Vec<(&'static str, ClientControlResponse)> {
modules: vec![supervisor_health_entry()],
},
),
(
"client_control_response_supervisor_stderr_tail",
ClientControlResponse::SupervisorStderrTail {
module_id: "aft-tools".to_string(),
tail: StderrTail {
capture: StderrCaptureState::Captured,
entries: vec![
StderrTailEntry::Line {
text: "config error: missing top-level `storage`".to_string(),
truncated: false,
},
StderrTailEntry::ProcessStart,
StderrTailEntry::Line {
text: "config error: missing top-level `stor".to_string(),
truncated: true,
},
],
dropped_lines: 12,
},
},
),
(
// Pinned separately because it is the state the empty-tail convention
// could not express, and a fixture is the only thing that keeps the
// distinction from being collapsed back into an empty list later.
"client_control_response_supervisor_stderr_tail_not_captured",
ClientControlResponse::SupervisorStderrTail {
module_id: "aft-tools".to_string(),
tail: StderrTail {
capture: StderrCaptureState::NotCaptured {
reason: "stderr pipe was not available on spawn".to_string(),
},
entries: Vec::new(),
dropped_lines: 0,
},
},
),
(
"client_control_response_supervisor_stderr_tail_incomplete",
ClientControlResponse::SupervisorStderrTail {
module_id: "aft-tools".to_string(),
tail: StderrTail {
capture: StderrCaptureState::Incomplete {
reason: "stderr read failed: reader failed".to_string(),
},
entries: vec![StderrTailEntry::Line {
text: "config error: missing top-level `storage`".to_string(),
truncated: false,
}],
dropped_lines: 0,
},
},
),
]
}

Expand All @@ -280,6 +334,7 @@ fn thin_core_ops() -> Vec<String> {
"supervisor.set_enabled".to_string(),
"supervisor.health_probe".to_string(),
"supervisor.health".to_string(),
"supervisor.stderr_tail".to_string(),
]
}

Expand Down
Loading