瀏覽代碼

Version 0.1: arity-safe version ordering, Ctrl+C rollback-or-continue, removal-aware lot planning

Three production fixes from the 0.2.0 security-release round:

- Version.greater_than/less_than ignored version components beyond the
  shorter operand's arity, so 0.2.1 never counted as newer than 0.2 —
  patch releases were invisible to 'usm update' (inversion-0.1.1 and
  spry-0.2.1 both shipped without reaching a single client). Missing
  components now count as zero: 0.2.1 > 0.2, 0.2 == 0.2.0, 1.0.0.1 > 1.0.
- SIGINT during a running transaction now resolves at safe checkpoints
  (before each per-package operation and whenever an operation fails —
  the terminal signals the child too, so the op usually dies with it):
  a new interrupt_decided signal asks whether to roll the journaled
  transaction back or clear the flag and keep going, the failed
  operation re-running (every operation is restartable). No handler
  connected — non-interactive contexts — rolls back, the previous
  behaviour. The POSIX handler is armed only for the transaction's
  duration; the signal is GIR-clean.
- strategise() satisfied build-phase dependencies against the
  pre-transaction disk state, but the transaction removes old packages
  BEFORE building: updating astralis+inversion together removed old
  inversion, planned both into one lot, and astralis's build failed
  with the pc file already gone. Resources provided by planned removals
  now satisfy nothing, so an update pair splits across lots and the
  dependent builds against the freshly installed replacement.

usm-tests: 411 passed, 0 failed (version-ordering matrix, interrupt
reply parsing, flag cycle; MANIFEST 0.0.1 -> 0.1 so the fix itself
propagates past the arity bug it fixes).
clanker 4 周之前
父節點
當前提交
e138a0723d

+ 1 - 1
MANIFEST.usm

@@ -1,6 +1,6 @@
 {
   "name": "usm",
-  "version": "0.0.1",
+  "version": "0.1",
   "summary": "Universal Source Manifest",
   "licences": [ {"name": "GPLv3", "category": "libre", "text": "src/LICENSE"} ],
   "flags": ["ninjaStyleProgress"],

+ 22 - 0
src/cli/Confirm.vala

@@ -37,4 +37,26 @@ namespace Usm.Cli {
         }
         return builder.str;
     }
+
+    /**
+     * Parses one interrupt-prompt reply; see
+     * {@link Usm.TransactionInterrupt.parse_reply}.
+     */
+    public bool parse_interrupt_reply(string? reply) {
+        return Usm.TransactionInterrupt.parse_reply(reply);
+    }
+
+    /**
+     * The {@link Usm.Transaction.InterruptDecision} hook: narrates where
+     * the Ctrl+C landed and asks whether to roll back or keep going.
+     */
+    public bool interrupt_decision_prompt(string subject, string phase_verb) {
+        printerr("\nInterrupt received while %s \"%s\".\n", phase_verb, subject);
+        stdout.printf("Roll back or keep going? [r]oll back / [c]ontinue (Enter = roll back): ");
+        stdout.flush();
+        var keep_going = parse_interrupt_reply(confirm_read_line());
+        printerr(keep_going ? "Continuing — the interrupted operation re-runs.\n"
+                            : "Rolling the transaction back.\n");
+        return keep_going;
+    }
 }

+ 1 - 0
src/cli/Downgrade.vala

@@ -97,6 +97,7 @@ private int downgrade_main(string[] args) {
             install_order = downgrade_order,
             state = state
         };
+        transaction.interrupt_decided.connect((sender, subject, phase_verb) => Usm.Cli.interrupt_decision_prompt(subject, phase_verb));
 
         // Planning before the prompt populates the rebuildDependants
         // entries so the summary shows the whole transaction

+ 1 - 0
src/cli/Install.vala

@@ -178,6 +178,7 @@ private int install_main(string[] args) {
             system_packages = resolution.system_packages,
             system_install_plan = system_install_plan
         };
+        transaction.interrupt_decided.connect((sender, subject, phase_verb) => Usm.Cli.interrupt_decision_prompt(subject, phase_verb));
 
         // Planning before the prompt populates the rebuildDependants
         // entries so the summary shows the whole transaction

+ 1 - 0
src/cli/Rebuild.vala

@@ -61,6 +61,7 @@ private int rebuild_main(string[] args) {
             resource_finder = new Usm.ResourceFinder(paths),
             state = state
         };
