From 6f070b5ad687a50020c5c5afa8bbb4cfa032ff57 Mon Sep 17 00:00:00 2001 From: Sal <59910950+ss-o@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:04:35 +0100 Subject: [PATCH 1/4] fix(unload): keep ownership across repeated loads of one plugin (WIP, #113) Work in progress, not for review: the interleaved unload order (other plugin unloaded first) regresses against next; see the issue. --- .github/workflows/zsh-n.yml | 12 ++ lib/zsh/autoload.zsh | 26 ++- tests/repeated-load-ownership.zsh | 153 +++++++++++++++++ tests/unload-ownership-contracts.zsh | 4 +- zi.zsh | 245 ++++++++++++++++++++++++++- 5 files changed, 428 insertions(+), 12 deletions(-) create mode 100644 tests/repeated-load-ownership.zsh diff --git a/.github/workflows/zsh-n.yml b/.github/workflows/zsh-n.yml index 7d62b5f7..3f52667e 100644 --- a/.github/workflows/zsh-n.yml +++ b/.github/workflows/zsh-n.yml @@ -46,6 +46,7 @@ on: - "tests/plugin-autoload-ownership.zsh" - "tests/plugin-standard-callbacks.zsh" - "tests/release-tag-verification.zsh" + - "tests/repeated-load-ownership.zsh" - "tests/scheduler-idle.zsh" - "tests/fixtures/plugin-standard-callbacks/**" - "tests/self-update-reload.zsh" @@ -114,6 +115,17 @@ jobs: - name: Test unload ownership contracts run: zsh -f tests/unload-ownership-contracts.zsh + repeated-load-ownership: + name: Repeated Load Ownership + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install Zsh + run: sudo apt update && sudo apt-get install -yq zsh + - name: Test ownership across repeated loads of one plugin + run: zsh -f tests/repeated-load-ownership.zsh + unload-hook-dispatch: name: Unload Hook Dispatch runs-on: ubuntu-latest diff --git a/lib/zsh/autoload.zsh b/lib/zsh/autoload.zsh index 43623aba..5d36c53b 100755 --- a/lib/zsh/autoload.zsh +++ b/lib/zsh/autoload.zsh @@ -262,6 +262,8 @@ ZI[EXTENDED_GLOB]="" ZI[FUNCTIONS__$REPLY]="" ZI[FUNCTIONS_BEFORE__$REPLY]="" ZI[FUNCTIONS_AFTER__$REPLY]="" + ZI[FUNCTIONS_OWNED__$REPLY]="" + ZI[REPEAT__$REPLY]="" # Option diffing ZI[OPTIONS__$REPLY]="" ZI[OPTIONS_BEFORE__$REPLY]="" @@ -1120,7 +1122,9 @@ ZI[EXTENDED_GLOB]="" local comp_wid="${(Q)orig_saved[3]}" local orig_saved2="${(Q)orig_saved[4]}" # Saved target function local orig_saved3="${(Q)orig_saved[5]}" # Saved previous $widget's contents - local found_time_key="${keys[(r)TIME_<->_${uspl2//\//---}]}" to_process_plugin + # The plugin's newest load: an earlier load of the same plugin is not + # the one whose records are held when it was loaded more than once. + local found_time_key="${keys[(R)TIME_<->_${uspl2//\//---}]}" to_process_plugin integer found_time_idx=0 idx=0 to_process_plugin="" [[ "$found_time_key" = (#b)TIME_(<->)_* ]] && found_time_idx="${match[1]}" @@ -1137,8 +1141,9 @@ ZI[EXTENDED_GLOB]="" integer found_idx2="${entry_splitted2[(I)*\ $orig_saved1\ *]}" if (( found_idx || found_idx2 )) then - # Skip multiple loads of the same plugin - # TODO: #113 Fully handle multiple plugin loads + # Skip later loads of the same plugin: a repeated load keeps the + # first load's records (z-shell/zi#113), so the chain continues + # with the next other plugin. if [[ "$oth_uspl2" != "$uspl2" ]]; then to_process_plugin="$oth_uspl2" break # Only the first one is needed @@ -1229,6 +1234,21 @@ ZI[EXTENDED_GLOB]="" .zi-diff-functions-compute "$uspl2" typeset -a func func=( "${(z)ZI[FUNCTIONS__$uspl2]}" ) + # Functions earlier loads of this plugin created (z-shell/zi#113), except + # those another plugin loaded since then created as well. + () { + builtin setopt local_options extended_glob + local owned other_uspl2 + for owned in "${(z)ZI[FUNCTIONS_OWNED__$uspl2]}"; do + [[ -z $owned ]] && continue + for other_uspl2 in "${ZI_REGISTERED_PLUGINS[@]}"; do + [[ $other_uspl2 == "$uspl2" ]] && continue + .zi-diff-functions-compute "$other_uspl2" 2>/dev/null + (( ${${(z)ZI[FUNCTIONS__$other_uspl2]}[(Ie)$owned]} )) && continue 2 + done + (( ${func[(Ie)$owned]} )) || func+=( "$owned" ) + done + } local f for f in "${(on)func[@]}"; do [[ -z "$f" ]] && continue diff --git a/tests/repeated-load-ownership.zsh b/tests/repeated-load-ownership.zsh new file mode 100644 index 00000000..412275f7 --- /dev/null +++ b/tests/repeated-load-ownership.zsh @@ -0,0 +1,153 @@ +#!/usr/bin/env zsh +# -*- mode: zsh; sh-indentation: 2; indent-tabs-mode: nil; sh-basic-offset: 2; -*- +# vim: ft=zsh sw=2 ts=2 et +# +# z-shell/zi#113: a repeated load of the same plug-in, then one unload, must +# remove everything the plug-in added across its loads and restore what it +# replaced, without taking state another plug-in loaded in between now owns. +# Each scenario runs in its own clean shell. + +builtin emulate -R zsh +setopt pipe_fail + +fail() { + builtin print -u2 -r -- "not ok - $1" + exit 1 +} + +typeset project_root="${ZI_TEST_CHECKOUT:-${0:A:h:h}}" +typeset temp_root +temp_root="$(command mktemp -d "${TMPDIR:-/tmp}/zi-repeated-load-test.XXXXXXXX")" || + fail "create temporary directory" +trap 'command rm -rf -- "$temp_root"' EXIT INT TERM + +# write_plugin DIR FUNCTION: a plug-in that defines FUNCTION, makes it the +# zi-repeat-widget widget and binds ^X^P to that widget. +write_plugin() { + command mkdir -p "${temp_root}/$1" || fail "create the $1 plug-in directory" + builtin print -rl -- \ + "$2() { builtin print -r -- $2; }" \ + "zle -N zi-repeat-widget $2" \ + "bindkey '^X^P' zi-repeat-widget" \ + > "${temp_root}/$1/$1.plugin.zsh" || fail "write the $1 plug-in" +} + +# run_case NAME: run the scenario NAME in a fresh isolated shell. +run_case() { + local case_root="${temp_root}/$1" + command mkdir -p "${case_root}"/{home,cache,config,data,zdotdir} || fail "$1: create isolated environment" + env \ + HOME="${case_root}/home" \ + XDG_CACHE_HOME="${case_root}/cache" \ + XDG_CONFIG_HOME="${case_root}/config" \ + XDG_DATA_HOME="${case_root}/data" \ + ZDOTDIR="${case_root}/zdotdir" \ + ZI_TEST_CHECKOUT="$project_root" \ + ZI_TEST_ROOT="$temp_root" \ + ZI_TEST_CASE="$1" \ + zsh -f <<'ZSH' || fail "$1" +builtin emulate -R zsh +setopt pipe_fail + +builtin source "${ZI_TEST_CHECKOUT}/zi.zsh" || return 1 +.zi-prepare-home || return 1 + +check() { + eval "$1" && return 0 + builtin print -u2 -r -- "${ZI_TEST_CASE}: $2" + return 1 +} + +typeset binding_before="$(bindkey '^X^P')" +typeset p="${ZI_TEST_ROOT}/p" q="${ZI_TEST_ROOT}/q" + +case $ZI_TEST_CASE in + twice) + # The same plug-in loaded twice, then unloaded once. + zi load "$p" >/dev/null 2>&1 + zi load "$p" >/dev/null 2>&1 + zi unload "$p" >/dev/null 2>&1 + check '(( ! ${+functions[pa_fn]} ))' "the function survived unload" || return 1 + check '[[ -z ${widgets[zi-repeat-widget]} ]]' "the widget survived unload: ${widgets[zi-repeat-widget]}" || return 1 + check '[[ "$(bindkey "^X^P")" == "$binding_before" ]]' "the binding was not restored: $(bindkey '^X^P')" || return 1 + ;; + changed) + # The plug-in gains a function between its loads; both must go. + zi load "$p" >/dev/null 2>&1 + builtin print -r -- 'pb_fn() { :; }' >> "${p}/p.plugin.zsh" + zi load "$p" >/dev/null 2>&1 + zi unload "$p" >/dev/null 2>&1 + check '(( ! ${+functions[pa_fn]} ))' "the first load's function survived unload" || return 1 + check '(( ! ${+functions[pb_fn]} ))' "the second load's function survived unload" || return 1 + check '[[ -z ${widgets[zi-repeat-widget]} ]]' "the widget survived unload: ${widgets[zi-repeat-widget]}" || return 1 + check '[[ "$(bindkey "^X^P")" == "$binding_before" ]]' "the binding was not restored: $(bindkey '^X^P')" || return 1 + ;; + interleaved) + # Another plug-in takes the widget and binding between the two loads. + # After p's unload, q is still loaded and must keep what it owns. + zi load "$p" >/dev/null 2>&1 + zi load "$q" >/dev/null 2>&1 + zi load "$p" >/dev/null 2>&1 + zi unload "$p" >/dev/null 2>&1 + check '(( ! ${+functions[pa_fn]} ))' "p's function survived unload" || return 1 + check '(( ${+functions[qa_fn]} ))' "q's function was removed" || return 1 + check '[[ ${widgets[zi-repeat-widget]} == user:qa_fn ]]' "q does not own the widget: ${widgets[zi-repeat-widget]}" || return 1 + check '[[ "$(bindkey "^X^P")" == "\"^X^P\" zi-repeat-widget" ]]' "q's binding was lost: $(bindkey '^X^P')" || return 1 + zi unload "$q" >/dev/null 2>&1 + check '(( ! ${+functions[qa_fn]} ))' "q's function survived its unload" || return 1 + check '[[ -z ${widgets[zi-repeat-widget]} ]]' "the widget survived both unloads: ${widgets[zi-repeat-widget]}" || return 1 + check '[[ "$(bindkey "^X^P")" == "$binding_before" ]]' "the binding was not restored after both unloads: $(bindkey '^X^P')" || return 1 + ;; + prior) + # A function the user defined before any load is not the plug-in's. + pa_fn() { builtin print -r -- user; } + zi load "$p" >/dev/null 2>&1 + zi load "$p" >/dev/null 2>&1 + zi unload "$p" >/dev/null 2>&1 + check '(( ${+functions[pa_fn]} ))' "a function defined before the first load was removed" || return 1 + ;; + shared) + # p defines a helper on its first load; r, loaded next, defines the same + # helper again. After p's repeated load and unload, r still owns it. + builtin print -r -- 'shared_fn() { :; }' >> "${p}/p.plugin.zsh" + zi load "$p" >/dev/null 2>&1 + unfunction shared_fn + zi load "${ZI_TEST_ROOT}/r" >/dev/null 2>&1 + zi load "$p" >/dev/null 2>&1 + zi unload "$p" >/dev/null 2>&1 + check '(( ${+functions[shared_fn]} ))' "a function the still-loaded r created was removed" || return 1 + check '(( ! ${+functions[pa_fn]} ))' "p's function survived unload" || return 1 + ;; + reload) + # Load, unload, load again, unload: each cycle is independent. + zi load "$p" >/dev/null 2>&1 + zi unload "$p" >/dev/null 2>&1 + zi load "$p" >/dev/null 2>&1 + check '[[ ${widgets[zi-repeat-widget]} == user:pa_fn ]]' "the second cycle did not install the widget: ${widgets[zi-repeat-widget]}" || return 1 + zi unload "$p" >/dev/null 2>&1 + check '(( ! ${+functions[pa_fn]} ))' "the function survived the second unload" || return 1 + check '[[ -z ${widgets[zi-repeat-widget]} ]]' "the widget survived the second unload: ${widgets[zi-repeat-widget]}" || return 1 + check '[[ "$(bindkey "^X^P")" == "$binding_before" ]]' "the binding was not restored after the second unload: $(bindkey '^X^P')" || return 1 + ;; + *) + builtin print -u2 -r -- "unknown case: ${ZI_TEST_CASE}" + return 1 + ;; +esac +ZSH +} + +typeset -i failures=0 +typeset scenario +for scenario in twice changed interleaved prior shared reload; do + write_plugin p pa_fn + write_plugin q qa_fn + command mkdir -p "${temp_root}/r" || fail "create the r plug-in directory" + builtin print -r -- 'shared_fn() { :; }' > "${temp_root}/r/r.plugin.zsh" || fail "write the r plug-in" + if ( run_case "$scenario" ); then + builtin print -r -- "ok - ${scenario}" + else + (( failures++ )) + fi +done +(( failures == 0 )) || fail "${failures} repeated-load scenario(s) failed" diff --git a/tests/unload-ownership-contracts.zsh b/tests/unload-ownership-contracts.zsh index b44a949c..9ad7f4ff 100644 --- a/tests/unload-ownership-contracts.zsh +++ b/tests/unload-ownership-contracts.zsh @@ -9,8 +9,8 @@ # per load instance rather than by plug-in id. Measuring the current behaviour # first showed the defect is narrower than that issue's prose: every # multi-plug-in path already restores the correct previous owner, and only a -# repeated load of the *same* plug-in leaks. The repeated-load case is therefore -# deliberately absent here; it belongs with its fix, not ahead of it. +# repeated load of the *same* plug-in leaked. That case is covered by +# tests/repeated-load-ownership.zsh, together with its fix. builtin emulate -R zsh setopt pipe_fail diff --git a/zi.zsh b/zi.zsh index d209cf50..3963a1fd 100644 --- a/zi.zsh +++ b/zi.zsh @@ -658,7 +658,7 @@ builtin setopt no_aliases fi quoted="${(q)quoted}" # Remember the bindkey, only when load is in progress (it can be dstart that leads execution here). - [[ -n ${ZI[CUR_USPL2]} ]] && ZI[BINDKEYS__${ZI[CUR_USPL2]}]+="$quoted " + [[ -n ${ZI[CUR_USPL2]} ]] && .zi-repeat-should-record bindkey "$string" "${opts[-M]}" && ZI[BINDKEYS__${ZI[CUR_USPL2]}]+="$quoted " # Remember for dtrace. [[ ${ZI[DTRACE]} = 1 ]] && ZI[BINDKEYS___dtrace/_dtrace]+="$quoted " else @@ -786,7 +786,7 @@ builtin setopt no_aliases local quoted="$2" quoted="${(q)quoted}" # Remember only when load is in progress (it can be dstart that leads execution here). - [[ -n ${ZI[CUR_USPL2]} ]] && ZI[WIDGETS_DELETE__${ZI[CUR_USPL2]}]+="$quoted " + [[ -n ${ZI[CUR_USPL2]} ]] && .zi-repeat-should-record widget "$2" && ZI[WIDGETS_DELETE__${ZI[CUR_USPL2]}]+="$quoted " # Remember for dtrace. [[ ${ZI[DTRACE]} = 1 ]] && ZI[WIDGETS_DELETE___dtrace/_dtrace]+="$quoted " # These will be saved and restored. @@ -802,7 +802,7 @@ builtin setopt no_aliases local quoted="$1 $widname $completion_widget $targetfun $saved_widcontents" quoted="${(q)quoted}" # Remember only when load is in progress (it can be dstart that leads execution here). - [[ -n ${ZI[CUR_USPL2]} ]] && ZI[WIDGETS_SAVED__${ZI[CUR_USPL2]}]+="$quoted " + [[ -n ${ZI[CUR_USPL2]} ]] && .zi-repeat-should-record widget "$2" && ZI[WIDGETS_SAVED__${ZI[CUR_USPL2]}]+="$quoted " # Remember for dtrace. [[ ${ZI[DTRACE]} = 1 ]] && ZI[WIDGETS_SAVED___dtrace/_dtrace]+="$quoted " # These will be deleted. @@ -811,7 +811,7 @@ builtin setopt no_aliases local quoted="$2" quoted="${(q)quoted}" # Remember only when load is in progress (it can be dstart that leads execution here). - [[ -n ${ZI[CUR_USPL2]} ]] && ZI[WIDGETS_DELETE__${ZI[CUR_USPL2]}]+="$quoted " + [[ -n ${ZI[CUR_USPL2]} ]] && .zi-repeat-should-record widget "$2" && ZI[WIDGETS_DELETE__${ZI[CUR_USPL2]}]+="$quoted " # Remember for dtrace. [[ ${ZI[DTRACE]} = 1 ]] && ZI[WIDGETS_DELETE___dtrace/_dtrace]+="$quoted " fi @@ -1189,20 +1189,251 @@ builtin setopt no_aliases # Full or light load? [[ $mode == light ]] && ZI[STATES__$uspl2]=1 || ZI[STATES__$uspl2]=2 ZI_REPORTS[$uspl2]= ZI_CUR_BIND_MAP=( empty 1 ) + if (( ret )); then + # A repeated load keeps what earlier loads of this plugin created, so one + # unload still removes it (z-shell/zi#113): the functions the previous + # load added join the owned set, and the widget and bindkey records stay. + ZI[REPEAT__$uspl2]=1 + .zi-keep-previous-load-functions "$uspl2" + else + ZI[REPEAT__$uspl2]= ZI[FUNCTIONS_OWNED__$uspl2]= + ZI[BINDKEYS__$uspl2]= + ZI[WIDGETS_SAVED__$uspl2]= ZI[WIDGETS_DELETE__$uspl2]= + fi # Functions. ZI[FUNCTIONS_BEFORE__$uspl2]= ZI[FUNCTIONS_AFTER__$uspl2]= ZI[FUNCTIONS__$uspl2]= # Objects. - ZI[ZSTYLES__$uspl2]= ZI[BINDKEYS__$uspl2]= + ZI[ZSTYLES__$uspl2]= ZI[ALIASES__$uspl2]= - # Widgets. - ZI[WIDGETS_SAVED__$uspl2]= ZI[WIDGETS_DELETE__$uspl2]= # Rest (options and (f)path). ZI[OPTIONS__$uspl2]= ZI[PATH__$uspl2]= ZI[OPTIONS_BEFORE__$uspl2]= ZI[OPTIONS_AFTER__$uspl2]= ZI[FPATH__$uspl2]= return ret } # ]]] +# FUNCTION: .zi-keep-previous-load-functions. [[[ +# Adds the functions the previous load of plugin $1 created to its owned set, +# before the next load takes a fresh function baseline. +.zi-keep-previous-load-functions() { + builtin emulate -LR zsh ${=${options[xtrace]:#off}:+-o xtrace} + local uspl2="$1" f + [[ ${ZI[FUNCTIONS_BEFORE__$uspl2]} == *[^[:space:]]* ]] || return 0 + local -A created + for f in "${(z)ZI[FUNCTIONS_AFTER__$uspl2]}"; do + created[$f]=1 + done + for f in "${(z)ZI[FUNCTIONS_BEFORE__$uspl2]}"; do + created[$f]=0 + done + for f in "${(@k)created}"; do + [[ ${created[$f]} == 1 ]] && ZI[FUNCTIONS_OWNED__$uspl2]+="$f " + done + return 0 +} # ]]] +# FUNCTION: .zi-repeat-record-matches. [[[ +# Succeeds when record $3 of kind $2 (widget-saved, widget-delete or bindkey) +# describes object $4: a widget name, or a quoted key with keymap $5 (empty +# for the default map). +.zi-repeat-record-matches() { + local kind="$2" entry="$3" name="$4" map="$5" + local -a fields + case $kind in + widget-saved) + fields=( "${(z)${(Q)entry}}" ) + [[ ${(Q)fields[2]} == "$name" ]] ;; + widget-delete) + [[ ${(Q)entry} == "$name" ]] ;; + bindkey) + fields=( "${(z)${(Q)entry}}" ) + [[ ${fields[1]} == "$name" ]] || return 1 + if [[ -n $map ]]; then + [[ ${(Q)fields[4]} == -M && ${(Q)fields[5]} == "$map" ]] + else + [[ ${(Q)fields[4]} != -M ]] + fi ;; + *) return 1 ;; + esac +} # ]]] +# FUNCTION: .zi-repeat-owns. [[[ +# On a repeated load of plugin $1, succeeds when an earlier load of it +# recorded object $3 (a widget name for kind "widget", a quoted key for kind +# "bindkey", with keymap $4) and no plugin loaded since then recorded the +# same object, so the earlier record still describes what to restore. +.zi-repeat-owns() { + builtin emulate -LR zsh ${=${options[xtrace]:#off}:+-o xtrace} + builtin setopt extended_glob + local uspl2="$1" kind="$2" name="$3" map="$4" entry key other + local -a kinds + if [[ $kind == widget ]]; then + kinds=( widget-saved widget-delete ) + else + kinds=( bindkey ) + fi + .zi-repeat-recorded "$uspl2" "$kind" "$name" "$map" || return 1 + # The newest load of this plugin that has finished. + integer last=0 idx + for key in ${(k)ZI[(I)TIME_<->_${(b)uspl2//\//---}]}; do + [[ $key == (#b)TIME_(<->)_* ]] && (( match[1] > last )) && last=${match[1]} + done + for key in ${(k)ZI[(I)TIME_<->_*]}; do + [[ $key == (#b)TIME_(<->)_(*) ]] || continue + idx=${match[1]} other="${match[2]//---//}" + (( idx > last )) && [[ $other != "$uspl2" ]] || continue + for kind in "${kinds[@]}"; do + case $kind in + widget-saved) local field=WIDGETS_SAVED ;; + widget-delete) local field=WIDGETS_DELETE ;; + *) local field=BINDKEYS ;; + esac + for entry in "${(z)ZI[${field}__$other]}"; do + .zi-repeat-record-matches "$other" "$kind" "$entry" "$name" "$map" && return 1 + done + done + done + return 0 +} # ]]] +# FUNCTION: .zi-repeat-recorded. [[[ +# Succeeds when plugin $1 holds a record of object $3 (kind $2, keymap $4). +.zi-repeat-recorded() { + local uspl2="$1" kind="$2" name="$3" map="$4" entry + if [[ $kind == widget ]]; then + for entry in "${(z)ZI[WIDGETS_SAVED__$uspl2]}"; do + .zi-repeat-record-matches "$uspl2" widget-saved "$entry" "$name" && return 0 + done + for entry in "${(z)ZI[WIDGETS_DELETE__$uspl2]}"; do + .zi-repeat-record-matches "$uspl2" widget-delete "$entry" "$name" && return 0 + done + else + for entry in "${(z)ZI[BINDKEYS__$uspl2]}"; do + .zi-repeat-record-matches "$uspl2" bindkey "$entry" "$name" "$map" && return 0 + done + fi + return 1 +} # ]]] +# FUNCTION: .zi-repeat-forget. [[[ +# Drops plugin $1's records of object $3 (kind $2, keymap $4), used when +# another plugin took the object over between two loads of $1. +.zi-repeat-forget() { + builtin emulate -LR zsh ${=${options[xtrace]:#off}:+-o xtrace} + local uspl2="$1" kind="$2" name="$3" map="$4" entry kept + if [[ $kind == widget ]]; then + for entry in "${(z)ZI[WIDGETS_SAVED__$uspl2]}"; do + .zi-repeat-record-matches "$uspl2" widget-saved "$entry" "$name" || kept+="$entry " + done + ZI[WIDGETS_SAVED__$uspl2]="$kept" + kept= + for entry in "${(z)ZI[WIDGETS_DELETE__$uspl2]}"; do + .zi-repeat-record-matches "$uspl2" widget-delete "$entry" "$name" || kept+="$entry " + done + ZI[WIDGETS_DELETE__$uspl2]="$kept" + else + for entry in "${(z)ZI[BINDKEYS__$uspl2]}"; do + .zi-repeat-record-matches "$uspl2" bindkey "$entry" "$name" "$map" || kept+="$entry " + done + ZI[BINDKEYS__$uspl2]="$kept" + fi +} # ]]] +# FUNCTION: .zi-repeat-should-record. [[[ +# Called before a new widget or bindkey record for the load in progress. +# Succeeds when the record should be added. On a repeated load, an object +# this plugin still owns keeps its first record, so unload restores what +# preceded the first load. For an object another plugin took over in between, +# this plugin's earlier instance leaves the ownership chain: the plugin that +# took it over inherits what the earlier instance replaced, and this load +# records afresh on top of it. +.zi-repeat-should-record() { + local uspl2="${ZI[CUR_USPL2]}" + [[ -n $uspl2 && ${ZI[REPEAT__$uspl2]} == 1 ]] || return 0 + .zi-repeat-recorded "$uspl2" "$@" || return 0 + .zi-repeat-owns "$uspl2" "$@" && return 1 + .zi-repeat-relink "$uspl2" "$@" + .zi-repeat-forget "$uspl2" "$@" + return 0 +} # ]]] +# FUNCTION: .zi-repeat-relink. [[[ +# Plugin $1 held object $3 (kind $2, keymap $4) from an earlier load, and the +# first plugin loaded after that load took it over, recording $1's version as +# the one to restore. Point that record at what $1's earlier load replaced +# instead, since $1's earlier instance is about to leave the chain. +.zi-repeat-relink() { + builtin emulate -LR zsh ${=${options[xtrace]:#off}:+-o xtrace} + builtin setopt extended_glob + local uspl2="$1" kind="$2" name="$3" map="$4" entry key other taker + local -a fields + # What the earlier load of $1 replaced. + integer created=0 found=0 + local prev + if [[ $kind == widget ]]; then + for entry in "${(z)ZI[WIDGETS_SAVED__$uspl2]}"; do + .zi-repeat-record-matches "$uspl2" widget-saved "$entry" "$name" || continue + fields=( "${(z)${(Q)entry}}" ) + prev="${(Q)fields[5]}" found=1 + break + done + if (( ! found )); then + for entry in "${(z)ZI[WIDGETS_DELETE__$uspl2]}"; do + .zi-repeat-record-matches "$uspl2" widget-delete "$entry" "$name" && { created=1 found=1; break; } + done + fi + else + for entry in "${(z)ZI[BINDKEYS__$uspl2]}"; do + .zi-repeat-record-matches "$uspl2" bindkey "$entry" "$name" "$map" || continue + fields=( "${(z)${(Q)entry}}" ) + prev="${(Q)fields[3]}" found=1 + break + done + fi + (( found )) || return 0 + # The first plugin loaded after $1's newest finished load that recorded the object. + integer last=0 idx best=0 + for key in ${(k)ZI[(I)TIME_<->_${(b)uspl2//\//---}]}; do + [[ $key == (#b)TIME_(<->)_* ]] && (( match[1] > last )) && last=${match[1]} + done + for key in ${(k)ZI[(I)TIME_<->_*]}; do + [[ $key == (#b)TIME_(<->)_(*) ]] || continue + idx=${match[1]} other="${match[2]//---//}" + (( idx > last )) && [[ $other != "$uspl2" ]] && (( ! best || idx < best )) || continue + if [[ $kind == widget ]]; then + for entry in "${(z)ZI[WIDGETS_SAVED__$other]}"; do + .zi-repeat-record-matches "$other" widget-saved "$entry" "$name" && { best=$idx taker=$other; break; } + done + else + for entry in "${(z)ZI[BINDKEYS__$other]}"; do + .zi-repeat-record-matches "$other" bindkey "$entry" "$name" "$map" && { best=$idx taker=$other; break; } + done + fi + done + [[ -n $taker ]] || return 0 + local kept + if [[ $kind == widget ]]; then + for entry in "${(z)ZI[WIDGETS_SAVED__$taker]}"; do + if .zi-repeat-record-matches "$taker" widget-saved "$entry" "$name"; then + if (( created )); then + # The object did not exist before $1's earlier load: for the taker it + # is now a widget it created. + ZI[WIDGETS_DELETE__$taker]+="${(q)name} " + continue + fi + fields=( "${(z)${(Q)entry}}" ) + fields[5]="${(q)prev}" + entry="${(q)${(j: :)fields}}" + fi + kept+="$entry " + done + ZI[WIDGETS_SAVED__$taker]="$kept" + else + for entry in "${(z)ZI[BINDKEYS__$taker]}"; do + if .zi-repeat-record-matches "$taker" bindkey "$entry" "$name" "$map"; then + fields=( "${(z)${(Q)entry}}" ) + fields[3]="${(q)prev}" + entry="${(q)${(j: :)fields}}" + fi + kept+="$entry " + done + ZI[BINDKEYS__$taker]="$kept" + fi +} # ]]] # FUNCTION: .zi-get-object-path. [[[ .zi-get-object-path() { local type="$1" id_as="$2" local_dir dirname From 4b682fe3ce3a033270762cd14d04baebb5370aae Mon Sep 17 00:00:00 2001 From: Sal <59910950+ss-o@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:30:00 +0100 Subject: [PATCH 2/4] fix(unload): narrow repeated-load ownership to the same plugin Keep the records of earlier loads only when no other plugin took one of the plugin's widgets or bindings over in between; otherwise keep the behaviour next already has, which leaves that case open in #113. Drop the relink, owner-tracking and record-dedupe helpers of the first attempt, and the unread REPEAT__ key. Compute the other plugins' function sets once per unload instead of once per owned function. Pin the interleaved case in both unload orders, a key rebound by another plugin, a plugin loaded before the first load, and a wrapped builtin widget in tests/repeated-load-ownership.zsh. Refs #113 --- lib/zsh/autoload.zsh | 24 +-- tests/repeated-load-ownership.zsh | 60 +++++-- tests/unload-ownership-contracts.zsh | 3 +- zi.zsh | 239 ++++++--------------------- 4 files changed, 113 insertions(+), 213 deletions(-) diff --git a/lib/zsh/autoload.zsh b/lib/zsh/autoload.zsh index 5d36c53b..c152a599 100755 --- a/lib/zsh/autoload.zsh +++ b/lib/zsh/autoload.zsh @@ -263,7 +263,6 @@ ZI[EXTENDED_GLOB]="" ZI[FUNCTIONS_BEFORE__$REPLY]="" ZI[FUNCTIONS_AFTER__$REPLY]="" ZI[FUNCTIONS_OWNED__$REPLY]="" - ZI[REPEAT__$REPLY]="" # Option diffing ZI[OPTIONS__$REPLY]="" ZI[OPTIONS_BEFORE__$REPLY]="" @@ -1122,9 +1121,7 @@ ZI[EXTENDED_GLOB]="" local comp_wid="${(Q)orig_saved[3]}" local orig_saved2="${(Q)orig_saved[4]}" # Saved target function local orig_saved3="${(Q)orig_saved[5]}" # Saved previous $widget's contents - # The plugin's newest load: an earlier load of the same plugin is not - # the one whose records are held when it was loaded more than once. - local found_time_key="${keys[(R)TIME_<->_${uspl2//\//---}]}" to_process_plugin + local found_time_key="${keys[(r)TIME_<->_${uspl2//\//---}]}" to_process_plugin integer found_time_idx=0 idx=0 to_process_plugin="" [[ "$found_time_key" = (#b)TIME_(<->)_* ]] && found_time_idx="${match[1]}" @@ -1141,9 +1138,9 @@ ZI[EXTENDED_GLOB]="" integer found_idx2="${entry_splitted2[(I)*\ $orig_saved1\ *]}" if (( found_idx || found_idx2 )) then - # Skip later loads of the same plugin: a repeated load keeps the - # first load's records (z-shell/zi#113), so the chain continues - # with the next other plugin. + # Skip later loads of the same plugin. A repeated load keeps the + # first load's records unless another plugin took them over in + # between, which is still open in z-shell/zi#113. if [[ "$oth_uspl2" != "$uspl2" ]]; then to_process_plugin="$oth_uspl2" break # Only the first one is needed @@ -1239,13 +1236,16 @@ ZI[EXTENDED_GLOB]="" () { builtin setopt local_options extended_glob local owned other_uspl2 + local -a others_created + [[ -n ${ZI[FUNCTIONS_OWNED__$uspl2]} ]] || return 0 + for other_uspl2 in "${ZI_REGISTERED_PLUGINS[@]}"; do + [[ $other_uspl2 == "$uspl2" ]] && continue + .zi-diff-functions-compute "$other_uspl2" 2>/dev/null + others_created+=( "${(z)ZI[FUNCTIONS__$other_uspl2]}" ) + done for owned in "${(z)ZI[FUNCTIONS_OWNED__$uspl2]}"; do [[ -z $owned ]] && continue - for other_uspl2 in "${ZI_REGISTERED_PLUGINS[@]}"; do - [[ $other_uspl2 == "$uspl2" ]] && continue - .zi-diff-functions-compute "$other_uspl2" 2>/dev/null - (( ${${(z)ZI[FUNCTIONS__$other_uspl2]}[(Ie)$owned]} )) && continue 2 - done + (( ${others_created[(Ie)$owned]} )) && continue (( ${func[(Ie)$owned]} )) || func+=( "$owned" ) done } diff --git a/tests/repeated-load-ownership.zsh b/tests/repeated-load-ownership.zsh index 412275f7..05f853c9 100644 --- a/tests/repeated-load-ownership.zsh +++ b/tests/repeated-load-ownership.zsh @@ -4,8 +4,9 @@ # # z-shell/zi#113: a repeated load of the same plug-in, then one unload, must # remove everything the plug-in added across its loads and restore what it -# replaced, without taking state another plug-in loaded in between now owns. -# Each scenario runs in its own clean shell. +# replaced. When another plug-in took the plug-in's widget or binding over +# between its loads, the result must stay as it is on next: that case is still +# open in the issue. Each scenario runs in its own clean shell. builtin emulate -R zsh setopt pipe_fail @@ -84,19 +85,45 @@ case $ZI_TEST_CASE in ;; interleaved) # Another plug-in takes the widget and binding between the two loads. - # After p's unload, q is still loaded and must keep what it owns. + # That case stays open in z-shell/zi#113; the repeated load must leave it + # exactly as next does: p's second load records afresh, its unload hands + # the widget back to what q replaced, and q's unload then restores q's. zi load "$p" >/dev/null 2>&1 zi load "$q" >/dev/null 2>&1 zi load "$p" >/dev/null 2>&1 zi unload "$p" >/dev/null 2>&1 - check '(( ! ${+functions[pa_fn]} ))' "p's function survived unload" || return 1 check '(( ${+functions[qa_fn]} ))' "q's function was removed" || return 1 - check '[[ ${widgets[zi-repeat-widget]} == user:qa_fn ]]' "q does not own the widget: ${widgets[zi-repeat-widget]}" || return 1 - check '[[ "$(bindkey "^X^P")" == "\"^X^P\" zi-repeat-widget" ]]' "q's binding was lost: $(bindkey '^X^P')" || return 1 + check '[[ ${widgets[zi-repeat-widget]} == user:pa_fn ]]' "the widget differs from next: ${widgets[zi-repeat-widget]}" || return 1 + check '[[ "$(bindkey "^X^P")" == "\"^X^P\" zi-repeat-widget" ]]' "the binding differs from next: $(bindkey '^X^P')" || return 1 + ;; + interleaved-other-order) + # The same, unloading q first: p keeps its live widget, as on next. + zi load "$p" >/dev/null 2>&1 + zi load "$q" >/dev/null 2>&1 + zi load "$p" >/dev/null 2>&1 zi unload "$q" >/dev/null 2>&1 - check '(( ! ${+functions[qa_fn]} ))' "q's function survived its unload" || return 1 - check '[[ -z ${widgets[zi-repeat-widget]} ]]' "the widget survived both unloads: ${widgets[zi-repeat-widget]}" || return 1 - check '[[ "$(bindkey "^X^P")" == "$binding_before" ]]' "the binding was not restored after both unloads: $(bindkey '^X^P')" || return 1 + check '(( ${+functions[pa_fn]} ))' "p's function was removed by q's unload" || return 1 + check '[[ ${widgets[zi-repeat-widget]} == user:pa_fn ]]' "q's unload changed p's live widget: ${widgets[zi-repeat-widget]}" || return 1 + check '[[ "$(bindkey "^X^P")" == "\"^X^P\" zi-repeat-widget" ]]' "q's unload changed p's binding: $(bindkey '^X^P')" || return 1 + ;; + older) + # q loaded before p's first load is not a takeover between p's loads: + # p's repeated load still keeps its records, and its unload removes it. + zi load "$q" >/dev/null 2>&1 + zi load "$p" >/dev/null 2>&1 + zi load "$p" >/dev/null 2>&1 + zi unload "$p" >/dev/null 2>&1 + check '(( ! ${+functions[pa_fn]} ))' "p's function survived unload" || return 1 + check '[[ ${widgets[zi-repeat-widget]} == user:qa_fn ]]' "q's widget was not restored: ${widgets[zi-repeat-widget]}" || return 1 + ;; + binding-taken) + # Another plug-in rebinds only the key between p's loads: like a widget + # takeover, the result stays as on next. + zi load "$p" >/dev/null 2>&1 + zi load "${ZI_TEST_ROOT}/k" >/dev/null 2>&1 + zi load "$p" >/dev/null 2>&1 + zi unload "$p" >/dev/null 2>&1 + check '[[ "$(bindkey "^X^P")" == "\"^X^P\" end-of-line" ]]' "the binding differs from next: $(bindkey '^X^P')" || return 1 ;; prior) # A function the user defined before any load is not the plug-in's. @@ -106,6 +133,15 @@ case $ZI_TEST_CASE in zi unload "$p" >/dev/null 2>&1 check '(( ${+functions[pa_fn]} ))' "a function defined before the first load was removed" || return 1 ;; + wrapped) + # A plug-in that replaces an existing widget, loaded twice, restores the + # original widget on unload, not its own first replacement. + zi load "${ZI_TEST_ROOT}/w" >/dev/null 2>&1 + zi load "${ZI_TEST_ROOT}/w" >/dev/null 2>&1 + zi unload "${ZI_TEST_ROOT}/w" >/dev/null 2>&1 + check '[[ ${widgets[forward-char]} == builtin ]]' "the replaced widget was not restored: ${widgets[forward-char]}" || return 1 + check '(( ! ${+functions[w_fn]} ))' "the replacement function survived unload" || return 1 + ;; shared) # p defines a helper on its first load; r, loaded next, defines the same # helper again. After p's repeated load and unload, r still owns it. @@ -139,11 +175,13 @@ ZSH typeset -i failures=0 typeset scenario -for scenario in twice changed interleaved prior shared reload; do +for scenario in twice changed interleaved interleaved-other-order older binding-taken prior wrapped shared reload; do write_plugin p pa_fn write_plugin q qa_fn - command mkdir -p "${temp_root}/r" || fail "create the r plug-in directory" + command mkdir -p "${temp_root}/r" "${temp_root}/k" "${temp_root}/w" || fail "create the r, k and w plug-in directories" builtin print -r -- 'shared_fn() { :; }' > "${temp_root}/r/r.plugin.zsh" || fail "write the r plug-in" + builtin print -r -- "bindkey '^X^P' end-of-line" > "${temp_root}/k/k.plugin.zsh" || fail "write the k plug-in" + builtin print -rl -- 'w_fn() { zle .forward-char; }' 'zle -N forward-char w_fn' > "${temp_root}/w/w.plugin.zsh" || fail "write the w plug-in" if ( run_case "$scenario" ); then builtin print -r -- "ok - ${scenario}" else diff --git a/tests/unload-ownership-contracts.zsh b/tests/unload-ownership-contracts.zsh index 9ad7f4ff..6560fba6 100644 --- a/tests/unload-ownership-contracts.zsh +++ b/tests/unload-ownership-contracts.zsh @@ -10,7 +10,8 @@ # first showed the defect is narrower than that issue's prose: every # multi-plug-in path already restores the correct previous owner, and only a # repeated load of the *same* plug-in leaked. That case is covered by -# tests/repeated-load-ownership.zsh, together with its fix. +# tests/repeated-load-ownership.zsh, together with its fix; a plug-in taken +# over between two of its own loads is still open there. builtin emulate -R zsh setopt pipe_fail diff --git a/zi.zsh b/zi.zsh index 3963a1fd..96b1555c 100644 --- a/zi.zsh +++ b/zi.zsh @@ -658,7 +658,7 @@ builtin setopt no_aliases fi quoted="${(q)quoted}" # Remember the bindkey, only when load is in progress (it can be dstart that leads execution here). - [[ -n ${ZI[CUR_USPL2]} ]] && .zi-repeat-should-record bindkey "$string" "${opts[-M]}" && ZI[BINDKEYS__${ZI[CUR_USPL2]}]+="$quoted " + [[ -n ${ZI[CUR_USPL2]} ]] && ZI[BINDKEYS__${ZI[CUR_USPL2]}]+="$quoted " # Remember for dtrace. [[ ${ZI[DTRACE]} = 1 ]] && ZI[BINDKEYS___dtrace/_dtrace]+="$quoted " else @@ -786,7 +786,7 @@ builtin setopt no_aliases local quoted="$2" quoted="${(q)quoted}" # Remember only when load is in progress (it can be dstart that leads execution here). - [[ -n ${ZI[CUR_USPL2]} ]] && .zi-repeat-should-record widget "$2" && ZI[WIDGETS_DELETE__${ZI[CUR_USPL2]}]+="$quoted " + [[ -n ${ZI[CUR_USPL2]} ]] && ZI[WIDGETS_DELETE__${ZI[CUR_USPL2]}]+="$quoted " # Remember for dtrace. [[ ${ZI[DTRACE]} = 1 ]] && ZI[WIDGETS_DELETE___dtrace/_dtrace]+="$quoted " # These will be saved and restored. @@ -802,7 +802,7 @@ builtin setopt no_aliases local quoted="$1 $widname $completion_widget $targetfun $saved_widcontents" quoted="${(q)quoted}" # Remember only when load is in progress (it can be dstart that leads execution here). - [[ -n ${ZI[CUR_USPL2]} ]] && .zi-repeat-should-record widget "$2" && ZI[WIDGETS_SAVED__${ZI[CUR_USPL2]}]+="$quoted " + [[ -n ${ZI[CUR_USPL2]} ]] && ZI[WIDGETS_SAVED__${ZI[CUR_USPL2]}]+="$quoted " # Remember for dtrace. [[ ${ZI[DTRACE]} = 1 ]] && ZI[WIDGETS_SAVED___dtrace/_dtrace]+="$quoted " # These will be deleted. @@ -811,7 +811,7 @@ builtin setopt no_aliases local quoted="$2" quoted="${(q)quoted}" # Remember only when load is in progress (it can be dstart that leads execution here). - [[ -n ${ZI[CUR_USPL2]} ]] && .zi-repeat-should-record widget "$2" && ZI[WIDGETS_DELETE__${ZI[CUR_USPL2]}]+="$quoted " + [[ -n ${ZI[CUR_USPL2]} ]] && ZI[WIDGETS_DELETE__${ZI[CUR_USPL2]}]+="$quoted " # Remember for dtrace. [[ ${ZI[DTRACE]} = 1 ]] && ZI[WIDGETS_DELETE___dtrace/_dtrace]+="$quoted " fi @@ -1189,14 +1189,15 @@ builtin setopt no_aliases # Full or light load? [[ $mode == light ]] && ZI[STATES__$uspl2]=1 || ZI[STATES__$uspl2]=2 ZI_REPORTS[$uspl2]= ZI_CUR_BIND_MAP=( empty 1 ) - if (( ret )); then + if (( ret )) && ! .zi-repeat-taken-over "$uspl2"; then # A repeated load keeps what earlier loads of this plugin created, so one # unload still removes it (z-shell/zi#113): the functions the previous # load added join the owned set, and the widget and bindkey records stay. - ZI[REPEAT__$uspl2]=1 + # When another plugin took one of those widgets or bindings over in + # between, the records start again as before; that case remains open. .zi-keep-previous-load-functions "$uspl2" else - ZI[REPEAT__$uspl2]= ZI[FUNCTIONS_OWNED__$uspl2]= + ZI[FUNCTIONS_OWNED__$uspl2]= ZI[BINDKEYS__$uspl2]= ZI[WIDGETS_SAVED__$uspl2]= ZI[WIDGETS_DELETE__$uspl2]= fi @@ -1231,208 +1232,68 @@ builtin setopt no_aliases done return 0 } # ]]] -# FUNCTION: .zi-repeat-record-matches. [[[ -# Succeeds when record $3 of kind $2 (widget-saved, widget-delete or bindkey) -# describes object $4: a widget name, or a quoted key with keymap $5 (empty -# for the default map). -.zi-repeat-record-matches() { - local kind="$2" entry="$3" name="$4" map="$5" +# FUNCTION: .zi-repeat-object-key. [[[ +# Sets REPLY to a comparable identity for record $2 of kind $1 (widget-saved, +# widget-delete or bindkey): "widget NAME", or "bindkey MAP KEY" with an empty +# MAP for the default keymap. Fails for a record without such an object. +.zi-repeat-object-key() { + local kind="$1" entry="$2" local -a fields + REPLY= case $kind in widget-saved) fields=( "${(z)${(Q)entry}}" ) - [[ ${(Q)fields[2]} == "$name" ]] ;; + REPLY="widget ${(Q)fields[2]}" ;; widget-delete) - [[ ${(Q)entry} == "$name" ]] ;; + REPLY="widget ${(Q)entry}" ;; bindkey) fields=( "${(z)${(Q)entry}}" ) - [[ ${fields[1]} == "$name" ]] || return 1 - if [[ -n $map ]]; then - [[ ${(Q)fields[4]} == -M && ${(Q)fields[5]} == "$map" ]] - else - [[ ${(Q)fields[4]} != -M ]] - fi ;; + [[ ${(Q)fields[4]} == -[AN] ]] && return 1 + REPLY="bindkey ${${(M)${(Q)fields[4]}:#-M}:+${(Q)fields[5]}} ${fields[1]}" ;; *) return 1 ;; esac -} # ]]] -# FUNCTION: .zi-repeat-owns. [[[ -# On a repeated load of plugin $1, succeeds when an earlier load of it -# recorded object $3 (a widget name for kind "widget", a quoted key for kind -# "bindkey", with keymap $4) and no plugin loaded since then recorded the -# same object, so the earlier record still describes what to restore. -.zi-repeat-owns() { - builtin emulate -LR zsh ${=${options[xtrace]:#off}:+-o xtrace} - builtin setopt extended_glob - local uspl2="$1" kind="$2" name="$3" map="$4" entry key other - local -a kinds - if [[ $kind == widget ]]; then - kinds=( widget-saved widget-delete ) - else - kinds=( bindkey ) - fi - .zi-repeat-recorded "$uspl2" "$kind" "$name" "$map" || return 1 - # The newest load of this plugin that has finished. - integer last=0 idx - for key in ${(k)ZI[(I)TIME_<->_${(b)uspl2//\//---}]}; do - [[ $key == (#b)TIME_(<->)_* ]] && (( match[1] > last )) && last=${match[1]} + [[ -n $REPLY ]] +} # ]]] +# FUNCTION: .zi-repeat-objects. [[[ +# Sets reply to the widget and bindkey identities plugin $1 has recorded. +.zi-repeat-objects() { + local uspl2="$1" entry + reply=() + for entry in "${(z)ZI[WIDGETS_SAVED__$uspl2]}"; do + [[ -n $entry ]] && .zi-repeat-object-key widget-saved "$entry" && reply+=( "$REPLY" ) done - for key in ${(k)ZI[(I)TIME_<->_*]}; do - [[ $key == (#b)TIME_(<->)_(*) ]] || continue - idx=${match[1]} other="${match[2]//---//}" - (( idx > last )) && [[ $other != "$uspl2" ]] || continue - for kind in "${kinds[@]}"; do - case $kind in - widget-saved) local field=WIDGETS_SAVED ;; - widget-delete) local field=WIDGETS_DELETE ;; - *) local field=BINDKEYS ;; - esac - for entry in "${(z)ZI[${field}__$other]}"; do - .zi-repeat-record-matches "$other" "$kind" "$entry" "$name" "$map" && return 1 - done - done + for entry in "${(z)ZI[WIDGETS_DELETE__$uspl2]}"; do + [[ -n $entry ]] && .zi-repeat-object-key widget-delete "$entry" && reply+=( "$REPLY" ) + done + for entry in "${(z)ZI[BINDKEYS__$uspl2]}"; do + [[ -n $entry ]] && .zi-repeat-object-key bindkey "$entry" && reply+=( "$REPLY" ) done - return 0 -} # ]]] -# FUNCTION: .zi-repeat-recorded. [[[ -# Succeeds when plugin $1 holds a record of object $3 (kind $2, keymap $4). -.zi-repeat-recorded() { - local uspl2="$1" kind="$2" name="$3" map="$4" entry - if [[ $kind == widget ]]; then - for entry in "${(z)ZI[WIDGETS_SAVED__$uspl2]}"; do - .zi-repeat-record-matches "$uspl2" widget-saved "$entry" "$name" && return 0 - done - for entry in "${(z)ZI[WIDGETS_DELETE__$uspl2]}"; do - .zi-repeat-record-matches "$uspl2" widget-delete "$entry" "$name" && return 0 - done - else - for entry in "${(z)ZI[BINDKEYS__$uspl2]}"; do - .zi-repeat-record-matches "$uspl2" bindkey "$entry" "$name" "$map" && return 0 - done - fi - return 1 -} # ]]] -# FUNCTION: .zi-repeat-forget. [[[ -# Drops plugin $1's records of object $3 (kind $2, keymap $4), used when -# another plugin took the object over between two loads of $1. -.zi-repeat-forget() { - builtin emulate -LR zsh ${=${options[xtrace]:#off}:+-o xtrace} - local uspl2="$1" kind="$2" name="$3" map="$4" entry kept - if [[ $kind == widget ]]; then - for entry in "${(z)ZI[WIDGETS_SAVED__$uspl2]}"; do - .zi-repeat-record-matches "$uspl2" widget-saved "$entry" "$name" || kept+="$entry " - done - ZI[WIDGETS_SAVED__$uspl2]="$kept" - kept= - for entry in "${(z)ZI[WIDGETS_DELETE__$uspl2]}"; do - .zi-repeat-record-matches "$uspl2" widget-delete "$entry" "$name" || kept+="$entry " - done - ZI[WIDGETS_DELETE__$uspl2]="$kept" - else - for entry in "${(z)ZI[BINDKEYS__$uspl2]}"; do - .zi-repeat-record-matches "$uspl2" bindkey "$entry" "$name" "$map" || kept+="$entry " - done - ZI[BINDKEYS__$uspl2]="$kept" - fi -} # ]]] -# FUNCTION: .zi-repeat-should-record. [[[ -# Called before a new widget or bindkey record for the load in progress. -# Succeeds when the record should be added. On a repeated load, an object -# this plugin still owns keeps its first record, so unload restores what -# preceded the first load. For an object another plugin took over in between, -# this plugin's earlier instance leaves the ownership chain: the plugin that -# took it over inherits what the earlier instance replaced, and this load -# records afresh on top of it. -.zi-repeat-should-record() { - local uspl2="${ZI[CUR_USPL2]}" - [[ -n $uspl2 && ${ZI[REPEAT__$uspl2]} == 1 ]] || return 0 - .zi-repeat-recorded "$uspl2" "$@" || return 0 - .zi-repeat-owns "$uspl2" "$@" && return 1 - .zi-repeat-relink "$uspl2" "$@" - .zi-repeat-forget "$uspl2" "$@" - return 0 } # ]]] -# FUNCTION: .zi-repeat-relink. [[[ -# Plugin $1 held object $3 (kind $2, keymap $4) from an earlier load, and the -# first plugin loaded after that load took it over, recording $1's version as -# the one to restore. Point that record at what $1's earlier load replaced -# instead, since $1's earlier instance is about to leave the chain. -.zi-repeat-relink() { +# FUNCTION: .zi-repeat-taken-over. [[[ +# Succeeds when a plugin loaded after the newest load of plugin $1 recorded +# one of the widgets or bindings $1 recorded, i.e. took it over in between. +.zi-repeat-taken-over() { builtin emulate -LR zsh ${=${options[xtrace]:#off}:+-o xtrace} builtin setopt extended_glob - local uspl2="$1" kind="$2" name="$3" map="$4" entry key other taker - local -a fields - # What the earlier load of $1 replaced. - integer created=0 found=0 - local prev - if [[ $kind == widget ]]; then - for entry in "${(z)ZI[WIDGETS_SAVED__$uspl2]}"; do - .zi-repeat-record-matches "$uspl2" widget-saved "$entry" "$name" || continue - fields=( "${(z)${(Q)entry}}" ) - prev="${(Q)fields[5]}" found=1 - break - done - if (( ! found )); then - for entry in "${(z)ZI[WIDGETS_DELETE__$uspl2]}"; do - .zi-repeat-record-matches "$uspl2" widget-delete "$entry" "$name" && { created=1 found=1; break; } - done - fi - else - for entry in "${(z)ZI[BINDKEYS__$uspl2]}"; do - .zi-repeat-record-matches "$uspl2" bindkey "$entry" "$name" "$map" || continue - fields=( "${(z)${(Q)entry}}" ) - prev="${(Q)fields[3]}" found=1 - break - done - fi - (( found )) || return 0 - # The first plugin loaded after $1's newest finished load that recorded the object. - integer last=0 idx best=0 + local uspl2="$1" key other REPLY + local -a reply mine shared + .zi-repeat-objects "$uspl2" + mine=( "${reply[@]}" ) + (( ${#mine} )) || return 1 + integer last=0 for key in ${(k)ZI[(I)TIME_<->_${(b)uspl2//\//---}]}; do [[ $key == (#b)TIME_(<->)_* ]] && (( match[1] > last )) && last=${match[1]} done for key in ${(k)ZI[(I)TIME_<->_*]}; do [[ $key == (#b)TIME_(<->)_(*) ]] || continue - idx=${match[1]} other="${match[2]//---//}" - (( idx > last )) && [[ $other != "$uspl2" ]] && (( ! best || idx < best )) || continue - if [[ $kind == widget ]]; then - for entry in "${(z)ZI[WIDGETS_SAVED__$other]}"; do - .zi-repeat-record-matches "$other" widget-saved "$entry" "$name" && { best=$idx taker=$other; break; } - done - else - for entry in "${(z)ZI[BINDKEYS__$other]}"; do - .zi-repeat-record-matches "$other" bindkey "$entry" "$name" "$map" && { best=$idx taker=$other; break; } - done - fi + (( match[1] > last )) || continue + other="${match[2]//---//}" + [[ $other == "$uspl2" ]] && continue + .zi-repeat-objects "$other" + shared=( "${(@)reply:*mine}" ) + (( ${#shared} )) && return 0 done - [[ -n $taker ]] || return 0 - local kept - if [[ $kind == widget ]]; then - for entry in "${(z)ZI[WIDGETS_SAVED__$taker]}"; do - if .zi-repeat-record-matches "$taker" widget-saved "$entry" "$name"; then - if (( created )); then - # The object did not exist before $1's earlier load: for the taker it - # is now a widget it created. - ZI[WIDGETS_DELETE__$taker]+="${(q)name} " - continue - fi - fields=( "${(z)${(Q)entry}}" ) - fields[5]="${(q)prev}" - entry="${(q)${(j: :)fields}}" - fi - kept+="$entry " - done - ZI[WIDGETS_SAVED__$taker]="$kept" - else - for entry in "${(z)ZI[BINDKEYS__$taker]}"; do - if .zi-repeat-record-matches "$taker" bindkey "$entry" "$name" "$map"; then - fields=( "${(z)${(Q)entry}}" ) - fields[3]="${(q)prev}" - entry="${(q)${(j: :)fields}}" - fi - kept+="$entry " - done - ZI[BINDKEYS__$taker]="$kept" - fi + return 1 } # ]]] # FUNCTION: .zi-get-object-path. [[[ .zi-get-object-path() { From ed8af154cc6fb717dadf1e31de3da608b3a6f75c Mon Sep 17 00:00:00 2001 From: Sal <59910950+ss-o@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:50:58 +0100 Subject: [PATCH 3/4] fix(unload): keep functions another twice-loaded plugin owns The other-owner check read only the other plugin's newest load. When that plugin was itself loaded more than once, its functions live in its owned set, so unloading a third plugin deleted them. Count the owned set too, and pin the sequence as the shared-twice scenario. Refs #113 --- lib/zsh/autoload.zsh | 4 ++-- tests/repeated-load-ownership.zsh | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/lib/zsh/autoload.zsh b/lib/zsh/autoload.zsh index c152a599..ae8815da 100755 --- a/lib/zsh/autoload.zsh +++ b/lib/zsh/autoload.zsh @@ -1232,7 +1232,7 @@ ZI[EXTENDED_GLOB]="" typeset -a func func=( "${(z)ZI[FUNCTIONS__$uspl2]}" ) # Functions earlier loads of this plugin created (z-shell/zi#113), except - # those another plugin loaded since then created as well. + # those another registered plugin created as well, on any of its loads. () { builtin setopt local_options extended_glob local owned other_uspl2 @@ -1241,7 +1241,7 @@ ZI[EXTENDED_GLOB]="" for other_uspl2 in "${ZI_REGISTERED_PLUGINS[@]}"; do [[ $other_uspl2 == "$uspl2" ]] && continue .zi-diff-functions-compute "$other_uspl2" 2>/dev/null - others_created+=( "${(z)ZI[FUNCTIONS__$other_uspl2]}" ) + others_created+=( "${(z)ZI[FUNCTIONS__$other_uspl2]}" "${(z)ZI[FUNCTIONS_OWNED__$other_uspl2]}" ) done for owned in "${(z)ZI[FUNCTIONS_OWNED__$uspl2]}"; do [[ -z $owned ]] && continue diff --git a/tests/repeated-load-ownership.zsh b/tests/repeated-load-ownership.zsh index 05f853c9..133ffd7e 100644 --- a/tests/repeated-load-ownership.zsh +++ b/tests/repeated-load-ownership.zsh @@ -154,6 +154,19 @@ case $ZI_TEST_CASE in check '(( ${+functions[shared_fn]} ))' "a function the still-loaded r created was removed" || return 1 check '(( ! ${+functions[pa_fn]} ))' "p's function survived unload" || return 1 ;; + shared-twice) + # As shared, but r is itself loaded twice, so r's claim on the helper + # comes from r's earlier load, not from its newest one. + builtin print -r -- 'shared_fn() { :; }' >> "${p}/p.plugin.zsh" + zi load "$p" >/dev/null 2>&1 + unfunction shared_fn + zi load "${ZI_TEST_ROOT}/r" >/dev/null 2>&1 + zi load "${ZI_TEST_ROOT}/r" >/dev/null 2>&1 + zi load "$p" >/dev/null 2>&1 + zi unload "$p" >/dev/null 2>&1 + check '(( ${+functions[shared_fn]} ))' "a function the still-loaded r created on its earlier load was removed" || return 1 + check '(( ! ${+functions[pa_fn]} ))' "p's function survived unload" || return 1 + ;; reload) # Load, unload, load again, unload: each cycle is independent. zi load "$p" >/dev/null 2>&1 @@ -175,7 +188,7 @@ ZSH typeset -i failures=0 typeset scenario -for scenario in twice changed interleaved interleaved-other-order older binding-taken prior wrapped shared reload; do +for scenario in twice changed interleaved interleaved-other-order older binding-taken prior wrapped shared shared-twice reload; do write_plugin p pa_fn write_plugin q qa_fn command mkdir -p "${temp_root}/r" "${temp_root}/k" "${temp_root}/w" || fail "create the r, k and w plug-in directories" From 2832076269c7871d923aa9fd6384f680979dcaff Mon Sep 17 00:00:00 2001 From: Sal <59910950+ss-o@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:32:31 +0100 Subject: [PATCH 4/4] fix(unload): treat bindkey -M main as the default keymap A plugin that rebinds a key through `bindkey -M main` between two loads of another plugin changes the same binding as a plain `bindkey`, but the takeover check gave the two different identities, so the repeated load kept its records and unload removed the other plugin's live binding. Give `-M main` the default keymap's identity and pin it as main-taken. Check after the loads in twice, changed, prior and wrapped that the plugin really loaded, so those scenarios cannot pass when nothing ran. Refs #113 --- tests/repeated-load-ownership.zsh | 19 +++++++++++++++++-- zi.zsh | 12 +++++++++--- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/tests/repeated-load-ownership.zsh b/tests/repeated-load-ownership.zsh index 133ffd7e..a4d925a9 100644 --- a/tests/repeated-load-ownership.zsh +++ b/tests/repeated-load-ownership.zsh @@ -67,6 +67,8 @@ case $ZI_TEST_CASE in # The same plug-in loaded twice, then unloaded once. zi load "$p" >/dev/null 2>&1 zi load "$p" >/dev/null 2>&1 + check '(( ${+functions[pa_fn]} ))' "the loads did not define the function" || return 1 + check '[[ ${widgets[zi-repeat-widget]} == user:pa_fn ]]' "the loads did not define the widget: ${widgets[zi-repeat-widget]}" || return 1 zi unload "$p" >/dev/null 2>&1 check '(( ! ${+functions[pa_fn]} ))' "the function survived unload" || return 1 check '[[ -z ${widgets[zi-repeat-widget]} ]]' "the widget survived unload: ${widgets[zi-repeat-widget]}" || return 1 @@ -77,6 +79,7 @@ case $ZI_TEST_CASE in zi load "$p" >/dev/null 2>&1 builtin print -r -- 'pb_fn() { :; }' >> "${p}/p.plugin.zsh" zi load "$p" >/dev/null 2>&1 + check '(( ${+functions[pa_fn]} && ${+functions[pb_fn]} ))' "the loads did not define both functions" || return 1 zi unload "$p" >/dev/null 2>&1 check '(( ! ${+functions[pa_fn]} ))' "the first load's function survived unload" || return 1 check '(( ! ${+functions[pb_fn]} ))' "the second load's function survived unload" || return 1 @@ -125,11 +128,21 @@ case $ZI_TEST_CASE in zi unload "$p" >/dev/null 2>&1 check '[[ "$(bindkey "^X^P")" == "\"^X^P\" end-of-line" ]]' "the binding differs from next: $(bindkey '^X^P')" || return 1 ;; + main-taken) + # The same, with the key rebound through `bindkey -M main`: main is the + # default keymap, so this is the same takeover. + zi load "$p" >/dev/null 2>&1 + zi load "${ZI_TEST_ROOT}/m" >/dev/null 2>&1 + zi load "$p" >/dev/null 2>&1 + zi unload "$p" >/dev/null 2>&1 + check '[[ "$(bindkey "^X^P")" == "\"^X^P\" end-of-line" ]]' "the binding differs from next: $(bindkey '^X^P')" || return 1 + ;; prior) # A function the user defined before any load is not the plug-in's. pa_fn() { builtin print -r -- user; } zi load "$p" >/dev/null 2>&1 zi load "$p" >/dev/null 2>&1 + check '[[ ${widgets[zi-repeat-widget]} == user:pa_fn ]]' "the loads did not run the plug-in: ${widgets[zi-repeat-widget]}" || return 1 zi unload "$p" >/dev/null 2>&1 check '(( ${+functions[pa_fn]} ))' "a function defined before the first load was removed" || return 1 ;; @@ -138,6 +151,7 @@ case $ZI_TEST_CASE in # original widget on unload, not its own first replacement. zi load "${ZI_TEST_ROOT}/w" >/dev/null 2>&1 zi load "${ZI_TEST_ROOT}/w" >/dev/null 2>&1 + check '[[ ${widgets[forward-char]} == user:w_fn ]]' "the loads did not replace the widget: ${widgets[forward-char]}" || return 1 zi unload "${ZI_TEST_ROOT}/w" >/dev/null 2>&1 check '[[ ${widgets[forward-char]} == builtin ]]' "the replaced widget was not restored: ${widgets[forward-char]}" || return 1 check '(( ! ${+functions[w_fn]} ))' "the replacement function survived unload" || return 1 @@ -188,12 +202,13 @@ ZSH typeset -i failures=0 typeset scenario -for scenario in twice changed interleaved interleaved-other-order older binding-taken prior wrapped shared shared-twice reload; do +for scenario in twice changed interleaved interleaved-other-order older binding-taken main-taken prior wrapped shared shared-twice reload; do write_plugin p pa_fn write_plugin q qa_fn - command mkdir -p "${temp_root}/r" "${temp_root}/k" "${temp_root}/w" || fail "create the r, k and w plug-in directories" + command mkdir -p "${temp_root}/r" "${temp_root}/k" "${temp_root}/m" "${temp_root}/w" || fail "create the r, k, m and w plug-in directories" builtin print -r -- 'shared_fn() { :; }' > "${temp_root}/r/r.plugin.zsh" || fail "write the r plug-in" builtin print -r -- "bindkey '^X^P' end-of-line" > "${temp_root}/k/k.plugin.zsh" || fail "write the k plug-in" + builtin print -r -- "bindkey -M main '^X^P' end-of-line" > "${temp_root}/m/m.plugin.zsh" || fail "write the m plug-in" builtin print -rl -- 'w_fn() { zle .forward-char; }' 'zle -N forward-char w_fn' > "${temp_root}/w/w.plugin.zsh" || fail "write the w plug-in" if ( run_case "$scenario" ); then builtin print -r -- "ok - ${scenario}" diff --git a/zi.zsh b/zi.zsh index 96b1555c..fc880e47 100644 --- a/zi.zsh +++ b/zi.zsh @@ -1235,9 +1235,10 @@ builtin setopt no_aliases # FUNCTION: .zi-repeat-object-key. [[[ # Sets REPLY to a comparable identity for record $2 of kind $1 (widget-saved, # widget-delete or bindkey): "widget NAME", or "bindkey MAP KEY" with an empty -# MAP for the default keymap. Fails for a record without such an object. +# MAP for the default keymap, also when it is named main. Fails for a record +# without such an object. .zi-repeat-object-key() { - local kind="$1" entry="$2" + local kind="$1" entry="$2" map local -a fields REPLY= case $kind in @@ -1249,7 +1250,12 @@ builtin setopt no_aliases bindkey) fields=( "${(z)${(Q)entry}}" ) [[ ${(Q)fields[4]} == -[AN] ]] && return 1 - REPLY="bindkey ${${(M)${(Q)fields[4]}:#-M}:+${(Q)fields[5]}} ${fields[1]}" ;; + # main is the default keymap: `bindkey -M main KEY` and `bindkey KEY` + # change the same binding, so they share one identity. + map= + [[ ${(Q)fields[4]} == -M ]] && map=${(Q)fields[5]} + [[ $map == main ]] && map= + REPLY="bindkey $map ${fields[1]}" ;; *) return 1 ;; esac [[ -n $REPLY ]]