Skip to content

Commit 9bc031b

Browse files
authored
fix(cram): show partial output on timeout (#15269)
When cram tests time out, it's helpful to see the partial output emitted so far instead of an error message. This PR accomplishes that. Signed-off-by: Rudi Grinberg <me@rgrinberg.com>
1 parent 800bc76 commit 9bc031b

4 files changed

Lines changed: 241 additions & 107 deletions

File tree

doc/changes/changed/15269.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Show partial output timed out cram tests (#15269, @rgrinberg)

src/dune_rules/cram/cram_exec.ml

Lines changed: 163 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,8 @@ let metadata_entry_repr =
166166
type metadata_result =
167167
| Present of metadata_entry
168168
| Missing_unreachable
169+
| Timed_out
170+
| Not_ran
169171

170172
let metadata_result_repr =
171173
Repr.variant
@@ -176,6 +178,12 @@ let metadata_result_repr =
176178
; Repr.case0 "Missing_unreachable" ~test:(function
177179
| Missing_unreachable -> true
178180
| _ -> false)
181+
; Repr.case0 "Timed_out" ~test:(function
182+
| Timed_out -> true
183+
| _ -> false)
184+
; Repr.case0 "Not_ran" ~test:(function
185+
| Not_ran -> true
186+
| _ -> false)
179187
]
180188
;;
181189

@@ -275,6 +283,29 @@ let read_and_attach_exit_codes (sh_script : sh_script)
275283
loop [] metadata_entries sh_script.cram_to_output times
276284
;;
277285

286+
let mark_timed_out_command =
287+
let rec loop acc = function
288+
| [] -> None
289+
| (Cram_lexer.Comment _ as comment) :: blocks -> loop (comment :: acc) blocks
290+
| Command ({ metadata = Present _; _ } as command) :: blocks ->
291+
loop (Command command :: acc) blocks
292+
| Command { metadata = Timed_out | Not_ran; _ } :: _ ->
293+
Code_error.raise "unexpected timeout metadata" []
294+
| Command ({ metadata = Missing_unreachable; block; _ } as command) :: blocks ->
295+
(match Fpath.exists (Path.to_string block.output_file) with
296+
| false -> None
297+
| true ->
298+
let blocks =
299+
List.map blocks ~f:(function
300+
| Cram_lexer.Comment _ as comment -> comment
301+
| Command command ->
302+
Command { command with metadata = Not_ran; duration = None })
303+
in
304+
Some (List.rev acc @ (Command { command with metadata = Timed_out } :: blocks)))
305+
in
306+
fun cram_to_output -> loop [] cram_to_output
307+
;;
308+
278309
let line_number =
279310
let open Re in
280311
seq [ set "123456789"; rep digit ]
@@ -344,12 +375,15 @@ let sanitize ~parent_script cram_to_output : command_out Cram_lexer.block list =
344375
| Command { block; metadata; duration = _ } ->
345376
let output =
346377
let output =
347-
match Io.read_file ~binary:false block.output_file with
348-
| exception _ -> None
349-
| contents -> Some (Ansi_color.strip contents)
378+
match metadata with
379+
| Not_ran -> None
380+
| Present _ | Missing_unreachable | Timed_out ->
381+
(match Io.read_file ~binary:false block.output_file with
382+
| exception _ -> None
383+
| contents -> Some (Ansi_color.strip contents))
350384
in
351385
match metadata with
352-
| Missing_unreachable -> output
386+
| Missing_unreachable | Not_ran | Timed_out -> output
353387
| Present { build_path_prefix_map; exit_code = _ } ->
354388
Option.map
355389
output
@@ -381,17 +415,26 @@ let compose_cram_output (cram_to_output : _ Cram_lexer.block list) =
381415
let line = sprintf "%c %s" (if i = 0 then '$' else '>') line in
382416
add_line_prefixed_with_two_space line);
383417
let unreachable = "***** UNREACHABLE *****" in
384-
let () =
385-
Option.value output ~default:unreachable
386-
|> String.split_lines
387-
|> List.iter ~f:add_line_prefixed_with_two_space
418+
let add_output output =
419+
output |> String.split_lines |> List.iter ~f:add_line_prefixed_with_two_space
420+
in
421+
let add_output_or_unreachable = function
422+
| None -> add_line_prefixed_with_two_space unreachable
423+
| Some output -> add_output output
388424
in
389425
(match metadata with
390426
| Missing_unreachable ->
427+
add_output_or_unreachable output;
391428
Option.iter output ~f:(fun (_ : string) ->
392429
add_line_prefixed_with_two_space unreachable)
393-
| Present { exit_code = 0; build_path_prefix_map = _ } -> ()
430+
| Timed_out ->
431+
Option.iter output ~f:add_output;
432+
add_line_prefixed_with_two_space "[timed out]"
433+
| Not_ran -> add_line_prefixed_with_two_space "[not ran]"
434+
| Present { exit_code = 0; build_path_prefix_map = _ } ->
435+
add_output_or_unreachable output
394436
| Present { exit_code; build_path_prefix_map = _ } ->
437+
add_output_or_unreachable output;
395438
add_line_prefixed_with_two_space (sprintf "[%d]" exit_code)));
396439
Buffer.contents buf
397440
;;
@@ -549,6 +592,75 @@ let make_temp_dir ~script =
549592
temp_dir
550593
;;
551594