+        transaction.interrupt_decided.connect((sender, subject, phase_verb) => Usm.Cli.interrupt_decision_prompt(subject, phase_verb));
         transaction.progress_updated.connect(progress.on_transaction_progress);
         transaction.rebuild_package(target);
         progress.finish();

+ 1 - 0
src/cli/Remove.vala

@@ -82,6 +82,7 @@ private int remove_main(string[] args) {
             to_remove = to_remove,
             state = state
         };
+        transaction.interrupt_decided.connect((sender, subject, phase_verb) => Usm.Cli.interrupt_decision_prompt(subject, phase_verb));
 
         // Planning before the prompt lets the removal order (dependents
         // before providers, orphans last) be computed and shown

+ 1 - 0
src/cli/Update.vala

@@ -242,6 +242,7 @@ private int update_main(string[] args) {
             system_packages = resolution.system_packages,
             system_install_plan = system_install_plan
         };
+        transaction.interrupt_decided.connect((sender, subject, phase_verb) => Usm.Cli.interrupt_decision_prompt(subject, phase_verb));
 
         // Planning before the prompt populates the rebuildDependants
         // entries so the summary shows the whole transaction

+ 61 - 0
src/lib/Interrupt.vala

@@ -0,0 +1,61 @@
+namespace Usm {
+
+    /**
+     * SIGINT capture for running transactions: a raw POSIX handler sets a
+     * static flag the transaction loop polls at safe checkpoints — before
+     * each per-package operation and whenever an operation fails. The
+     * terminal's Ctrl+C reaches the whole foreground process group, so the
+     * usual outcome is the running child (build, test) dying with the
+     * signal, the operation erroring, and the checkpoint resolving the
+     * interrupt: roll the journaled transaction back, or clear the flag
+     * and keep going (the failed operation re-runs; every operation is
+     * restartable).
+     *
+     * The handler is installed only for the duration of a transaction —
+     * {@link Transaction.run} and {@link Transaction.rebuild_package} call
+     * {@link install} on entry and {@link restore} on exit — so outside a
+     * transaction SIGINT keeps its default disposition.
+     */
+    public class TransactionInterrupt : Object {
+
+        /**
+         * Set by the handler; polled by the transaction checkpoints.
+         * (Vala has no volatile fields — a plain bool store/load is
+         * atomic in practice, and the handler only ever sets it true.)
+         */
+        public static bool requested = false;
+
+        [CCode (has_target = false)]
+        private static void handle_sigint(int signal_number) {
+            requested = true;
+        }
+
+        /**
+         * Arms the handler and clears any stale request. Signal-safe:
+         * the handler only assigns the flag.
+         */
+        public static void install() {
+            Posix.signal(Posix.Signal.INT, (Posix.sighandler_t) handle_sigint);
+            requested = false;
+        }
+
+        /** Restores SIGINT's default disposition and clears the flag. */
+        public static void restore() {
+            Posix.signal(Posix.Signal.INT, Posix.SIG_DFL);
+            requested = false;
+        }
+
+        /**
+         * Parses one interrupt-prompt reply: `c`/`continue` keep the
+         * transaction going; anything else — including EOF and an empty
+         * line — rolls back, the safe default.
+         */
+        public static bool parse_reply(string? reply) {
+            if(reply == null) {
+                return false;
+            }
+            var answer = reply.strip().down();
+            return answer == "c" || answer == "continue";
+        }
+    }
+}

+ 87 - 19
src/lib/Transaction.vala

