Skip to content

Commit 4c99d10

Browse files
authored
Merge pull request #120 from tower/feature/tow-771-cli-logs-are-truncated
TOW-771 - logs being truncated
2 parents 0c1073e + 622be56 commit 4c99d10

7 files changed

Lines changed: 141 additions & 69 deletions

File tree

crates/tower-cmd/src/apps.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use config::Config;
33

44
use tower_api::models::Run;
55

6-
use crate::{api, output, util::dates};
6+
use crate::{api, output};
77

88
pub fn apps_cmd() -> Command {
99
Command::new("apps")
@@ -51,8 +51,7 @@ pub async fn do_logs(config: Config, cmd: &ArgMatches) {
5151

5252
if let Ok(resp) = api::describe_run_logs(&config, &name, seq).await {
5353
for line in resp.log_lines {
54-
let ts = dates::format_str(&line.reported_at);
55-
output::log_line(&ts, &line.content, output::LogLineType::Remote);
54+
output::remote_log_event(&line);
5655
}
5756
}
5857
}

crates/tower-cmd/src/mcp.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ use rmcp::{
2525
use rsa::pkcs1::DecodeRsaPublicKey;
2626
use serde::Deserialize;
2727
use serde_json::{json, Value};
28+
use tokio::net::TcpListener;
2829
use tower_api::apis::Error as ApiError;
2930

3031
use crate::{api, deploy, run, towerfile_gen::TowerfileGenerator, Config, Error};
@@ -210,7 +211,7 @@ async fn run_http_server(config: Config, port: u16) -> Result<(), Error> {
210211
);
211212

212213
let router = Router::new().nest_service("/mcp", service);
213-
let listener = tokio::net::TcpListener::bind(&bind_addr).await?;
214+
let listener = TcpListener::bind(&bind_addr).await?;
214215
axum::serve(listener, router)
215216
.with_graceful_shutdown(async {
216217
tokio::signal::ctrl_c().await.unwrap();

crates/tower-cmd/src/output.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,11 @@ pub fn log_line(timestamp: &str, message: &str, t: LogLineType) {
127127
write(&line);
128128
}
129129

130+
pub fn remote_log_event(log: &tower_api::models::RunLogLine) {
131+
let ts = crate::util::dates::format_str(&log.reported_at);
132+
log_line(&ts, &log.content, LogLineType::Remote);
133+
}
134+
130135
pub fn package_error(err: tower_package::Error) {
131136
let msg = match err {
132137
tower_package::Error::NoManifest => "No manifest was found".to_string(),

crates/tower-cmd/src/run.rs

Lines changed: 66 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ use tower_package::{Package, PackageSpec};
88
use tower_runtime::{local::LocalApp, App, AppLauncher, OutputReceiver, Status};
99
use tower_telemetry::{debug, Context};
1010

11-
use tokio::sync::{mpsc::unbounded_channel, oneshot};
11+
use tokio::sync::{
12+
mpsc::{unbounded_channel, Receiver as MpscReceiver},
13+
oneshot::{self, Receiver as OneshotReceiver},
14+
};
15+
use tokio::time::{sleep, timeout, Duration};
1216

1317
use crate::{api, output, util::dates};
1418

@@ -243,8 +247,50 @@ pub async fn do_run_remote(
243247
}
244248
}
245249

250+
async fn stream_logs_until_complete(
251+
mut log_stream: MpscReceiver<api::LogStreamEvent>,
252+
mut run_complete: OneshotReceiver<Run>,
253+
enable_ctrl_c: bool,
254+
run_link: &str,
255+
) -> Result<Option<Run>, Error> {
256+
loop {
257+
tokio::select! {
258+
event = log_stream.recv() => match event {
259+
Some(api::LogStreamEvent::EventLog(log)) => {
260+
output::remote_log_event(&log);
261+
},
262+
None => return Ok(None),
263+
_ => {},
264+
},
265+
res = &mut run_complete => {
266+
let completed_run = res?;
267+
drain_remaining_logs(log_stream).await;
268+
return Ok(Some(completed_run));
269+
},
270+
_ = tokio::signal::ctrl_c(), if enable_ctrl_c => {
271+
output::write("Received Ctrl+C, stopping log streaming...\n");
272+
output::write("Note: The run will continue in Tower cloud\n");
273+
output::write(&format!(" See more: {}\n", run_link));
274+
return Ok(None);
275+
},
276+
}
277+
}
278+
}
279+
280+
async fn drain_remaining_logs(mut log_stream: MpscReceiver<api::LogStreamEvent>) {
281+
let drain_duration = Duration::from_secs(5);
282+
let _ = timeout(drain_duration, async {
283+
while let Some(event) = log_stream.recv().await {
284+
if let api::LogStreamEvent::EventLog(log) = event {
285+
output::remote_log_event(&log);
286+
}
287+
}
288+
})
289+
.await;
290+
}
291+
246292
async fn do_follow_run(config: Config, run: &Run) -> Result<(), Error> {
247-
let enable_ctrl_c = !output::get_output_mode().is_mcp(); // Disable Ctrl+C in MCP mode
293+
let enable_ctrl_c = !output::get_output_mode().is_mcp();
248294
let mut spinner = output::spinner("Waiting for run to start...");
249295
match wait_for_run_start(&config, &run).await {
250296
Err(err) => {
@@ -257,35 +303,24 @@ async fn do_follow_run(config: Config, run: &Run) -> Result<(), Error> {
257303

258304
// We do this here, explicitly, to not double-monitor our API via the
259305
// `wait_for_run_start` function above.
260-
let mut run_complete = monitor_run_completion(&config, run);
306+
let run_complete = monitor_run_completion(&config, run);
261307

262308
// Now we follow the logs from the run. We can stream them from the cloud to here using
263309
// the stream_logs API endpoint.
264310
match api::stream_run_logs(&config, &run.app_name, run.number).await {
265-
Ok(mut output) => loop {
266-
let should_exit = tokio::select! {
267-
Some(event) = output.recv() => {
268-
if let api::LogStreamEvent::EventLog(log) = &event {
269-
let ts = dates::format_str(&log.reported_at);
270-
output::log_line(&ts, &log.content, output::LogLineType::Remote);
271-
}
272-
false
273-
},
274-
res = &mut run_complete => {
275-
handle_run_completion(res)?;
276-
true
277-
},
278-
_ = tokio::signal::ctrl_c(), if enable_ctrl_c => {
279-
output::write("Received Ctrl+C, stopping log streaming...\n");
280-
output::write("Note: The run will continue in Tower cloud\n");
281-
output::write(&format!(" See more: {}\n", run.dollar_link));
282-
true
283-
},
284-
};
285-
if should_exit {
286-
break;
311+
Ok(log_stream) => {
312+
let completed_run = stream_logs_until_complete(
313+
log_stream,
314+
run_complete,
315+
enable_ctrl_c,
316+
&run.dollar_link,
317+
)
318+
.await?;
319+
320+
if let Some(run) = completed_run {
321+
handle_run_completion(Ok(run))?;
287322
}
288-
},
323+
}
289324
Err(err) => {
290325
output::error(&format!("Failed to stream run logs: {:?}", err));
291326
return Err(Error::LogStreamFailed);
@@ -297,9 +332,7 @@ async fn do_follow_run(config: Config, run: &Run) -> Result<(), Error> {
297332
Ok(())
298333
}
299334

300-
fn handle_run_completion(
301-
res: Result<Run, tokio::sync::oneshot::error::RecvError>,
302-
) -> Result<(), Error> {
335+
fn handle_run_completion(res: Result<Run, oneshot::error::RecvError>) -> Result<(), Error> {
303336
match res {
304337
Ok(completed_run) => match completed_run.status {
305338
tower_api::models::run::Status::Errored => {
@@ -540,7 +573,7 @@ async fn monitor_status(app: LocalApp) -> Status {
540573
);
541574
return tower_runtime::Status::Running; // Return a default status for timeout
542575
}
543-
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
576+
sleep(Duration::from_millis(100)).await;
544577
continue;
545578
}
546579
}
@@ -556,7 +589,7 @@ async fn monitor_status(app: LocalApp) -> Status {
556589
output::error("Failed to get app status after timeout");
557590
return tower_runtime::Status::Running; // Return a default status for timeout
558591
}
559-
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
592+
sleep(Duration::from_millis(100)).await;
560593
}
561594
}
562595
}
@@ -585,7 +618,7 @@ async fn wait_for_run_start(config: &Config, run: &Run) -> Result<(), Error> {
585618
break;
586619
} else {
587620
// Wait half a second to to try again.
588-
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
621+
sleep(Duration::from_millis(500)).await;
589622
}
590623
}
591624

@@ -602,7 +635,7 @@ async fn wait_for_run_completion(config: &Config, run: &Run) -> Result<Run, Erro
602635
return Ok(res.run);
603636
} else {
604637
// Wait half a second to to try again.
605-
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
638+
sleep(Duration::from_millis(500)).await;
606639
}
607640
}
608641
}

tests/integration/features/cli_runs.feature

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,10 @@ Feature: CLI Run Commands
3131
Then the output should show "Scheduling run..." spinner
3232
And the output should show "Waiting for run to start..." spinner
3333
And both spinners should complete successfully
34+
35+
Scenario: CLI run should show logs that arrive after run completes
36+
Given I have a simple hello world application named "app-logs-after-completion"
37+
When I run "tower deploy --create" via CLI
38+
And I run "tower run" via CLI
39+
Then the output should show "First log before run completes"
40+
And the output should show "Second log after run completes"

tests/integration/features/steps/mcp_steps.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -898,3 +898,8 @@ def step_then_receive_workflow_help_stdio(context):
898898
assert "content" in result
899899
content = result["content"][0]["text"]
900900
assert "Tower Application Development Workflow" in content
901+
902+
903+
@given('I have a simple hello world application named "{app_name}"')
904+
def step_create_hello_world_app_named(context, app_name):
905+
create_towerfile(context, app_name=app_name)

tests/mock-api-server/main.py

Lines changed: 54 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,11 @@ async def describe_run(name: str, seq: int):
260260
now_time = datetime.datetime.now()
261261
elapsed = (now_time - created_time).total_seconds()
262262

263-
if elapsed > 5: # After 5 seconds, mark as completed
263+
# For logs-after-completion test apps, complete quickly to test log draining
264+
# Use 1 second so CLI has time to start streaming before completion
265+
completion_threshold = 1.0 if "logs-after-completion" in name else 5.0
266+
267+
if elapsed > completion_threshold:
264268
run_data["status"] = "exited"
265269
run_data["status_group"] = "successful"
266270
run_data["exit_code"] = 0
@@ -449,58 +453,76 @@ async def refresh_session(refresh_params: Dict[str, Any] = None):
449453
}
450454

451455

456+
NORMAL_LOG_ENTRIES = [
457+
(1, "Starting application...", "2025-08-22T12:00:00Z"),
458+
(2, "Hello, World!", "2025-08-22T12:00:01Z"),
459+
(3, "Application completed successfully", "2025-08-22T12:00:02Z"),
460+
]
461+
462+
463+
def make_log_data(seq: int, line_num: int, content: str, timestamp: str):
464+
return {
465+
"channel": "program",
466+
"content": content,
467+
"line_num": line_num,
468+
"reported_at": timestamp,
469+
"run_id": f"mock-run-{seq}",
470+
}
471+
472+
473+
def make_log_event(seq: int, line_num: int, content: str, timestamp: str):
474+
return f"event: log\ndata: {json.dumps(make_log_data(seq, line_num, content, timestamp))}\n\n"
475+
476+
452477
@app.get("/v1/apps/{name}/runs/{seq}/logs")
453478
async def describe_run_logs(name: str, seq: int):
454479
"""Mock endpoint for getting run logs."""
455480
if name not in mock_apps_db:
456481
raise HTTPException(status_code=404, detail=f"App '{name}' not found")
457482

458-
# Return mock log entries
459483
return {
460484
"log_lines": [
461-
{"timestamp": "2025-08-22T12:00:00Z", "message": "Starting application..."},
462-
{"timestamp": "2025-08-22T12:00:01Z", "message": "Hello, World!"},
463-
{
464-
"timestamp": "2025-08-22T12:00:02Z",
465-
"message": "Application completed successfully",
466-
},
485+
make_log_data(seq, line_num, content, timestamp)
486+
for line_num, content, timestamp in NORMAL_LOG_ENTRIES
467487
]
468488
}
469489

470490

491+
async def generate_logs_after_completion_test_stream(seq: int):
492+
"""Log before run completion, then log after.
493+
494+
Timeline: Run completes at 1 second, second log sent at 1.5 seconds.
495+
"""
496+
yield make_log_event(
497+
seq, 1, "First log before run completes", "2025-08-22T12:00:00Z"
498+
)
499+
await asyncio.sleep(1.5)
500+
yield make_log_event(
501+
seq, 2, "Second log after run completes", "2025-08-22T12:00:01Z"
502+
)
503+
504+
505+
async def generate_normal_log_stream(seq: int):
506+
"""Normal log stream for regular tests."""
507+
for line_num, content, timestamp in NORMAL_LOG_ENTRIES:
508+
yield make_log_event(seq, line_num, content, timestamp)
509+
await asyncio.sleep(0.1)
510+
511+
471512
@app.get("/v1/apps/{name}/runs/{seq}/logs/stream")
472513
async def stream_run_logs(name: str, seq: int):
473514
"""Mock endpoint for streaming run logs."""
474515

475516
if name not in mock_apps_db:
476517
raise HTTPException(status_code=404, detail=f"App '{name}' not found")
477518

478-
async def generate_log_stream():
479-
# Simulate streaming logs with proper SSE format for Tower CLI
480-
mock_logs = [
481-
{"timestamp": "2025-08-22T12:00:00Z", "content": "Starting application..."},
482-
{"timestamp": "2025-08-22T12:00:01Z", "content": "Hello, World!"},
483-
{
484-
"timestamp": "2025-08-22T12:00:02Z",
485-
"content": "Application completed successfully",
486-
},
487-
]
488-
489-
for i, log_entry in enumerate(mock_logs):
490-
# Format as RunLogLine structure expected by CLI
491-
log_data = {
492-
"channel": "program",
493-
"content": log_entry["content"],
494-
"line_num": i + 1,
495-
"reported_at": log_entry["timestamp"],
496-
"run_id": f"mock-run-{seq}",
497-
}
498-
# Proper SSE format with event type and data
499-
yield f"event: log\ndata: {json.dumps(log_data)}\n\n"
500-
await asyncio.sleep(0.1) # Small delay between logs
519+
if "logs-after-completion" in name:
520+
stream = generate_logs_after_completion_test_stream(seq)
521+
else:
522+
stream = generate_normal_log_stream(seq)
501523

502524
return StreamingResponse(
503-
generate_log_stream(),
525+
stream,
504526
media_type="text/event-stream",
505527
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
506528
)

0 commit comments

Comments
 (0)