Skip to content

Mast v0.7.6: Stop actually stops Sail commands - #55

Merged
stackrats merged 7 commits into
mainfrom
fix/sail-command-stop
Oct 14, 2026
Merged

stackrats merged 7 commits into
mainfrom
fix/sail-command-stop

Conversation

@stackrats

@stackrats stackrats commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner

Builds on #54. Merge that first; this diff then narrows to its own commits.

What

  • Stopping a project command that runs through Sail reported success and stopped nothing. This covers commands like sail artisan dev and sail npm run dev. There were two causes:
    1. The stop ran as root. The script finds a command's processes by the MAST_COMMAND_RUN marker in /proc/<pid>/environ. Reading another user's environ takes CAP_SYS_PTRACE, which Docker's default capability set leaves out, so root could read none of sail's processes.
    2. Even sail can't read its PHP processes. Sail's PHP binary carries cap_net_bind_service so artisan serve can bind port 80. Running a binary with file capabilities makes the process non-dumpable, which gives its environ to root, and nobody without CAP_SYS_PTRACE can read it. So the marker never reached Horizon, the scheduler, the queue, Reverb or Pail. For a command that is PHP from the start, like sail artisan horizon, it found nothing at all.
  • Consequence: nothing was flagged as left behind, so the next Start, Restart or auto-start launched a second copy alongside the first. Found on a real project (two artisan dev stacks in one container, both started by one Mast process):
    • Two Horizon masters, two queue:listen, and two schedule:work, so every scheduled task ran twice.
    • The second stack's Vite failed on the port the first still held. On its way out, laravel-vite-plugin deleted public/hot. Pages fell back to an old build while the command still showed as running.
  • Fix:
    • The stop runs as the user the command started as.
    • Each run starts through a small sh wrapper. It records pid start name in /tmp/mast-runs/<run> inside the container, then execs the command, so the recorded PID is the command's own. The name means a record left by an earlier Mast session can still say which command it was.
    • The stop signals the recorded process, anything carrying the run's marker, and every descendant of either. Descendants are read from /proc/<pid>/stat, which anyone can read.
    • A start-time check means a reused PID is never signalled, and a completed stop removes the record.
  • Since v0.7.2 (78fea8c).

Verified

  • Docker test stop_reaches_command_processes_no_one_can_read. Both runs start through their real argv, wrapper included. A setgid copy of busybox stands in for Sail's PHP: a non-dumpable process whose environ its own user can't read, with a child under it.
    • With the root stop, or with environment matching alone, it fails: the command and its child are still running.
    • With this change it stops both, leaves another run alone, and removes the record.
  • Dry run in the real Sail container, against the leftover artisan dev stack. It finds the 22 marked processes and the 7 PHP processes beneath them that the marker can't see (Horizon master, supervisor and worker, Reverb, scheduler, queue:listen, Pail). It finds nothing from the current run and nothing from Sail's own artisan serve.
  • failed_sail_cleanup_must_succeed_before_a_later_command_can_run: its fake sail recognised the cleanup call by -u root. It now recognises it as the one exec without a marker.
  • cargo test -p mast-engine passes, Docker tests included.
  • cargo clippy -p mast-engine --all-targets -D warnings is clean on Rust 1.96 and 1.99.

Not in this PR

  • Runs started by earlier versions have no record and carry markers this Mast doesn't track. The new stop logic finds them given the run id, but nothing offers that yet.
  • Showing every long-running process in a project's containers in the UI, with owner, count and Stop, is v0.7.7.

On Plasma under Wayland the titlebar's minimise, maximise and close did
nothing — no hover, no click — and the bar stood taller than every other
window's. Neither was GTK's doing: tao 0.35 replaced the window's titlebar
on Wayland with its own GTK header bar, wrapped in an event box set above
its children, so every pointer event landed on the box and none reached
the buttons. Dragging still worked only because the window handles that
itself. And being a header bar, it asked for client-side decorations, so
KWin never drew its own.

