Compare commits

...
19 Commits
Author SHA1 Message Date
vhaudiquet 46407375d0 deb/cross: stop exporting PKG_CONFIG_LIBDIR for cross builds
CI / build (push) Successful in 3m13s
CI / test (push) Skipped
CI / publish (push) Skipped
CI / snap (push) In progress
dpkg-buildpackage and sbuild export no pkg-config redirection: the
environment is the package's to set.  Pointing the whole build at the
target multiarch pkgconfig dirs makes every pkg-config-consuming tool
resolve against the target libraries, the host-side tools of the same
build included: the host linker is then handed target-arch -L paths
(the kernel's resolve_btfids logging 'skipping incompatible
/usr/lib/aarch64-linux-gnu/libelf.so') and only survives through
fallbacks when the build-architecture libraries happen to be installed
too.

Packages whose cross builds need target pkg-config data must arrange
for it themselves, the way the kernel packaging builds rtla statically
against no target system libraries.
2026-09-25 20:54:06 +02:00
vhaudiquet ce19c5648b quirks: inject the kernel's host-tool libraries as :native for cross
The resolute and stonking kernels declare libelf-dev, libdw-dev and
libssl-dev unqualified, unlike the Debian control which carries the
same names qualified :native for the host-side tools (resolve_btfids,
gendwarfksyms, sign-file).  The dpkg cross rules resolve an unqualified
Multi-Arch: same name against the host architecture only, so a cross
build installs no build-architecture variants and the kernel's
host-side tools cannot link (-ldw, -lelf, -lssl all fail).

Inject the :native variants for the affected series — the declared
unqualified dependencies stay, so the target-side tools keep their
host-architecture libraries.  Series-scoped so the entry can be
dropped once the control is fixed upstream.
2026-09-25 20:53:10 +02:00
vhaudiquet 99c6f63cd4 deb: pin the cross variant choice of M-A:same build-deps in tests
An unqualified Multi-Arch: same build-dependency resolves to the
host-architecture variant only, even when a build-architecture
candidate exists: that is what dpkg's checker accepts (KnownFacts
accepts foreign, host and all instances) and what sbuild installs,
whose dummy package carries the parsed Build-Depends unqualified and
whose sbuild-cross-resolver only filters foreign M-A:foreign and
Essential packages out of the apt universe — verified by resolving a
sbuild-style dummy:arm64 against the resolute/stonking indexes, which
yields libelf-dev:arm64 & co and never the amd64 variants.

The build-architecture side of such a library is only ever installed
through an explicit :native declaration.  Pin both rules so a resolver
regression towards dual-installing unqualified dependencies cannot
reappear unnoticed.
2026-09-25 20:53:02 +02:00
vhaudiquet 4b52f8e9e9 docs: add crates.io installation instructions
CI / build (push) Successful in 3m1s
CI / test (push) Skipped
CI / publish (push) Skipped
CI / snap (push) Successful in 6m26s
pkh v0.1.0 is published to crates.io, so cargo install pkh is now the
primary installation path; building from source stays as the
alternative. Drop the 'no distribution channel' notice.
2026-09-24 22:19:24 +02:00
vhaudiquet 6df490cf9a ci: install build-essential in the publish job
CI / build (push) Successful in 3m3s
CI / test (push) Skipped
CI / snap (push) Successful in 6m9s
CI / publish (push) Successful in 2m11s
cargo publish verifies the packaged tarball with a full build before
uploading, and that build needs a C linker for build-script crates
(proc-macro2 was the first to fail). The job's container setup step
omitted build-essential, unlike the build and test jobs, so the
verification failed with 'linker cc not found' on the v0.1.0 tag.
2026-09-24 21:41:15 +02:00
vhaudiquet f5b7704647 data: add an agent SKILL.md teaching pkh usage
CI / build (push) Successful in 3m1s
CI / test (push) Skipped
CI / snap (push) Successful in 6m1s
CI / publish (push) Failing after 53s
Add data/skill/SKILL.md in the agent-skills open format: a skill
directory that agents (Claude Code, Codex, OpenCode, ...) discover and
load on demand. It documents the shared option surface, the pull,
chlog, build, deb, lint, put workflow and the flags that keep runs
non-interactive; the command reference was generated from the live
--help output of every subcommand.

It lives under data/ so a later module can embed it and ship it with
the binary, for example a 'pkh skill' installer writing it into the
agent skill directories.
2026-09-22 23:55:56 +02:00
vhaudiquet 4c1edc7dcd docs: update example gif
CI / build (push) Successful in 3m13s
CI / test (push) Skipped
CI / publish (push) Skipped
CI / snap (push) Successful in 6m7s
2026-09-22 21:35:35 +02:00
vhaudiquet 9a66f8f7df deb: retry the interrupt chroot removal while children die off
dpkg defers SIGINT until it reaches a safe state, so it can still be
writing into the chroot when the watchdog's rm -rf starts racing
through it, failing with "directory not empty" and leaving the tree
half-removed. Retry the removal for a few seconds while the
interrupted children finish dying off; a genuinely stuck tree still
ends in the pkh prune message.
2026-09-22 19:57:01 +02:00
vhaudiquet 7a6337e1cb deb: build the tree the caller pointed at before the name search
CI / build (push) Successful in 3m16s
CI / test (push) Skipped
CI / publish (push) Skipped
CI / snap (push) Failing after 7m17s
build_binary_package stages the parent of the requested cwd, then
re-derived the package directory inside the staging area from
package/version name patterns plus the calling process's working
directory. That only works by accident for interactive users sitting
in the package directory: an embedded caller whose tree lives at
<job>/tree matches no pattern, and the process cwd means nothing to
a library consumer — the bc build above failed here even though the
tree was staged correctly.

The pointed-at tree is authoritative anyway: its changelog defined
the package, version and series for this build. Resolve its staged
copy outright when it carries a debian/ tree, keep the pattern
search (with the quirks overrides) as a fallback, and hand the
resolved directory to local::build instead of searching again.
2026-09-22 14:31:25 +02:00
vhaudiquet 9186bbbe51 deb: classify the staged listing through the context
The fallback listing of find_package_directory called Path::is_dir
on entries returned by list_files — a host-side stat. For an unshare
context (every local build) those paths are rooted inside the chroot
and do not exist at the same host path, so every entry came out a
non-directory, the 'Found directories' list silently stayed empty
and the failure degraded to the list-less 'Could not find package
directory' variant, hiding the actual layout (seen building bc from
an ubuntu/devel checkout whose staged tree was named 'tree').

Classify entries through the new ContextDriver::is_dir, and log
every entry instead of only those matching the broken host stat.
2026-09-22 14:31:10 +02:00
vhaudiquet 681fa3d687 context: add ContextDriver::is_dir
list_files returns context-relative paths (rooted inside the chroot
for an unshare context, on the remote for ssh): whether an entry is
a directory can only be decided through the context, never with a
host-side stat. Give every driver a proper is_dir rather than
approximating it with exists, so callers can keep directories and
files apart — the deb package-directory search lists directories
only.
2026-09-22 14:29:12 +02:00
vhaudiquet cc5bbd2297 chlog: drop a trailing buildN before appending ubuntu1
An Ubuntu upload of a package sitting at X-2build1 produced
X-2build1ubuntu1: the blind append misrepresents the lineage and,
sorting below the proper X-2ubuntu1, could never supersede it. A real
change on top of a rebuild replaces the marker instead, so the
trailing buildN is now stripped before the ubuntu counter is appended
or incremented: X-2build1 becomes X-2ubuntu1, X-2ubuntu1build1
becomes X-2ubuntu2.
2026-09-22 13:00:40 +02:00
vhaudiquet 5a1c1672cd cli: intercept Ctrl+C for deb, build and put
The SIGINT handler only records the interruption and wakes a watchdog
through a self-pipe (async-signal-safe); the watchdog runs the whole
shutdown in thread context — the live view's reporter first, then the
notice and the log hint, then the cleanup hooks, then exit 130. Flows
park in wait_for_shutdown instead of racing it with their own exit,
and an end-to-end test drives the sequence by re-spawning the test
binary and raising SIGINT at itself.
2026-09-22 10:44:05 +02:00
vhaudiquet adde0ee977 deb,ui: tear the live view down through the interrupt core
The ephemeral guard registers its chroot removal as an interrupt
cleanup hook and, once interrupted, stands down from its own teardown
so the two cannot race umount/rm; bootstrap bails out of tarball
extraction and the lockfile wait, keeping the hook registered on the
bootstrap error path so the watchdog can remove the partial tree. The
live view registers an interrupt reporter that suspends the widget and
returns the log-file hint, silences the tty rendering of the ^C
keypress (the echoed "^C" can wrap near the right edge and shift the
teardown erase by a row, leaving the first widget line on screen) and
kills the shared draw target so late log records cannot repaint the
cleared bars. Failure summaries stay quiet when interrupted: the
captured errors are just the killed children's death throes, and the
dose-builddebcheck diagnosis is skipped for a dependency failure the
user interrupted themselves.
2026-09-22 10:43:58 +02:00
vhaudiquet 84405a6762 interrupt: add the passive interrupt core
The library holds only the state its own types need when a Ctrl+C
arrives: the interrupted flag flows check to stand down, the cleanup
hook registry for resources that must not outlive the process (the
ephemeral build chroot), and the live view's reporter slot. The
signal handling itself is CLI wiring and lands separately: nothing
here installs handlers, prints or exits, so a library consumer
embedding these types keeps its own signal disposition.
2026-09-22 10:43:49 +02:00
vhaudiquet 8250a0e3b1 ci: publish the crate to crates.io on a v* tag
CI / build (push) Successful in 3m15s
CI / test (push) Skipped
CI / publish (push) Skipped
CI / snap (push) Failing after 7m34s
Trusted publishing is GitHub-Actions-only, so authentication goes
through a crates.io API token stored as the CARGO_REGISTRY_TOKEN
secret, scoped to the pkh crate. The job gates on the build job and
fails loudly when the tag does not match the version in Cargo.toml,
since cargo publish ships the declared version regardless of the
tag name.
2026-09-21 23:14:27 +02:00
vhaudiquet edfd7ed5ed snap: declare the dual license and ship the license texts
CI / build (push) Successful in 3m11s
CI / test (push) Skipped
CI / snap (push) Failing after 7m18s
The store listing carries the crate's MIT OR GPL-2.0-only
expression, and both texts ride along in the snap under
/usr/share/doc/pkh: the MIT grant requires the notice to
accompany copies, and GPL-2 requires the license text with
distribution.
2026-09-21 22:41:09 +02:00
vhaudiquet 6545d327e4 docs: add example gif
CI / build (push) Successful in 3m13s
CI / test (push) Skipped
CI / snap (push) Failing after 7m23s
2026-09-21 21:33:52 +02:00
vhaudiquet b118a54bff deps: describe the crate and dual-license it MIT or GPL-2.0-only
CI / build (push) Successful in 3m2s
CI / test (push) Skipped
CI / snap (push) Failing after 7m15s
Publishing to crates.io requires a description and a license; the
repository and readme give the crates.io page the right links. The
license texts ship as LICENSE-MIT and LICENSE-GPL.

Dual licensing is valid while no lintian-derived code is in tree:
shelling out to lintian is mere aggregation. When derived lint
collections are implemented they must land in a separate GPL-2-only
crate, since their copyright belongs to lintian's authors and
cannot carry an MIT grant; the core crate stays dual.
2026-09-21 20:46:46 +02:00
27 changed files with 1801 additions and 378 deletions
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 799 KiB

