diff --git a/src/cli.toit b/src/cli.toit index 29766af..ce3196b 100644 --- a/src/cli.toit +++ b/src/cli.toit @@ -373,11 +373,17 @@ class Command: // Handle __complete requests before any other processing. if add-completion and not arguments.is-empty and arguments[0] == "__complete": + // Silence the default logger: the completion candidates are printed to + // stdout, so any library logging from completion callbacks would + // corrupt the completion output. + log.set-default (log.default.with-level log.FATAL-LEVEL) if add-ui-help: add-ui-options_ completion-args := arguments[1..] if not completion-args.is-empty and completion-args[0] == "--": completion-args = completion-args[1..] - result := complete_ this completion-args + result := complete-with-timeout_ this completion-args + // The completion scripts treat a non-zero exit as "no completions". + if not result: exit 1 result.candidates.do: | candidate/CompletionCandidate_ | print candidate.to-string if result.extensions and not result.extensions.is-empty: diff --git a/src/completion_.toit b/src/completion_.toit index f489236..4a03b91 100644 --- a/src/completion_.toit +++ b/src/completion_.toit @@ -54,6 +54,32 @@ class CompletionResult_: constructor .candidates --.directive=DIRECTIVE-DEFAULT_ --.extensions=null: +/** +The maximum time a completion request may take. + +Completion requests run while the user is waiting for the shell to + react to a tab-press, and shells don't interrupt slow completion + commands. If a completion callback misbehaves (for example by hanging + on a network operation), this limit bounds how long the user's prompt + is blocked. +*/ +COMPLETION-TIMEOUT-MS_ ::= 5_000 + +/** +Computes completion candidates like $complete_, but guards against + misbehaving completion callbacks. + +Returns null if the computation throws or takes longer than + $timeout-ms. The caller should then exit with a non-zero exit code, + which the generated completion scripts treat as "no completions". +*/ +complete-with-timeout_ root/Command arguments/List --timeout-ms/int=COMPLETION-TIMEOUT-MS_ -> CompletionResult_?: + result/CompletionResult_? := null + catch: + with-timeout --ms=timeout-ms: + result = complete_ root arguments + return result + /** Computes completion candidates for the given $arguments. diff --git a/tests/completion_test.toit b/tests/completion_test.toit index 79237f2..af74f54 100644 --- a/tests/completion_test.toit +++ b/tests/completion_test.toit @@ -44,6 +44,8 @@ main: test-command-group-completion test-command-group-after-default-entered test-command-group-with-extensions + test-slow-callback-times-out + test-throwing-callback-yields-null test-empty-input: root := cli.Command "app" @@ -867,3 +869,30 @@ test-command-group-with-extensions: expect-equals 1 result.extensions.size expect (result.extensions.contains ".toit") + +test-slow-callback-times-out: + root := cli.Command "app" + --options=[ + cli.Option "host" --help="Target host." + --completion=:: | context/cli.CompletionContext | + sleep --ms=1_000 + [cli.CompletionCandidate "too-late"], + ] + --run=:: null + result := complete-with-timeout_ root ["--host", ""] --timeout-ms=50 + expect-null result + + // A fast callback is unaffected. + result = complete-with-timeout_ root ["--", ""] --timeout-ms=1_000 + expect-not-null result + +test-throwing-callback-yields-null: + root := cli.Command "app" + --options=[ + cli.Option "host" --help="Target host." + --completion=:: | context/cli.CompletionContext | + throw "callback is broken", + ] + --run=:: null + result := complete-with-timeout_ root ["--host", ""] + expect-null result