tao 0.36 (tauri-apps/tao#1218) dropped that header bar and leaves the
choice to GTK, which asks for server-side decorations through KDE's
protocol wherever the compositor offers it. Tauri 2.12 carries tao 0.37,
so this moves Tauri to 2.12.3 and @tauri-apps/api with it — the release
build refuses mismatched Rust and JS minors.

Run against this Plasma session with WAYLAND_DEBUG, 0.7.4 sends
request_mode(1), client-side; this build sends request_mode(2) and KWin
answers mode(2), drawing a Breeze titlebar over a window whose geometry
is now the content alone. Desktops that tile still lose the titlebar via
linux_env::wants_no_titlebar, whose comment claimed GTK3 had no
server-side decoration support on Wayland at all — it does, and the
comment now says who draws the bar where.
CI's stable toolchain moved to Rust 1.99, whose new double_must_use lint
fires on the #[must_use] async-trait 0.1.91 puts on every generated
method — six errors at RuntimeAdapter's attribute, none of them in code
written here, and enough to stop clippy before it reached any crate that
depends on mast-docker. async-trait 0.1.92 exists to resolve exactly
that lint (dtolnay/async-trait#303). With it, clippy 1.99 runs clean
across the whole workspace with -D warnings.
Stopping a project command that runs through Sail — `sail artisan dev`,
`sail npm run dev` — reported success and stopped nothing. The stop
script finds the command's processes by the MAST_COMMAND_RUN marker in
/proc/<pid>/environ, and it ran as root. Reading another user's environ
takes CAP_SYS_PTRACE, which Docker's default capability set leaves out,
so as root every one of the command's sail-owned processes was
unreadable, the marker was never found, and the script exited 0.

Since nothing was flagged as left behind, the next Start, Restart or
auto-start launched a second copy beside the first. On a real project
that left two `artisan dev` stacks in one container — two Horizon
masters, two queue listeners, two schedulers running every task twice —
and the second stack's Vite, failing on the port the first still held,
deleted public/hot on its way out, so pages fell back to an old build
while the command showed as running.

The stop now runs as the user the command was started as, which can
read and signal all of its own processes. The existing test ran the
script as the processes' owner and so could never see this; a new
Docker-gated test runs the real stop argv against an Alpine container
with the command owned by uid 1000. Against the root stop it fails with
the command still running; with this change it stops the command and
leaves another run's process alone.
Stopping as the command's own user still missed the processes that
matter most. Sail's PHP binary carries a file capability
(cap_net_bind_service, so `artisan serve` can bind port 80), and running
a binary with file capabilities makes the process non-dumpable: its
/proc/<pid>/environ passes to root, readable by no one without
CAP_SYS_PTRACE — not even `sail`. So the environment marker only ever
found the sh and node processes around a command. For `sail artisan
dev`, stopping those would have orphaned Horizon, the scheduler, the
queue listener, Reverb and Pail; for a command that is PHP from the
start, such as `sail artisan horizon`, it found nothing at all.

Each run now starts through a small sh wrapper that records its PID and
start time in /tmp/mast-runs/<run> inside the container, then execs the
command, so the PID is the command's own. The stop takes the recorded
process (if that PID still has the recorded start time, so a reused
number is never signalled), anything carrying the run's marker, and
every descendant of either, read from /proc/<pid>/stat, which anyone
can read. A completed stop removes the record.

The Docker test now starts its runs through their real argv, wrapper
included, with a setgid copy of busybox standing in for Sail's PHP — a
non-dumpable process whose environment its own user cannot read — and a
child under it. Matching by environment alone, it fails with both still
running; with the record and the tree it stops them and leaves another
run alone. Dry-run in a real Sail container against a leftover
`artisan dev` stack, it finds the 22 marked processes and the 7 PHP
processes beneath them that the marker cannot see, and nothing else.
A record now reads `pid start name`, so one left behind by an earlier
Mast session — a run this process never started and has no operation
for — can still say which command it was, not just which PID. The name
is reduced to a single field (anything outside [A-Za-z0-9._-] becomes
`_`), and the stop reads only the first two fields, so anything added
after them later cannot shift what it compares.
@stackrats
stackrats merged commit 69836e5 into main Oct 14, 2026
6 checks passed
@stackrats stackrats mentioned this pull request Oct 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant