Never reuse a command string for Plasma's executable engine

Plasma's executable data engine keys its command containers by the command
string. DataEngine::connectSource calls requestSource, which returns a
pre-existing container when one matches, and then delivers that container's
cached data; the process itself only starts when the container is first
constructed. Re-issuing a byte-identical command therefore reports success,
with a plausible exit code and stdout, while running nothing at all.

That is precisely what the joystick produces. A stick held at full deflection
emits the same command every tick, so the camera would take one step and then
stop, with the widget showing no error because it received a successful
result. The same applies to a key held down, and to repeated presses of
Center or Home.

Every command now carries a trailing shell comment holding a counter, so the
engine never sees a string it has already cached. `#` starts a comment in sh,
and the commands already contain quotes so they always go through sh -c,
meaning the suffix changes the engine's key without changing what runs.

Resolution state moved from comparing the probe's command string to a plain
boolean, since the string is no longer stable across calls.

Tested: 65 node checks, including that successive sequence numbers differ and
that the suffix lands after a chained diagonal command. tests/test_resolver.sh
additionally runs the generated command through a real shell both with and
without the suffix and requires identical output, so the uniqueness trick
cannot quietly alter behaviour. Full suite of 172 checks across seven suites
passes, shellcheck clean, QML free of syntax errors.

This is a defect established by reading Plasma's source, not a confirmed
diagnosis of the reported fault. The two earlier theories for that fault, PATH
resolution and a stale pinned device node, were both ruled out by the
reporter: linkctl is installed from a pacman package into /usr/bin, and the
widget's device field was never set.
This commit is contained in:
brad
2026-10-07 23:10:02 -07:00
parent fa2d4e89d8
commit 82974f242d
4 changed files with 70 additions and 6 deletions
+17
View File
@@ -23,6 +23,23 @@ function baseCmd(linkctl, device) {
return c;
}
/*
* Make a command string unique by appending a shell comment.
*
* Plasma's executable data engine keys its containers by the command string.
* DataEngine::connectSource hands back a pre-existing container and delivers
* its *cached* data rather than running the command again; the process only
* starts when the container is first created. So re-issuing an identical
* command can report success without the camera moving at all, which is
* exactly what a stick held at full deflection produces every tick.
*
* `#` starts a comment in sh, so the trailing counter changes the key the
* engine sees without changing what runs.
*/
function uniqueify(cmd, seq) {
return cmd + " # " + seq;
}
// Directories searched for the binary, in order. Mirrors the resolver in
// bin/linkctl-macro. $HOME is left for the shell to expand.
var SEARCH_DIRS = [
+11 -6
View File
@@ -31,7 +31,9 @@ PlasmoidItem {
// a login shell adds to PATH, so a bare name often cannot be run here
// even when it works in a terminal.
property string resolvedBin: ""
property string probeSource: ""
property bool resolving: false
// Incremented per command so no two command strings are ever identical.
property int seq: 0
readonly property bool ready: root.resolvedBin !== ""
readonly property int stepMax: Plasmoid.configuration.maxStep
readonly property int tickMs: Plasmoid.configuration.tickInterval
@@ -113,8 +115,8 @@ PlasmoidItem {
return;
}
root.statusText = "Looking for linkctl...";
root.probeSource = Logic.resolveCommand(root.configuredBin);
sh.exec(root.probeSource);
root.resolving = true;
sh.exec(Logic.resolveCommand(root.configuredBin));
}
function absorb(stdout) {
@@ -148,8 +150,8 @@ PlasmoidItem {
disconnectSource(source);
root.busy = false;
if (source === root.probeSource) {
root.probeSource = "";
if (root.resolving) {
root.resolving = false;
const found = String(out).trim().split("\n")[0].trim();
if (found !== "") {
root.resolvedBin = found;
@@ -171,7 +173,10 @@ PlasmoidItem {
function exec(cmd) {
root.busy = true;
connectSource(cmd);
root.seq += 1;
// Never reuse a command string: the engine would serve the
// previous run's cached output and not run anything.
connectSource(Logic.uniqueify(cmd, root.seq));
}
}
+15
View File
@@ -125,6 +125,21 @@ deep(ctx.absorb('{"pan":1}\nbroken {\n{"tilt":2}'), { pan: 1, tilt: 2 },
"absorb survives a malformed line between good ones");
deep(ctx.absorb('Pan: 12.3°'), {}, "absorb ignores human-readable output");
// --- uniqueify ---------------------------------------------------------
// Plasma's executable engine keys containers by command string and serves a
// pre-existing container's cached output instead of re-running, so repeating
// a command verbatim reports success without moving the camera.
eq(ctx.uniqueify("'linkctl' --force 'center'", 1),
"'linkctl' --force 'center' # 1", "uniqueify appends a comment");
eq(ctx.uniqueify("a", 1) === ctx.uniqueify("a", 2), false,
"successive sequence numbers differ");
// The suffix must be a shell comment so it cannot alter what runs.
eq(ctx.uniqueify("x", 7).includes(" # "), true, "suffix is a shell comment");
// A diagonal tick is two chained commands; the suffix must land at the end
// and so comment out nothing meaningful.
const tc = ctx.tickCommand(B, 1, 1, 6, false, false);
eq(ctx.uniqueify(tc, 3), tc + " # 3", "uniqueify appends after a chained command");
// --- resolveCommand ----------------------------------------------------
// Plasma runs applet commands through `sh -c` with plasmashell's environment,
// which never sources a login profile. A bare name therefore has to be
+27
View File
@@ -93,5 +93,32 @@ make_exe "$TMP/preferred/linkctl"
expect "prefers PATH over the search list" "$TMP/both" \
"$TMP/preferred:/usr/bin:/bin" "$TMP/preferred/linkctl"
# The uniqueness suffix exists only to change the string Plasma keys its
# command containers by. It must not change what the shell actually runs.
ask_logic() {
node -e '
const fs = require("fs"), vm = require("vm");
const src = fs.readFileSync(process.argv[1], "utf8");
const ctx = { Math, JSON, String };
vm.createContext(ctx);
vm.runInContext(src.replace(/^\s*\.pragma\s+library\s*$/m, ""), ctx);
const tick = ctx.tickCommand(ctx.baseCmd("echo", ""), 1, 1, 6, false, false);
process.stdout.write(process.argv[2] === "unique"
? ctx.uniqueify(tick, 42) : tick);
' "$LOGIC" "$1"
}
checks=$((checks + 1))
plain_out=$(sh -c "$(ask_logic plain)" 2>&1)
unique_out=$(sh -c "$(ask_logic unique)" 2>&1)
if [[ $plain_out == "$unique_out" && -n $plain_out ]]; then
printf 'ok %s\n' "uniqueness suffix does not change what the shell runs"
else
failures=$((failures + 1))
printf 'FAIL %s\n plain: %s\n unique: %s\n' \
"uniqueness suffix does not change what the shell runs" \
"$plain_out" "$unique_out"
fi
printf '\n%d checks, %d failures\n' "$checks" "$failures"
[[ $failures -eq 0 ]]