Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -800,6 +800,15 @@ gets the same chooser.
`ui/PickerList.qml` hands `Row` `Picker.HIDDEN_COLS` and the chooser never inherits Mode or Kind
from `ViewState`, whatever the header menu has switched on for the browser window.

**Stalled chooser listings.** `PickerListing.qml` owns a separate read-only backend for list,
listpaths and window. Navigation invalidates its output immediately, kills and reaps that worker,
then starts only the latest request with a fresh stdout parser. The chooser's other `Backend`
opts into `pickerOnly`: only picker checks and capability reads may be sent, and shutdown can
terminate a stuck identity check without touching a file-manager write backend. `PickerLifecycle`
waits for both workers only after the reply file has been saved. `tests/picker-stall.sh` drives
blocked FIFO reads, latest-navigation recovery, incomplete stale output, early cancellation,
failed spawn and process reaping under offscreen Quickshell; it needs no production mount.

**Recent, and why it is read-only.** `SendPicker.html` draws a Recent row above Home in the rail,
says the location's own name where the path would be, and draws Parent disabled with the words
"unavailable in Recent". The history it lists is the desktop's own,
Expand Down
35 changes: 35 additions & 0 deletions tests/picker-stall-helper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#!/usr/bin/env python3
"""A sequential backend blocked in a real FIFO read, with no user mount or cloud traffic."""
import json
import os
from pathlib import Path
import signal
import sys

root = Path(os.environ['FLEA_PICKER_FIXTURE'])
assert (root / '.flea-test-sandbox').is_file()
with (root / 'pids').open('a') as log:
log.write(f'{os.getpid()}\n')
signal.signal(signal.SIGTERM, signal.SIG_IGN)
for line in sys.stdin:
request = json.loads(line)
with (root / 'requests').open('a') as log:
log.write(line)
command = request['c']
if command == 'list' and request['path'] == '/stalled':
print('{"t":"blocked"}', flush=True)
# Leave an incomplete reply behind: a reused stdout parser would join it to the next worker.
print('{"t":"listed","path":"/stale",', end='', flush=True)
with (root / 'stall.fifo').open() as pipe:
pipe.read()
elif command == 'picker':
print('{"t":"picker","id":1,"op":"blocked"}', flush=True)
with (root / 'stall.fifo').open() as pipe:
pipe.read()
elif command in ('list', 'listpaths'):
print(json.dumps({'t': 'listed', 'path': request.get('path', '/'), 'n': 1, 'read': 0, 'sort': 0}), flush=True)
print('{"t":"rows","start":0,"rows":[{"n":"local.txt","d":false,"s":1,"m":1,"p":33188,"i":"text-x-generic","t":false,"k":0}],"kinds":[]}', flush=True)
elif command == 'window':
print('{"t":"rows","start":1,"rows":[{"n":"next.txt"}],"kinds":[]}', flush=True)
elif command == 'quit':
break
80 changes: 80 additions & 0 deletions tests/picker-stall.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import QtQuick
import Quickshell
import "flea" as Flea

