From 9ed4a457a6bd3075f8510a858510d6cf4a94ded0 Mon Sep 17 00:00:00 2001 From: jiminu Date: Sat, 17 Oct 2026 19:18:32 +0900 Subject: [PATCH 1/2] fix: compile the completion dump atomically zcompile unlinks the shared .zcompdump.zwc and recreates it read-only in place, so shells starting together (a terminal restoring several tabs) race and fail with "can't write zwc file". Compile into a private file and rename it over the shared one. --- config/shared/zsh/completion.zsh | 18 +++++++++++++++--- tests/integration/native_completion_test.go | 15 +++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/config/shared/zsh/completion.zsh b/config/shared/zsh/completion.zsh index bc4c33e..6c556ae 100644 --- a/config/shared/zsh/completion.zsh +++ b/config/shared/zsh/completion.zsh @@ -39,6 +39,17 @@ _selfishell_completion_files_changed() { return 1 } +# zcompile unlinks and recreates the read-only .zwc in place, so concurrent +# shells fail with "can't write zwc file"; compile privately, then rename. +_selfishell_completion_compile() { + local temporary="$ZCOMPDUMP.tmp.$$.$RANDOM.zwc" + + zcompile "$temporary" "$ZCOMPDUMP" && command mv -f "$temporary" "$ZCOMPDUMP.zwc" || { + command rm -f "$temporary" + return 1 + } +} + if [[ -o interactive ]] && { _selfishell_completion_needs_audit "$ZCOMPDUMP" || _selfishell_completion_files_changed "$ZCOMPDUMP"; }; then # -i excludes insecure entries; -D rebuilds even when an unaudited dump has @@ -56,7 +67,7 @@ if [[ -o interactive ]] && { _selfishell_completion_needs_audit "$ZCOMPDUMP" || fi # Invalidate the old marker before rebuilding; a failed write must not # let the next shell skip the audit and restore insecure fpath entries. - if compdump && zcompile "$ZCOMPDUMP" && [[ "${_comp_secure:-}" != yes && + if compdump && _selfishell_completion_compile && [[ "${_comp_secure:-}" != yes && ! -e "$ZCOMPDUMP.audit" && ! -L "$ZCOMPDUMP.audit" ]]; then command touch "$ZCOMPDUMP.audit" 2>/dev/null fi @@ -64,11 +75,12 @@ if [[ -o interactive ]] && { _selfishell_completion_needs_audit "$ZCOMPDUMP" || else compinit -C -d "$ZCOMPDUMP" fi -unfunction _selfishell_completion_needs_audit _selfishell_completion_files_changed if [[ -s "$ZCOMPDUMP" && ( ! -s "$ZCOMPDUMP.zwc" || "$ZCOMPDUMP" -nt "$ZCOMPDUMP.zwc" ) ]]; then - zcompile "$ZCOMPDUMP" + _selfishell_completion_compile fi +unfunction _selfishell_completion_needs_audit _selfishell_completion_files_changed \ + _selfishell_completion_compile if (( $+functions[zinit] )); then zinit cdreplay -q diff --git a/tests/integration/native_completion_test.go b/tests/integration/native_completion_test.go index 06cc471..624167d 100644 --- a/tests/integration/native_completion_test.go +++ b/tests/integration/native_completion_test.go @@ -275,6 +275,21 @@ func TestNativeCompletionAudit(t *testing.T) { } } +// Terminals restoring several tabs start shells that recompile the dump at once. +func TestNativeConcurrentDumpCompile(t *testing.T) { + t.Parallel() + home := nativeHome(t) + r := nativeRun(t, home, `startup='_selfishell_command_path() { return 1; }; source "$SELFISHELL_SOURCE"' +zsh -fc "$startup" +for i in {1..8}; do + zsh -fc "repeat 20; do command touch \"\$HOME/.zcompdump\"; $startup; done" & +done +wait +leftovers=("$HOME"/.zcompdump.tmp*(DN)) +(( ! $#leftovers )) || print -rl -- $leftovers`, "ZDOTDIR="+home, "SELFISHELL_SOURCE="+nativeCompletion()) + nativeQuiet(t, r) +} + func nativeCompletionProbe(t *testing.T, home, dir, mode, compile string) capture { t.Helper() if mode == "" { From 8cf968b66ff06b781df8c8504616118dfeb6e2fb Mon Sep 17 00:00:00 2001 From: jiminu Date: Sat, 17 Oct 2026 19:30:16 +0900 Subject: [PATCH 2/2] fix: skip dump compile when a directory occupies the bytecode path mv -f moves the compiled file into a directory at the target instead of replacing it, so a directory there would collect a file on every rebuild. --- config/shared/zsh/completion.zsh | 2 ++ tests/integration/native_completion_test.go | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/config/shared/zsh/completion.zsh b/config/shared/zsh/completion.zsh index 6c556ae..cfb8d52 100644 --- a/config/shared/zsh/completion.zsh +++ b/config/shared/zsh/completion.zsh @@ -41,9 +41,11 @@ _selfishell_completion_files_changed() { # zcompile unlinks and recreates the read-only .zwc in place, so concurrent # shells fail with "can't write zwc file"; compile privately, then rename. +# mv would move the file into a directory occupying the target, so skip one. _selfishell_completion_compile() { local temporary="$ZCOMPDUMP.tmp.$$.$RANDOM.zwc" + [[ ! -d "$ZCOMPDUMP.zwc" ]] || return 1 zcompile "$temporary" "$ZCOMPDUMP" && command mv -f "$temporary" "$ZCOMPDUMP.zwc" || { command rm -f "$temporary" return 1 diff --git a/tests/integration/native_completion_test.go b/tests/integration/native_completion_test.go index 624167d..b76dd02 100644 --- a/tests/integration/native_completion_test.go +++ b/tests/integration/native_completion_test.go @@ -276,6 +276,7 @@ func TestNativeCompletionAudit(t *testing.T) { } // Terminals restoring several tabs start shells that recompile the dump at once. +// A directory occupying the bytecode path must not receive the compiled file. func TestNativeConcurrentDumpCompile(t *testing.T) { t.Parallel() home := nativeHome(t) @@ -285,7 +286,10 @@ for i in {1..8}; do zsh -fc "repeat 20; do command touch \"\$HOME/.zcompdump\"; $startup; done" & done wait -leftovers=("$HOME"/.zcompdump.tmp*(DN)) +command rm -f "$HOME/.zcompdump.zwc" && command mkdir "$HOME/.zcompdump.zwc" && + command touch -t 202001010000 "$HOME/.zcompdump.zwc" || exit 10 +zsh -fc "$startup" +leftovers=("$HOME"/.zcompdump.tmp*(DN) "$HOME"/.zcompdump.zwc/*(DN)) (( ! $#leftovers )) || print -rl -- $leftovers`, "ZDOTDIR="+home, "SELFISHELL_SOURCE="+nativeCompletion()) nativeQuiet(t, r) }