@xmoon76/dsh-pi-tui 0.3.2 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -7
- package/README.zh-CN.md +22 -5
- package/dist/builtins.mjs +1 -1
- package/dist/extensions.mjs +1 -1
- package/dist/index.d.mts +124 -2
- package/dist/index.mjs +2 -2
- package/dist/{src-oZjot5jQ.mjs → src-Txng8gIG.mjs} +4193 -912
- package/package.json +5 -2
- package/scripts/e2e-session-lease.sh +388 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xmoon76/dsh-pi-tui",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "The dsh-pi-tui bundle: a TUI surface for DeepSeek Harness profiles (dsh --profile pi-tui)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "XMoon <xmoonox@gmail.com>",
|
|
@@ -142,6 +142,7 @@
|
|
|
142
142
|
"@types/node": "^22.0.0",
|
|
143
143
|
"@xmoon76/pi-tui": "^0.84.3",
|
|
144
144
|
"@xterm/headless": "5.5.0",
|
|
145
|
+
"husky": "^9.1.7",
|
|
145
146
|
"tsdown": "0.22.0",
|
|
146
147
|
"tsx": "^4.23.12",
|
|
147
148
|
"typescript": "^5.9.0"
|
|
@@ -170,6 +171,8 @@
|
|
|
170
171
|
"smoke:vim": "node scripts/vim-plugin-smoke.mjs",
|
|
171
172
|
"smoke:examples": "node scripts/examples-plugin-smoke.mjs",
|
|
172
173
|
"pack:release": "rm -f xmoon76-dsh-pi-tui-*.tgz && pnpm pack --pack-destination .",
|
|
173
|
-
"smoke:pack": "node scripts/tarball-smoke.mjs"
|
|
174
|
+
"smoke:pack": "node scripts/tarball-smoke.mjs",
|
|
175
|
+
"verify:prepush": "pnpm typecheck:fork && pnpm test:fork && pnpm test:docs && node scripts/naming-gate.mjs && pnpm audit --prod --audit-level high && pnpm pack:release",
|
|
176
|
+
"verify:prepush:nofork": "pnpm test:docs && node scripts/naming-gate.mjs && pnpm audit --prod --audit-level high && pnpm pack:release"
|
|
174
177
|
}
|
|
175
178
|
}
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Real two-process E2E for the session lease lifecycle (PR #13).
|
|
3
|
+
#
|
|
4
|
+
# Covers the merge-gate regressions that headless suites cannot:
|
|
5
|
+
#
|
|
6
|
+
# Case 4 (merge blocker) — same-process reopen during COOLING:
|
|
7
|
+
# P1 open A → switch A→B (A enters COOLING) → switch B→A BEFORE the
|
|
8
|
+
# cooling release (~2s window) → wait past the old release time →
|
|
9
|
+
# P2 open A must be REFUSED (A is active again and still owned by P1).
|
|
10
|
+
# PROOF of the reactivation window: A's owner.lock INODE must stay
|
|
11
|
+
# unchanged across the B→A switch. A physical release would unlink the
|
|
12
|
+
# lock and a RELEASED re-acquire would create a NEW file (new inode);
|
|
13
|
+
# an unchanged inode proves the same held lock was never released, so
|
|
14
|
+
# reserveForActivation really took the held-COOLING reactivation path.
|
|
15
|
+
#
|
|
16
|
+
# ABA (recommended) — cooling#1 → reactivate → cooling#2:
|
|
17
|
+
# A→B (cooling#1) → B→A (reactivation) → A→B (cooling#2). The old
|
|
18
|
+
# verifier #1 must be epoch-stale: the lock must NOT disappear early
|
|
19
|
+
# (the current cooling#2 cannot settle before ~1.5s), and only the
|
|
20
|
+
# CURRENT (epoch2) release may hand A back (then P2 opens it).
|
|
21
|
+
#
|
|
22
|
+
# The E2E runs the REAL bundle (this repo's dist/) in TWO real dsh
|
|
23
|
+
# processes under an isolated DSH_HOME with a dedicated profile, driven
|
|
24
|
+
# through tmux. It does NOT need a model connection: the sessions are
|
|
25
|
+
# pre-seeded as minimal durable artifacts, and /resume switches them.
|
|
26
|
+
#
|
|
27
|
+
# Usage:
|
|
28
|
+
# scripts/e2e-session-lease.sh [--keep] [--no-aba]
|
|
29
|
+
#
|
|
30
|
+
# --keep keep the tmux session and DSH_HOME on failure for inspection
|
|
31
|
+
# --no-aba skip the ABA lifecycle case
|
|
32
|
+
#
|
|
33
|
+
# This script is intentionally NOT part of `pnpm test:bundle` (it needs a
|
|
34
|
+
# real TTY via tmux, a pnpm-installable profile, and ~40s of wall time).
|
|
35
|
+
# Run it on demand: scripts/e2e-session-lease.sh
|
|
36
|
+
|
|
37
|
+
set -euo pipefail
|
|
38
|
+
|
|
39
|
+
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
40
|
+
PROFILE_NAME="e2e-lease"
|
|
41
|
+
KEEP=0
|
|
42
|
+
RUN_ABA=1
|
|
43
|
+
for arg in "$@"; do
|
|
44
|
+
case "$arg" in
|
|
45
|
+
--keep) KEEP=1 ;;
|
|
46
|
+
--no-aba) RUN_ABA=0 ;;
|
|
47
|
+
*) echo "unknown argument: $arg" >&2; exit 2 ;;
|
|
48
|
+
esac
|
|
49
|
+
done
|
|
50
|
+
|
|
51
|
+
E2E_HOME="$(mktemp -d /tmp/dsh-e2e-lease-XXXXXX)"
|
|
52
|
+
TMUX_SESSION="dsh-e2e-lease-$$"
|
|
53
|
+
WORK_DIR="$E2E_HOME/work"
|
|
54
|
+
PROFILE_DIR="$E2E_HOME/profiles/$PROFILE_NAME"
|
|
55
|
+
|
|
56
|
+
# The session project key for WORK_DIR (see projectKey in
|
|
57
|
+
# session-persistence-jsonl: slashes become "-", unsafe units escape as
|
|
58
|
+
# ~XXXX, leading dashes stripped, wrapped in --...--).
|
|
59
|
+
PROJECT_KEY="$(node -e "
|
|
60
|
+
const cwd = process.argv[1];
|
|
61
|
+
let readable = ''; let sep = false;
|
|
62
|
+
for (const ch of cwd) {
|
|
63
|
+
if (ch === '/' || ch === '\\\\' || ch === ':') { if (!sep) readable += '-'; sep = true; }
|
|
64
|
+
else if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) { readable += ch; sep = false; }
|
|
65
|
+
else { readable += '~' + ch.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0'); sep = false; }
|
|
66
|
+
}
|
|
67
|
+
const slug = readable.replace(/^-+/, '') || 'root';
|
|
68
|
+
console.log('--' + slug.slice(0, 251) + '--');
|
|
69
|
+
" "$WORK_DIR")"
|
|
70
|
+
|
|
71
|
+
PASS=0
|
|
72
|
+
FAIL=0
|
|
73
|
+
|
|
74
|
+
say() { printf '%s\n' "$*"; }
|
|
75
|
+
ok() { PASS=$((PASS+1)); printf ' \033[32mok\033[0m %s\n' "$*"; }
|
|
76
|
+
bad() { FAIL=$((FAIL+1)); printf ' \033[31mFAIL\033[0m %s\n' "$*"; }
|
|
77
|
+
|
|
78
|
+
cleanup() {
|
|
79
|
+
if [ "$KEEP" -eq 1 ]; then
|
|
80
|
+
echo "== kept for inspection: tmux session $TMUX_SESSION, DSH_HOME $E2E_HOME =="
|
|
81
|
+
else
|
|
82
|
+
tmux kill-session -t "$TMUX_SESSION" 2>/dev/null || true
|
|
83
|
+
rm -rf "$E2E_HOME"
|
|
84
|
+
fi
|
|
85
|
+
}
|
|
86
|
+
trap cleanup EXIT
|
|
87
|
+
|
|
88
|
+
# ── 1. profile + isolated DSH_HOME ───────────────────────────────────────
|
|
89
|
+
echo "== setup: isolated DSH_HOME at $E2E_HOME =="
|
|
90
|
+
mkdir -p "$PROFILE_DIR" "$WORK_DIR"
|
|
91
|
+
cat > "$PROFILE_DIR/package.json" <<EOF
|
|
92
|
+
{
|
|
93
|
+
"name": "dsh-profile-e2e-rewind",
|
|
94
|
+
"private": true,
|
|
95
|
+
"dependencies": {
|
|
96
|
+
"@xmoon76/dsh-pi-tui": "link:$REPO_ROOT"
|
|
97
|
+
},
|
|
98
|
+
"dsh": {
|
|
99
|
+
"profile": {
|
|
100
|
+
"bundles": ["@deepseek-ai/dsh-base", "@xmoon76/dsh-pi-tui"]
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
EOF
|
|
105
|
+
cat > "$PROFILE_DIR/pnpm-workspace.yaml" <<'EOF'
|
|
106
|
+
packages:
|
|
107
|
+
- .
|
|
108
|
+
nodeLinker: hoisted
|
|
109
|
+
autoInstallPeers: false
|
|
110
|
+
EOF
|
|
111
|
+
if ! (cd "$PROFILE_DIR" && CI=true pnpm install --silent 2>/dev/null); then
|
|
112
|
+
echo "FAIL: pnpm install for the E2E profile failed" >&2
|
|
113
|
+
exit 1
|
|
114
|
+
fi
|
|
115
|
+
|
|
116
|
+
# ── 2. seed two durable session artifacts ───────────────────────────────
|
|
117
|
+
# Hand-seeded minimal artifacts: header frame + one event frame (the JSONL
|
|
118
|
+
# backend's per-frame container). The owner.lock lives next to the log.
|
|
119
|
+
seed_session() {
|
|
120
|
+
local id="$1"
|
|
121
|
+
local dir="$E2E_HOME/sessions/$PROJECT_KEY/$id"
|
|
122
|
+
mkdir -p "$dir"
|
|
123
|
+
printf '%s\n' "{\"type\":\"session\",\"version\":0,\"id\":\"$id\",\"createdAt\":1787409012905,\"cwd\":\"$WORK_DIR\",\"delegationDepth\":0,\"agentPreset\":\"standard\"}" \
|
|
124
|
+
| zstd -q -c > "$dir/f1"
|
|
125
|
+
printf '%s\n' \
|
|
126
|
+
"{\"type\":\"permission/preset\",\"seq\":0,\"time\":1787409012921,\"data\":{\"preset\":\"danger-full-access\"}}" \
|
|
127
|
+
"{\"type\":\"sandbox/mode\",\"seq\":1,\"time\":1787409012925,\"data\":{\"mode\":\"danger-full-access\"}}" \
|
|
128
|
+
| zstd -q -c > "$dir/f2"
|
|
129
|
+
cat "$dir/f1" "$dir/f2" > "$dir/session.jsonl.zstd"
|
|
130
|
+
rm -f "$dir/f1" "$dir/f2"
|
|
131
|
+
}
|
|
132
|
+
SESSION_A="session-e2e-a"
|
|
133
|
+
SESSION_B="session-e2e-b"
|
|
134
|
+
seed_session "$SESSION_A"
|
|
135
|
+
seed_session "$SESSION_B"
|
|
136
|
+
|
|
137
|
+
lock_path() { echo "$E2E_HOME/sessions/$PROJECT_KEY/$1/session.jsonl.zstd.owner.lock"; }
|
|
138
|
+
lock_inode() { stat -c %i "$(lock_path "$1")" 2>/dev/null || echo "MISSING"; }
|
|
139
|
+
|
|
140
|
+
# ── 3. tmux harness ─────────────────────────────────────────────────────
|
|
141
|
+
tmux new-session -d -s "$TMUX_SESSION" -x 120 -y 34
|
|
142
|
+
tmux split-window -t "$TMUX_SESSION" -h
|
|
143
|
+
PANE1="$TMUX_SESSION:0.0"
|
|
144
|
+
PANE2="$TMUX_SESSION:0.1"
|
|
145
|
+
|
|
146
|
+
title_of() { tmux display-message -t "$1" -p '#{pane_title}' 2>/dev/null || true; }
|
|
147
|
+
now_ms() { date +%s%3N; }
|
|
148
|
+
LAST_ENTER_MS=0
|
|
149
|
+
send_cmd() { # send_cmd <pane> <text> — paced to dodge the PasteBurst trap
|
|
150
|
+
tmux send-keys -t "$1" -l "$2"
|
|
151
|
+
sleep 0.2
|
|
152
|
+
tmux send-keys -t "$1" Enter
|
|
153
|
+
LAST_ENTER_MS="$(now_ms)" # the Enter that triggers the command
|
|
154
|
+
}
|
|
155
|
+
wait_title() { # wait_title <pane> <substr> <timeout_s> — 0 on match, 1 on timeout
|
|
156
|
+
local pane="$1" want="$2" timeout="$3" elapsed=0
|
|
157
|
+
while [[ "$(title_of "$pane")" != *"$want"* ]]; do
|
|
158
|
+
sleep 0.2
|
|
159
|
+
elapsed=$((elapsed+1))
|
|
160
|
+
if [ "$elapsed" -ge $((timeout*5)) ]; then return 1; fi
|
|
161
|
+
done
|
|
162
|
+
return 0
|
|
163
|
+
}
|
|
164
|
+
launch_p2() { # launch_p2 — a fresh dsh attempt on session A in pane 2
|
|
165
|
+
tmux send-keys -t "$PANE2" -l "env -u NO_COLOR DSH_HOME=$E2E_HOME dsh --profile $PROFILE_NAME --session $SESSION_A"
|
|
166
|
+
sleep 0.4
|
|
167
|
+
tmux send-keys -t "$PANE2" Enter
|
|
168
|
+
}
|
|
169
|
+
p2_out() {
|
|
170
|
+
tmux capture-pane -t "$PANE2" -p 2>/dev/null | sed 's/\x1b\[[0-9;]*m//g' | tr '\n' ' ' || true
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
# ── 4. Case 4: same-process reopen during COOLING ───────────────────────
|
|
174
|
+
echo "== Case 4: A → B (cooling) → A (reactivation) → P2 refused =="
|
|
175
|
+
tmux send-keys -t "$PANE1" -l "env -u NO_COLOR DSH_HOME=$E2E_HOME dsh --profile $PROFILE_NAME --session $SESSION_A"
|
|
176
|
+
sleep 0.4
|
|
177
|
+
tmux send-keys -t "$PANE1" Enter
|
|
178
|
+
if ! wait_title "$PANE1" "$SESSION_A" 20; then bad "P1 did not open $SESSION_A"; else ok "P1 opened $SESSION_A"; fi
|
|
179
|
+
|
|
180
|
+
# Switch A → B: A must enter COOLING with its lock still held. Record the
|
|
181
|
+
# authoritative cooling-start LOWER BOUND: the Enter of the A→B command
|
|
182
|
+
# (LAST_ENTER_MS right after this send_cmd). beginCooling cannot happen
|
|
183
|
+
# before that Enter, so the old verifier's earliest release (quiet 1s + 2
|
|
184
|
+
# interval sleeps ≈ 2000ms after ITS beginCooling) is AT OR AFTER that
|
|
185
|
+
# Enter + 2000.
|
|
186
|
+
send_cmd "$PANE1" "/resume $SESSION_B"
|
|
187
|
+
T_A2B_ENTER="$LAST_ENTER_MS" # the A→B Enter (cooling#1 lower bound)
|
|
188
|
+
if ! wait_title "$PANE1" "$SESSION_B" 120; then bad "P1 did not switch to $SESSION_B"; else ok "P1 switched to $SESSION_B"; fi
|
|
189
|
+
A_LOCK="$(lock_path "$SESSION_A")"
|
|
190
|
+
if [ -f "$A_LOCK" ]; then ok "A lock still held during cooling"; else bad "A lock lost during cooling"; fi
|
|
191
|
+
INODE_COOLING="$(lock_inode "$SESSION_A")"
|
|
192
|
+
|
|
193
|
+
# Switch B → A INSIDE the cooling window. The OLD verifier's earliest
|
|
194
|
+
# possible release: quiet 1s, then sampling starts immediately — the 3rd
|
|
195
|
+
# stable sample (release point) is 1000 + 2×500 = 2000ms after ITS
|
|
196
|
+
# beginCooling, which cannot precede the A→B Enter (T_A2B_ENTER). So
|
|
197
|
+
# earliest release >= T_A2B_ENTER + 2000ms. The reactivation COMPLETES
|
|
198
|
+
# no later than the moment wait_title(A) returns (T_REACT_DONE). If
|
|
199
|
+
# T_REACT_DONE is before T_A2B_ENTER + 2000ms, the reactivation finished
|
|
200
|
+
# before the old verifier could possibly release — overlap is GUARANTEED
|
|
201
|
+
# regardless of title-update latency. The unchanged inode is a secondary
|
|
202
|
+
# witness.
|
|
203
|
+
send_cmd "$PANE1" "/resume $SESSION_A"
|
|
204
|
+
if ! wait_title "$PANE1" "$SESSION_A" 120; then bad "P1 did not switch back to $SESSION_A"; else ok "P1 reactivated $SESSION_A during cooling"; fi
|
|
205
|
+
T_REACT_DONE="$(now_ms)"
|
|
206
|
+
REACT_MS=$((T_REACT_DONE - T_A2B_ENTER))
|
|
207
|
+
INODE_REACTIVATED="$(lock_inode "$SESSION_A")"
|
|
208
|
+
if [ "$REACT_MS" -lt 2000 ] && [ "$INODE_COOLING" = "$INODE_REACTIVATED" ] && [ "$INODE_COOLING" != "MISSING" ]; then
|
|
209
|
+
ok "reactivation completed ${REACT_MS}ms after the A→B Enter (<2.0s earliest-release bound), same held lock (inode $INODE_COOLING)"
|
|
210
|
+
else
|
|
211
|
+
bad "reactivation not inside the cooling window: ${REACT_MS}ms (inode $INODE_COOLING → $INODE_REACTIVATED)"
|
|
212
|
+
fi
|
|
213
|
+
|
|
214
|
+
# Wait PAST the original cooling release time: the old verifier must have
|
|
215
|
+
# been stale, so A stays owned by P1 (same lock, same inode).
|
|
216
|
+
sleep 3
|
|
217
|
+
if [ -f "$A_LOCK" ] && [ "$(lock_inode "$SESSION_A")" = "$INODE_REACTIVATED" ]; then
|
|
218
|
+
ok "A still owned by P1 past the old release time (same lock)"
|
|
219
|
+
else
|
|
220
|
+
bad "A lost its lock after reactivation"
|
|
221
|
+
fi
|
|
222
|
+
|
|
223
|
+
# P2 (second real dsh process) tries to open A: must be REFUSED. The
|
|
224
|
+
# assertion matches ONLY the lock-refusal notice (another live process
|
|
225
|
+
# owns the session), never the resume-failure wording ("stays locked by
|
|
226
|
+
# this TUI" would mean P2's own lock check wrongly passed).
|
|
227
|
+
launch_p2
|
|
228
|
+
sleep 3
|
|
229
|
+
if echo "$(p2_out)" | grep -qi "is open in another dsh process\|cannot lock session\|refusing to resume"; then ok "P2 was refused for $SESSION_A"; else bad "P2 was NOT refused: $(p2_out)"; fi
|
|
230
|
+
|
|
231
|
+
# ── 5. ABA: cooling#1 → reactivate → cooling#2 ──────────────────────────
|
|
232
|
+
if [ "$RUN_ABA" -eq 1 ]; then
|
|
233
|
+
echo "== ABA: A→B→A→B — old verifier must be stale =="
|
|
234
|
+
# From A (just reactivated): B → A → B quickly. The OVERLAP proof: the
|
|
235
|
+
# first A→B Enter (T_ABA_ENTER) is the LOWER BOUND of cooling#1's
|
|
236
|
+
# beginCooling, so verifier #1's earliest release is >= that Enter +
|
|
237
|
+
# 2000ms (quiet 1s + 2×0.5s interval sleeps; sampling starts right
|
|
238
|
+
# after the quiet). If cooling#2's start (the third switch's
|
|
239
|
+
# wait_title return, an UPPER bound for its beginCooling) happens
|
|
240
|
+
# before the first Enter + 2000ms, verifier #1 is GUARANTEED to still
|
|
241
|
+
# be running when cooling#2 begins — the ABA race is real. The
|
|
242
|
+
# lock-disappearance timing below then distinguishes a stale-verifier
|
|
243
|
+
# leak (<1.2s, impossible for the current epoch) from the current
|
|
244
|
+
# epoch's normal verified release.
|
|
245
|
+
send_cmd "$PANE1" "/resume $SESSION_B"
|
|
246
|
+
T_ABA_ENTER="$LAST_ENTER_MS" # the first A→B Enter (cooling#1 lower bound)
|
|
247
|
+
wait_title "$PANE1" "$SESSION_B" 120
|
|
248
|
+
send_cmd "$PANE1" "/resume $SESSION_A"
|
|
249
|
+
wait_title "$PANE1" "$SESSION_A" 120
|
|
250
|
+
send_cmd "$PANE1" "/resume $SESSION_B"
|
|
251
|
+
wait_title "$PANE1" "$SESSION_B" 120
|
|
252
|
+
T_ABA2="$(now_ms)" # cooling#2 started (upper bound of its beginCooling)
|
|
253
|
+
ABA_OVERLAP_MS=$((T_ABA2 - T_ABA_ENTER))
|
|
254
|
+
if [ "$ABA_OVERLAP_MS" -lt 2000 ]; then
|
|
255
|
+
ok "cooling#2 began ${ABA_OVERLAP_MS}ms after the cooling#1 Enter — old verifier #1 was still running (real ABA race)"
|
|
256
|
+
else
|
|
257
|
+
bad "cooling#2 began ${ABA_OVERLAP_MS}ms after the cooling#1 Enter — no overlap with the old verifier (race not exercised)"
|
|
258
|
+
fi
|
|
259
|
+
# The CURRENT cooling epoch cannot settle before quiet 1s + 2×0.5s
|
|
260
|
+
# interval sleeps ≈ 2.0s (sampling starts right after the quiet). The
|
|
261
|
+
# OLD verifier #1, if it were NOT epoch-stale, would release A early
|
|
262
|
+
# (its own samples completed around the last switch, ~0.9s into
|
|
263
|
+
# cooling#2 given the measured overlap). Measure when the lock
|
|
264
|
+
# disappears: before 1.5s = stale-verifier leak; after ~2.0s = the
|
|
265
|
+
# CURRENT epoch's release.
|
|
266
|
+
start_ms=$(now_ms)
|
|
267
|
+
elapsed=0
|
|
268
|
+
while [ -f "$(lock_path $SESSION_A)" ] && [ "$elapsed" -lt 60 ]; do
|
|
269
|
+
sleep 0.1
|
|
270
|
+
elapsed=$((elapsed+1))
|
|
271
|
+
done
|
|
272
|
+
if [ -f "$(lock_path $SESSION_A)" ]; then
|
|
273
|
+
bad "A lock never released within 6s of cooling#2"
|
|
274
|
+
else
|
|
275
|
+
gone_ms=$(now_ms)
|
|
276
|
+
gap_ms=$((gone_ms - start_ms))
|
|
277
|
+
if [ "$gap_ms" -lt 1500 ]; then
|
|
278
|
+
bad "A lock released ${gap_ms}ms into cooling#2 — a stale verifier released a later lifecycle"
|
|
279
|
+
else
|
|
280
|
+
ok "lock held through the stale-verifier window; released at ${gap_ms}ms (current epoch)"
|
|
281
|
+
fi
|
|
282
|
+
fi
|
|
283
|
+
# The CURRENT cooling#2 released the lock; P2 may now open A.
|
|
284
|
+
launch_p2
|
|
285
|
+
sleep 4
|
|
286
|
+
if [[ "$(title_of "$PANE2")" == *"$SESSION_A"* ]]; then
|
|
287
|
+
ok "P2 opened $SESSION_A after the epoch2 release"
|
|
288
|
+
else
|
|
289
|
+
bad "P2 could not open A after release: $(p2_out)"
|
|
290
|
+
fi
|
|
291
|
+
fi
|
|
292
|
+
|
|
293
|
+
# ── 6. PINNED sticky quarantine: cooling failure → refused → stale takeover ─
|
|
294
|
+
if [ "$RUN_ABA" -eq 1 ]; then
|
|
295
|
+
echo "== PINNED: cooling failure → quarantine → P1 reopen refused → P2 refused → P1 exit → stale takeover =="
|
|
296
|
+
# Use a THIRD session C (A is owned by P2 after the ABA case). P1 is on
|
|
297
|
+
# B; switch B → C so C becomes live, then C → B so C enters COOLING,
|
|
298
|
+
# and IMMEDIATELY append an extra durable event to C's log (a simulated
|
|
299
|
+
# external writer). The cooling verifier's parity triple (event count,
|
|
300
|
+
# last seq, tail fingerprint) then never matches the final snapshot →
|
|
301
|
+
# the retirement cannot settle → after the 5s max window C is PINNED.
|
|
302
|
+
SESSION_C="session-e2e-c"
|
|
303
|
+
seed_session "$SESSION_C"
|
|
304
|
+
send_cmd "$PANE1" "/resume $SESSION_C"
|
|
305
|
+
wait_title "$PANE1" "$SESSION_C" 120
|
|
306
|
+
send_cmd "$PANE1" "/resume $SESSION_B"
|
|
307
|
+
wait_title "$PANE1" "$SESSION_B" 120
|
|
308
|
+
C_DIR="$E2E_HOME/sessions/$PROJECT_KEY/$SESSION_C"
|
|
309
|
+
# A WELL-FORMED user/message event that the DSH reader ACCEPTS (the
|
|
310
|
+
# artifact must stay resumable): seq CONTINUOUS with the log (max seq + 1,
|
|
311
|
+
# the JSONL backend rejects a gap) and a fresh current time. Its
|
|
312
|
+
# seq/time/content differ from anything P1 wrote, so the cooling
|
|
313
|
+
# verifier's parity triple (event count, last seq, tail fingerprint)
|
|
314
|
+
# never matches the final snapshot → the retirement cannot settle →
|
|
315
|
+
# after the 5s max window C is PINNED.
|
|
316
|
+
C_LAST_SEQ="$(zstd -q -d -c "$C_DIR/session.jsonl.zstd" 2>/dev/null \
|
|
317
|
+
| grep -o '"seq":[0-9]*' | sed 's/"seq"://' | sort -n | tail -1 || true)"
|
|
318
|
+
C_LAST_SEQ="${C_LAST_SEQ:-0}"
|
|
319
|
+
C_NEXT_SEQ=$((C_LAST_SEQ + 1))
|
|
320
|
+
C_NOW_MS="$(date +%s%3N)"
|
|
321
|
+
printf '{"type":"user/message","seq":%s,"time":%s,"data":{"content":[{"type":"text","text":"e2e injected external writer"}],"source":{"kind":"user"},"role":"user","id":"e2e-injected-0001"},"surfaceOp":"append"}\n' \
|
|
322
|
+
"$C_NEXT_SEQ" "$C_NOW_MS" | zstd -q -c >> "$C_DIR/session.jsonl.zstd"
|
|
323
|
+
# Wait past the cooling max (5s) so the verifier pins C.
|
|
324
|
+
sleep 6
|
|
325
|
+
if [ -f "$(lock_path $SESSION_C)" ]; then ok "C lock still held after the failed cooling"; else bad "C lock lost after the failed cooling"; fi
|
|
326
|
+
# P1 attempts to reopen the quarantined C: must be REFUSED with the
|
|
327
|
+
# STICKY-QUARANTINE notice (not a generic failure).
|
|
328
|
+
send_cmd "$PANE1" "/resume $SESSION_C"
|
|
329
|
+
sleep 2
|
|
330
|
+
P1_OUT="$(tmux capture-pane -t "$PANE1" -p 2>/dev/null | sed 's/\x1b\[[0-9;]*m//g' | tr '\n' ' ')"
|
|
331
|
+
if echo "$P1_OUT" | grep -qi "safety quarantine\|restart this TUI before reopening"; then
|
|
332
|
+
ok "P1 reopen of the quarantined C was refused (sticky quarantine notice)"
|
|
333
|
+
else
|
|
334
|
+
bad "P1 reopen of the quarantined C was NOT refused: $P1_OUT"
|
|
335
|
+
fi
|
|
336
|
+
if [[ "$(title_of "$PANE1")" == *"$SESSION_B"* ]]; then ok "P1 stayed on B (the quarantine did not switch)"; else bad "P1 left B after the refused reopen"; fi
|
|
337
|
+
# P2 (a second real dsh process) tries to open C: must be REFUSED too
|
|
338
|
+
# (the lock is still held by P1's quarantine). Match ONLY the
|
|
339
|
+
# cross-process lock-refusal notice — the same precise wording as Case
|
|
340
|
+
# 4 — never the resume-failure "stays locked by this TUI" (which would
|
|
341
|
+
# mean P2's own lock check wrongly passed). First quit the OLD dsh
|
|
342
|
+
# still running in pane 2 (it opened A in the ABA case) so the pane is
|
|
343
|
+
# a clean shell again.
|
|
344
|
+
tmux send-keys -t "$PANE2" -l "/exit"
|
|
345
|
+
sleep 0.4
|
|
346
|
+
tmux send-keys -t "$PANE2" Enter
|
|
347
|
+
sleep 2
|
|
348
|
+
tmux send-keys -t "$PANE2" -l "env -u NO_COLOR DSH_HOME=$E2E_HOME dsh --profile $PROFILE_NAME --session $SESSION_C"
|
|
349
|
+
sleep 0.4
|
|
350
|
+
tmux send-keys -t "$PANE2" Enter
|
|
351
|
+
sleep 3
|
|
352
|
+
if echo "$(p2_out)" | grep -qi "is open in another dsh process\|cannot lock session\|refusing to resume"; then ok "P2 was refused for the quarantined C"; else bad "P2 was NOT refused for the quarantined C: $(p2_out)"; fi
|
|
353
|
+
# P1 exits: the editor may hold the refused /resume text, so /exit would
|
|
354
|
+
# not be a clean command — kill the OWNER of C's lock precisely (the pid
|
|
355
|
+
# recorded in the lock file IS the dsh process; this is the script's own
|
|
356
|
+
# process, never a user session). The stale lock remains, and P2's next
|
|
357
|
+
# open takes it over (stale takeover).
|
|
358
|
+
C_LOCK="$(lock_path $SESSION_C)"
|
|
359
|
+
if [ -f "$C_LOCK" ]; then
|
|
360
|
+
OWNER_PID="$(node -e "const fs=require('fs');const j=JSON.parse(fs.readFileSync(process.argv[1],'utf8'));console.log(j.pid)" "$C_LOCK" 2>/dev/null || true)"
|
|
361
|
+
if [ -n "$OWNER_PID" ]; then
|
|
362
|
+
kill "$OWNER_PID" 2>/dev/null || true
|
|
363
|
+
fi
|
|
364
|
+
fi
|
|
365
|
+
sleep 3
|
|
366
|
+
tmux send-keys -t "$PANE2" -l "/exit"
|
|
367
|
+
sleep 0.4
|
|
368
|
+
tmux send-keys -t "$PANE2" Enter
|
|
369
|
+
sleep 2
|
|
370
|
+
tmux send-keys -t "$PANE2" -l "env -u NO_COLOR DSH_HOME=$E2E_HOME dsh --profile $PROFILE_NAME --session $SESSION_C"
|
|
371
|
+
sleep 0.4
|
|
372
|
+
tmux send-keys -t "$PANE2" Enter
|
|
373
|
+
sleep 5
|
|
374
|
+
if [[ "$(title_of "$PANE2")" == *"$SESSION_C"* ]]; then
|
|
375
|
+
ok "P2 opened C after P1 exit (stale takeover of the quarantined lock)"
|
|
376
|
+
else
|
|
377
|
+
bad "P2 could not open C after P1 exit: $(p2_out)"
|
|
378
|
+
fi
|
|
379
|
+
fi
|
|
380
|
+
|
|
381
|
+
# ── summary ─────────────────────────────────────────────────────────────
|
|
382
|
+
echo
|
|
383
|
+
echo "== e2e-session-lease: $PASS passed, $FAIL failed =="
|
|
384
|
+
if [ "$FAIL" -ne 0 ]; then
|
|
385
|
+
echo "== FAILURES: keep with --keep for inspection =="
|
|
386
|
+
exit 1
|
|
387
|
+
fi
|
|
388
|
+
exit 0
|