ShellRoot {
id: root
property string scenario: Quickshell.env("FLEA_PICKER_CASE")
property bool stalled: false
property bool localReady: false
property bool paged: false
property bool validationBlocked: false
property bool stopping: false
property int failures: 0
property double began: Date.now()
function stop() { stopping = true; lifecycle.quit() }
Flea.PickerListing {
id: listing
onFailed: function(reason) {
if (root.scenario.indexOf("missing") >= 0) root.stop()
else { console.log(reason); root.failures++; root.stop() }
}
onMessage: function(message) {
if (message.t === "blocked") {
root.stalled = true
next.start()
} else if (message.t === "listed") {
if (message.path !== "/local") root.failures++
root.localReady = true
listing.window(1, 1)
} else if (message.t === "rows" && message.start === 1) {
root.paged = message.rows[0].n === "next.txt"
root.stop()
}
}
}
Flea.Backend {
id: checks
pickerOnly: true
onPickerResult: function(message) { if (message.op === "blocked") root.validationBlocked = true }
onFailed: function(where, input, message, mode) {
if (root.scenario.indexOf("missing") < 0) { console.log(message); root.failures++ }
}
}
Flea.PickerLifecycle {
id: lifecycle
checks: checks
listing: listing
onStopped: {
var good = root.stopping && !root.failures && Date.now() - root.began < 2000
if (root.scenario.indexOf("missing") < 0 && root.scenario.indexOf("early") < 0)
good = good && root.stalled && root.validationBlocked
if (root.scenario === "navigate") good = good && root.localReady && root.paged
console.log("picker-stall", root.scenario, good ? "PASS" : "FAIL", Date.now() - root.began)
Qt.exit(good ? 0 : 1)
}
}
Timer {
id: next
interval: 100
onTriggered: {
if (root.scenario === "navigate") {
listing.request({c: "list", path: "/superseded", first: 1})
listing.request({c: "list", path: "/local", first: 1})
} else root.stop()
}
}
Timer {
id: failedStartOrder
interval: 100
onTriggered: { root.stopping = true; listing.quit(); checks.testFailedStartDuringQuit() }
}
Timer { interval: 3000; running: true; onTriggered: Qt.exit(1) }
Component.onCompleted: {
if (scenario === "missing-order") { failedStartOrder.start(); return }
listing.request({c: "list", path: "/stalled", first: 1})
checks.send({c: "transfer", rows: [0], dest: "/not-allowed"})
checks.send({c: "picker", op: "validate", id: 1})
if (scenario.indexOf("early") === 0) stop()
}
}
77 changes: 77 additions & 0 deletions tests/picker-stall.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# A real Quickshell Process blocks on a private FIFO just as a sequential backend can block on
# FUSE. New navigation must reap it, discard its incomplete stdout line and page the replacement.
# In parallel the identity-check backend blocks on its own read: cancel must reap both, even if
# neither reads another stdin byte. Immediate cancellation covers the before-onStarted race.
set -eu
. "$(dirname "$0")/../tools/flea-sandbox-guard"
cd "$(dirname "$0")/.."
command -v qs >/dev/null
fixture="$FIXTURE_ROOT/picker-stall-$$"
sandbox_make "$fixture"
cleanup() {
# On a failed assertion the owned helper may still be blocked; never signal a recycled PID.
python3 - "$fixture" <<'PYEND'
import os
from pathlib import Path
import signal
import sys
root = Path(sys.argv[1])
assert (root / '.flea-test-sandbox').is_file()
for text in (root / 'pids').read_text().splitlines() if (root / 'pids').exists() else []:
process = Path('/proc') / text
try:
if os.stat(process).st_uid == os.getuid() and os.fsencode(root / 'helper') in (process / 'cmdline').read_bytes().split(b'\0'):
os.kill(int(text), signal.SIGKILL)
except FileNotFoundError:
pass
PYEND
sandbox_remove "$fixture"
}
trap cleanup EXIT
mkdir -p "$fixture/flea/js"
cp ui/PickerListing.qml ui/PickerLifecycle.qml ui/Backend.qml "$fixture/flea/"
cp ui/js/Messages.js "$fixture/flea/js/"
# Drive the real runningChanged handler in the rare cancel-before-FailedToStart order. Natural
# missing-executable timing here fails before cancel; this test-only method fixes the event order,
# not the handler, so restoring the old guard makes missing-order time out.
python3 - "$fixture/flea/Backend.qml" <<'PYEND'
from pathlib import Path
import sys
path = Path(sys.argv[1])
text = path.read_text().replace(' function send(object) {', '''
function testFailedStartDuringQuit() {
if (child.running) throw new Error("Failed-start fixture unexpectedly has a child")
root.queueing = true
root.quitting = true
child.runningChanged()
}
function send(object) {
''', 1)
path.write_text(text)
PYEND
printf 'module flea\nsingleton ViewState 1.0 ViewState.qml\n' > "$fixture/flea/qmldir"
printf 'pragma Singleton\nimport QtQuick\nQtObject { property var state: ({}) }\n' > "$fixture/flea/ViewState.qml"
cp tests/picker-stall.qml "$fixture/shell.qml"
cp tests/picker-stall-helper.py "$fixture/helper"
chmod +x "$fixture/helper"
mkfifo "$fixture/stall.fifo"
for scenario in navigate cancel early missing early-missing missing-order; do
: > "$fixture/pids"
: > "$fixture/requests"
helper="$fixture/helper"
[[ $scenario != *missing* ]] || helper="$fixture/missing"
if ! QT_QPA_PLATFORM=offscreen FLEA_BIN="$helper" FLEA_PICKER_CASE="$scenario" FLEA_PICKER_FIXTURE="$fixture" \
timeout 6 qs -p "$fixture/shell.qml" > "$fixture/output" 2>&1; then
cat "$fixture/output"; exit 1
fi
grep -F "picker-stall $scenario PASS" "$fixture/output" || { cat "$fixture/output"; exit 1; }
if [[ $scenario == navigate ]] && [[ $(wc -l < "$fixture/pids") != 3 ]]; then
echo "FAIL: superseded navigation launched an extra worker"; exit 1
fi
while read -r pid; do
if kill -0 "$pid" 2>/dev/null; then echo "FAIL: owned helper $pid survived"; exit 1; fi
done < "$fixture/pids"
if grep -q '"c":"transfer"' "$fixture/requests"; then echo 'FAIL: picker sent a write'; exit 1; fi
done
printf 'picker-stall: 6 process lifecycle scenarios passed\n'
1 change: 1 addition & 0 deletions tests/run-all.sh
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ done
# Named, not run: each needs something this script cannot assume it has. One list, read twice: it
# is printed here and it is what the audit below checks, so a suite cannot be quietly excluded.
not_run="
picker-stall|needs Quickshell; runs offscreen read-only worker cancellation tests
ui|needs the display, and refuses beside a Flea it did not start
drag|needs the display and a real pointer through uinput
cardsizes|needs the display, a real pointer through uinput, and Hyprland to resize the window
Expand Down
15 changes: 14 additions & 1 deletion ui/Backend.qml
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,12 @@ Item {

// A quit is in flight, so the child's exit is the end the shell asked for, not a failure.
property bool quitting: false
// Only the portal chooser opts in: it has no filesystem write operations to drain.
property bool pickerOnly: false

// Everything the UI sends goes through here, so the protocol has exactly one author.
function send(object) {
if (root.pickerOnly && object.c !== "picker" && object.c !== "formats") return
var line = JSON.stringify(object) + "\n"
if (root.queueing) {
root.pending.push(line)
Expand Down Expand Up @@ -280,6 +283,12 @@ Item {
return
}
root.quitting = true
if (root.pickerOnly) {
root.pending = []
if (child.running) child.signal(9)
else root.quitReady()
return
}
// Before the child is up a quit would only join the pending queue and never be written,
// and nothing can be in flight yet, so there is nothing to drain and the shell goes now.
if (root.queueing || !child.running) {
Expand Down Expand Up @@ -337,6 +346,7 @@ Item {
}

onStarted: {
if (root.pickerOnly && root.quitting) { child.signal(9); return }
root.queueing = false
// Asked once per process: which formats exist cannot change while the backend runs.
root.askFormats()
Expand All @@ -351,7 +361,10 @@ Item {
if (root.queueing && !child.running) {
root.queueing = false
root.pending = []
root.failed("backend", "", "the backend could not be started", 0)
// FailedToStart has no exited signal. A chooser already cancelling still owes its
// lifecycle an answer; a started child clears queueing and must be reaped in exited.
if (root.pickerOnly && root.quitting) root.quitReady()
else root.failed("backend", "", "the backend could not be started", 0)
}
}

Expand Down
28 changes: 28 additions & 0 deletions ui/PickerLifecycle.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import QtQuick
import Quickshell

// Called only after the portal reply has been saved (or the save has failed). Both processes are
// owned by this chooser; neither can write selected files, so a stalled read needs no write drain.
Item {
id: root
required property var checks
required property var listing
signal stopped()
property bool checksStopped: false
property bool listingStopped: false
property bool finished: false
function quit() { listing.quit(); checks.quit() }
function finish() {
if (finished || !checksStopped || !listingStopped) return
finished = true
stopped()
}
Connections {
target: root.checks
function onQuitReady() { root.checksStopped = true; root.finish() }
}
Connections {
target: root.listing
function onQuitReady() { root.listingStopped = true; root.finish() }
}
}
85 changes: 85 additions & 0 deletions ui/PickerListing.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import QtQuick
import Quickshell
import Quickshell.Io

// Listings can block inside FUSE. Only this read-only worker is replaceable; selection identities
// belong to the picker's separate backend, and the file manager's write backend is never signalled.
Item {
id: root
signal message(var value)
signal failed(string reason)
signal quitReady()
property var current: null
property var pending: null
property bool quitting: false

function clear() {
pending = null
if (current) {
current.obsolete = true
if (current.running) current.signal(9)
}
}
function request(value) {
if (quitting || (value.c !== "list" && value.c !== "listpaths")) return
pending = value
if (current) {
current.obsolete = true
if (current.running) current.signal(9)
} else launch()
}
function launch() {
if (quitting || !pending || current) return
var value = pending
pending = null
current = worker.createObject(root, {request: value})
current.running = true
}
function window(start, count) {
if (current && current.running && !current.obsolete && !quitting)
current.write(JSON.stringify({c: "window", start: start, count: count}) + "\n")
}
function quit() {
quitting = true
pending = null
if (!current) { quitReady(); return }
current.obsolete = true
if (current.running) current.signal(9)
}
function ended(process, reason) {
if (current !== process) return
current = null
process.destroy()
if (quitting) quitReady()
else if (pending) Qt.callLater(launch)
else if (!process.obsolete) failed(reason)
}
Component.onDestruction: if (current && current.running) current.signal(9)

Component {
id: worker
Process {
id: process
property var request
property bool obsolete: false
property bool started: false
command: [Quickshell.env("FLEA_BIN") || "flea", "--backend"]
stdinEnabled: true
onStarted: {
started = true
if (obsolete || root.quitting) { signal(9); return }
write(JSON.stringify(request) + "\n")
}
stdout: SplitParser {
onRead: function(line) {
if (process.obsolete || root.quitting || root.current !== process || !line) return
try { root.message(JSON.parse(line)) }
catch (error) { root.failed("The listing backend sent an invalid reply.") }
}
}
onExited: function(code, status) { root.ended(process, "The listing backend exited with code " + code) }
// A failed QProcess spawn emits runningChanged, but never exited.
onRunningChanged: if (!running && !started) root.ended(process, "The listing backend could not be started.")
}
}
}
Loading