Skip to content

Commit 1be7dab

Browse files
authored
Merge branch 'main' into spofford/complete
2 parents 93766e8 + f966961 commit 1be7dab

7 files changed

Lines changed: 209 additions & 56 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ bump. Currently experimental: project bundling, project dependencies
88
# Unreleased
99

1010
* feat: `icp completions <SHELL>` prints a shell completion script for `bash`, `zsh`, `fish`, `powershell`, or `elvish` to stdout. See the [installation guide](docs/guides/installation.md#shell-completions) for where to put it.
11+
* fix: `icp canister logs` output formats are corrected. `--json` now emits machine-readable JSON and the default emits the human-readable lines (the two were swapped), and `--follow --json` emits newline-delimited JSON, one record per line, streamed as each record arrives. This is breaking for scripts: parsing the default output as JSON now requires `--json`, and consumers of `--follow --json` must read one JSON object per line.
1112

1213
# v1.3.0
1314

‎crates/icp-cli/src/commands/canister/logs.rs‎

Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use std::io::stdout;
1+
use std::io::{ErrorKind, Write as _, stdout};
22

33
use anyhow::{Context as _, anyhow};
44
use candid::Principal;
@@ -179,17 +179,9 @@ async fn fetch_and_display_logs(
179179
.context("Failed to fetch canister logs")?;
180180

181181
if args.json {
182-
println!(
183-
"{}",
184-
result
185-
.canister_log_records
186-
.iter()
187-
.map(format_log)
188-
.format("\n")
189-
);
190-
} else {
182+
let mut out = stdout();
191183
serde_json::to_writer(
192-
stdout(),
184+
&mut out,
193185
&JsonListRecord {
194186
log_records: result
195187
.canister_log_records
@@ -202,6 +194,18 @@ async fn fetch_and_display_logs(
202194
.collect(),
203195
},
204196
)?;
197+
// Terminate the document with a newline: it is the entirety of stdout, so without
198+
// it the shell prompt lands mid-line. Covered by `canister_logs_single_fetch`.
199+
writeln!(out)?;
200+
} else {
201+
println!(
202+
"{}",
203+
result
204+
.canister_log_records
205+
.iter()
206+
.map(format_log)
207+
.format("\n")
208+
);
205209
}
206210

207211
Ok(())
@@ -219,7 +223,7 @@ async fn follow_logs(
219223
let mut last_idx: Option<u64> = None;
220224
let interval = std::time::Duration::from_secs(interval_seconds);
221225

222-
loop {
226+
'follow: loop {
223227
let filter = match last_idx {
224228
Some(idx) => Some(CanisterLogFilter::ByIdx {
225229
start: idx + 1, // Start from the next log index after the last one we displayed
@@ -250,17 +254,26 @@ async fn follow_logs(
250254

251255
if !new_logs.is_empty() {
252256
for log in &new_logs {
253-
if args.json {
254-
serde_json::to_writer(
255-
stdout(),
256-
&JsonFollowRecord {
257-
timestamp: log.timestamp_nanos,
258-
index: log.idx,
259-
content: String::from_utf8_lossy(&log.content).into_owned(),
260-
},
261-
)?;
257+
let line = if args.json {
258+
serde_json::to_string(&JsonFollowRecord {
259+
timestamp: log.timestamp_nanos,
260+
index: log.idx,
261+
content: String::from_utf8_lossy(&log.content).into_owned(),
262+
})?
262263
} else {
263-
println!("{}", format_log(log));
264+
format_log(log)
265+
};
266+
// Under `--json` this makes the output newline-delimited: the newline both
267+
// separates records for incremental consumers and, because Rust's stdout is a
268+
// `LineWriter` regardless of whether it is a terminal, flushes the record so
269+
// that `--follow` actually streams. Covered by `canister_logs_follow_mode_json`.
270+
match writeln!(stdout().lock(), "{line}") {
271+
Ok(()) => {}
272+
// The consumer closed the pipe (`... --follow --json | head -1`). That is
273+
// an ordinary way to stop a tail, so stop quietly rather than reporting a
274+
// broken pipe. Covered by `canister_logs_follow_broken_pipe`.
275+
Err(e) if e.kind() == ErrorKind::BrokenPipe => break 'follow,
276+
Err(e) => return Err(e.into()),
264277
}
265278
}
266279
// Update last_idx to the highest idx we've displayed

‎crates/icp-cli/tests/canister_logs_tests.rs‎

Lines changed: 154 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ use {
55
indoc::formatdoc,
66
predicates::prelude::PredicateBooleanExt,
77
predicates::str::contains,
8+
std::io::{BufRead as _, BufReader},
9+
std::process::Stdio,
810
std::time::Duration,
911
};
1012

@@ -74,7 +76,8 @@ async fn canister_logs_single_fetch() {
7476
.assert()
7577
.success();
7678

77-
// Fetch logs
79+
// Fetch logs: the default emits the human-readable "[idx. timestamp]: content" lines,
80+
// not JSON. (Regression: this orientation was once swapped with `--json`.)
7881
ctx.icp()
7982
.current_dir(&project_dir)
8083
.args([
@@ -86,10 +89,51 @@ async fn canister_logs_single_fetch() {
8689
])
8790
.assert()
8891
.success()
89-
.stdout(contains("Test message 1"))
90-
.stdout(contains("Test message 2"));
92+
.stdout(contains("]: Test message 1"))
93+
.stdout(contains("]: Test message 2"))
94+
.stdout(contains("log_records").not());
95+
96+
// With --json: parseable JSON in the JsonListRecord shape, terminated by a newline.
97+
let json_stdout = ctx
98+
.icp()
99+
.current_dir(&project_dir)
100+
.args([
101+
"canister",
102+
"logs",
103+
"logger",
104+
"--json",
105+
"--environment",
106+
"random-environment",
107+
])
108+
.assert()
109+
.success()
110+
.get_output()
111+
.stdout
112+
.clone();
113+
let json_stdout = String::from_utf8(json_stdout).expect("stdout is not valid UTF-8");
114+
assert!(
115+
json_stdout.ends_with('\n'),
116+
"--json output should end with a newline, got {json_stdout:?}"
117+
);
118+
let parsed: serde_json::Value =
119+
serde_json::from_str(json_stdout.trim()).expect("--json output should be valid JSON");
120+
let records = parsed["log_records"]
121+
.as_array()
122+
.expect("--json output should contain a log_records array");
123+
for message in ["Test message 1", "Test message 2"] {
124+
let record = records
125+
.iter()
126+
.find(|r| r["content"].as_str() == Some(message))
127+
.unwrap_or_else(|| panic!("--json output should contain {message:?}"));
128+
assert!(record["timestamp"].is_u64());
129+
assert!(record["index"].is_u64());
130+
}
91131
}
92132

133+
/// Every `--follow` behaviour, in one network start and one `moc` deploy: each phase would
134+
/// otherwise pay for its own, which dominates the wall clock. Covers (1) polling for logs
135+
/// that do not exist yet, (2) `--json` streaming newline-delimited records as they arrive,
136+
/// and (3) a consumer that stops reading ending the command quietly.
93137
#[cfg(unix)] // moc
94138
#[tokio::test]
95139
async fn canister_logs_follow_mode() {
@@ -126,27 +170,23 @@ async fn canister_logs_follow_mode() {
126170
.assert()
127171
.success();
128172

129-
// Trigger repeated logging (will log 5 times over 5 seconds)
130-
ctx.icp()
131-
.current_dir(&project_dir)
132-
.args([
133-
"canister",
134-
"call",
135-
"--environment",
136-
"random-environment",
137-
"logger",
138-
"log_repeated",
139-
"(\"Repeated\")",
140-
])
141-
.assert()
142-
.success();
143-
144-
// Start following logs with a timeout of 7 seconds (enough to see several logs)
145-
// The logs are not present yet, so if e.g. "5 Repeated" is present in stdout after the timeout, then we correctly polled for new logs
146-
ctx.icp()
147-
.current_dir(&project_dir)
148-
.timeout(Duration::from_secs(7))
149-
.args([
173+
let call = |method: &str, message: &str| {
174+
ctx.icp()
175+
.current_dir(&project_dir)
176+
.args([
177+
"canister",
178+
"call",
179+
"--environment",
180+
"random-environment",
181+
"logger",
182+
method,
183+
&format!("(\"{message}\")"),
184+
])
185+
.assert()
186+
.success();
187+
};
188+
let follow_args = |json: bool| {
189+
let mut args = vec![
150190
"canister",
151191
"logs",
152192
"logger",
@@ -155,14 +195,103 @@ async fn canister_logs_follow_mode() {
155195
"1",
156196
"--environment",
157197
"random-environment",
158-
])
198+
];
199+
if json {
200+
args.push("--json");
201+
}
202+
args
203+
};
204+
205+
// Phase 1: the human-readable output polls for new logs. `log_repeated` returns
206+
// immediately and logs 5 times over 5 seconds from a recurring timer, so none of these
207+
// records exist when the follow starts — seeing "5 Repeated" within the 7s window means
208+
// we really polled rather than replaying the 1-hour lookback.
209+
call("log_repeated", "Repeated");
210+
ctx.icp()
211+
.current_dir(&project_dir)
212+
.timeout(Duration::from_secs(7))
213+
.args(follow_args(false))
159214
.assert()
160215
.failure() // Will timeout/be interrupted
161216
.stdout(contains("1 Repeated"))
162217
.stdout(contains("2 Repeated"))
163218
.stdout(contains("3 Repeated"))
164219
.stdout(contains("4 Repeated"))
165220
.stdout(contains("5 Repeated"));
221+
222+
// Phase 2: `--json` streams newline-delimited records. Same fresh-logs setup, and
223+
// because the timeout SIGKILLs the process rather than letting it exit, nothing buffered
224+
// in stdout is flushed on the way out: any output we observe must have been flushed as
225+
// the records arrived. Parsing each line on its own also pins the delimiting, since
226+
// `serde_json::from_str` rejects the trailing content of a concatenated `{...}{...}`.
227+
call("log_repeated", "Streamed");
228+
let stdout = ctx
229+
.icp()
230+
.current_dir(&project_dir)
231+
.timeout(Duration::from_secs(7))
232+
.args(follow_args(true))
233+
.assert()
234+
.failure() // Will timeout/be interrupted
235+
.get_output()
236+
.stdout
237+
.clone();
238+
let stdout = String::from_utf8(stdout).expect("stdout is not valid UTF-8");
239+
let mut contents = Vec::new();
240+
for line in stdout.lines() {
241+
let record: serde_json::Value = serde_json::from_str(line)
242+
.unwrap_or_else(|e| panic!("--follow --json line {line:?} is not valid JSON: {e}"));
243+
assert!(record["timestamp"].is_u64());
244+
assert!(record["index"].is_u64());
245+
contents.push(
246+
record["content"]
247+
.as_str()
248+
.expect("--follow --json record should contain a content string")
249+
.to_string(),
250+
);
251+
}
252+
for i in 1..=5 {
253+
let message = format!("{i} Streamed");
254+
assert!(
255+
contents.iter().any(|c| c.contains(&message)),
256+
"--follow --json output should contain {message:?}, got {contents:?}"
257+
);
258+
}
259+
260+
// Phase 3: a consumer that stops reading — `icp canister logs --follow | head -1` — must
261+
// not make the command report a broken pipe. `--json` writes far more often now that it
262+
// streams, so it reaches the closed pipe first, but both modes share the same write.
263+
for json in [true, false] {
264+
let mut child = ctx
265+
.icp_std()
266+
.current_dir(&project_dir)
267+
.args(follow_args(json))
268+
.stdout(Stdio::piped())
269+
.spawn()
270+
.expect("failed to spawn icp canister logs --follow");
271+
272+
// Read one record, then close the read end of the pipe, exactly as `head -1` does.
273+
let mut reader = BufReader::new(child.stdout.take().expect("stdout is piped"));
274+
let mut line = String::new();
275+
reader
276+
.read_line(&mut line)
277+
.expect("failed to read the first record");
278+
assert!(
279+
!line.is_empty(),
280+
"expected a first record before the pipe closed (json: {json})"
281+
);
282+
drop(reader);
283+
284+
// Produce another record so the next poll must write to the now-closed pipe. The
285+
// earlier phases left plenty for the first poll to emit, but this makes the write
286+
// independent of how much the lookback happens to return.
287+
call("log", &format!("After close {json}"));
288+
289+
let status = child.wait().expect("failed to wait for icp canister logs");
290+
assert!(
291+
status.success(),
292+
"a closed stdout pipe should end `--follow` quietly (json: {json}), got {status}"
293+
);
294+
}
166295
}
167296

168297
#[cfg(unix)] // moc

‎crates/icp-cli/tests/common/context.rs‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,14 @@ impl TestContext {
9999
}
100100

101101
pub(crate) fn icp(&self) -> Command {
102-
let mut cmd = Command::new(icp_bin_path());
102+
Command::from_std(self.icp_std())
103+
}
104+
105+
/// The same isolated `icp` invocation as [`TestContext::icp`], as a raw
106+
/// [`std::process::Command`] so a caller can wire up stdio itself — e.g. to spawn a
107+
/// long-running command and close its stdout pipe part-way through.
108+
pub(crate) fn icp_std(&self) -> std::process::Command {
109+
let mut cmd = std::process::Command::new(icp_bin_path());
103110

104111
// Isolate the command
105112
cmd.current_dir(self.home_path());

‎docs-site/package-lock.json‎

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎docs-site/versions.json‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22
"$comment": "Before first release: Empty array redirects to /main/. After releases: Add versions here (newest first). Only 'version' is required; 'latest: true' marks the current release.",
33
"versions": [
44
{
5-
"version": "1.2",
5+
"version": "1.3",
66
"latest": true
77
},
8+
{
9+
"version": "1.2"
10+
},
811
{
912
"version": "1.1"
1013
},

‎network-launcher-version‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
15.0.0-2026-07-31-04-23
1+
15.0.0-2026-08-06-04-21

0 commit comments

Comments
 (0)