Skip to content

Mast v0.7.7: what runs in the app container, and who started it - #56

Merged
stackrats merged 10 commits into
mainfrom
feat/running-processes
Oct 14, 2026
Merged

stackrats merged 10 commits into
mainfrom
feat/running-processes

Conversation

@stackrats

Copy link
Copy Markdown
Owner

Stacked on #55, which is stacked on #54. Merge those first; this diff then narrows to its own three commits.

What

  • "In the app container" on the project card. One chip per process tree running in the app container beyond its own startup. Each docker exec leaves such a tree, and it outlives its client.

    • Each chip shows who started it, with the memory it holds and how long it has been running.
    • Green is a command Mast is running.
    • Amber is anything else: a copy an earlier Mast session left (quitting Mast never stops what it started), something a terminal or an agent started, or an orphan init adopted.
    • The chip's menu shows the command line, process count, the daemons inside, and Stop for anything Mast isn't running.
  • Who started it, from evidence Mast leaves rather than environ, which Sail's non-dumpable PHP processes don't let anyone read:

    The container's own startup (supervisord and its artisan serve) is left out.

  • Duplicates. Horizon, the scheduler, Reverb, Vite or Pail running more than once becomes one advisory per set of copies. It explains the cost ("scheduled tasks run twice") and offers to stop each copy Mast isn't running. Process chips show ×2 in amber.

  • Process chips now show the truth. A daemon is running when a process is that daemon. The old substring check matched a dev-stack runner's arguments, so a Horizon that had died still showed as running. That's the case right now on a second project here.

  • Vite and public/hot. Each case gets an advisory with a fix:

    • Vite is running but the hot file is missing (pages get the old build);
    • the hot file points at a different dev server;
    • nothing is listening where the hot file points.

    The fixes are Restart for the dev command, and Remove public/hot through the usual preview dialog. public/ is now watched, filtered to hot.

  • No more doubling on relaunch. Running a Sail command first stops any copy of that exact command an earlier Mast session left running, and says so in the output. Nothing started outside Mast is stopped without a click.

  • Action::StopContainerRun stops one tree by root PID and start time, so a stale listing can't reach a reused PID.

  • The design is recorded in ADR-0011.

Verified

  • Unit tests (22 for the inventory):
    • parsing;
    • grouping trees and assigning owners;
    • a record whose PID was reused;
    • orphans versus the container's own processes;
    • a daemon the container's supervisor runs (counted, never offered for stopping);
    • Mast's own scans and half-started processes being skipped;
    • artisan detection only in program position;
    • grouping of duplicate advisories;
    • every Vite case.
  • Docker test running the real scan and tree-stop scripts in busybox: ownership from a record, a mismatched start time stopping nothing, and a stop leaving the neighbouring tree alone.
  • Against the real containers on this machine, using this branch's engine read-only through mast status --json:
    • kusina.design: two artisan dev stacks (3 GB, 30 processes, up 6 h; 0.5 GB, 17 processes, up 3 h), Horizon ×2 and Scheduler ×2. One advisory names both stacks, and the Vite advisory names 127.0.0.1:5288 against port 5180. The pre-0.7.6 stacks have no records, so they show as started outside Mast, which is honest.
    • api.thinksolar.local: Horizon and the scheduler correctly read as not running. Its stack has 13 processes left, after both panes died. 0.7.4 shows Horizon green there.
  • The real ProjectCard rendered with that live data in headless Chrome: the advisories with their fixes, the ×2 chips, the run chips with memory, and an open run menu.
  • cargo test --workspace passes.
  • cargo clippy --workspace --all-targets -D warnings is clean on Rust 1.96 and 1.99.
  • vp check is clean, and vp test passes 198 tests.

Not in this PR

  • Naming the host-side starter of a tree started outside Mast (which terminal, or which agent session). Docker exposes no link from an exec to its client, so it stays "outside Mast" rather than a guess.
  • Flagging a running Mast command whose stack has lost panes. thinksolar's dev has, and its chip is green.

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.
Mast watched containers, but everything that matters for a Laravel app
runs as processes inside one, and every `docker exec` leaves a tree
there that outlives its client. Quitting Mast, the Stop that silently
failed before 0.7.6, an agent's `sail artisan horizon` — each left
trees nothing showed. A real container held two `artisan dev` stacks
(3 GB and 0.5 GB): two Horizon masters, two schedulers running every
task twice, and a Vite whose exit deleted the other's public/hot, so
pages served an old build while every chip was green.

Each reconcile pass now walks /proc once per running app container and
groups the processes into trees: whatever a `docker exec` started, or
init adopted, and everything beneath it. ProjectSummary carries them as
container_runs, each with an owner, age, memory and the daemon kinds
inside. Ownership comes from what Mast itself leaves, never from
environ, which Sail's PHP processes do not let anyone read: run records
name Mast's commands (this session's, or an earlier one's), Mast's
registry claims its supervised daemons, and the rest were started
outside Mast or orphaned. The container's own startup is left out.

Process chips now count copies (ProcessState.instances), and running
means a process that is the daemon. The old substring check matched a
dev-stack runner's arguments and showed a Horizon that had died as
running — true on a second project here today.

Advisories put fixes beside the problem. A daemon that must run once
running more than once is one advisory per set of copies, offering to
stop each copy Mast is not running. public/hot is compared with
VITE_PORT and probed, and pages loading an old build, another server's
assets or a dead address each say so, with restart or remove-hot to
fix it. public/ is watched, filtered to `hot`, so the card follows the
file.

Action::StopContainerRun stops one tree by root PID and start time, as
root (CAP_KILL is enough, and the tree comes from /proc/<pid>/stat), so
a stale listing can never reach a process that has reused the number.
Running a Sail command first stops any copy of that same command an
earlier Mast session left, so relaunching Mast no longer doubles an
auto-started stack; nothing started outside Mast is stopped without a
click.

The Docker test helpers move to test_docker for the inventory's own
real-container test, which runs the scan and the tree stop in busybox:
ownership from a record, a mismatched start time stopping nothing, and
a stop leaving the neighbouring tree alone. ADR-0011 records the model.
A new row on the project card, "In the app container", has one chip per
process tree: Mast's own commands green, anything else amber — a copy
an earlier Mast session left, something a terminal or an agent
started, an orphan. Each says who started it and how much memory it
holds, so a forgotten 3 GB stack beside a 0.5 GB one stands out without
opening anything. Its menu gives the full command line, process count,
age and the daemons inside, what the owner means, and, for anything
Mast is not running, Stop.

Advisories render with the card's warnings, each fix beside its
sentence: Stop for a duplicate copy (with its age, since two copies of
one stack share a name), Restart for the dev command, and Remove
public/hot through the same preview-and-confirm dialog as every other
repair. Process chips show ×2 in amber when a daemon runs twice.

The store keeps which runs are stopping, so a run stopped from its chip
shows as stopping in its advisory too.
@stackrats
stackrats merged commit 6e6247c 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