+39
View File
@@ -3,6 +3,7 @@ name: CI
on: on:
push: push:
branches: [ "main", "ci-test" ] branches: [ "main", "ci-test" ]
tags: [ "v*" ]
pull_request: pull_request:
branches: [ "main" ] branches: [ "main" ]
@@ -121,3 +122,41 @@ jobs:
name: snap name: snap
path: ./*.snap path: ./*.snap
if-no-files-found: error if-no-files-found: error
publish:
# Publishes the crate to crates.io on a v* tag. Trusted publishing
# (OIDC) is GitHub-Actions-only, so authentication goes through a
# crates.io API token stored as the CARGO_REGISTRY_TOKEN secret,
# scoped to the pkh crate.
if: startsWith(github.ref, 'refs/tags/v')
needs: build
runs-on: ubuntu-latest
container:
image: ubuntu:26.04
options: --privileged --cap-add SYS_ADMIN --security-opt apparmor:unconfined
steps:
- name: Set up container image
run: |
apt-get update
apt-get install -y nodejs sudo curl wget ca-certificates build-essential
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- name: Install build dependencies
run: |
sudo apt-get update
sudo apt-get install -y pkg-config libssl-dev libgpg-error-dev libgpgme-dev
- name: Check the tag matches the crate version
# cargo publish ships whatever version Cargo.toml declares,
# regardless of the tag: a mismatch must fail loudly instead of
# publishing the wrong version under the release tag.
run: |
crate_version="$(awk -F'"' '/^version =/{print $2; exit}' Cargo.toml)"
tag_version="${GITHUB_REF_NAME#v}"
if [ "$crate_version" != "$tag_version" ]; then
echo "tag $GITHUB_REF_NAME does not match crate version $crate_version" >&2
exit 1
fi
- name: Publish
run: cargo publish
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
+2
View File
@@ -48,6 +48,8 @@ Rules:
- `prune`, `package_info` — remaining subcommand modules - `prune`, `package_info` — remaining subcommand modules
- `context` — build contexts: local, ssh, chroot/schroot, unshare - `context` — build contexts: local, ssh, chroot/schroot, unshare
(`src/context/`) (`src/context/`)
- `interrupt` — Ctrl+C interception and interrupt-time cleanup
(`src/interrupt.rs`)
- `debian` — Debian format primitives: control, versions, checksums, - `debian` — Debian format primitives: control, versions, checksums,
arch (`src/debian/`) arch (`src/debian/`)
- `apt`, `launchpad`, `distro_info`, `quirks` — archive/distro - `apt`, `launchpad`, `distro_info`, `quirks` — archive/distro
+4
View File
@@ -3,6 +3,10 @@ name = "pkh"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
authors = ["vhaudiquet"] authors = ["vhaudiquet"]
description = "pkh is a packaging helper for Debian/Ubuntu packages"
license = "MIT OR GPL-2.0-only"
repository = "https://git.vhaudiquet.fr/vhaudiquet/pkh"
readme = "README.md"
[dependencies] [dependencies]
clap = { version = "4.5.51", features = ["cargo"] } clap = { version = "4.5.51", features = ["cargo"] }
+338
View File
@@ -0,0 +1,338 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
<https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Moe Ghoul>, 1 April 1989
Moe Ghoul, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025-2026 Valentin Haudiquet
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+9 -1
View File
@@ -2,9 +2,17 @@
`pkh` is a packaging helper for Debian/Ubuntu packages. `pkh` is a packaging helper for Debian/Ubuntu packages.
![](.github/pkh.gif)
## Installation ## Installation
No distribution channel is published yet; build from source: From crates.io:
```
cargo install pkh
```
Or build from source (the same system packages are needed either way):
``` ```
sudo apt install pkg-config libssl-dev libgpg-error-dev libgpgme-dev sudo apt install pkg-config libssl-dev libgpg-error-dev libgpgme-dev
+20
View File
@@ -15,18 +15,38 @@ quirks:
# against the host architecture, whose dependency closure conflicts with # against the host architecture, whose dependency closure conflicts with
# the `:native` python3. Resolve it against the build architecture # the `:native` python3. Resolve it against the build architecture
# until the control is fixed upstream. # until the control is fixed upstream.
# The resolute and stonking kernels declare the host-tool libraries
# unqualified (libelf-dev for resolve_btfids, libdw-dev for
# gendwarfksyms, libssl-dev for sign-file), unlike the Debian control,
# which carries the same names qualified `:native`. The dpkg cross
# rules resolve an unqualified Multi-Arch: same name against the host
# architecture only, so a cross build installs no build-architecture
# variants and the kernel's host-side tools cannot link. Inject the
# build-architecture variants until the control is fixed upstream.
linux: linux:
deb: deb:
- series: [resolute] - series: [resolute]
dependencies: dependencies:
replace: replace:
llvm-21-dev: llvm-21-dev:native <!stage1> llvm-21-dev: llvm-21-dev:native <!stage1>
- series: [resolute, stonking]
dependencies:
inject:
- libelf-dev:native
- libdw-dev:native
- libssl-dev:native
linux-riscv: linux-riscv:
deb: deb:
- series: [resolute] - series: [resolute]
dependencies: dependencies:
replace: replace:
llvm-21-dev: llvm-21-dev:native <!stage1> llvm-21-dev: llvm-21-dev:native <!stage1>
- series: [resolute, stonking]
dependencies:
inject:
- libelf-dev:native
- libdw-dev:native
- libssl-dev:native
# Add more packages and their quirks as needed # Add more packages and their quirks as needed
# example-package: # example-package:
+130
View File
@@ -0,0 +1,130 @@
---
name: pkh
description: 'Drive pkh, a Debian/Ubuntu packaging helper: pull source packages, generate changelog entries, build .dsc/.deb, lint, and upload to a PPA. Use it whenever the task touches Debian or Ubuntu packaging: patching an existing package, preparing an SRU, backport or NMU, scaffolding a new .deb, rebuilding for a PPA, or uploading a source package. Trigger on "update the changelog", "package this", or a bare package name, even when the user never mentions Debian.'
---
# pkh
`pkh` wraps the Debian packaging toolchain (`dch`, `dpkg-buildpackage`,
`sbuild`, `dpkg-source`, `quilt`, `lintian`, PPA uploads) in one CLI.
The subcommands share one set of option names, so `-s` always targets
the series and `--ppa` always names the PPA. Each step also does more
than the raw tool it replaces: `pull` fetches the orig tarball with the
source, `chlog` commits the entry it writes, `deb` sets up a chroot and
installs the build dependencies.
Check the install with `pkh --version`. Each command lists its flags
with `pkh <command> --help`, so check there instead of guessing. pkh
shells out to host tools (git, dpkg-dev, quilt, mmdebstrap, lintian,
pristine-tar, schroot, ...). Install the ones your workflow uses, or
use the classic snap, which carries them.
## Shared options
| Option | Meaning |
|---|---|
| `-d, --dist <dist>` | Target distribution, `debian` or `ubuntu` |
| `-s, --series <series>` | Target series, for example `resolute` or `noble` |
| `-v, --version <version>` | Target package version |
| `-a, --arch <arch>` | Target architecture, for example `amd64` or `riscv64` |
| `-p, --pocket <pocket>` | Distribution pocket: `updates`, `security`, `proposed` |
| `--ppa <user/ppa>` | Act on the named PPA |
Defaults come from the host vendor, its development series, and its
architecture. When the target differs, pass the flags: packaging for
Ubuntu on a Debian host needs `-d ubuntu`, and a series or architecture
that differs from the host needs `-s` or `-a`.
## Patch an Ubuntu package
```
pkh pull hello # source and orig tarball; add -d ubuntu off an Ubuntu host
# edit the package, committing each patch to git
pkh chlog # generates the entry, opens it for editing, commits it
git add debian/changelog && git commit -m "d/changelog"
pkh build # source package, written next to the tree
pkh deb # binary build in a chroot with build deps installed
pkh lint # lintian plus pkh-native checks
pkh put --ppa user/hello_xxx # uploads the .changes file from the build
git push xxx user-fork # push the branch to your fork
```
Run `pkh chlog` and `pkh build` from the root of the source tree; they
act on the package in the current directory.
## Command reference
- `pkh new [name]` scaffolds a buildable source package. `--lang`
picks the build system (`rust`, `python`, `meson`, `cmake`,
`autotools`, `go`, `shell`, `makefile`); `--source <PATH>` packages
existing sources instead. `--upstream-version` and `--revision` set
the version. `--description`, `--homepage`, `--license <SPDX>`,
`--command`, `--maintainer "Name <email>"`, and `--depends` fill in
the package metadata, with the maintainer defaulting to
`DEBFULLNAME`/`DEBEMAIL` and then git config. `--quilt` and
`--native` choose the source format; `--orig-from
release|git|path|snapshot` and `--orig-path` control the orig
tarball. The changelog starts as `UNRELEASED`; `--release` targets
`--series` instead. `--defaults` answers every remaining question
with its default, which keeps the run non-interactive.
- `pkh pull <package>` fetches a source package from the archive or
git. `--archive` skips git. `--ppa user/ppa` and `--repository
<suite-url>` pull from a PPA or an external flat repository instead.
`-d`, `-s`, `-v`, and `-p` target an exact source.
- `pkh chlog` generates the changelog entry from the commits since the
last version tag, opens it for editing, and commits it. `--backport`,
`--nmu`, and `--rebuild` apply the matching numbering scheme
(`3.1-1ubuntu2~24.04.1`, `1.0-1.1`, `1.0-1build1`); `-v` sets an
explicit version instead.
- `pkh build` produces the .dsc. `--orig auto|always|never` controls
whether the upload includes the orig tarball; the default, `auto`,
includes it only when the upstream version changed.
- `pkh deb` builds the binary packages in an isolated context with the
build dependencies installed. `--ppa` (repeatable) adds dependency
sources, `--inject <package|.deb>` preinstalls a package, and `-j`
caps parallel jobs. `--cross` cross-compiles instead of using
qemu-binfmt, but most packages cannot cross-compile, so prefer qemu.
Leave `--mode` unset unless you need a specific build context.
- `pkh lint [path]` runs lintian plus the pkh-native checks. `--json`
emits a machine-readable report and `--list-tags` prints the native
tag catalog. `--fail-on` sets the severities that fail the run
(errors by default), `--suppress-tags` ignores tags, `--check` runs a
single native check, and `--info`, `--pedantic`, and
`--experimental` add detail. `--repack` packs the tree fresh instead
of reusing the existing build output.
- `pkh put [changes]` uploads a .changes file to `--ppa user/ppa`.
With no argument it uploads the .changes from this package's last
build, found next to the source tree. `--force` re-uploads a file
that was already uploaded.
- `pkh prune` removes build artifacts and caches. Run it with
`--dry-run` first to list them. `--all` also deletes the cached
chroot tarballs, which take long to download again, so use it when
you need the disk space.
## Notes for agent runs
- Pass `-d`, `-s`, and `-a` whenever the target differs from the host,
so runs are reproducible.
- Keep runs non-interactive. Pass explicit flags, use `pkh new
--defaults`, and set `EDITOR` before `pkh chlog` (`EDITOR=true` keeps
the generated text). Commands may ask short questions on the
terminal; flags avoid most prompts.
- Pass `RUST_LOG=debug` for pkh's own logs. `--verbose` on `pkh build`
and `pkh deb` prints raw tool output instead of the live view.
- pkh writes the build artifacts (.dsc, .changes, logs) next to the
source tree. `pkh put` finds them without arguments, and `pkh prune`
removes them again.
- Run `pkh lint` before `pkh put`. It exits nonzero when findings reach
the `--fail-on` level, which defaults to errors.
- pkh intercepts Ctrl+C, runs its cleanup hooks, and exits with status
130. `pkh prune` removes anything left over.
The upstream repository is https://git.vhaudiquet.fr/vhaudiquet/pkh.
Its README has longer workflow examples.
+7
View File
@@ -9,6 +9,7 @@ description: |
This snap uses classic confinement and carries the packaging This snap uses classic confinement and carries the packaging
toolchain it drives (dpkg-dev, git, mmdebstrap, lintian, quilt, ...) toolchain it drives (dpkg-dev, git, mmdebstrap, lintian, quilt, ...)
so it behaves the same on any Debian/Ubuntu host. so it behaves the same on any Debian/Ubuntu host.
license: MIT OR GPL-2.0-only
adopt-info: pkh-part adopt-info: pkh-part
confinement: classic confinement: classic
@@ -83,6 +84,12 @@ parts:
override-prime: | override-prime: |
craftctl default craftctl default
ln -sfn fakeroot-sysv "${CRAFT_PRIME}/usr/bin/fakeroot" ln -sfn fakeroot-sysv "${CRAFT_PRIME}/usr/bin/fakeroot"
# Ship the license texts with the binary: the MIT grant requires
# the notice to accompany copies, and GPL-2 requires the license
# text alongside distribution.
mkdir -p "${CRAFT_PRIME}/usr/share/doc/pkh"
cp "${CRAFT_PART_SRC}/LICENSE-MIT" "${CRAFT_PART_SRC}/LICENSE-GPL" \
"${CRAFT_PRIME}/usr/share/doc/pkh/"
# Classic-confined ELFs default to the host loader, which pins the # Classic-confined ELFs default to the host loader, which pins the
# snap to hosts shipping at least the build environment's glibc, # snap to hosts shipping at least the build environment's glibc,
# and cannot see the libraries deduplicated against the base. # and cannot see the libraries deduplicated against the base.
+65 -2
View File
@@ -137,7 +137,9 @@ pub async fn generate_entry(
enum Bump { enum Bump {
/// Regular upload: increment the trailing number /// Regular upload: increment the trailing number
Normal, Normal,
/// Ubuntu upload: `1.0-9` becomes `1.0-9ubuntu1` /// Ubuntu upload: `1.0-9` becomes `1.0-9ubuntu1`; a trailing
/// no-change-rebuild marker is dropped first (`1.0-9build1` becomes
/// `1.0-9ubuntu1`)
Ubuntu, Ubuntu,
/// Non-maintainer upload: `1.0-1` becomes `1.0-1.1`, native `1.0` /// Non-maintainer upload: `1.0-1` becomes `1.0-1.1`, native `1.0`
/// becomes `1.0+nmu1` /// becomes `1.0+nmu1`
@@ -158,7 +160,7 @@ fn compute_new_version(
bump: Bump, bump: Bump,
) -> Result<String, Box<dyn std::error::Error>> { ) -> Result<String, Box<dyn std::error::Error>> {
match bump { match bump {
Bump::Ubuntu => increment_suffix(old_version, "ubuntu"), Bump::Ubuntu => increment_suffix(strip_build_suffix(old_version), "ubuntu"),
Bump::Rebuild => increment_suffix(old_version, "build"), Bump::Rebuild => increment_suffix(old_version, "build"),
Bump::Nmu => { Bump::Nmu => {
if old_version.contains('-') { if old_version.contains('-') {
@@ -177,6 +179,19 @@ fn compute_new_version(
} }
} }
/// The version an Ubuntu upload is numbered from: a trailing
/// no-change-rebuild marker is dropped, because a real change on top of a
/// rebuild replaces the marker rather than appending to it — `X-2build1`
/// becomes `X-2ubuntu1`, where an appended `X-2build1ubuntu1` would
/// misrepresent the lineage and sort below `X-2ubuntu1`
fn strip_build_suffix(version: &str) -> &str {
let stem = version.trim_end_matches(|c: char| c.is_ascii_digit());
match stem.strip_suffix("build") {
Some(base) if stem.len() < version.len() => &version[..base.len()],
_ => version,
}
}
/// The version bump a regular ([`EntryKind::Normal`]) upload gets, derived /// The version bump a regular ([`EntryKind::Normal`]) upload gets, derived
/// from the vendor of the target series: Ubuntu series number their uploads /// from the vendor of the target series: Ubuntu series number their uploads
/// the Ubuntu way (`1.0-1` becomes `1.0-1ubuntu1`), everything else — /// the Ubuntu way (`1.0-1` becomes `1.0-1ubuntu1`), everything else —
@@ -1139,6 +1154,22 @@ mod tests {
compute_new_version("15.2.0-9ubuntu1", Bump::Ubuntu).unwrap(), compute_new_version("15.2.0-9ubuntu1", Bump::Ubuntu).unwrap(),
"15.2.0-9ubuntu2" "15.2.0-9ubuntu2"
); );
// Ubuntu upload on top of a rebuild drops the buildN marker:
// appending would give 15.2.0-9build1ubuntu1, which sorts below
// the proper 15.2.0-9ubuntu1
assert_eq!(
compute_new_version("15.2.0-9build1", Bump::Ubuntu).unwrap(),
"15.2.0-9ubuntu1"
);
assert_eq!(
compute_new_version("15.2.0-9ubuntu1build1", Bump::Ubuntu).unwrap(),
"15.2.0-9ubuntu2"
);
// Native packages
assert_eq!(
compute_new_version("15.2.0build1", Bump::Ubuntu).unwrap(),
"15.2.0ubuntu1"
);
// No change rebuild // No change rebuild
assert_eq!( assert_eq!(
@@ -1395,6 +1426,38 @@ mod tests {
assert_eq!(entry.new_version, "1.0-1ubuntu2"); assert_eq!(entry.new_version, "1.0-1ubuntu2");
} }
/// An Ubuntu upload of a package whose changelog carries a rebuild
/// version drops the buildN marker: 1.0-1build1 numbers the next entry
/// 1.0-1ubuntu1 (1.0-1build1ubuntu1 would sort below 1.0-1ubuntu1).
/// Relies on the host distro-info data listing noble (see the
/// distro_info tests). The git repo provides the maintainer identity:
/// DEBFULLNAME/DEBEMAIL are process-global and other tests mutate them
/// in parallel.
#[tokio::test]
async fn test_generate_entry_ubuntu_series_numbering_after_rebuild() {
let temp_dir = TempDir::new().unwrap();
let repo_dir = temp_dir.path();
setup_repo(repo_dir);
let changelog_path = repo_dir.join("debian/changelog");
std::fs::create_dir_all(repo_dir.join("debian")).unwrap();
std::fs::write(
&changelog_path,
"mypackage (1.0-1build1) noble; urgency=medium\n\n * Initial release\n\n -- Maintainer <m@e.com> Wed, 01 Jan 2020 00:00:00 +0000\n",
)
.unwrap();
let entry = generate_entry(
"debian/changelog",
Some(repo_dir),
None,
Some("noble"),
EntryKind::Normal,
)
.await
.unwrap();
assert_eq!(entry.new_version, "1.0-1ubuntu1");
}
#[test] #[test]
fn test_get_maintainer_info() { fn test_get_maintainer_info() {
let _identity = IDENTITY_LOCK.blocking_lock(); let _identity = IDENTITY_LOCK.blocking_lock();
+15
View File
@@ -70,6 +70,12 @@ pub trait ContextDriver {
fn read_file(&self, path: &Path) -> io::Result<String>; fn read_file(&self, path: &Path) -> io::Result<String>;
fn write_file(&self, path: &Path, content: &str) -> io::Result<()>; fn write_file(&self, path: &Path, content: &str) -> io::Result<()>;
fn exists(&self, path: &Path) -> io::Result<bool>; fn exists(&self, path: &Path) -> io::Result<bool>;
/// Check if a path is a directory inside the context
///
/// Distinct from [`ContextDriver::exists`] because paths returned by
/// [`ContextDriver::list_files`] are context-relative and can only be
/// classified through the context, never with a host-side stat.
fn is_dir(&self, path: &Path) -> io::Result<bool>;
/// Clean up any resources held by the driver (e.g. unmount overlay filesystems). /// Clean up any resources held by the driver (e.g. unmount overlay filesystems).
/// Called before the chroot directory is removed. /// Called before the chroot directory is removed.
@@ -313,6 +319,15 @@ impl Context {
self.driver().as_ref().unwrap().exists(path) self.driver().as_ref().unwrap().exists(path)
} }
/// Check if a path is a directory inside context
///
/// Paths returned by [`Context::list_files`] are context-relative
/// (e.g. rooted inside the chroot for an unshare context): whether they
/// are directories can only be decided through the context.
pub fn is_dir(&self, path: &Path) -> io::Result<bool> {
self.driver().as_ref().unwrap().is_dir(path)
}
/// Clean up any resources held by the driver (e.g. unmount overlay filesystems). /// Clean up any resources held by the driver (e.g. unmount overlay filesystems).
/// Called before the chroot directory is removed. /// Called before the chroot directory is removed.
pub fn cleanup(&self) -> io::Result<()> { pub fn cleanup(&self) -> io::Result<()> {
+4
View File
@@ -155,6 +155,10 @@ impl ContextDriver for LocalDriver {
fn exists(&self, path: &Path) -> io::Result<bool> { fn exists(&self, path: &Path) -> io::Result<bool> {
Ok(path.exists()) Ok(path.exists())
} }
fn is_dir(&self, path: &Path) -> io::Result<bool> {
Ok(path.is_dir())
}
} }
fn copy_dir_recursive(src: &Path, dest: &Path) -> io::Result<()> { fn copy_dir_recursive(src: &Path, dest: &Path) -> io::Result<()> {
+34
View File
@@ -385,6 +385,40 @@ mod tests {
assert!(!dest.join("src/.svn").exists()); assert!(!dest.join("src/.svn").exists());
} }
/// The unshare driver maps context-relative paths onto the chroot root
/// on the host: `is_dir` must answer through that mapping (a host-side
/// stat of the unmapped path sees nothing), which is what lets the deb
/// package-directory search classify staged entries.
#[test]
fn test_unshare_is_dir_maps_through_the_chroot_root() {
let chroot = tempfile::tempdir().unwrap();
fs::create_dir_all(chroot.path().join("tmp/work/tree/debian")).unwrap();
fs::write(chroot.path().join("tmp/work/orig.tar.xz"), "tar").unwrap();
let base = Context::new(ContextConfig::Local).unwrap();
let ctx = Context::with_parent(
ContextConfig::Unshare {
path: chroot.path().to_string_lossy().to_string(),
parent: None,
},
Arc::new(base),
);
assert!(ctx.is_dir(std::path::Path::new("/tmp/work/tree")).unwrap());
assert!(
ctx.exists(std::path::Path::new("/tmp/work/tree/debian"))
.unwrap()
);
assert!(
!ctx.is_dir(std::path::Path::new("/tmp/work/orig.tar.xz"))
.unwrap()
);
assert!(
!ctx.exists(std::path::Path::new("/tmp/work/missing"))
.unwrap()
);
}
/// The overlay-mount path exposes the tree verbatim, so pruning happens /// The overlay-mount path exposes the tree verbatim, so pruning happens
/// after the fact: nested VCS metadata must be removed recursively. /// after the fact: nested VCS metadata must be removed recursively.
#[test] #[test]
+10
View File
@@ -296,6 +296,16 @@ impl ContextDriver for SchrootDriver {
)?; )?;
Ok(status.success()) Ok(status.success())
} }
fn is_dir(&self, path: &Path) -> io::Result<bool> {
let status = self.run(
"test",
&["-d".to_string(), path.to_string_lossy().to_string()],
&[],
None,
)?;
Ok(status.success())
}
} }
#[cfg(test)] #[cfg(test)]
+8
View File
@@ -306,6 +306,14 @@ impl ContextDriver for SshDriver {
Err(_) => Ok(false), Err(_) => Ok(false),
} }
} }
fn is_dir(&self, path: &Path) -> io::Result<bool> {
let sess = connect_ssh(&self.host, self.user.as_deref(), self.port)?;
let sftp = sess.sftp().map_err(io::Error::other)?;
// Same error tolerance as `exists`: an unreachable path is not a
// directory, and the caller decides what absence means.
Ok(sftp.stat(path).map(|stat| stat.is_dir()).unwrap_or(false))
}
} }
impl SshDriver { impl SshDriver {
+5
View File
@@ -356,6 +356,11 @@ impl ContextDriver for UnshareDriver {
let host_path = Path::new(&self.path).join(path.to_string_lossy().trim_start_matches('/')); let host_path = Path::new(&self.path).join(path.to_string_lossy().trim_start_matches('/'));
self.parent().exists(&host_path) self.parent().exists(&host_path)
} }
fn is_dir(&self, path: &Path) -> io::Result<bool> {
let host_path = Path::new(&self.path).join(path.to_string_lossy().trim_start_matches('/'));
Ok(host_path.is_dir())
}
} }
impl UnshareDriver { impl UnshareDriver {
+7 -24
View File
@@ -62,20 +62,6 @@ pub fn setup_environment(
.map_err(|e| format!("Invalid UTF-8 in dpkg-architecture output: {e}"))?; .map_err(|e| format!("Invalid UTF-8 in dpkg-architecture output: {e}"))?;
parse_dpkg_architecture_output(&dpkg_architecture, env); parse_dpkg_architecture_output(&dpkg_architecture, env);
// In-tree tools locate their libraries with the *host* pkg-config during
// cross builds (the kernel's tools/build feature checks derive their
// cflags/ldflags from `pkg-config --cflags/--libs`), whose search path
// only covers the build architecture's pkgconfig dirs. Point it at the
// target's so `libtraceevent` & co resolve to target-arch libraries:
// linux-riscv cross builds die in rtla's Makefile.config otherwise, even
// with the target -dev packages installed.
if let Some(multiarch) = env.get("DEB_HOST_MULTIARCH").cloned() {
env.insert(
"PKG_CONFIG_LIBDIR".to_string(),
format!("/usr/lib/{multiarch}/pkgconfig:/usr/share/pkgconfig"),
);
}
env.insert("DEB_BUILD_PROFILES".to_string(), "cross".to_string()); env.insert("DEB_BUILD_PROFILES".to_string(), "cross".to_string());
Ok(()) Ok(())
@@ -304,21 +290,18 @@ mod tests {
assert!(cross_suites("noble", None, "not-a-distro").is_err()); assert!(cross_suites("noble", None, "not-a-distro").is_err());
} }
/// setup_environment exports the target multiarch pkg-config libdir: /// setup_environment exports the dpkg cross variables and the 'cross'
/// tools' feature checks run the *host* pkg-config, which must find the /// build profile, and nothing beyond what dpkg-buildpackage exports:
/// target's .pc files (rtla hard-errors on libtraceevent otherwise, /// no pkg-config redirection (the environment is the package's to
/// failing linux-riscv cross builds despite the target -dev packages /// set, and target-arch pkgconfig paths would also poison the
/// being installed). /// host-side tools of the same build).
#[test] #[test]
fn test_setup_environment_exports_cross_pkg_config_libdir() { fn test_setup_environment_exports_dpkg_cross_variables_only() {
let mut env = HashMap::new(); let mut env = HashMap::new();
let ctx = Arc::new(Context::new(crate::context::ContextConfig::Local).unwrap()); let ctx = Arc::new(Context::new(crate::context::ContextConfig::Local).unwrap());
setup_environment(&mut env, "riscv64", ctx).unwrap(); setup_environment(&mut env, "riscv64", ctx).unwrap();
assert_eq!( assert!(!env.contains_key("PKG_CONFIG_LIBDIR"));
env.get("PKG_CONFIG_LIBDIR").map(String::as_str),
Some("/usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig")
);
assert_eq!( assert_eq!(
env.get("DEB_BUILD_PROFILES").map(String::as_str), env.get("DEB_BUILD_PROFILES").map(String::as_str),
Some("cross") Some("cross")
+80 -271
View File
@@ -1,139 +1,24 @@
use crate::context::{self, Context, ContextConfig}; use crate::context::{self, Context, ContextConfig};
use crate::deb::{Phase, enter_phase}; use crate::deb::{Phase, enter_phase};
use crate::interrupt::CleanupHookGuard;
use crate::report::BuildView; use crate::report::BuildView;
use directories::ProjectDirs; use directories::ProjectDirs;
use std::any::Any;
use std::error::Error; use std::error::Error;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use std::sync::Arc; use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use tar::Archive; use tar::Archive;
use xz2::read::XzDecoder; use xz2::read::XzDecoder;
// ---------------------------------------------------------------------------
// Process-global cleanup hooks
//
// On Ctrl-C, the SIGINT handler in `ui::deb` restores the terminal and then
// `libc::_exit(130)`s, skipping all destructors — including
// [`EphemeralContextGuard::drop`] — which leaks the freshly bootstrapped
// chroot under /tmp together with its bind-mounted /proc and any overlayfs
// mounts. To make interrupt-time cleanup possible anyway, resources register
// a self-contained cleanup hook here; the SIGINT handler drains and runs the
// registry right before exiting.
// ---------------------------------------------------------------------------
/// A boxed, send-safe cleanup hook body
type CleanupFn = Box<dyn Fn() + Send>;
/// A pending cleanup hook together with its registry id
struct CleanupHook {
id: u64,
f: CleanupFn,
}
/// Registry of cleanup hooks waiting to run at interrupt time
static CLEANUP_HOOKS: Mutex<Vec<CleanupHook>> = Mutex::new(Vec::new());
/// Source of the registry ids used to deregister a specific hook
static NEXT_CLEANUP_HOOK_ID: AtomicU64 = AtomicU64::new(1);
/// Register a hook to be run by [`run_cleanup_hooks`] (i.e. when the process
/// is interrupted), returning a guard whose drop deregisters the hook again
fn register_cleanup_hook(f: CleanupFn) -> CleanupHookGuard {
let id = NEXT_CLEANUP_HOOK_ID.fetch_add(1, Ordering::Relaxed);
CLEANUP_HOOKS.lock().unwrap().push(CleanupHook { id, f });
CleanupHookGuard(id)
}
/// RAII handle to a registered cleanup hook: dropping it (or an explicit
/// [`CleanupHookGuard::deregister`]) removes the hook from the registry so
/// the interrupt path can no longer run it
struct CleanupHookGuard(u64);
impl CleanupHookGuard {
/// Registry id of the hook (used to filter the registry in tests)
#[cfg(test)]
fn id(&self) -> u64 {
self.0
}
/// Remove the hook from the registry; returns whether it was still pending
fn deregister(&mut self) -> bool {
deregister_cleanup_hook(self.0)
}
}
impl Drop for CleanupHookGuard {
fn drop(&mut self) {
deregister_cleanup_hook(self.0);
}
}
/// Remove a hook from the registry; returns whether it was still pending
fn deregister_cleanup_hook(id: u64) -> bool {
let mut hooks = CLEANUP_HOOKS.lock().unwrap();
let len_before = hooks.len();
hooks.retain(|hook| hook.id != id);
hooks.len() != len_before
}
/// Drain and run every registered cleanup hook exactly once
///
/// Called from the SIGINT handler right before the process exits. Draining
/// uses `try_lock` with a bounded retry instead of a blocking lock: if the
/// signal interrupted the main thread while it held [`CLEANUP_HOOKS`] (inside
/// register/deregister), blocking on the same non-recursive mutex from the
/// handler would deadlock the process. Timing out therefore skips cleanup
/// (leaking, as before this registry existed) rather than hanging.
pub(crate) fn run_cleanup_hooks() {
run_drained_hooks(drain_cleanup_hooks());
}
/// Take every pending hook out of the registry, waiting at most ~1s for the
/// registry lock (see [`run_cleanup_hooks`] for why this must not block forever)
fn drain_cleanup_hooks() -> Vec<CleanupHook> {
const RETRIES: usize = 200;
const RETRY_DELAY: Duration = Duration::from_millis(5);
for _ in 0..RETRIES {
if let Ok(mut hooks) = CLEANUP_HOOKS.try_lock() {
return std::mem::take(&mut *hooks);
}
std::thread::sleep(RETRY_DELAY);
}
log::error!("Timed out waiting for the cleanup hook registry; skipping interrupt cleanup");
Vec::new()
}
/// Run drained hooks one by one, isolating panics so that one failing hook
/// cannot skip the remaining ones
fn run_drained_hooks(hooks: Vec<CleanupHook>) {
for CleanupHook { id, f } in hooks {
// Hooks are arbitrary user code; assert unwind safety so they can be
// run inside a catching context
if let Err(panic) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
log::error!("Cleanup hook {id} panicked: {}", panic_message(&panic));
}
}
}
/// Best-effort message extraction from a panic payload
fn panic_message(panic: &(dyn Any + Send)) -> String {
if let Some(s) = panic.downcast_ref::<&str>() {
(*s).to_string()
} else if let Some(s) = panic.downcast_ref::<String>() {
s.clone()
} else {
"non-string panic payload".to_string()
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Interrupt-time chroot cleanup // Interrupt-time chroot cleanup
//
// On Ctrl-C, the watchdog in `crate::interrupt` runs the hook registered in
// [`EphemeralContextGuard::new_with_context`] right before exiting — the
// interrupt sequence skips all destructors, which would otherwise leak the
// freshly bootstrapped chroot under /tmp together with its bind-mounted
// /proc and any overlayfs mounts.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Interrupt-time cleanup of an ephemeral chroot: unmount every host-side /// Interrupt-time cleanup of an ephemeral chroot: unmount every host-side
@@ -143,9 +28,9 @@ fn panic_message(panic: &(dyn Any + Send)) -> String {
/// Unlike [`EphemeralContextGuard::drop`], this deliberately does NOT go /// Unlike [`EphemeralContextGuard::drop`], this deliberately does NOT go
/// through the context manager, the ephemeral context's driver (whose /// through the context manager, the ephemeral context's driver (whose
/// `cleanup()` unmounts the tracked overlays) or the base context's command /// `cleanup()` unmounts the tracked overlays) or the base context's command
/// builder: the signal may arrive while the interrupted thread holds any of /// builder: interrupt-time hooks must be self-contained, and those
/// those mutexes, and re-locking them from the signal handler would deadlock. /// machineries may be mid-mutation on the interrupted thread. Instead it
/// Instead it only reads /proc/mounts and spawns umount/rm directly. /// only reads /proc/mounts and spawns umount/rm directly.
/// ///
/// It also differs from `drop` in that it removes the chroot regardless of /// It also differs from `drop` in that it removes the chroot regardless of
/// the build result: the build was aborted, and leaving a still-mounted /// the build result: the build was aborted, and leaving a still-mounted
@@ -175,31 +60,48 @@ fn sigint_cleanup_chroot(chroot_path: &Path) {
} }
} }
// Remove the chroot tree itself (tolerates a missing directory) // Remove the chroot tree itself (tolerates a missing directory). A
let status = privileged_command("rm", is_root) // child the Ctrl+C interrupted may still be finishing its writeout —
.arg("-rf") // dpkg defers SIGINT until it reaches a safe state — so retry while rm
.arg(chroot_path) // reports the tree non-empty instead of leaving it half-removed.
.status(); const RETRIES: usize = 10;
match status { const RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(300);
Ok(status) if status.success() => { let mut last = None;
for attempt in 0..=RETRIES {
if attempt > 0 {
std::thread::sleep(RETRY_DELAY);
}
last = Some(
privileged_command("rm", is_root)
.arg("-rf")
.arg(chroot_path)
.status(),
);
if matches!(&last, Some(Ok(status)) if status.success()) {
break;
}
}
match last {
Some(Ok(status)) if status.success() => {
log::debug!( log::debug!(
"Removed chroot {} during interrupt cleanup", "Removed chroot {} during interrupt cleanup",
chroot_path.display() chroot_path.display()
); );
} }
Ok(status) => { Some(Ok(status)) => {
log::error!( log::error!(
"Failed to remove chroot {} during interrupt cleanup \ "Failed to remove chroot {} during interrupt cleanup \
(rm exited with {status}); run `pkh prune`", (rm exited with {status}); run `pkh prune`",
chroot_path.display() chroot_path.display()
); );
} }
Err(e) => { Some(Err(e)) => {
log::error!( log::error!(
"Failed to run rm for chroot {} during interrupt cleanup: {e}; run `pkh prune`", "Failed to run rm for chroot {} during interrupt cleanup: {e}; run `pkh prune`",
chroot_path.display() chroot_path.display()
); );
} }
None => unreachable!("at least one rm attempt ran"),
} }
} }
@@ -334,15 +236,14 @@ impl EphemeralContextGuard {
// Register the interrupt-time cleanup hook before any heavy work: if // Register the interrupt-time cleanup hook before any heavy work: if
// the user hits Ctrl-C during bootstrap or the build itself, the // the user hits Ctrl-C during bootstrap or the build itself, the
// SIGINT handler unmounts and removes the chroot through this hook // interrupt watchdog unmounts and removes the chroot through this
// (see `sigint_cleanup_chroot`). This only works for a local base // hook (see `sigint_cleanup_chroot`). This only works for a local
// context: the hook must be self-contained (stored path + direct // base context: the hook must be self-contained (stored path +
// umount/rm subprocesses) and cannot go through `base_ctx`, whose // direct umount/rm subprocesses) and cannot go through `base_ctx`.
// driver mutex may be held by the interrupted thread. For remote or // For remote or nested bases the chroot lives elsewhere, and
// nested bases the chroot lives elsewhere, and leftovers stay // leftovers stay handled by `pkh prune` as before.
// handled by `pkh prune` as before.
let cleanup_hook = if matches!(base_ctx.config, ContextConfig::Local) { let cleanup_hook = if matches!(base_ctx.config, ContextConfig::Local) {
Some(register_cleanup_hook(Box::new({ Some(crate::interrupt::register_cleanup_hook(Box::new({
let chroot_path = chroot_path.clone(); let chroot_path = chroot_path.clone();
move || sigint_cleanup_chroot(&chroot_path) move || sigint_cleanup_chroot(&chroot_path)
}))) })))
@@ -359,10 +260,18 @@ impl EphemeralContextGuard {
Self::download_and_extract_chroot(series, arch, &chroot_path, base_ctx.clone(), view) Self::download_and_extract_chroot(series, arch, &chroot_path, base_ctx.clone(), view)
.await .await
{ {
// The guard (and its Drop) never materializes on this path, so // On a Ctrl+C the interrupt watchdog owns the tree: keep the
// stop tracking the chroot for interrupt cleanup; as before, a // hook registered (forgetting the guard) so it removes the
// failed bootstrap leaves its partial directory in place. // partial directory, instead of the historical behavior of
drop(cleanup_hook); // leaving it in place. Without an interrupt this is a plain
// bootstrap failure and the partial directory stays, as before.
if crate::interrupt::interrupted()
&& let Some(hook) = cleanup_hook
{
std::mem::forget(hook);
} else {
drop(cleanup_hook);
}
return Err(e); return Err(e);
} }
@@ -437,6 +346,11 @@ impl EphemeralContextGuard {
let poll_interval = 5; // Check every 5 seconds let poll_interval = 5; // Check every 5 seconds
while ctx.exists(&lockfile_path)? { while ctx.exists(&lockfile_path)? {
// Stop waiting on a Ctrl+C: the interrupt watchdog removes the
// (yet empty) chroot and exits without waiting for the poll
if crate::interrupt::interrupted() {
return Err("Interrupted while waiting for the chroot tarball".into());
}
if wait_time >= timeout { if wait_time >= timeout {
log::warn!( log::warn!(
"Lockfile {} exists and has been present for more than {} seconds. \ "Lockfile {} exists and has been present for more than {} seconds. \
@@ -585,6 +499,11 @@ impl EphemeralContextGuard {
// too expensive for multi-hundred-MB chroot tarballs) // too expensive for multi-hundred-MB chroot tarballs)
let mut count = 0usize; let mut count = 0usize;
for entry in archive.entries()? { for entry in archive.entries()? {
// Bail on a Ctrl+C before the interrupt watchdog's rm -rf races
// this loop writing entries into the tree being removed
if crate::interrupt::interrupted() {
return Err("Interrupted while extracting the chroot".into());
}
let mut entry = entry?; let mut entry = entry?;
entry.unpack_in(chroot_path)?; entry.unpack_in(chroot_path)?;
count += 1; count += 1;
@@ -702,6 +621,20 @@ impl EphemeralContextGuard {
impl Drop for EphemeralContextGuard { impl Drop for EphemeralContextGuard {
fn drop(&mut self) { fn drop(&mut self) {
// On Ctrl+C the interrupt watchdog owns the chroot teardown through
// the registered hook: duplicating it here would race the hook's
// umount/rm (mounts vanish under each other). Dropping this guard
// would normally deregister the hook, so while the watchdog runs it
// must be leaked instead to keep it registered (if it was already
// drained, forgetting is a harmless no-op).
if crate::interrupt::interrupted() {
context::manager().set_current_ephemeral(self.previous_context.clone());
if let Some(hook) = self.cleanup_hook.take() {
std::mem::forget(hook);
}
return;
}
// Deregister the interrupt-time cleanup hook first: the normal // Deregister the interrupt-time cleanup hook first: the normal
// cleanup below takes care of the chroot, so the hook must not fire // cleanup below takes care of the chroot, so the hook must not fire
// afterwards. (If a SIGINT arrived mid-drop and the hook is already // afterwards. (If a SIGINT arrived mid-drop and the hook is already
@@ -799,132 +732,8 @@ impl Drop for EphemeralContextGuard {
} }
#[cfg(test)] #[cfg(test)]
mod cleanup_registry_tests { mod chroot_cleanup_tests {
use super::*; use super::*;
use std::sync::atomic::AtomicUsize;
/// Serializes these tests: they drain the process-global registry, and
/// unrelated tests (e.g. live end-to-end builds) may hold registrations
/// concurrently that must be neither run nor lost. Poison-proof: a test
/// failing while holding the lock must not cascade into the others.
static TEST_LOCK: Mutex<()> = Mutex::new(());
fn test_lock() -> std::sync::MutexGuard<'static, ()> {
TEST_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Drain the registry and take out only the hooks with the given ids,
/// putting everything else back so unrelated registrations (e.g. hooks of
/// live end-to-end builds running concurrently) stay pending
fn take_hooks(ids: &[u64]) -> Vec<CleanupHook> {
let drained = drain_cleanup_hooks();
let mut mine = Vec::new();
let mut others = Vec::new();
for hook in drained {
if ids.contains(&hook.id) {
mine.push(hook);
} else {
others.push(hook);
}
}
CLEANUP_HOOKS.lock().unwrap().extend(others);
mine
}
/// Register a hook that counts its invocations
fn counting_hook() -> (CleanupHookGuard, Arc<AtomicUsize>) {
let counter = Arc::new(AtomicUsize::new(0));
let seen = counter.clone();
let guard = register_cleanup_hook(Box::new(move || {
seen.fetch_add(1, Ordering::SeqCst);
}));
(guard, counter)
}
/// Hooks run in registration order, and draining means each hook runs
/// exactly once even across repeated cleanup passes.
#[test]
fn hooks_run_once_in_registration_order() {
let _serial = test_lock();
let log = Arc::new(Mutex::new(Vec::new()));
let mut guards = Vec::new();
let mut ids = Vec::new();
for name in ["hook-a", "hook-b", "hook-c"] {
let log = log.clone();
// The returned guard must stay alive: dropping it deregisters
let guard = register_cleanup_hook(Box::new(move || log.lock().unwrap().push(name)));
ids.push(guard.id());
guards.push(guard);
}
// Only our own hooks are extracted; they run in registration order
let mine = take_hooks(&ids);
assert_eq!(mine.len(), ids.len());
run_drained_hooks(mine);
assert_eq!(*log.lock().unwrap(), vec!["hook-a", "hook-b", "hook-c"]);
// Draining removed them: a second pass runs nothing again
assert!(take_hooks(&ids).is_empty());
assert_eq!(*log.lock().unwrap(), vec!["hook-a", "hook-b", "hook-c"]);
drop(guards);
}
/// A panicking hook is contained by the runner: it neither aborts the
/// process nor skips the hooks registered around it.
#[test]
fn panicking_hook_does_not_skip_the_others() {
let _serial = test_lock();
// The hook below panics on purpose: do not record it as a test
// failure in the end-of-run matrix
let _quiet = crate::test_support::suppress_failure_recording();
let (before, ran_before) = counting_hook();
let boom = register_cleanup_hook(Box::new(|| panic!("cleanup exploded")));
let (after, ran_after) = counting_hook();
let ids = [before.id(), boom.id(), after.id()];
run_drained_hooks(take_hooks(&ids));
assert_eq!(ran_before.load(Ordering::SeqCst), 1);
assert_eq!(ran_after.load(Ordering::SeqCst), 1);
}
/// Explicit deregistration removes the hook: it is no longer drained and
/// never runs; a second deregistration reports it as already gone.
#[test]
fn deregistered_hook_never_runs() {
let _serial = test_lock();
let (mut guard, ran) = counting_hook();
assert!(guard.deregister());
assert!(!guard.deregister());
assert!(take_hooks(&[guard.id()]).is_empty());
assert_eq!(ran.load(Ordering::SeqCst), 0);
}
/// Dropping the registration guard deregisters the hook implicitly.
#[test]
fn dropping_the_guard_deregisters_the_hook() {
let _serial = test_lock();
let id;
let ran;
{
let (guard, counter) = counting_hook();
id = guard.id();
ran = counter;
drop(guard);
}
assert!(take_hooks(&[id]).is_empty());
assert_eq!(ran.load(Ordering::SeqCst), 0);
}
/// /proc/mounts path fields use octal escapes for whitespace and /// /proc/mounts path fields use octal escapes for whitespace and
/// backslashes; anything else must be kept verbatim. /// backslashes; anything else must be kept verbatim.
+47 -6
View File
@@ -34,6 +34,7 @@ pub async fn build(
series: &str, series: &str,
pocket: Option<&str>, pocket: Option<&str>,
build_root: &str, build_root: &str,
package_dir: &Path,
cross: bool, cross: bool,
ppa: &[String], ppa: &[String],
inject_packages: &[String], inject_packages: &[String],
@@ -231,10 +232,8 @@ pub async fn build(
return Err("Could not install essential packages for the build".into()); return Err("Could not install essential packages for the build".into());
} }
// Find the actual package directory // The package directory was resolved by the caller (the staged copy of
// Find the actual package directory // the tree the user pointed at, or the name-pattern search fallback)
let package_dir =
crate::deb::find_package_directory(Path::new(build_root), package, version, series, &ctx)?;
let package_dir_str = package_dir let package_dir_str = package_dir
.to_str() .to_str()
.ok_or("Invalid package directory path")?; .ok_or("Invalid package directory path")?;
@@ -725,8 +724,17 @@ fn install_build_dependencies(
let status = cap(&mut cmd, sink).status()?; let status = cap(&mut cmd, sink).status()?;
if !status.success() { if !status.success() {
view.suspend(); view.suspend();
if let Err(e) = // Diagnosing a dependency failure the user interrupted themselves
dose3_explain_dependencies(package, version, arch, build_root, cross, ctx.clone()) // is wasted work
if !crate::interrupt::interrupted()
&& let Err(e) = dose3_explain_dependencies(
package,
version,
arch,
build_root,
cross,
ctx.clone(),
)
{ {
log::debug!("dose-builddebcheck diagnosis failed: {e}"); log::debug!("dose-builddebcheck diagnosis failed: {e}");
} }
@@ -1146,6 +1154,39 @@ mod tests {
assert_eq!(spec, "t:amd64"); assert_eq!(spec, "t:amd64");
} }
/// Cross parity with dpkg's checker and sbuild's apt-resolved dummy
/// package: an unqualified Multi-Arch: same name resolves to the
/// host-architecture variant even when a build-architecture candidate
/// exists — the build-architecture side is only ever installed
/// through an explicit `:native` declaration (or a pkh quirk
/// injecting one), never alongside the host variant.
#[test]
fn install_spec_unqualified_same_package_never_dual_installs() {
let host = "arm64";
let build = "amd64";
let spec = install_spec_for(
&rel("t"),
&[cand("amd64", "same"), cand("arm64", "same")],
build,
host,
)
.unwrap();
assert_eq!(spec, "t:arm64");
// The build-architecture side comes from `:native` declarations:
// the co-installable build-arch variant of the same source
// package.
let spec = install_spec_for(
&rel("t:native"),
&[cand("amd64", "same"), cand("arm64", "same")],
build,
host,
)
.unwrap();
assert_eq!(spec, "t:amd64");
}
/// Qualified dependencies install exactly the variant their qualifier /// Qualified dependencies install exactly the variant their qualifier
/// selects per dpkg semantics; virtual names (no candidates) pass /// selects per dpkg semantics; virtual names (no candidates) pass
/// through unqualified for apt to resolve a provider. /// through unqualified for apt to resolve a provider.
+224 -5
View File
@@ -269,6 +269,19 @@ async fn build_binary_package_impl(
.ok_or("Cannot find parent directory name")?; .ok_or("Cannot find parent directory name")?;
let build_root = format!("{}/{}", build_root, parent_dir_name.to_str().unwrap()); let build_root = format!("{}/{}", build_root, parent_dir_name.to_str().unwrap());
// Resolve the package directory inside the staging area. The tree
// the caller pointed at is authoritative (its changelog defined the
// package/version/series above), so its staged copy wins; the
// name-pattern search only runs as a fallback.
let package_dir = resolve_package_directory(
Path::new(&build_root),
cwd,
&package,
&version,
series,
&build_ctx,
)?;
// Run the build using target build mode. It returns the exact set of // Run the build using target build mode. It returns the exact set of
// artifacts produced by this build (binary packages registered in // artifacts produced by this build (binary packages registered in
// debian/files plus the generated .buildinfo/.changes), as paths // debian/files plus the generated .buildinfo/.changes), as paths
@@ -282,6 +295,7 @@ async fn build_binary_package_impl(
series, series,
pocket.as_deref(), pocket.as_deref(),
&build_root, &build_root,
&package_dir,
cross, cross,
ppa, ppa,
inject, inject,
@@ -329,6 +343,38 @@ async fn build_binary_package_impl(
result result
} }
/// Resolve the package directory for a build inside the staged build root.
///
/// The tree the caller pointed at is authoritative: `cwd`'s changelog
/// already defined the package, version and series for this build, so its
/// staged copy is used outright when it carries a `debian/` tree. The
/// name-pattern search ([`find_package_directory`], including the quirks
/// overrides) only runs when that copy cannot be resolved — a default `.`
/// cwd has no basename, and the pointed-at tree may live outside the staged
/// parent. Embedded callers are the motivation: their working directory
/// names (`tree`, `checkout`, ...) match none of the search patterns.
pub(crate) fn resolve_package_directory(
build_root: &Path,
cwd: &Path,
package: &str,
version: &str,
series: &str,
ctx: &context::Context,
) -> Result<PathBuf, Box<dyn Error>> {
if let Some(tree_name) = cwd.file_name() {
let staged_tree = build_root.join(tree_name);
if ctx.is_dir(&staged_tree)? && ctx.exists(&staged_tree.join("debian"))? {
log::debug!(
"Using the staged copy of {} at {}",
cwd.display(),
staged_tree.display()
);
return Ok(staged_tree);
}
}
find_package_directory(build_root, package, version, series, ctx)
}
/// Find the current package directory by trying both patterns: /// Find the current package directory by trying both patterns:
/// - package/package /// - package/package
/// - package/package-origversion /// - package/package-origversion
@@ -413,11 +459,13 @@ pub(crate) fn find_package_directory(
let entries = ctx.list_files(package_parent)?; let entries = ctx.list_files(package_parent)?;
let mut found_dirs = Vec::new(); let mut found_dirs = Vec::new();
for entry in entries { for entry in entries {
if entry.is_dir() { // list_files yields context-relative paths (e.g. rooted inside
if let Some(file_name) = entry.file_name() { // the chroot for an unshare context): classify through the
found_dirs.push(file_name.to_string_lossy().into_owned()); // context, a host-side stat would miss every entry.
} let is_dir = ctx.is_dir(&entry)?;
log::debug!(" - {}", entry.display()); log::debug!(" - {}", entry.display());
if is_dir && let Some(file_name) = entry.file_name() {
found_dirs.push(file_name.to_string_lossy().into_owned());
} }
} }
@@ -501,6 +549,101 @@ mod tests {
} }
} }
/// An unshare context mapped over `chroot_root`, parented on a local
/// context like the ephemeral build contexts are: exists/list_files/
/// is_dir answer through the path mapping, no namespace privileges
/// needed.
fn unshare_test_context(chroot_root: &Path) -> Context {
let base = Context::new(crate::context::ContextConfig::Local).unwrap();
Context::with_parent(
crate::context::ContextConfig::Unshare {
path: chroot_root.to_string_lossy().to_string(),
parent: None,
},
Arc::new(base),
)
}
/// The staging-area listing must classify entries through the context:
/// an unshare context returns build-root-relative paths that a host-side
/// stat never sees (they live under the chroot root on the host), which
/// used to silently empty the 'Found directories' list of the search
/// failure message — and with it every hint about the actual layout.
#[test]
fn find_package_directory_lists_staged_directories_through_the_context() {
let chroot = tempfile::tempdir().unwrap();
// Staged parent holding a single tree whose name matches none of
// the search patterns (the embedded-caller layout: <job>/tree)
let staged_parent = chroot.path().join("tmp/pkh-build-1/j-42");
std::fs::create_dir_all(staged_parent.join("tree/debian")).unwrap();
let ctx = unshare_test_context(chroot.path());
let err = find_package_directory(
Path::new("/tmp/pkh-build-1/j-42"),
"bc",
"1.07.1-1ubuntu1",
"questing",
&ctx,
)
.expect_err("no candidate matches a tree named 'tree'");
let message = err.to_string();
assert!(
message.contains("Found directories: tree"),
"error should list the staged directories through the context: {message}"
);
}
/// An explicit cwd must resolve to its staged copy even when its name
/// matches none of the search patterns: the pointed-at tree is what the
/// parsed changelog came from.
#[test]
fn resolve_package_directory_prefers_the_pointed_tree() {
let chroot = tempfile::tempdir().unwrap();
let staged_parent = chroot.path().join("tmp/pkh-build-1/j-42");
std::fs::create_dir_all(staged_parent.join("tree/debian/source")).unwrap();
let ctx = unshare_test_context(chroot.path());
let resolved = resolve_package_directory(
Path::new("/tmp/pkh-build-1/j-42"),
Path::new("/work/jobs/j-42/tree"),
"bc",
"1.07.1-1ubuntu1",
"questing",
&ctx,
)
.expect("the staged copy of the pointed-at tree must resolve");
assert_eq!(resolved, PathBuf::from("/tmp/pkh-build-1/j-42/tree"));
}
/// When the pointed-at tree is not in the staging area under its own
/// name, resolution falls back to the name-pattern search.
#[test]
fn resolve_package_directory_falls_back_to_the_name_search() {
let chroot = tempfile::tempdir().unwrap();
let staged_parent = chroot.path().join("tmp/pkh-build-1/j-42");
// Staged copy of a pulled tree: <pkg>/<pkg>-<origversion>
std::fs::create_dir_all(staged_parent.join("bc/bc-1.07.1/debian")).unwrap();
let ctx = unshare_test_context(chroot.path());
let resolved = resolve_package_directory(
Path::new("/tmp/pkh-build-1/j-42"),
// A tree never staged under that name
Path::new("/work/other/checkout"),
"bc",
"1.07.1-1ubuntu1",
"questing",
&ctx,
)
.expect("the pulled-tree layout must resolve via the name search");
assert_eq!(
resolved,
PathBuf::from("/tmp/pkh-build-1/j-42/bc/bc-1.07.1")
);
}
async fn test_build_end_to_end( async fn test_build_end_to_end(
package: &str, package: &str,
series: &str, series: &str,
@@ -837,4 +980,80 @@ mod tests {
"error should name the unsatisfied dependency: {err}" "error should name the unsatisfied dependency: {err}"
); );
} }
/// An embedded-caller layout — the tree checked out at <job>/tree, a
/// name matching none of the search patterns — must build: the staged
/// copy of the tree the caller pointed at is resolved directly instead
/// of being re-derived from package/version names (which used to fail
/// with 'Could not find package directory').
#[tokio::test]
#[test_log::test]
async fn test_deb_builds_a_tree_named_directory_end_to_end() {
let temp_dir = tempfile::tempdir().unwrap();
let pkg_dir = temp_dir.path().join("j-42/tree");
std::fs::create_dir_all(pkg_dir.join("debian/source")).unwrap();
std::fs::write(
pkg_dir.join("debian/changelog"),
"pkh-treetest (1.0) noble; urgency=medium\n\n \
* Synthetic package built from a directory named 'tree'.\n\n \
-- pkh tests <pkh@example.com> Tue, 15 Sep 2026 08:00:00 +0000\n",
)
.unwrap();
std::fs::write(
pkg_dir.join("debian/control"),
"Source: pkh-treetest\n\
Section: devel\n\
Priority: optional\n\
Maintainer: pkh tests <pkh@example.com>\n\
Standards-Version: 4.7.4\n\
Build-Depends: debhelper-compat (= 13)\n\
Architecture: any\n\
\n\
Package: pkh-treetest\n\
Architecture: any\n\
Depends: ${misc:Depends}, ${shlibs:Depends}\n\
Description: Package-directory resolution regression package\n \
Its tree lives in a directory whose name matches none of the\n \
package-directory search patterns.\n",
)
.unwrap();
std::fs::write(
pkg_dir.join("debian/rules"),
"#!/usr/bin/make -f\n%:\n\tdh $@\n",
)
.unwrap();
use std::os::unix::fs::PermissionsExt;
let rules = pkg_dir.join("debian/rules");
let mut perms = std::fs::metadata(&rules).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&rules, perms).unwrap();
std::fs::write(pkg_dir.join("debian/source/format"), "3.0 (native)\n").unwrap();
let ctx = Arc::new(Context::new(crate::context::ContextConfig::Local).unwrap());
crate::deb::build_binary_package(DebBuildOptions {
series: Some("noble".to_string()),
cwd: Some(pkg_dir),
ctx: Some(ctx),
..Default::default()
})
.await
.expect("a tree named 'tree' must build");
let deb_files: Vec<String> = std::fs::read_dir(temp_dir.path().join("j-42"))
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.collect();
assert!(
deb_files
.iter()
.any(|f| f.starts_with("pkh-treetest_1.0_") && f.ends_with(".deb")),
".deb not produced for the 'tree'-named directory, got: {deb_files:?}"
);
}
} }
+310
View File
@@ -0,0 +1,310 @@
//! Passive interrupt state shared between the CLI and the library.
//!
//! Everything active about Ctrl+C lives in the CLI (`main.rs`): it installs
//! the SIGINT handler, wakes a watchdog thread, prints the interrupt notice
//! and exits with the conventional status 130. This module only holds the
//! state the library's own types need:
//!
//! - the interrupted flag ([`mark_interrupted`] / [`interrupted`]), read by
//! flows so they stand down while the watchdog tears everything down;
//! - the cleanup hook registry ([`register_cleanup_hook`]) for resources
//! that must not outlive the process (e.g. the ephemeral build chroot,
//! see [`crate::deb::ephemeral`]), drained and run by the CLI watchdog
//! right before exiting ([`run_cleanup_hooks`]);
//! - the reporter slot ([`set_reporter`]): the live build view registers
//! how to clear the terminal (and where the full log lives); the CLI
//! runs it as the first step of the shutdown.
//!
//! Nothing here installs signal handlers, prints or exits: a library
//! consumer embedding these types keeps its own signal disposition.
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Mutex, PoisonError};
/// How the live view reports an interrupt: it clears the terminal and
/// returns the log-file hint to print below the notice, if any
pub type Reporter = Box<dyn FnOnce() -> Option<String> + Send>;
/// A boxed, send-safe cleanup hook body
type CleanupFn = Box<dyn Fn() + Send>;
/// The reporter run before the cleanup hooks; taken out when it runs
static REPORTER: Mutex<Option<Reporter>> = Mutex::new(None);
/// Whether a Ctrl+C has been intercepted since the CLI installed the
/// handler
static INTERRUPTED: AtomicBool = AtomicBool::new(false);
/// Registry of cleanup hooks waiting to run at interrupt time
static CLEANUP_HOOKS: Mutex<Vec<CleanupHook>> = Mutex::new(Vec::new());
/// Source of the registry ids used to deregister a specific hook
static NEXT_CLEANUP_HOOK_ID: AtomicU64 = AtomicU64::new(1);
/// A pending cleanup hook together with its registry id
struct CleanupHook {
id: u64,
f: CleanupFn,
}
/// Record that a Ctrl+C has been intercepted; called by the CLI signal
/// handler
pub fn mark_interrupted() {
INTERRUPTED.store(true, Ordering::SeqCst);
}
/// Whether a Ctrl+C has been intercepted; flows use this to stay quiet and
/// to leave the cleanup to the CLI watchdog
pub fn interrupted() -> bool {
INTERRUPTED.load(Ordering::SeqCst)
}
/// Register how the live view reports an interrupt: the CLI watchdog runs
/// it as the first step of the shutdown, before the cleanup hooks. At most
/// one reporter runs per process: a later call replaces the one set before.
/// Without any reporter the watchdog only prints the plain notice.
pub fn set_reporter(report: Reporter) {
*REPORTER.lock().unwrap_or_else(PoisonError::into_inner) = Some(report);
}
/// Take the registered reporter out of the slot; `None` when no live view
/// registered one (`--verbose`, piped output)
pub fn take_reporter() -> Option<Reporter> {
REPORTER
.lock()
.unwrap_or_else(PoisonError::into_inner)
.take()
}
/// Register a hook to be run when the process is interrupted (after the
/// reporter), returning a guard whose drop deregisters the hook again.
///
/// Hooks must be self-contained — stored paths plus direct subprocesses —
/// and must never block indefinitely: they run in the watchdog while the
/// interrupted flow is still unwinding, and a second Ctrl+C during cleanup
/// is a no-op.
pub fn register_cleanup_hook(f: CleanupFn) -> CleanupHookGuard {
let id = NEXT_CLEANUP_HOOK_ID.fetch_add(1, Ordering::Relaxed);
CLEANUP_HOOKS
.lock()
.unwrap_or_else(PoisonError::into_inner)
.push(CleanupHook { id, f });
CleanupHookGuard(id)
}
/// RAII handle to a registered cleanup hook: dropping it (or an explicit
/// [`CleanupHookGuard::deregister`]) removes the hook from the registry so
/// the interrupt path can no longer run it
pub struct CleanupHookGuard(u64);
impl CleanupHookGuard {
/// Registry id of the hook (used to filter the registry in tests)
#[cfg(test)]
fn id(&self) -> u64 {
self.0
}
/// Remove the hook from the registry; returns whether it was still
/// pending
pub fn deregister(&mut self) -> bool {
deregister_cleanup_hook(self.0)
}
}
impl Drop for CleanupHookGuard {
fn drop(&mut self) {
deregister_cleanup_hook(self.0);
}
}
/// Remove a hook from the registry; returns whether it was still pending
fn deregister_cleanup_hook(id: u64) -> bool {
let mut hooks = CLEANUP_HOOKS.lock().unwrap_or_else(PoisonError::into_inner);
let len_before = hooks.len();
hooks.retain(|hook| hook.id != id);
hooks.len() != len_before
}
/// Drain and run every registered cleanup hook exactly once.
///
/// Called by the CLI watchdog right before the process exits. Draining uses
/// `try_lock` with a bounded retry instead of a blocking lock as a hard
/// upper bound on interrupt latency: the sequence must never hang waiting
/// for a lock, however unlikely a stalled holder is. Timing out therefore
/// skips cleanup (leaking) rather than hanging.
pub fn run_cleanup_hooks() {
run_drained_hooks(drain_cleanup_hooks());
}
/// Take every pending hook out of the registry, waiting at most ~1s for the
/// registry lock (see [`run_cleanup_hooks`] for why this must not block
/// forever)
fn drain_cleanup_hooks() -> Vec<CleanupHook> {
const RETRIES: usize = 200;
const RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(5);
for _ in 0..RETRIES {
if let Ok(mut hooks) = CLEANUP_HOOKS.try_lock() {
return std::mem::take(&mut *hooks);
}
std::thread::sleep(RETRY_DELAY);
}
log::error!("Timed out waiting for the cleanup hook registry; skipping interrupt cleanup");
Vec::new()
}
/// Run drained hooks one by one, isolating panics so that one failing hook
/// cannot skip the remaining ones
fn run_drained_hooks(hooks: Vec<CleanupHook>) {
for CleanupHook { id, f } in hooks {
// Hooks are arbitrary user code; assert unwind safety so they can be
// run inside a catching context
if let Err(panic) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
log::error!("Cleanup hook {id} panicked: {}", panic_message(&panic));
}
}
}
/// Best-effort message extraction from a panic payload
fn panic_message(panic: &(dyn std::any::Any + Send)) -> String {
if let Some(s) = panic.downcast_ref::<&str>() {
(*s).to_string()
} else if let Some(s) = panic.downcast_ref::<String>() {
s.clone()
} else {
"non-string panic payload".to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use std::sync::atomic::AtomicUsize;
/// Serializes these tests: they drain the process-global registry, and
/// unrelated tests may hold registrations concurrently that must be
/// neither run nor lost. Poison-proof: a test failing while holding the
/// lock must not cascade into the others.
static TEST_LOCK: StdMutex<()> = StdMutex::new(());
fn test_lock() -> std::sync::MutexGuard<'static, ()> {
TEST_LOCK.lock().unwrap_or_else(PoisonError::into_inner)
}
/// Drain the registry and take out only the hooks with the given ids,
/// putting everything else back so unrelated registrations stay pending
fn take_hooks(ids: &[u64]) -> Vec<CleanupHook> {
let drained = drain_cleanup_hooks();
let mut mine = Vec::new();
let mut others = Vec::new();
for hook in drained {
if ids.contains(&hook.id) {
mine.push(hook);
} else {
others.push(hook);
}
}
CLEANUP_HOOKS
.lock()
.unwrap_or_else(PoisonError::into_inner)
.extend(others);
mine
}
/// Register a hook that counts its invocations
fn counting_hook() -> (CleanupHookGuard, Arc<AtomicUsize>) {
let counter = Arc::new(AtomicUsize::new(0));
let seen = counter.clone();
let guard = register_cleanup_hook(Box::new(move || {
seen.fetch_add(1, Ordering::SeqCst);
}));
(guard, counter)
}
/// Hooks run in registration order, and draining means each hook runs
/// exactly once even across repeated cleanup passes.
#[test]
fn hooks_run_once_in_registration_order() {
let _serial = test_lock();
let log = Arc::new(StdMutex::new(Vec::new()));
let mut guards = Vec::new();
let mut ids = Vec::new();
for name in ["hook-a", "hook-b", "hook-c"] {
let log = log.clone();
// The returned guard must stay alive: dropping it deregisters
let guard = register_cleanup_hook(Box::new(move || {
log.lock().unwrap().push(name);
}));
ids.push(guard.id());
guards.push(guard);
}
// Only our own hooks are extracted; they run in registration order
let mine = take_hooks(&ids);
assert_eq!(mine.len(), ids.len());
run_drained_hooks(mine);
assert_eq!(*log.lock().unwrap(), vec!["hook-a", "hook-b", "hook-c"]);
// Draining removed them: a second pass runs nothing again
assert!(take_hooks(&ids).is_empty());
assert_eq!(*log.lock().unwrap(), vec!["hook-a", "hook-b", "hook-c"]);
drop(guards);
}
/// A panicking hook is contained by the runner: it neither aborts the
/// process nor skips the hooks registered around it.
#[test]
fn panicking_hook_does_not_skip_the_others() {
let _serial = test_lock();
// The hook below panics on purpose: do not record it as a test
// failure in the end-of-run matrix
let _quiet = crate::test_support::suppress_failure_recording();
let (before, ran_before) = counting_hook();
let boom = register_cleanup_hook(Box::new(|| panic!("cleanup exploded")));
let (after, ran_after) = counting_hook();
let ids = [before.id(), boom.id(), after.id()];
run_drained_hooks(take_hooks(&ids));
assert_eq!(ran_before.load(Ordering::SeqCst), 1);
assert_eq!(ran_after.load(Ordering::SeqCst), 1);
}
/// Explicit deregistration removes the hook: it is no longer drained and
/// never runs; a second deregistration reports it as already gone.
#[test]
fn deregistered_hook_never_runs() {
let _serial = test_lock();
let (mut guard, ran) = counting_hook();
assert!(guard.deregister());
assert!(!guard.deregister());
assert!(take_hooks(&[guard.id()]).is_empty());
assert_eq!(ran.load(Ordering::SeqCst), 0);
}
/// Dropping the registration guard deregisters the hook implicitly.
#[test]
fn dropping_the_guard_deregisters_the_hook() {
let _serial = test_lock();
let id;
let ran;
{
let (guard, counter) = counting_hook();
id = guard.id();
ran = counter;
drop(guard);
}
assert!(take_hooks(&[id]).is_empty());
assert_eq!(ran.load(Ordering::SeqCst), 0);
}
}
+3
View File
@@ -19,6 +19,9 @@ pub mod deb;
pub mod debian; pub mod debian;
/// Obtain general information about distribution, series, etc /// Obtain general information about distribution, series, etc
pub mod distro_info; pub mod distro_info;
/// Passive interrupt state: the interrupted flag, the cleanup hook registry
/// and the live view's reporter slot (the CLI owns the signal handling)
pub mod interrupt;
/// Launchpad integration: PPA upload targets and account discovery /// Launchpad integration: PPA upload targets and account discovery
pub mod launchpad; pub mod launchpad;
/// Lint a source tree: lintian wrapper for full parity plus pkh-native checks (`pkh lint`) /// Lint a source tree: lintian wrapper for full parity plus pkh-native checks (`pkh lint`)
+226
View File
@@ -20,6 +20,155 @@ fn current_dir_or_exit() -> std::path::PathBuf {
} }
} }
/// CLI-side Ctrl+C wiring. The passive state (interrupted flag, cleanup
/// hook registry, reporter slot) lives in `pkh::interrupt`; everything that
/// installs, prints or exits lives here: the SIGINT handler only wakes a
/// watchdog through a self-pipe (async-signal-safe), and the watchdog runs
/// the whole shutdown in thread context — the live view's reporter clears
/// the terminal, the notice is printed, further Ctrl+C is absorbed as a
/// no-op, the cleanup hooks release their resources, and the process exits
/// with the conventional status 130, skipping destructors.
mod interrupt {
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
/// Whether the interrupt notice has been shown; the first caller prints
/// it, later ones stay silent
static NOTICE_SHOWN: AtomicBool = AtomicBool::new(false);
/// Whether handler, self-pipe and watchdog are in place
static INSTALLED: AtomicBool = AtomicBool::new(false);
/// Write end of the self-pipe the signal handler wakes the watchdog
/// through; `-1` until [`install`] set it up
static SELF_PIPE_WRITE: AtomicI32 = AtomicI32::new(-1);
/// Install the process-global Ctrl+C (SIGINT) handler; idempotent.
///
/// When the self-pipe or the watchdog cannot be set up, the default
/// SIGINT disposition is kept (the process dies immediately) rather
/// than installing a handler that could not run the shutdown.
pub fn install() {
if INSTALLED.swap(true, Ordering::SeqCst) {
return;
}
let mut fds = [0 as libc::c_int; 2];
// SAFETY: pipe(2) into a two-element array we own
if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
INSTALLED.store(false, Ordering::SeqCst);
return;
}
let (read_fd, write_fd) = (fds[0], fds[1]);
// The write end is used from the signal handler: non-blocking, so
// even a full pipe degrades to a dropped wake-up instead of
// blocking the handler.
// SAFETY: fcntl(2) on a pipe file descriptor we just created
unsafe {
let flags = libc::fcntl(write_fd, libc::F_GETFL);
libc::fcntl(write_fd, libc::F_SETFL, flags | libc::O_NONBLOCK);
}
SELF_PIPE_WRITE.store(write_fd, Ordering::SeqCst);
let spawned = std::thread::Builder::new()
.name("pkh-interrupt".to_string())
.spawn(move || watchdog(read_fd));
if spawned.is_err() {
// SAFETY: closing pipe file descriptors we just created
unsafe {
libc::close(read_fd);
libc::close(write_fd);
}
SELF_PIPE_WRITE.store(-1, Ordering::SeqCst);
INSTALLED.store(false, Ordering::SeqCst);
return;
}
// SAFETY: installing a signal handler whose body only records the
// interruption and writes to the self-pipe (async-signal-safe)
unsafe {
libc::signal(libc::SIGINT, on_sigint as *const () as usize);
}
}
/// Never returns: park the calling thread until the watchdog exits the
/// process
///
/// The watchdog owns the interrupt shutdown; a caller that would
/// otherwise reach its own `std::process::exit` and kill the process
/// mid-cleanup must park here instead.
pub fn wait_for_shutdown() -> ! {
loop {
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
/// Signal handler body: record the interruption and wake the watchdog
/// through the self-pipe
extern "C" fn on_sigint(_sig: libc::c_int) {
pkh::interrupt::mark_interrupted();
let fd = SELF_PIPE_WRITE.load(Ordering::SeqCst);
if fd >= 0 {
// SAFETY: write(2) of one byte to the self-pipe is
// async-signal-safe; a failed write (e.g. EAGAIN) drops the
// wake-up instead of blocking the handler
unsafe {
libc::write(fd, b"x".as_ptr().cast(), 1);
}
}
}
/// Watchdog body: block until the signal handler's byte arrives, then
/// run the shutdown sequence
fn watchdog(read_fd: libc::c_int) {
let mut byte = [0u8; 1];
// SAFETY: read(2) into a local buffer of the announced length
let received = unsafe { libc::read(read_fd, byte.as_mut_ptr().cast(), 1) };
// The write end is never closed, so a short read cannot happen in
// practice; on error there is nothing to clean up either way.
if received > 0 {
run_interrupt_sequence();
}
}
/// Reporter, notice, cleanup hooks, exit 130: the whole shutdown, run
/// in the watchdog thread immediately on Ctrl+C — never in the signal
/// handler itself
///
/// Further Ctrl+C while this runs writes bytes nobody reads: absorbed
/// as a no-op (send SIGTERM/SIGKILL if a hook ever hangs).
fn run_interrupt_sequence() {
let hint =
pkh::interrupt::take_reporter().and_then(|report| {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(report)) {
Ok(hint) => hint,
Err(_) => {
log::error!("Interrupt reporter panicked");
None
}
}
});
show_notice();
if let Some(hint) = hint {
eprintln!("{hint}");
}
pkh::interrupt::run_cleanup_hooks();
// SAFETY: raw exit bypassing destructors, intended at interrupt time
unsafe {
libc::_exit(130);
}
}
/// Print the interrupt notice, once per process: the first caller
/// prints it, later ones stay silent
fn show_notice() {
if NOTICE_SHOWN.swap(true, Ordering::SeqCst) {
return;
}
eprintln!("CTRL+C: Build interrupted by user.");
}
}
fn main() { fn main() {
let rt = tokio::runtime::Runtime::new().unwrap(); let rt = tokio::runtime::Runtime::new().unwrap();
let logger = let logger =
@@ -513,6 +662,7 @@ fn main() {
} }
Some(("build", sub_matches)) => { Some(("build", sub_matches)) => {
let cwd = current_dir_or_exit(); let cwd = current_dir_or_exit();
interrupt::install();
let verbose = sub_matches let verbose = sub_matches
.get_one::<bool>("verbose") .get_one::<bool>("verbose")
.copied() .copied()
@@ -563,6 +713,11 @@ fn main() {
} }
} }
Err(e) => { Err(e) => {
// On Ctrl+C the interrupt watchdog owns the shutdown
// (see `pkh deb`): park here instead of racing it
if pkh::interrupt::interrupted() {
interrupt::wait_for_shutdown();
}
// The unmet-dependency diagnostics first, then the // The unmet-dependency diagnostics first, then the
// summary: the exact rendering the flow used to do. // summary: the exact rendering the flow used to do.
if let Some(unmet) = if let Some(unmet) =
@@ -584,6 +739,7 @@ fn main() {
} }
Some(("put", sub_matches)) => { Some(("put", sub_matches)) => {
let cwd = current_dir_or_exit(); let cwd = current_dir_or_exit();
interrupt::install();
let ppa = sub_matches.get_one::<String>("ppa").map(|s| s.as_str()); let ppa = sub_matches.get_one::<String>("ppa").map(|s| s.as_str());
let changes = sub_matches let changes = sub_matches
.get_one::<String>("changes") .get_one::<String>("changes")
@@ -613,12 +769,22 @@ fn main() {
prompter: &prompter, prompter: &prompter,
}; };
if let Err(e) = rt.block_on(async { pkh::put::put(&options).await }) { if let Err(e) = rt.block_on(async { pkh::put::put(&options).await }) {
// On Ctrl+C the interrupt watchdog owns the shutdown (see
// `pkh deb`): park here instead of racing it
if pkh::interrupt::interrupted() {
interrupt::wait_for_shutdown();
}
error!("{}", e); error!("{}", e);
std::process::exit(1); std::process::exit(1);
} }
} }
Some(("deb", sub_matches)) => { Some(("deb", sub_matches)) => {
let cwd = current_dir_or_exit(); let cwd = current_dir_or_exit();
// Ctrl+C during the build must say what happened and release the
// ephemeral chroot instead of dying on the default disposition.
// The live view (when enabled) registers its own reporter on top
// of this to clear the widget first.
interrupt::install();
let series = sub_matches.get_one::<String>("series").cloned(); let series = sub_matches.get_one::<String>("series").cloned();
let pocket = sub_matches.get_one::<String>("pocket").cloned(); let pocket = sub_matches.get_one::<String>("pocket").cloned();
let arch = sub_matches.get_one::<String>("arch").cloned(); let arch = sub_matches.get_one::<String>("arch").cloned();
@@ -685,6 +851,13 @@ fn main() {
match result { match result {
Ok(_) => info!("Done."), Ok(_) => info!("Done."),
Err(e) => { Err(e) => {
// On Ctrl+C the interrupt watchdog owns the shutdown: it
// has already shown the notice, is releasing the build
// resources, and will exit with 130 — park here instead
// of racing it with another exit.
if pkh::interrupt::interrupted() {
interrupt::wait_for_shutdown();
}
error!("{}", e); error!("{}", e);
std::process::exit(1); std::process::exit(1);
} }
@@ -816,3 +989,56 @@ fn main() {
_ => unreachable!("Exhausted list of subcommands and subcommand_required prevents `None`"), _ => unreachable!("Exhausted list of subcommands and subcommand_required prevents `None`"),
} }
} }
#[cfg(test)]
mod tests {
use super::interrupt;
/// End-to-end check of the whole sequence: installed handler → self-pipe
/// → watchdog → notice + hooks → exit status 130. The sequence ends in
/// `libc::_exit`, so it cannot be exercised in-process: this test
/// re-spawns the test binary in child mode (env var), where the test
/// installs the handler and raises SIGINT at itself.
#[test]
fn sigint_sequence_prints_the_notice_and_exits_130() {
const CHILD_ENV: &str = "PKH_SIGINT_TEST_CHILD";
if std::env::var(CHILD_ENV).is_ok() {
// Child mode: install, register a pending hook, then interrupt
// ourselves. If the sequence never runs, the sleep below turns
// the failure into a wrong (zero) exit code instead of a hang.
interrupt::install();
// Alive until the watchdog drains it: a dropped guard would
// deregister the hook and the drain would run empty
let _hook = pkh::interrupt::register_cleanup_hook(Box::new(|| ()));
// SAFETY: kill(2) to our own process with SIGINT
unsafe {
libc::kill(libc::getpid(), libc::SIGINT);
}
std::thread::sleep(std::time::Duration::from_secs(30));
std::process::exit(0);
}
let exe = std::env::current_exe().expect("locate the test executable");
let output = std::process::Command::new(exe)
// --nocapture: libtest's capture buffer would otherwise swallow
// the watchdog's notice (threads spawned during a test inherit
// the capture), and the process exits before the harness prints
// anything it captured
.args([
"--exact",
"tests::sigint_sequence_prints_the_notice_and_exits_130",
"--test-threads=1",
"--nocapture",
])
.env(CHILD_ENV, "1")
.output()
.expect("re-spawn the test binary");
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(130), "child stderr:\n{stderr}");
assert!(
stderr.contains("CTRL+C: Build interrupted by user."),
"child stderr:\n{stderr}"
);
}
}
+48 -1
View File
@@ -265,15 +265,62 @@ mod tests {
fn linux_dependency_quirks_are_series_scoped() { fn linux_dependency_quirks_are_series_scoped() {
for package in ["linux", "linux-riscv"] { for package in ["linux", "linux-riscv"] {
let rules = get_deb_dependency_quirks(package, "resolute"); let rules = get_deb_dependency_quirks(package, "resolute");
assert_eq!(rules.len(), 1, "the resolute entry applies"); assert_eq!(rules.len(), 2, "the resolute entries apply");
assert_eq!( assert_eq!(
rules[0].replace.get("llvm-21-dev").map(String::as_str), rules[0].replace.get("llvm-21-dev").map(String::as_str),
Some("llvm-21-dev:native <!stage1>") Some("llvm-21-dev:native <!stage1>")
); );
// Only the stonking entry exists there: the llvm-21-dev
// replacement above is a resolute-only packaging state.
let rules = get_deb_dependency_quirks(package, "stonking");
assert_eq!(rules.len(), 1);
assert!(rules[0].replace.is_empty());
assert!(get_deb_dependency_quirks(package, "noble").is_empty()); assert!(get_deb_dependency_quirks(package, "noble").is_empty());
} }
} }
/// The kernels of the series whose control dropped the Debian-style
/// `:native` qualifiers on the host-tool libraries inject the
/// build-architecture variants: the dpkg cross rules resolve the
/// unqualified Multi-Arch: same names against the host architecture
/// only, leaving nothing for the kernel's host-side tools to link.
#[test]
fn linux_injects_native_host_tool_libraries_for_cross() {
for series in ["resolute", "stonking"] {
let mut clauses = vec![vec![parse("libelf-dev <!stage1>")]];
crate::quirks::apply_dependency_quirks("linux", series, &mut clauses, &opts()).unwrap();
let injected: Vec<&PkgRelation> = clauses[1..]
.iter()
.flatten()
.filter(|rel| {
["libelf-dev", "libdw-dev", "libssl-dev"].contains(&rel.package.as_str())
})
.collect();
assert_eq!(
injected.len(),
3,
"one :native clause per host-tool library"
);
assert!(
injected
.iter()
.all(|rel| rel.arch_qualifier.as_deref() == Some("native"))
);
// The declared dependency itself is untouched: the host
// (target) variant still installs for the checker.
assert_eq!(clauses[0][0].arch_qualifier, None);
}
}
/// Other kernel series keep their declared dependencies untouched.
#[test]
fn noble_linux_dependencies_are_not_rewritten() {
let mut clauses = vec![vec![parse("libelf-dev")]];
crate::quirks::apply_dependency_quirks("linux", "noble", &mut clauses, &opts()).unwrap();
assert_eq!(clauses.len(), 1);
assert_eq!(clauses[0][0].arch_qualifier, None);
}
/// `replace` rewrites exactly the dependencies whose declared name /// `replace` rewrites exactly the dependencies whose declared name
/// matches, wholesale: the replacement carries its own qualifier and /// matches, wholesale: the replacement carries its own qualifier and
/// restrictions. /// restrictions.
+4
View File
@@ -445,6 +445,10 @@ mod imp {
self.inner.exists(path) self.inner.exists(path)
} }
fn is_dir(&self, path: &Path) -> io::Result<bool> {
self.inner.is_dir(path)
}
fn cleanup(&self) -> io::Result<()> { fn cleanup(&self) -> io::Result<()> {
self.inner.cleanup() self.inner.cleanup()
} }
+141 -68
View File
@@ -11,14 +11,14 @@
use std::collections::VecDeque; use std::collections::VecDeque;
use std::fs::{self, File}; use std::fs::{self, File};
use std::io::Write; use std::io::Write;
use std::path::{Path, PathBuf}; use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use crossterm::{cursor, execute, style::Stylize, terminal::Clear, terminal::ClearType}; use crossterm::style::Stylize;
use directories::ProjectDirs; use directories::ProjectDirs;
use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use crate::context::{LineSink, Stream}; use crate::context::{LineSink, Stream};
@@ -84,6 +84,17 @@ impl DebUi {
let enabled = is_stdout_tty(); let enabled = is_stdout_tty();
let top = if enabled { let top = if enabled {
// The tty renders a ^C keypress as the two visible characters
// "^C"; on a terminal where the cursor sits near the right edge
// that wraps to the next row, and the erase at teardown —
// anchored to where indicatif last drew — ends up one row off,
// leaving the first widget line on screen. Rendering control
// characters raw instead (ECHOCTL off) makes the echo an
// invisible byte that moves nothing; ECHO itself stays on, so
// terminals showing a padlock while input is hidden are not
// triggered. Restored by `suspend_shared`.
suppress_control_char_echo();
multi.set_draw_target(ProgressDrawTarget::stderr());
let pb = multi.add(ProgressBar::new(0)); let pb = multi.add(ProgressBar::new(0));
pb.enable_steady_tick(Duration::from_millis(80)); pb.enable_steady_tick(Duration::from_millis(80));
pb.set_style(spinner_style()); pb.set_style(spinner_style());
@@ -110,7 +121,7 @@ impl DebUi {
bar_total: 0, bar_total: 0,
}), }),
tee: Mutex::new(None), tee: Mutex::new(None),
log_path: Mutex::new(log_path.clone()), log_path: Mutex::new(log_path),
timestamp, timestamp,
enabled, enabled,
suspended: AtomicBool::new(false), suspended: AtomicBool::new(false),
@@ -118,8 +129,13 @@ impl DebUi {
}), }),
}; };
// Ctrl+C: the signal wiring lives in the CLI; this view only
// registers with `crate::interrupt` how to clear itself and where
// the full log lives. The log path is read from the shared state at
// interrupt time, so the rename in `open_log` stays visible to the
// reporter.
if ui.shared.enabled { if ui.shared.enabled {
install_sigint_hook(&log_path); set_interrupt_reporter(ui.shared.clone());
} }
ui ui
@@ -128,7 +144,7 @@ impl DebUi {
/// Identify the binary package being built; names the log file and the /// Identify the binary package being built; names the log file and the
/// status bar /// status bar
pub fn set_target(&self, package: &str, version: &str, series: &str, arch: &str) { pub fn set_target(&self, package: &str, version: &str, series: &str, arch: &str) {
if self.shared.enabled { if self.active() {
self.shared.top.set_prefix(format!( self.shared.top.set_prefix(format!(
"Building {package} ({version}) for {series}/{arch}" "Building {package} ({version}) for {series}/{arch}"
)); ));
@@ -149,7 +165,6 @@ impl DebUi {
}; };
let _ = fs::rename(&old_path, &log_path); let _ = fs::rename(&old_path, &log_path);
*self.shared.log_path.lock().unwrap() = log_path.clone(); *self.shared.log_path.lock().unwrap() = log_path.clone();
update_sigint_log_path(&log_path);
if let Some(dir) = log_path.parent() { if let Some(dir) = log_path.parent() {
let _ = fs::create_dir_all(dir); let _ = fs::create_dir_all(dir);
@@ -182,7 +197,7 @@ impl DebUi {
st.bar_total = 0; st.bar_total = 0;
st.last_draw = Instant::now(); st.last_draw = Instant::now();
} }
if self.shared.enabled { if self.active() {
self.shared.top.set_style(spinner_style()); self.shared.top.set_style(spinner_style());
self.shared.top.set_message(label.to_string()); self.shared.top.set_message(label.to_string());
drop_pane(&self.shared); drop_pane(&self.shared);
@@ -197,19 +212,8 @@ impl DebUi {
/// Release the widget from the terminal (e.g. before printing /// Release the widget from the terminal (e.g. before printing
/// passthrough diagnostics or letting child cleanup commands write to /// passthrough diagnostics or letting child cleanup commands write to
/// the terminal); idempotent /// the terminal); idempotent
///
/// Steady ticks are disabled first: otherwise a tick can redraw a frame
/// right after the clear, leaving stale copies of the widget on screen.
fn suspend(&self) { fn suspend(&self) {
if !self.shared.enabled { suspend_shared(&self.shared);
return;
}
if self.shared.suspended.swap(true, Ordering::SeqCst) {
return;
}
self.shared.top.disable_steady_tick();
drop_pane(&self.shared);
self.shared.top.finish_and_clear();
} }
/// Success outcome body: clear the widget and print the artifacts, /// Success outcome body: clear the widget and print the artifacts,
@@ -226,9 +230,17 @@ impl DebUi {
/// Failure outcome body: clear the widget and print a summary (recent /// Failure outcome body: clear the widget and print a summary (recent
/// captured errors and the path to the full log) /// captured errors and the path to the full log)
///
/// On a Ctrl+C the interrupt watchdog owns the reporting — its captured
/// errors are just the killed children's death throes, and the watchdog
/// already points at the full log — so this prints nothing.
fn failure_summary(&self) { fn failure_summary(&self) {
self.suspend(); self.suspend();
if crate::interrupt::interrupted() {
return;
}
let st = self.shared.state.lock().unwrap(); let st = self.shared.state.lock().unwrap();
if self.shared.enabled && !st.errors.is_empty() { if self.shared.enabled && !st.errors.is_empty() {
eprintln!("Last captured errors:"); eprintln!("Last captured errors:");
@@ -256,7 +268,7 @@ impl DebUi {
/// widget. /// widget.
impl crate::report::BuildView for DebUi { impl crate::report::BuildView for DebUi {
fn target(&self, target: BuildTarget<'_>) { fn target(&self, target: BuildTarget<'_>) {
if self.shared.enabled { if self.active() {
self.shared.top.set_prefix(target.display.clone()); self.shared.top.set_prefix(target.display.clone());
} }
if target.tee_log { if target.tee_log {
@@ -529,61 +541,90 @@ fn default_log_path(timestamp: &str) -> PathBuf {
dir.join(format!("pkh-{timestamp}.log")) dir.join(format!("pkh-{timestamp}.log"))
} }
static SIGINT_LOG_PATH: Mutex<Option<PathBuf>> = Mutex::new(None); /// Register the interrupt reporter: release the widget from the terminal
static SIGINT_INSTALLED: AtomicBool = AtomicBool::new(false); /// and return the log-file hint to print below the notice
///
/// The CLI watchdog runs this as the first step of the interrupt shutdown,
/// so the widget disappears the moment Ctrl+C is hit. Registered only for
/// enabled views: in `--verbose` mode or with piped output there is no
/// widget and nothing to report.
fn set_interrupt_reporter(shared: Arc<Shared>) {
crate::interrupt::set_reporter(Box::new(move || interrupt_report(&shared)));
}
/// Install a best-effort Ctrl+C handler clearing the widget and pointing at /// [`suspend_shared`] plus the log-file hint, in teardown order
/// the log file before exiting ///
fn install_sigint_hook(log_path: &Path) { /// Testable end to end: the reporter closure is private to the interrupt
update_sigint_log_path(log_path); /// watchdog, but the drawing behavior is not.
if SIGINT_INSTALLED.swap(true, Ordering::SeqCst) { fn interrupt_report(shared: &Shared) -> Option<String> {
suspend_shared(shared);
let log_path = shared.log_path.lock().unwrap().clone();
if log_path.exists() {
Some(format!("Full log: {}", log_path.display()))
} else {
None
}
}
/// [`DebUi::suspend`] body, shared with the interrupt reporter
///
/// Steady ticks are disabled first: otherwise a tick can redraw a frame
/// right after the clear, leaving stale copies of the widget on screen.
/// The tty echo suppressed at view start is restored here, before the
/// erases: an echo from a keypress landing mid-teardown could not shift the
/// cursor anymore. The draw target is finally killed: the interrupted flow
/// keeps emitting log records while the cleanup hooks run, and every one of
/// them would otherwise make the log bridge repaint the cleared bars from
/// their cached frames.
fn suspend_shared(shared: &Shared) {
if !shared.enabled {
return; return;
} }
if shared.suspended.swap(true, Ordering::SeqCst) {
return;
}
restore_tty_echo();
shared.top.disable_steady_tick();
drop_pane(shared);
shared.top.finish_and_clear();
shared.multi.set_draw_target(ProgressDrawTarget::hidden());
}
// SAFETY: installing a signal handler; the handler itself is best-effort /// Termios snapshot taken when the echo is suppressed; `Some` only while the
// (it performs non async-signal-safe operations, acceptable here because /// live view is on screen
// it immediately exits afterwards). static SAVED_TTY_TERMIOS: Mutex<Option<libc::termios>> = Mutex::new(None);
/// Stop the tty from rendering control-character input (^C would show as a
/// visible two-character "^C") while the live view is up: rendered echoes
/// move the cursor without indicatif knowing, and the teardown erase ends
/// up aimed past the widget
///
/// ECHO itself stays on — turning it off would trigger terminals'
/// hidden-input padlock — so the only visible difference is that a ^C
/// keypress echoes as a raw, cursor-invisible control byte. No-op without a
/// tty on stdin.
fn suppress_control_char_echo() {
// SAFETY: tcgetattr on stdin with a valid, zero-initialized buffer
let mut termios: libc::termios = unsafe { std::mem::zeroed() };
// SAFETY: reading the current attributes of stdin
if unsafe { libc::tcgetattr(libc::STDIN_FILENO, &mut termios) } != 0 {
return;
}
*SAVED_TTY_TERMIOS.lock().unwrap() = Some(termios);
termios.c_lflag &= !libc::ECHOCTL;
// SAFETY: applying the modified attributes to stdin
unsafe { unsafe {
libc::signal(libc::SIGINT, on_sigint as *const () as usize); libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &termios);
} }
} }
/// Point the sigint handler at the current log file location /// Restore the tty attributes saved by [`suppress_tty_echo`]
fn update_sigint_log_path(log_path: &Path) { fn restore_tty_echo() {
*SIGINT_LOG_PATH.lock().unwrap() = Some(log_path.to_path_buf()); if let Some(termios) = SAVED_TTY_TERMIOS.lock().unwrap().take() {
} // SAFETY: re-applying the snapshot taken at view start
unsafe {
extern "C" fn on_sigint(_sig: libc::c_int) { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &termios);
// Best-effort cleanup: clear leftover widget lines and show the cursor }
let _ = execute!(
std::io::stdout(),
Clear(ClearType::FromCursorDown),
cursor::Show
);
if let Ok(guard) = SIGINT_LOG_PATH.try_lock()
&& let Some(path) = guard.as_ref()
{
eprintln!("\nInterrupted — full log: {}", path.display());
}
// Run the registered cleanup hooks (currently: unmount and remove the
// ephemeral build chroot, see `deb::ephemeral::sigint_cleanup_chroot`),
// then exit with the conventional 130 status.
//
// Like the terminal restoration above, this is NOT strictly
// async-signal-safe: it locks a mutex, spawns subprocesses and does I/O.
// That is a deliberate tradeoff, no worse than the rest of this handler:
// exiting immediately would skip all destructors and leak the chroot
// together with its bind-mounted /proc and overlay mounts. The hooks are
// self-contained (they only touch stored paths and spawn umount/rm
// directly), so they cannot deadlock on a lock the interrupted thread
// might have held; the hook registry itself is only ever taken with
// try_lock plus a bounded retry for the same reason. Note that SIGINT
// stays blocked for the duration of the handler, so a second Ctrl-C will
// not interrupt a slow cleanup — send SIGTERM/SIGKILL if it ever hangs.
crate::deb::ephemeral::run_cleanup_hooks();
// SAFETY: raw exit bypassing destructors, intended in a signal handler
unsafe {
libc::_exit(130);
} }
} }
@@ -702,6 +743,38 @@ mod tests {
assert!(ensure_pane(&shared).is_none()); assert!(ensure_pane(&shared).is_none());
} }
/// The interrupt report kills the shared draw target: during the cleanup
/// hooks the interrupted flow keeps emitting log records, and every one
/// of them would otherwise make the log bridge repaint the bars from
/// their cached frames — resurrecting the widget that was just cleared.
#[test]
fn interrupt_report_disables_further_redraws() {
let multi = MultiProgress::new();
let shared = Shared {
multi: multi.clone(),
top: multi.add(ProgressBar::new(0)),
pane: Mutex::new(None),
state: Mutex::new(Pipeline {
classifier: Box::new(GenericClassifier::new()),
lines: VecDeque::new(),
errors: Vec::new(),
last_draw: Instant::now(),
bar_total: 0,
}),
tee: Mutex::new(None),
log_path: Mutex::new(std::env::temp_dir().join("pkh-interrupt-report-test.log")),
timestamp: String::new(),
enabled: true,
suspended: AtomicBool::new(false),
started: Instant::now(),
};
interrupt_report(&shared);
assert!(shared.suspended.load(Ordering::SeqCst));
assert!(shared.multi.is_hidden());
}
/// Best-effort ANSI escape stripper, enough for the assertions above /// Best-effort ANSI escape stripper, enough for the assertions above
fn strip_ansi(line: &str) -> String { fn strip_ansi(line: &str) -> String {
let mut out = String::new(); let mut out = String::new();