Refuse Hermes removal while its files are in use
An open terminal can retain deleted SQLite WAL files across removal and reinstall, causing the updated desktop to refuse session writes. Check user processes before package/runtime removal and again after data confirmation, without killing sessions. Cover the failure with real SQLite writers in isolated fixtures. Co-Authored-By: GPT-6 Codex (xhigh) <noreply@openai.com>
This commit is contained in:
1 parent
827266fbed
commit
1b4ac8380b
2 files changed
+203
-2
No files matched your search
@@ -11,6 +11,14 @@ mock_bin="$test_tmp/bin"
|
||||
test_home="$test_tmp/home"
|
||||
mkdir -p "$mock_bin"
|
||||
|
||||
# Keep package-path checks scoped to the fixture, even with a live app open.
|
||||
python3 - "$ROOT/bin/omarchy-remove-ai-hermes" "$test_tmp" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
source, scratch = map(Path, sys.argv[1:])
|
||||
(scratch / 'remover').write_text(source.read_text().replace('/opt/hermes-desktop', str(scratch / 'package')))
|
||||
PY
|
||||
|
||||
cat >"$mock_bin/omarchy-pkg-drop" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\0' "$@" >>"$OMARCHY_TEST_DROP_LOG"
|
||||
@@ -32,6 +40,14 @@ SH
|
||||
cat >"$mock_bin/gum" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\0' "$@" >>"$OMARCHY_TEST_GUM_LOG"
|
||||
if [[ -n ${OMARCHY_TEST_PROMPT_GATE:-} ]]; then
|
||||
touch "$OMARCHY_TEST_PROMPT_GATE.started"
|
||||
for (( attempt=0; attempt<500; attempt++ )); do
|
||||
[[ ! -e $OMARCHY_TEST_PROMPT_GATE.continue ]] || exit 0
|
||||
sleep 0.01
|
||||
done
|
||||
exit 1
|
||||
fi
|
||||
exit "${OMARCHY_TEST_GUM_STATUS:-1}"
|
||||
SH
|
||||
cat >"$mock_bin/systemctl" <<'SH'
|
||||
@@ -70,7 +86,7 @@ remove() {
|
||||
OMARCHY_TEST_SYSTEMCTL_LOG="$test_tmp/systemctl-log" \
|
||||
OMARCHY_TEST_GUM_LOG="$test_tmp/gum-log" \
|
||||
HOME="$test_home" PATH="$mock_bin:$PATH" \
|
||||
bash "$ROOT/bin/omarchy-remove-ai-hermes" </dev/null >/dev/null 2>&1
|
||||
bash "$test_tmp/remover" </dev/null >"$test_tmp/output" 2>&1
|
||||
}
|
||||
|
||||
# script(1) puts the remover on a pty, which is the only way -t 0 answers true
|
||||
@@ -85,7 +101,7 @@ remove_tty() {
|
||||
OMARCHY_TEST_GUM_LOG="$test_tmp/gum-log" \
|
||||
OMARCHY_TEST_GUM_STATUS="${OMARCHY_TEST_GUM_STATUS:-1}" \
|
||||
HOME="$test_home" PATH="$mock_bin:$PATH" \
|
||||
script -qec "bash '$ROOT/bin/omarchy-remove-ai-hermes'" /dev/null >/dev/null 2>&1
|
||||
script -qec "bash '$test_tmp/remover'" /dev/null >"$test_tmp/output" 2>&1
|
||||
}
|
||||
|
||||
# The app brings its own uv and its own node; both are runtime, not data.
|
||||
@@ -229,3 +245,140 @@ OMARCHY_TEST_INSTALLER_STATUS=1 remove && fail "a failed CLI teardown surfaces i
|
||||
[[ ! -d $test_home/.hermes/hermes-agent ]] ||
|
||||
fail "a failed CLI teardown does not stop the runtime removal"
|
||||
pass "a failed CLI teardown is reported after the runtime is handled"
|
||||
|
||||
# Real SQLite writers exercise the kernel's live/deleted file descriptors.
|
||||
# Package, service and confirmation commands remain confined to the mocks.
|
||||
python3 - "$test_tmp" <<'PY'
|
||||
import os
|
||||
from pathlib import Path
|
||||
import pty
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
scratch = Path(sys.argv[1])
|
||||
writer_code = '''import os, sqlite3, sys
|
||||
c = sqlite3.connect(os.environ['TEST_DB'])
|
||||
c.execute('pragma journal_mode=wal')
|
||||
c.execute('create table fixture(value)')
|
||||
c.execute("insert into fixture values ('keep')")
|
||||
c.commit()
|
||||
print('ready', flush=True)
|
||||
sys.stdin.readline()
|
||||
c.close()
|
||||
'''
|
||||
|
||||
def setup(name):
|
||||
home = scratch / name
|
||||
runtime = home / '.hermes/hermes-agent'
|
||||
runtime.mkdir(parents=True)
|
||||
(runtime / '.hermes-bootstrap-complete').touch()
|
||||
(home / '.config/Hermes').mkdir(parents=True)
|
||||
env = {**os.environ, 'HOME': str(home), 'PATH': f"{scratch / 'bin'}:/usr/bin:/bin",
|
||||
'OMARCHY_TEST_GUM_STATUS': '0'}
|
||||
for key in ('DROP', 'INSTALLER', 'SYSTEMCTL', 'GUM'):
|
||||
log = home / (key + '.log')
|
||||
log.touch()
|
||||
env['OMARCHY_TEST_' + key + '_LOG'] = str(log)
|
||||
return home, runtime, env
|
||||
|
||||
def writer(db):
|
||||
child = subprocess.Popen([sys.executable, '-u', '-c', writer_code],
|
||||
env={**os.environ, 'TEST_DB': str(db)},
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
|
||||
assert child.stdout.readline().strip() == 'ready'
|
||||
return child
|
||||
|
||||
def stop(child):
|
||||
if child.poll() is None:
|
||||
child.stdin.write('\n')
|
||||
child.stdin.flush()
|
||||
child.wait(timeout=5)
|
||||
|
||||
def remove(env):
|
||||
master, slave = pty.openpty()
|
||||
try:
|
||||
return subprocess.run(['bash', str(scratch / 'remover')], env=env,
|
||||
stdin=slave, capture_output=True, text=True, timeout=10)
|
||||
finally:
|
||||
os.close(master)
|
||||
os.close(slave)
|
||||
|
||||
def blocked(result, home, runtime, child):
|
||||
assert result.returncode != 0 and str(child.pid) in result.stderr, result
|
||||
assert 'Close Hermes' in result.stderr, result.stderr
|
||||
assert (runtime / '.hermes-bootstrap-complete').exists()
|
||||
assert all((home / (name + '.log')).stat().st_size == 0
|
||||
for name in ('DROP', 'INSTALLER', 'SYSTEMCTL', 'GUM'))
|
||||
assert child.poll() is None, 'remover must not kill sessions'
|
||||
|
||||
for deleted in (False, True):
|
||||
home, runtime, env = setup('deleted-writer' if deleted else 'live-writer')
|
||||
db = home / '.hermes/state.db'
|
||||
child = writer(db)
|
||||
try:
|
||||
if deleted:
|
||||
for suffix in ('', '-wal', '-shm'):
|
||||
Path(str(db) + suffix).unlink()
|
||||
db.write_bytes(b'new database generation')
|
||||
blocked(remove(env), home, runtime, child)
|
||||
if deleted:
|
||||
assert db.read_bytes() == b'new database generation'
|
||||
finally:
|
||||
stop(child)
|
||||
assert remove(env).returncode == 0, 'removal succeeds once the writer closes'
|
||||
assert not (home / '.hermes').exists()
|
||||
print('ok - live and deleted SQLite holders block removal before any side effects; closing them allows retry')
|
||||
|
||||
for kind in ('terminal', 'desktop', 'working-directory'):
|
||||
home, runtime, env = setup(kind)
|
||||
executable_name = str(scratch / 'package/Hermes') if kind == 'desktop' else str(runtime / 'hermes')
|
||||
args = ['sleep', '30'] if kind == 'working-directory' else [executable_name, '30']
|
||||
child = subprocess.Popen(args, executable='/usr/bin/sleep',
|
||||
cwd=runtime if kind == 'working-directory' else scratch)
|
||||
try:
|
||||
blocked(remove(env), home, runtime, child)
|
||||
finally:
|
||||
child.terminate()
|
||||
child.wait(timeout=5)
|
||||
print('ok - terminal, packaged desktop and runtime working-directory processes are detected without a database')
|
||||
|
||||
home, runtime, env = setup('unrelated-writer')
|
||||
sibling = home / '.hermes-other'
|
||||
sibling.mkdir()
|
||||
child = writer(sibling / 'state.db')
|
||||
try:
|
||||
assert remove(env).returncode == 0, 'a sibling database does not block Hermes removal'
|
||||
assert child.poll() is None
|
||||
finally:
|
||||
stop(child)
|
||||
print('ok - unrelated database holders are left alone')
|
||||
|
||||
home, runtime, env = setup('prompt-race')
|
||||
gate = home / 'prompt'
|
||||
env['OMARCHY_TEST_PROMPT_GATE'] = str(gate)
|
||||
master, slave = pty.openpty()
|
||||
remover = subprocess.Popen(['bash', str(scratch / 'remover')], env=env, stdin=slave,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
os.close(slave)
|
||||
child = None
|
||||
try:
|
||||
deadline = time.monotonic() + 5
|
||||
while not Path(str(gate) + '.started').exists():
|
||||
assert remover.poll() is None and time.monotonic() < deadline, 'prompt was not reached'
|
||||
time.sleep(0.01)
|
||||
child = writer(home / '.hermes/state.db')
|
||||
Path(str(gate) + '.continue').touch()
|
||||
stdout, stderr = remover.communicate(timeout=10)
|
||||
assert remover.returncode != 0 and str(child.pid) in stderr, (stdout, stderr)
|
||||
assert (home / '.hermes/state.db-wal').exists()
|
||||
assert (home / '.config/Hermes').exists()
|
||||
finally:
|
||||
if child is not None:
|
||||
stop(child)
|
||||
if remover.poll() is None:
|
||||
remover.terminate()
|
||||
remover.wait(timeout=5)
|
||||
os.close(master)
|
||||
print('ok - a writer started during confirmation blocks data deletion')
|
||||
PY
|
||||
Reference in new issue
Block a user