595+
let raise_timeout_error ~src ~timeout =
596+
(match timeout with
597+
| None -> [ Pp.text "Cram test timed out" ]
598+
| Some (timeout_loc, timeout) ->
599+
let timeout_set_message =
600+
[ Pp.textf "A time limit of %.2fs has been set in " (Time.Span.to_secs timeout)
601+
; Pp.tag User_message.Style.Loc @@ Loc.pp_file_colon_line timeout_loc
602+
]
603+
|> Pp.concat
604+
|> Pp.hovbox
605+
in
606+
[ Pp.text "Cram test timed out"; timeout_set_message ])
607+
|> User_error.raise
608+
~loc:(Loc.in_file (Path.drop_optional_build_context_maybe_sandboxed src))
609+
;;
610+
611+
let combine_with_current_stanzas =
612+
let rec loop acc current expected =
613+
match current with
614+
| [] -> acc
615+
| Cram_lexer.Comment x :: current ->
616+
loop (Cram_lexer.Comment x :: acc) current expected
617+
| Command _ :: current ->
618+
(match expected with
619+
| [] -> acc
620+
| out :: expected -> loop (Cram_lexer.Command out :: acc) current expected)
621+
in
622+
fun current_stanzas expected -> loop [] current_stanzas expected |> List.rev
623+
;;
624+
625+
let print_timeout_correction_and_fail ~src ~conflict_markers ~timeout command_outputs =
626+
let open Fiber.O in
627+
let* () =
628+
let source_contents = Io.read_file ~binary:false src in
629+
let expected =
630+
let current_stanzas =
631+
Lexbuf.from_string source_contents ~fname:(Path.to_string src)
632+
|> cram_stanzas ~conflict_markers
633+
|> List.map ~f:snd
634+
in
635+
combine_with_current_stanzas current_stanzas command_outputs |> compose_cram_output
636+
in
637+
let corrected_file = Path.extend_basename src ~suffix:Filename.corrected in
638+
if String.equal source_contents expected
639+
then (
640+
Path.rm_rf corrected_file;
641+
Fiber.return ())
642+
else (
643+
Io.write_file ~binary:false corrected_file expected;
644+
let+ diff = Dune_engine.Print_diff.get src corrected_file in
645+
let () =
646+
let source_file = Path.drop_optional_build_context_src_exn src in
647+
let correction_file = Path.as_in_build_dir_exn corrected_file in
648+
Dune_engine.Diff_promotion.register_intermediate
649+
`Move
650+
~source_file
651+
~correction_file
652+
in
653+
match diff with
654+
| Error message -> Console.print_user_message message
655+
| Ok diff ->
656+
Dune_engine.Print_diff.Diff.print diff;
657+
(* The timeout error below is printed on stderr, so flush the diff first to
658+
keep the correction before the error in combined output. *)
659+
flush stdout)
660+
in
661+
raise_timeout_error ~src ~timeout
662+
;;
663+
552664
let run_cram_test
553665
env
554666
~src
@@ -620,42 +732,9 @@ let run_cram_test
620732
|> Dune_trace.Event.Cram.test ~test:src);
621733
sanitize ~parent_script:script detailed_output
622734
| Error `Timed_out ->
623-
let timeout_set_message =
624-
let timeout_loc, timeout = Option.value_exn timeout in
625-
[ Pp.textf "A time limit of %.2fs has been set in " (Time.Span.to_secs timeout)
626-
; Pp.tag User_message.Style.Loc @@ Loc.pp_file_colon_line timeout_loc
627-
]
628-
|> Pp.concat
629-
|> Pp.hovbox
630-
in
631-
let timeout_msg =
632-
match
633-
let completed_count =
634-
read_exit_codes_and_prefix_maps sh_script.metadata_file |> List.length
635-
in
636-
let command_blocks_only =
637-
List.filter_map sh_script.cram_to_output ~f:(function
638-
| Cram_lexer.Comment _ -> None
639-
| Command block_result -> Some block_result)
640-
in
641-
let total_commands = List.length command_blocks_only in
642-
if completed_count < total_commands
643-
then (
644-
(* Find the command that got stuck - it's the one at index completed_count *)
645-
match List.nth command_blocks_only completed_count with
646-
| Some { command; _ } -> Some (String.concat ~sep:" " command)
647-
| None -> None)
648-
else None
649-
with
650-
| None -> [ Pp.text "Cram test timed out" ]
651-
| Some cmd ->
652-
[ Pp.textf "Cram test timed out while running command:"
653-
; Pp.verbatimf " $ %s" cmd
654-
]
655-
in
656-
User_error.raise
657-
~loc:(Loc.in_file (Path.drop_optional_build_context_maybe_sandboxed src))
658-
(timeout_msg @ [ timeout_set_message ])
735+
(match read_and_attach_exit_codes sh_script |> mark_timed_out_command with
736+
| None -> raise_timeout_error ~src ~timeout
737+
| Some detailed_output -> sanitize ~parent_script:script detailed_output)
659738
;;
660739

661740
let run_produce_correction
@@ -691,7 +770,7 @@ module Script = Persistent.Make (struct
691770

692771
let name = "CRAM-RESULT"
693772
let sharing = false
694-
let version = 2
773+
let version = 3
695774
let repr = Repr.list command_out_repr
696775
end)
697776

@@ -706,35 +785,44 @@ let run_and_produce_output
706785
~setup_scripts
707786
shell
708787
=
709-
let cram_stanzas =
710-
Io.read_file ~binary:false script
711-
|> Lexbuf.from_string ~fname:(Path.to_string script)
712-
|> cram_stanzas ~conflict_markers
713-
|> List.map ~f:snd
714-
in
715-
let temp_dir = make_temp_dir ~script in
716-
(* We don't want the ".cram.run.t" dir around when executing the script. *)
717-
Path.rm_rf (Path.parent_exn script);
718-
let open Fiber.O in
719-
let+ commands =
720-
let env = make_run_env env ~temp_dir ~cwd in
721-
run_cram_test
722-
env
723-
~src
724-
~script
725-
~cram_stanzas
726-
~temp_dir
727-
~cwd
728-
~timeout
729-
~setup_scripts
730-
shell
731-
>>| List.filter_map ~f:(function
788+
let open Fiber.O in
789+
let* commands =
790+
let+ cram_to_output =
791+
let cram_stanzas =
792+
Io.read_file ~binary:false script
793+
|> Lexbuf.from_string ~fname:(Path.to_string script)
794+
|> cram_stanzas ~conflict_markers
795+
|> List.map ~f:snd
796+
in
797+
(* We don't want the ".cram.run.t" dir around when executing the script. *)
798+
Path.rm_rf (Path.parent_exn script);
799+
let temp_dir = make_temp_dir ~script in
800+
let env = make_run_env env ~temp_dir ~cwd in
801+
run_cram_test
802+
env
803+
~src
804+
~script
805+
~cram_stanzas
806+
~temp_dir
807+
~cwd
808+
~timeout
809+
~setup_scripts
810+
shell
811+
in
812+
List.filter_map cram_to_output ~f:(function
732813
| Cram_lexer.Command c -> Some c
733814
| Comment _ -> None)
734815
in
735-
let dst = Path.build dst in
736-
Path.mkdir_p (Path.parent_exn dst);
737-
Script.dump dst commands
816+
if
817+
List.exists commands ~f:(function
818+
| { metadata = Timed_out; _ } -> true
819+
| _ -> false)
820+
then print_timeout_correction_and_fail ~src ~conflict_markers ~timeout commands
821+
else (
822+
let dst = Path.build dst in
823+
Path.mkdir_p (Path.parent_exn dst);
824+
Script.dump dst commands;
825+
Fiber.return ())
738826
;;
739827

740828
module Run = struct
@@ -750,7 +838,7 @@ module Run = struct
750838
}
751839

752840
let name = "cram-run"
753-
let version = 6
841+
let version = 7
754842
let runs_process = true
755843
let can_run_in_action_runner = true
756844

@@ -867,7 +955,7 @@ module Diff = struct
867955
}
868956

869957
let name = "cram-generate"
870-
let version = 1
958+
let version = 2
871959
let runs_process = false
872960
let can_run_in_action_runner = false
873961
let bimap { script; out } f _ = { script = f script; out = f out }
@@ -889,17 +977,7 @@ module Diff = struct
889977
|> cram_stanzas ~conflict_markers:Ignore
890978
|> List.map ~f:snd
891979
in
892-
let rec loop acc current expected =
893-
match current with
894-
| [] -> acc
895-
| Cram_lexer.Comment x :: current ->
896-
loop (Cram_lexer.Comment x :: acc) current expected
897-
| Command _ :: current ->
898-
(match expected with
899-
| [] -> acc
900-
| out :: expected -> loop (Cram_lexer.Command out :: acc) current expected)
901-
in
902-
loop [] current_stanzas out |> List.rev
980+
combine_with_current_stanzas current_stanzas out
903981
in
904982
let expected = compose_cram_output combined in
905983
let corrected_file = Path.extend_basename script ~suffix:Filename.corrected in

test/blackbox-tests/test-cases/cram/timeout-no-command.t

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
1-
Testing that timeout errors don't include the command that caused the timeout.
2-
3-
This test demonstrates the current behavior where timeout error messages
4-
don't include information about which specific command caused the timeout.
1+
Testing that timeout errors include the partial output from the command that
2+
caused the timeout.
53

64
$ make_dune_project 3.20
75

86
$ cat > dune <<EOF
97
> (cram
10-
> (timeout 0.1))
8+
> (timeout 0.5))
119
> EOF
1210

1311
Create a cram test with multiple commands, where the second one will timeout:
@@ -17,17 +15,20 @@ Create a cram test with multiple commands, where the second one will timeout:
1715
> $ echo "This is the problematic command" && sleep 2
1816
> EOF
1917

20-
Run the test and verify that the timeout error doesn't mention
21-
which specific command caused the timeout:
18+
Run the test and verify that the timeout output includes the partial output of
19+
the command that timed out:
2220

2321
$ dune test test.t
2422
File "test.t", line 1, characters 0-0:
25-
Error: Cram test timed out while running command:
26-
$ echo "This is the problematic command" && sleep 2
27-
A time limit of 0.10s has been set in dune:2
23+
--- test.t
24+
+++ test.t.corrected
25+
@@ -1,2 +1,5 @@
26+
$ echo "This command runs fine"
27+
+ This command runs fine
28+
$ echo "This is the problematic command" && sleep 2
29+
+ This is the problematic command
30+
+ [timed out]
31+
File "test.t", line 1, characters 0-0:
32+
Error: Cram test timed out
33+
A time limit of 0.50s has been set in dune:2
2834
[1]
29-
30-
The error message above shows that we get a generic timeout message but no
31-
indication that it was the "echo && sleep 2" command that caused the timeout.
32-
This makes debugging timeout issues difficult when there are multiple commands
33-
in a test.

0 commit comments

Comments
 (0)