diff --git a/pure.zsh b/pure.zsh index 99de24f..12ed97c 100644 --- a/pure.zsh +++ b/pure.zsh @@ -412,13 +412,26 @@ prompt_pure_async_renice() { fi } +prompt_pure_clear_git_state() { + unset prompt_pure_git_dirty prompt_pure_git_last_dirty_check_timestamp prompt_pure_git_arrows prompt_pure_git_stash prompt_pure_git_fetch_pattern + typeset -gA prompt_pure_vcs_info + prompt_pure_vcs_info[branch]= + prompt_pure_vcs_info[top]= + prompt_pure_vcs_info[action]= + prompt_pure_vcs_info[pwd]= +} + prompt_pure_async_init() { typeset -g prompt_pure_async_inited if ((${prompt_pure_async_inited:-0})); then return fi + if ! async_start_worker "prompt_pure" -u -n 2>/dev/null; then + # Worker failed to start (e.g. zpty permission denied). + # Degrade gracefully by skipping async git operations. + return 1 + fi prompt_pure_async_inited=1 - async_start_worker "prompt_pure" -u -n async_register_callback "prompt_pure" prompt_pure_async_callback async_worker_eval "prompt_pure" prompt_pure_async_renice } @@ -447,17 +460,16 @@ prompt_pure_async_tasks() { async_flush_jobs "prompt_pure" fi - # Clear git state to handle runtime disabling. - unset prompt_pure_git_dirty prompt_pure_git_last_dirty_check_timestamp prompt_pure_git_arrows prompt_pure_git_stash prompt_pure_git_fetch_pattern - prompt_pure_vcs_info[branch]= - prompt_pure_vcs_info[top]= - prompt_pure_vcs_info[action]= - prompt_pure_vcs_info[pwd]= + prompt_pure_clear_git_state return fi - # Initialize the async worker. - prompt_pure_async_init + # Initialize the async worker. If it fails (e.g. zpty unavailable), + # skip all async tasks and show prompt without git info. + if ! prompt_pure_async_init; then + prompt_pure_clear_git_state + return + fi # Update the current working directory of the async worker. async_worker_eval "prompt_pure" builtin cd -q $PWD @@ -544,6 +556,11 @@ prompt_pure_async_callback() { local job=$1 code=$2 output=$3 exec_time=$4 next_pending=$6 local do_render=0 + if [[ $job != '[async]' ]] && + (( ! ${prompt_pure_async_inited:-0} )); then + return + fi + case $job in \[async]) # Handle all the errors that could indicate a crashed @@ -555,8 +572,13 @@ prompt_pure_async_callback() { # and defer the restart? typeset -g prompt_pure_async_inited=0 async_stop_worker prompt_pure - prompt_pure_async_init # Reinit the worker. - prompt_pure_async_tasks # Restart all tasks. + if prompt_pure_async_init; then + prompt_pure_async_tasks # Restart all tasks. + else + prompt_pure_clear_git_state + do_render=1 + next_pending=0 + fi # Reset render state due to restart. unset prompt_pure_async_render_requested @@ -968,7 +990,7 @@ prompt_pure_setup() { prompt '%F{242}>%f ' ) # Combine the parts with conditional logic. First the `:+` operator is - # used to replace `compare` either with `main` or an ampty string. Then + # used to replace `compare` either with `main` or an empty string. Then # the `:-` operator is used so that if `compare` becomes an empty # string, it is replaced with `secondary`. local ps4_symbols='${${'${ps4_parts[compare]}':+"'${ps4_parts[main]}'"}:-"'${ps4_parts[secondary]}'"}' diff --git a/tests/async-worker-failure.zsh b/tests/async-worker-failure.zsh new file mode 100644 index 0000000..e968990 --- /dev/null +++ b/tests/async-worker-failure.zsh @@ -0,0 +1,151 @@ +#!/usr/bin/env zsh + +source "${0:A:h}/test-helper.zsh" +set +e + +main() { + test_no_infinite_recursion_on_worker_failure || return + test_worker_startup_failure_clears_git_state || return + test_callback_no_recursion_on_worker_failure || return + test_callback_failed_recovery_clears_git_state || return + test_callback_recovery_calls_tasks_on_success || return + + print -- "async-worker-failure tests passed" +} + +test_no_infinite_recursion_on_worker_failure() { + # Simulate async_start_worker always failing (e.g. zpty permission denied). + async_start_worker() { return 1 } + + typeset -g prompt_pure_async_inited=0 + + # This should return 1 (failure), not infinitely recurse. + local ret=0 + prompt_pure_async_init || ret=$? + + assert_equal 1 $ret "prompt_pure_async_init should return 1 when worker fails to start" || return + assert_equal 0 $prompt_pure_async_inited "prompt_pure_async_inited should be reset to 0 on failure" || return + + unfunction async_start_worker +} + +test_worker_startup_failure_clears_git_state() { + # Simulate async_start_worker failing after Git state was previously populated. + async_start_worker() { + return 1 + } + + typeset -gA prompt_pure_vcs_info=(branch main top /tmp/repo action rebase pwd /tmp/repo) + typeset -g prompt_pure_git_dirty="*" + typeset -g prompt_pure_git_last_dirty_check_timestamp=1 + typeset -g prompt_pure_git_arrows="⇡" + typeset -g prompt_pure_git_stash=1 + typeset -g prompt_pure_git_fetch_pattern="pull|fetch" + typeset -g prompt_pure_async_inited=0 + + prompt_pure_async_tasks || : + + assert_empty "${prompt_pure_vcs_info[branch]-}" "branch should be cleared when worker fails to start" || return + assert_empty "${prompt_pure_vcs_info[top]-}" "top-level should be cleared when worker fails to start" || return + assert_empty "${prompt_pure_vcs_info[action]-}" "action should be cleared when worker fails to start" || return + assert_empty "${prompt_pure_vcs_info[pwd]-}" "pwd should be cleared when worker fails to start" || return + assert_empty "${prompt_pure_git_dirty-}" "dirty marker should be cleared when worker fails to start" || return + assert_empty "${prompt_pure_git_last_dirty_check_timestamp-}" "cached dirty timestamp should be cleared when worker fails to start" || return + assert_empty "${prompt_pure_git_arrows-}" "arrows should be cleared when worker fails to start" || return + assert_empty "${prompt_pure_git_stash-}" "stash should be cleared when worker fails to start" || return + assert_empty "${prompt_pure_git_fetch_pattern-}" "fetch pattern should be cleared when worker fails to start" || return + + unfunction async_start_worker +} + +test_callback_no_recursion_on_worker_failure() { + # Simulate async_start_worker always failing. + async_start_worker() { return 1 } + async_stop_worker() { : } + + # Stub prompt_pure_async_tasks to detect if it gets called. + local tasks_called=0 + prompt_pure_async_tasks() { tasks_called=1 } + + typeset -g prompt_pure_async_inited=0 + + # Simulate the callback receiving an async worker crash (code 3). + # Code 3 is representative; codes 2 and 130 share the same branch. + prompt_pure_async_callback '[async]' 3 '' 0 'worker crashed' 0 + + assert_equal 0 $prompt_pure_async_inited "prompt_pure_async_inited should remain 0 after failed recovery" || return + assert_equal 0 $tasks_called "prompt_pure_async_tasks should not be called when recovery fails" || return + + unfunction async_start_worker async_stop_worker prompt_pure_async_tasks +} + +test_callback_failed_recovery_clears_git_state() { + # Simulate async_start_worker failing after Git state was previously populated. + async_start_worker() { + return 1 + } + async_stop_worker() { + : + } + + typeset -gA prompt_pure_vcs_info=(branch main top /tmp/repo action rebase pwd /tmp/repo) + typeset -g prompt_pure_git_dirty="*" + typeset -g prompt_pure_git_last_dirty_check_timestamp=1 + typeset -g prompt_pure_git_arrows="⇡" + typeset -g prompt_pure_git_stash=1 + typeset -g prompt_pure_git_fetch_pattern="pull|fetch" + typeset -g prompt_pure_async_inited=1 + local render_called=0 + prompt_pure_preprompt_render() { + render_called=1 + } + + # Use next_pending=1 because a worker crash may be reported while buffered output remains. + prompt_pure_async_callback '[async]' 3 '' 0 'worker crashed' 1 + + assert_empty "${prompt_pure_vcs_info[branch]-}" "branch should be cleared when worker recovery fails" || return + assert_empty "${prompt_pure_vcs_info[top]-}" "top-level should be cleared when worker recovery fails" || return + assert_empty "${prompt_pure_vcs_info[action]-}" "action should be cleared when worker recovery fails" || return + assert_empty "${prompt_pure_vcs_info[pwd]-}" "pwd should be cleared when worker recovery fails" || return + assert_empty "${prompt_pure_git_dirty-}" "dirty marker should be cleared when worker recovery fails" || return + assert_empty "${prompt_pure_git_last_dirty_check_timestamp-}" "cached dirty timestamp should be cleared when worker recovery fails" || return + assert_empty "${prompt_pure_git_arrows-}" "arrows should be cleared when worker recovery fails" || return + assert_empty "${prompt_pure_git_stash-}" "stash should be cleared when worker recovery fails" || return + assert_empty "${prompt_pure_git_fetch_pattern-}" "fetch pattern should be cleared when worker recovery fails" || return + assert_equal 1 $render_called "prompt should be rendered after worker recovery clears git state" || return + + local async_job_called=0 + async_job() { + async_job_called=1 + } + prompt_pure_async_callback prompt_pure_async_vcs_info 0 "pwd ${(q)PWD} branch stale top /tmp/repo action rebase" 0 '' 0 + + assert_empty "${prompt_pure_vcs_info[branch]-}" "stale callback should not restore branch after worker recovery fails" || return + assert_empty "${prompt_pure_vcs_info[top]-}" "stale callback should not restore top-level after worker recovery fails" || return + assert_equal 0 $async_job_called "stale callback should not queue async jobs after worker recovery fails" || return + + unfunction async_start_worker async_stop_worker prompt_pure_preprompt_render async_job +} + +test_callback_recovery_calls_tasks_on_success() { + # Simulate async_start_worker succeeding on recovery. + async_start_worker() { return 0 } + async_stop_worker() { : } + async_register_callback() { : } + async_worker_eval() { : } + + # Stub prompt_pure_async_tasks to detect if it gets called. + local tasks_called=0 + prompt_pure_async_tasks() { tasks_called=1 } + + typeset -g prompt_pure_async_inited=0 + + # Simulate the callback receiving an async worker crash (code 2). + prompt_pure_async_callback '[async]' 2 '' 0 'worker crashed' 0 + + assert_equal 1 $tasks_called "prompt_pure_async_tasks should be called when recovery succeeds" || return + + unfunction async_start_worker async_stop_worker async_register_callback async_worker_eval prompt_pure_async_tasks +} + +main "$@"