@@ -159,12 +159,22 @@ namespace Usm {
          */
         public Vector<RebuildEntry> rebuilds { get; private set; default = new Vector<RebuildEntry>(); }
 
+        /**
+         * Emitted when a Ctrl+C lands mid-transaction: handlers answer
+         * whether to keep going (true — the interrupted operation re-runs)
+         * or roll the journaled transaction back (false). With no handler
+         * connected the signal's default return is false — the rollback
+         * behaviour non-interactive contexts want.
+         */
+        public signal bool interrupt_decided(string subject, string phase_verb);
+
         public void run() throws TransactionError {
 
             // 1. Verify the transaction is valid
             strategise();
 
             journal = new Journal(journal_backup_root());
+            TransactionInterrupt.install();
             try {
                 journal.begin();
 
@@ -216,6 +226,7 @@ namespace Usm {
                 throw new TransactionError.UNKNOWN_ERROR(@"Error running transaction: $(e.message)");
             }
             finally {
+                TransactionInterrupt.restore();
                 journal = null;
             }
         }
@@ -238,6 +249,7 @@ namespace Usm {
             current_task_type = TransactionTask.REBUILDING;
 
             journal = new Journal(journal_backup_root());
+            TransactionInterrupt.install();
             try {
                 journal.begin();
 
@@ -258,6 +270,7 @@ namespace Usm {
                 throw new TransactionError.UNKNOWN_ERROR(@"Error rebuilding package $(package.package_name): $(e.message)");
             }
             finally {
+                TransactionInterrupt.restore();
                 journal = null;
             }
         }
@@ -470,21 +483,40 @@ namespace Usm {
                     report_progress(TransactionTask.STRATEGISING, (float)strategise_current_task / (float)strategise_worst_case_task_count);
                     try {
                         var manifest = package.get_manifest();
+                        // Resources provided by packages this transaction
+                        // REMOVES are gone before the first build runs —
+                        // stale on disk at planning time, they satisfy
+                        // nothing (an update pair's replacement counts only
+                        // from the lot that installs it, so the dependency
+                        // lands in an earlier lot and the dependent builds
+                        // against the fresh artifact)
+                        PredicateDelegate<ResourceRef> removed_by_plan = d => planned_removal.any(p => {
+                            try {
+                                return p.get_manifest().satisfies(d);
+                            }
+                            catch(Error e) {
+                                return false;
+                            }
+                        });
                         // Build-phase refs must be satisfied by resources
-                        // already present or installed by an EARLIER lot: a
+                        // that SURVIVE the removal phase and are already
+                        // present or installed by an EARLIER lot: a
                         // same-lot provide is not installed yet when this
                         // package builds, so a build dependency on it defers
                         // the package to the next lot
-                        PredicateDelegate<ResourceRef> buildtime_satisfied = d => resource_finder.has_resource(d)
+                        PredicateDelegate<ResourceRef> buildtime_satisfied = d => !removed_by_plan(d) && (
+                            resource_finder.has_resource(d)
                             || preinstalled_resources.any(r => d.satisfied_by(r))
-                            || installed_by_earlier_lots.any(r => d.satisfied_by(r));
+                            || installed_by_earlier_lots.any(r => d.satisfied_by(r)));
                         // Manage-phase executables run at install time, when
                         // everything this lot has already admitted (in
-                        // topological order) is installed
-                        PredicateDelegate<ResourceRef> installtime_satisfied = d => resource_finder.has_resource(d)
+                        // topological order) is installed — but nothing the
+                        // removal phase takes away
+                        PredicateDelegate<ResourceRef> installtime_satisfied = d => !removed_by_plan(d) && (
+                            resource_finder.has_resource(d)
                             || preinstalled_resources.any(r => d.satisfied_by(r))
                             || installed_by_earlier_lots.any(r => d.satisfied_by(r))
-                            || installed_by_this_lot.any(r => d.satisfied_by(r));
+                            || installed_by_this_lot.any(r => d.satisfied_by(r)));
                         if(manifest.dependencies.manage.is_satisfied(installtime_satisfied) &&
                                 manifest.dependencies.build.is_satisfied(buildtime_satisfied)) {
                             lot.add(package);
@@ -838,23 +870,57 @@ namespace Usm {
 
         private void do_for(Enumerable<CachedPackage> packages, PackageDelegate func, TransactionTask task_type) throws TransactionError {
             foreach (var package in packages) {
-                try {
-                    begin_action(task_type, package.package_name, current_task);
-                    report_progress(task_type, 0.0f);
-                    func(package);
-                    lock(progress_lock) {
-                        current_task++;
-                    }
-                }
-                catch (TransactionError e) {
-                    throw e;
+                // A Ctrl+C that arrived between operations resolves before
+                // this one starts: keep going (clearing the flag) or roll
+                // the transaction back
+                if(TransactionInterrupt.requested && !resolve_interrupt(package.package_name, task_type)) {
+                    throw new TransactionError.INTERRUPTED(@"Interrupted before $(task_type.get_verb()) $(package.package_name)");
                 }
-                catch(Error e) {
-                    throw new TransactionError.UNKNOWN_ERROR(@"Error $(task_type.get_verb()) package $(package.package_name): $(e.message)");
+                while(true) {
+                    try {
+                        begin_action(task_type, package.package_name, current_task);
+                        report_progress(task_type, 0.0f);
+                        func(package);
+                        lock(progress_lock) {
+                            current_task++;
+                        }
+                        break;
+                    }
+                    catch (TransactionError e) {
+                        // The operation failed with a Ctrl+C pending — the
+                        // terminal signalled the child too. Ask: roll back,
+                        // or clear the flag and re-run this operation
+                        if(TransactionInterrupt.requested && resolve_interrupt(package.package_name, task_type)) {
+                            continue;
+                        }
+                        throw e;
+                    }
+                    catch(Error e) {
+                        if(TransactionInterrupt.requested && resolve_interrupt(package.package_name, task_type)) {
+                            continue;
+                        }
+                        throw new TransactionError.UNKNOWN_ERROR(@"Error $(task_type.get_verb()) package $(package.package_name): $(e.message)");
+                    }
                 }
             }
         }
 
+        /**
+         * Resolves a pending interrupt for {@link subject} in the phase
+         * {@link verb}: emits {@link interrupt_decided}. A "keep going"
+         * answer clears the flag so the retried operation runs
+         * uninterrupted; a rollback answer (or no handler — the signal's
+         * default) leaves it set and the caller unwinds to the journaled
+         * rollback.
+         */
+        private bool resolve_interrupt(string subject, TransactionTask task_type) {
+            bool keep_going = interrupt_decided(subject, task_type.get_verb());
+            if(keep_going) {
+                TransactionInterrupt.requested = false;
+            }
+            return keep_going;
+        }
+
         private void report_progress(TransactionTask task, float progress) {
             lock(progress_lock) {
                 progress_updated(task, current_subject, current_task, task_count, progress);
@@ -1169,7 +1235,9 @@ namespace Usm {
         INVALID_TRANSACTION,
         UNKNOWN_ERROR,
         BUILD_ERROR,
-        INSTALL_ERROR
+        INSTALL_ERROR,
+        /** A Ctrl+C the interrupt decision answered with "roll back". */
+        INTERRUPTED
     }
 
 

+ 26 - 6
src/lib/Version.vala

@@ -81,15 +81,25 @@ namespace Usm {
         }
 
         public bool greater_than(Version other) {
-            var parts = int.min(other.version_nums.length, version_nums.length);
+            var mine = version_nums ?? new int[0];
+            var theirs = other.version_nums ?? new int[0];
+            var parts = int.min(theirs.length, mine.length);
             for(int i = 0; i < parts; i++) {
-                if(version_nums[i] > other.version_nums[i]) {
+                if(mine[i] > theirs[i]) {
                     return true;
                 }
-                if(version_nums[i] < other.version_nums[i]) {
+                if(mine[i] < theirs[i]) {
                     return false;
                 }
             }
+            // Common prefix equal: the version carrying extra non-zero
+            // components is greater — missing components count as zero
+            // (0.2.1 > 0.2, 0.2.0 == 0.2)
+            for(int i = parts; i < mine.length; i++) {
+                if(mine[i] > 0) {
+                    return true;
+                }
+            }
             // So far, equal
             if(release == null && other.release == null) {
                 return false;
@@ -104,15 +114,25 @@ namespace Usm {
         }
 
         public bool less_than(Version other) {
-            var parts = int.min(other.version_nums.length, version_nums.length);
+            var mine = version_nums ?? new int[0];
+            var theirs = other.version_nums ?? new int[0];
+            var parts = int.min(theirs.length, mine.length);
             for(int i = 0; i < parts; i++) {
-                if(version_nums[i] < other.version_nums[i]) {
+                if(mine[i] < theirs[i]) {
                     return true;
                 }
-                if(version_nums[i] > other.version_nums[i]) {
+                if(mine[i] > theirs[i]) {
                     return false;
                 }
             }
+            // Common prefix equal: the other version carrying extra
+            // non-zero components makes this one smaller — missing
+            // components count as zero (0.2 < 0.2.1, 0.2 == 0.2.0)
+            for(int i = parts; i < theirs.length; i++) {
+                if(theirs[i] > 0) {
+                    return true;
+                }
+            }
             // So far, equal
             if(release == null && other.release == null) {
                 return false;

+ 1 - 0
src/lib/meson.build

@@ -16,6 +16,7 @@ sources += files('VersionConstraint.vala')
 sources += files('Resolver.vala')
 sources += files('Util.vala')
 sources += files('Transaction.vala')
+sources += files('Interrupt.vala')
 sources += files('Journal.vala')
 sources += files('Configuration.vala')
 sources += files('SystemPackageManager.vala')

+ 77 - 0
src/tests/TestMain.vala

@@ -2759,8 +2759,85 @@ echo "Installing libmeson-pkg.so to /usr/lib"
         Usm.Util.delete_tree(scratch);
     }
 
+    /**
+     * Version ordering across different-arity component lists: missing
+     * components count as zero. The regression this pins: 0.2.1 never
+     * registered as newer than 0.2, so patch releases were invisible to
+     * `usm update` (inversion-0.1.1, spry-0.2.1).
+     */
+    void test_version_ordering() throws Error {
+        var v02 = new Usm.Version.from_string("0.2");
+        var v021 = new Usm.Version.from_string("0.2.1");
+        var v020 = new Usm.Version.from_string("0.2.0");
+        var v03 = new Usm.Version.from_string("0.3");
+        var v1 = new Usm.Version.from_string("1");
+        var v10 = new Usm.Version.from_string("1.0");
+        var v1000 = new Usm.Version.from_string("1.0.0.0");
+        var v1001 = new Usm.Version.from_string("1.0.0.1");
+        var v01 = new Usm.Version.from_string("0.1");
+        var v01rel2 = new Usm.Version.from_string("0.1+2");
+
+        check(v021.greater_than(v02), "0.2.1 > 0.2 (patch release is newer)");
+        check(v02.less_than(v021), "0.2 < 0.2.1");
+        check(!v02.greater_than(v021), "0.2 not > 0.2.1");
+        check(!v021.less_than(v02), "0.2.1 not < 0.2");
+        check(v02.compare(v020) == 0, "0.2 == 0.2.0 (zero padding)");
+        check(v020.compare(v02) == 0, "0.2.0 == 0.2");
+        check(v021.compare(v020) > 0, "0.2.1 > 0.2.0");
+        check(v02.compare(v01) > 0, "0.2 > 0.1");
+        check(v03.greater_than(v021), "0.3 > 0.2.1 (minor beats patch)");
+        check(v1.compare(v10) == 0, "1 == 1.0");
+        check(v1000.compare(v10) == 0, "1.0 == 1.0.0.0");
+        check(v1001.greater_than(v1000), "1.0.0.1 > 1.0.0.0");
+        check(v1001.greater_than(v10), "1.0.0.1 > 1.0 across arity");
+        check(v10.less_than(v1001), "1.0 < 1.0.0.1 across arity");
+        check(v01rel2.greater_than(v01), "0.1+2 > 0.1 (release suffix)");
+        check(v01.compare(v01rel2) < 0, "0.1 < 0.1+2");
+        check(!v01.greater_than(v01rel2), "0.1 not > 0.1+2");
+        var v01_again = new Usm.Version.from_string("0.1");
+        check(v01.compare(v01_again) == 0, "0.1 == 0.1");
+    }
+
+    /**
+     * The interrupt prompt's reply parsing: only c/continue keep the
+     * transaction going; everything else — empty, junk, EOF — rolls
+     * back, the safe default.
+     */
+    void test_interrupt_reply_parsing() {
+        check(Usm.TransactionInterrupt.parse_reply("c"), "c continues");
+        check(Usm.TransactionInterrupt.parse_reply("C"), "C continues");
+        check(Usm.TransactionInterrupt.parse_reply("continue"), "continue continues");
+        check(Usm.TransactionInterrupt.parse_reply("  continue  "), "whitespace-trimmed continue continues");
+        check(!Usm.TransactionInterrupt.parse_reply("r"), "r rolls back");
+        check(!Usm.TransactionInterrupt.parse_reply("rollback"), "rollback rolls back");
+        check(!Usm.TransactionInterrupt.parse_reply(""), "empty rolls back");
+        check(!Usm.TransactionInterrupt.parse_reply("junk"), "junk rolls back");
+        check(!Usm.TransactionInterrupt.parse_reply(null), "EOF rolls back");
+    }
+
+    /**
+     * The SIGINT flag and its install/restore cycle: the flag is what the
+     * transaction checkpoints poll, and restore clears it with the
+     * default disposition.
+     */
+    void test_interrupt_flag_cycle() {
+        Usm.TransactionInterrupt.requested = true;
+        check(Usm.TransactionInterrupt.requested, "the interrupt flag is settable");
+        Usm.TransactionInterrupt.restore();
+        check(!Usm.TransactionInterrupt.requested, "restore clears the flag");
+    }
+
     int main() {
         test_default_ignore();
+        test_interrupt_reply_parsing();
+        test_interrupt_flag_cycle();
+        try {
+            test_version_ordering();
+        }
+        catch(Error e) {
+            failures++;
+            print("FAIL version ordering test threw: %s\n", e.message);
+        }
         try {
             test_ignore_patterns();
             test_ignore_always_included();