From 4c417d0734a8a70e37a4884091986b0dbedb3281 Mon Sep 17 00:00:00 2001 From: Interchouette <484423+Interchouette@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:11:35 +0200 Subject: [PATCH] fix(x): root and reply on separate tabs with stable Brave lock One CDP session posts root on tab 1 and overflow reply on tab 2, then closes tabs before shell cleanup. Flock-based brave.lock no longer unlinks while another ship holds the lock; CDP port bind retries on launch only. Co-authored-by: Cursor --- scripts/lib/twitter-brave-lock.sh | 44 ++++++++- scripts/post-twitter.mjs | 150 +++++++++++++++++++----------- scripts/post-twitter.sh | 56 ++++++++--- scripts/x-ship-resolve.mjs | 10 +- scripts/x-ship-resolve.test.mjs | 31 ++++-- 5 files changed, 206 insertions(+), 85 deletions(-) diff --git a/scripts/lib/twitter-brave-lock.sh b/scripts/lib/twitter-brave-lock.sh index 15a4f54..2b8856a 100644 --- a/scripts/lib/twitter-brave-lock.sh +++ b/scripts/lib/twitter-brave-lock.sh @@ -5,6 +5,20 @@ # One Brave CDP session at a time; pick a free debugging port (never steal :9224). # shellcheck shell=bash +# True when no process holds flock on the lock file (safe to remove orphan file). +twitter_brave_lock_unheld() { + local lock="${1:?}" + [[ -f "${lock}" ]] || return 0 + exec 8>>"${lock}" + if flock -n 8; then + flock -u 8 + exec 8>&- || true + return 0 + fi + exec 8>&- || true + return 1 +} + twitter_brave_stale_lock_pid() { local lock="${1:?}" [[ -f "${lock}" ]] || return 1 @@ -16,28 +30,45 @@ twitter_brave_stale_lock_pid() { twitter_brave_clear_stale_lock() { local lock="${1:?}" - if [[ -f "${lock}" ]] && ! twitter_brave_stale_lock_pid "${lock}"; then + # Never unlink while another process holds flock (PID file can be empty mid-acquire). + if twitter_brave_lock_unheld "${lock}"; then rm -f "${lock}" + return 0 + fi + if [[ -f "${lock}" ]] && ! twitter_brave_stale_lock_pid "${lock}"; then + # Holder PID died but flock should be gone; only clear if flock is also free. + if twitter_brave_lock_unheld "${lock}"; then + rm -f "${lock}" + fi fi } twitter_brave_acquire_lock() { local run_root="${1:?run root}" - local wait_secs="${2:-180}" + local wait_secs="${2:-300}" mkdir -p "${run_root}" local lock="${run_root}/brave.lock" local deadline=$((SECONDS + wait_secs)) while (( SECONDS < deadline )); do twitter_brave_clear_stale_lock "${lock}" - exec 9>"${lock}" + exec 9<>"${lock}" if flock -n 9; then + : >"${lock}" echo "$$" >&9 return 0 fi exec 9>&- || true sleep 2 done - echo "another X Brave session holds ${lock}; wait or stop the other ship/pulse/status run" >&2 + local holder="" + if [[ -f "${lock}" ]]; then + holder="$(tr -dc '0-9' <"${lock}" 2>/dev/null || true)" + fi + if [[ -n "${holder}" ]]; then + echo "another X Brave session holds ${lock} (pid ${holder}); wait or stop the other ship/pulse/status run" >&2 + else + echo "another X Brave session holds ${lock}; wait or stop the other ship/pulse/status run" >&2 + fi return 1 } @@ -72,3 +103,8 @@ twitter_brave_port_free() { fi return 0 } + +twitter_brave_cdp_ready() { + local port="${1:?}" + curl -fsS --connect-timeout 0.3 "http://127.0.0.1:${port}/json/version" >/dev/null 2>&1 +} diff --git a/scripts/post-twitter.mjs b/scripts/post-twitter.mjs index a59009f..3e74d82 100644 --- a/scripts/post-twitter.mjs +++ b/scripts/post-twitter.mjs @@ -429,20 +429,87 @@ async function resolveAfterPost(page, excludeIds, beforeId, toastHref) { return null; } -async function postReply(page, replyText, parent, excludeIds) { - await page.goto(parent.url, { +async function postReply(replyPage, replyText, parent, excludeIds) { + await replyPage.goto(parent.url, { waitUntil: "domcontentloaded", timeout: 60000, }); - await page.waitForTimeout(1000); - const btn = page.locator('[data-testid="reply"]').first(); + await replyPage.waitForTimeout(1000); + if (looksLoggedOut(replyPage.url(), await replyPage.content())) { + fail("logged out on reply status page"); + } + const btn = replyPage.locator('[data-testid="reply"]').first(); await btn.waitFor({ state: "visible", timeout: 20000 }); await btn.click(); - await page.waitForTimeout(800); - await fillComposer(page, replyText); - const toastHref = await clickPost(page); + await replyPage.waitForTimeout(800); + await fillComposer(replyPage, replyText); + const toastHref = await clickPost(replyPage); const skip = excludeIds.concat([parent.id]); - return resolveAfterPost(page, skip, parent.id, toastHref); + return resolveAfterPost(replyPage, skip, parent.id, toastHref); +} + +/** One root tab; close stray CDP tabs from profile restore. */ +async function prepareRootTab(context) { + const pages = context.pages(); + const rootPage = pages[0] || (await context.newPage()); + for (const page of pages.slice(1)) { + await page.close().catch(() => {}); + } + return rootPage; +} + +async function closeShipTabs(rootPage, replyPage) { + if (replyPage) { + await replyPage.close().catch(() => {}); + } + if (rootPage) { + await rootPage.close().catch(() => {}); + } +} + +async function postRootTweet(rootPage, text, quoteId, excludeIds, beforeId) { + let rootToastHref = null; + if (quoteId) { + const scope = await openQuoteComposer(rootPage, quoteId); + await fillComposer(rootPage, text, scope); + rootToastHref = await clickPost(scope); + } else { + await clickProfilePost(rootPage); + if (looksLoggedOut(rootPage.url(), await rootPage.content())) { + fail("logged out on compose"); + } + if (isStatusPermalink(rootPage.url())) { + fail( + "compose landed on a status permalink; refusing to type into that reply box" + ); + } + const dialog = rootPage + .locator('[role="dialog"]') + .filter({ has: rootPage.locator('[data-testid="tweetTextarea_0"]') }) + .first(); + const scope = (await dialog.isVisible().catch(() => false)) + ? dialog + : rootPage; + await fillComposer(rootPage, text, scope); + rootToastHref = await clickPost(scope); + } + + const found = await resolveAfterPost( + rootPage, + excludeIds, + beforeId, + rootToastHref + ); + if (!found) { + const cap = await captureOverlay(rootPage, "resolve-miss"); + fail( + `posted but could not resolve status (toast=${rootToastHref || "none"}; no newer own than ${beforeId}). screenshot=${relArtifact(cap.png)}` + ); + } + if (!statusIdNewer(found.id, beforeId)) { + fail(`resolve picked non-newer id ${found.id} (before=${beforeId})`); + } + return found; } async function main() { @@ -465,21 +532,23 @@ async function main() { const browser = await chromium.connectOverCDP(cdpUrl); const context = browser.contexts()[0] || (await browser.newContext()); - const page = context.pages()[0] || (await context.newPage()); + let rootPage = await prepareRootTab(context); + let replyPage = null; try { - await goProfile(page); - const before = await latestOwnOnProfile(page, [], ""); + await goProfile(rootPage); + const before = await latestOwnOnProfile(rootPage, [], ""); const beforeId = before && before.id ? before.id : "0"; if (inReplyToId) { + replyPage = await context.newPage(); const parent = { id: inReplyToId, url: `https://x.com/i/web/status/${inReplyToId}`, }; - const found = await postReply(page, text, parent, []); + const found = await postReply(replyPage, text, parent, []); if (!found) { - const cap = await captureOverlay(page, "in-reply-resolve-miss"); + const cap = await captureOverlay(replyPage, "in-reply-resolve-miss"); fail( `reply posted but could not resolve status under ${inReplyToId}. screenshot=${relArtifact(cap.png)}` ); @@ -487,53 +556,21 @@ async function main() { if (!statusIdNewer(found.id, beforeId)) { fail(`resolve picked non-newer id ${found.id} (before=${beforeId})`); } + await closeShipTabs(rootPage, replyPage); + replyPage = null; ok(found.id, found.url, `brave in-reply ok (parent=${inReplyToId})`, null); return; } const excludeIds = quoteId ? [quoteId] : []; - let rootToastHref = null; - if (quoteId) { - const scope = await openQuoteComposer(page, quoteId); - await fillComposer(page, text, scope); - rootToastHref = await clickPost(scope); - } else { - await clickProfilePost(page); - if (looksLoggedOut(page.url(), await page.content())) { - fail("logged out on compose"); - } - if (isStatusPermalink(page.url())) { - fail( - "compose landed on a status permalink; refusing to type into that reply box" - ); - } - const dialog = page - .locator('[role="dialog"]') - .filter({ has: page.locator('[data-testid="tweetTextarea_0"]') }) - .first(); - const scope = (await dialog.isVisible().catch(() => false)) - ? dialog - : page; - await fillComposer(page, text, scope); - rootToastHref = await clickPost(scope); - } - - const found = await resolveAfterPost( - page, + const found = await postRootTweet( + rootPage, + text, + quoteId, excludeIds, - beforeId, - rootToastHref + beforeId ); - if (!found) { - const cap = await captureOverlay(page, "resolve-miss"); - fail( - `posted but could not resolve status (toast=${rootToastHref || "none"}; no newer own than ${beforeId}). screenshot=${relArtifact(cap.png)}` - ); - } - if (!statusIdNewer(found.id, beforeId)) { - fail(`resolve picked non-newer id ${found.id} (before=${beforeId})`); - } let replyFound = null; if (replyFile) { @@ -546,14 +583,18 @@ async function main() { if (!replyText) { fail("overflow reply file empty (root would ship without tags/URL)"); } - replyFound = await postReply(page, replyText, found, excludeIds); + replyPage = await context.newPage(); + replyFound = await postReply(replyPage, replyText, found, excludeIds); if (!replyFound) { - const cap = await captureOverlay(page, "reply-resolve-miss"); + const cap = await captureOverlay(replyPage, "reply-resolve-miss"); fail( `root ${found.id} live but overflow reply did not resolve. screenshot=${relArtifact(cap.png)}` ); } } + await closeShipTabs(rootPage, replyPage); + rootPage = null; + replyPage = null; ok( found.id, found.url, @@ -561,6 +602,7 @@ async function main() { replyFound ); } catch (e) { + await closeShipTabs(rootPage, replyPage).catch(() => {}); fail(e && e.message ? e.message : String(e)); } // Do not call Playwright browser teardown here: on CDP that kills Brave while diff --git a/scripts/post-twitter.sh b/scripts/post-twitter.sh index 19e68ba..ad154aa 100755 --- a/scripts/post-twitter.sh +++ b/scripts/post-twitter.sh @@ -105,9 +105,9 @@ RUN_ROOT="${ITCY_TWITTER_RUN_DIR:-${ROOT}/pw/profile-x-run}" . "${ROOT}/scripts/lib/twitter-brave-lock.sh" twitter_brave_acquire_lock "${RUN_ROOT}" || exit 1 WORK="${RUN_ROOT}/run-$$" -# Prefer explicit env; otherwise pick a free port (never attach to a stale :9224). -CDP_PORT="$(twitter_brave_pick_cdp_port "${ITCY_TWITTER_CDP_PORT:-}")" BRAVE_PID="" +CDP_PORT="" +BRAVE_LOG="/tmp/itcy-twitter-brave-$$.log" cleanup() { if [[ -n "${BRAVE_PID}" ]] && kill -0 "${BRAVE_PID}" 2>/dev/null; then @@ -163,9 +163,8 @@ fi # Headed by default (X is hostile to headless). Override with ITCY_TWITTER_HEADLESS=1. HEADLESS="${ITCY_TWITTER_HEADLESS:-0}" -BRAVE_ARGS=( +BRAVE_ARGS_BASE=( --user-data-dir="${WORK}" - --remote-debugging-port="${CDP_PORT}" --no-first-run --no-default-browser-check --disable-session-crashed-bubble @@ -173,26 +172,53 @@ BRAVE_ARGS=( --no-startup-window ) if [[ "${HEADLESS}" == "1" ]]; then - BRAVE_ARGS+=(--headless=new) + BRAVE_ARGS_BASE+=(--headless=new) fi -nohup "${BROWSER_BIN}" "${BRAVE_ARGS[@]}" \ - >/tmp/itcy-twitter-brave-$$.log 2>&1 & -BRAVE_PID=$! +PREFERRED_PORT="${ITCY_TWITTER_CDP_PORT:-}" +PORT_CANDIDATES=() +if [[ -n "${PREFERRED_PORT}" ]]; then + PORT_CANDIDATES+=("${PREFERRED_PORT}") +fi +for p in $(seq 9230 9299); do + PORT_CANDIDATES+=("${p}") +done ready=0 -for _ in $(seq 1 50); do - if ! kill -0 "${BRAVE_PID}" 2>/dev/null; then - break +for try_port in "${PORT_CANDIDATES[@]}"; do + if ! twitter_brave_port_free "${try_port}"; then + continue fi - if curl -fsS "http://127.0.0.1:${CDP_PORT}/json/version" >/dev/null 2>&1; then - ready=1 + : >"${BRAVE_LOG}" + nohup "${BROWSER_BIN}" \ + "${BRAVE_ARGS_BASE[@]}" \ + --remote-debugging-port="${try_port}" \ + >"${BRAVE_LOG}" 2>&1 & + BRAVE_PID=$! + for _ in $(seq 1 50); do + if ! kill -0 "${BRAVE_PID}" 2>/dev/null; then + break + fi + if twitter_brave_cdp_ready "${try_port}"; then + ready=1 + CDP_PORT="${try_port}" + break + fi + sleep 0.2 + done + if [[ "${ready}" == "1" ]]; then break fi - sleep 0.2 + if [[ -n "${BRAVE_PID}" ]] && kill -0 "${BRAVE_PID}" 2>/dev/null; then + kill -TERM "${BRAVE_PID}" 2>/dev/null || true + fi + BRAVE_PID="" + pkill -TERM -f "user-data-dir=${WORK}" 2>/dev/null || true + sleep 0.3 done + if [[ "${ready}" != "1" ]]; then - echo "Brave CDP not ready on :${CDP_PORT} (see /tmp/itcy-twitter-brave-$$.log)" >&2 + echo "Brave CDP not ready on any port 9230-9299 (see ${BRAVE_LOG})" >&2 exit 1 fi diff --git a/scripts/x-ship-resolve.mjs b/scripts/x-ship-resolve.mjs index de600fc..69ce362 100644 --- a/scripts/x-ship-resolve.mjs +++ b/scripts/x-ship-resolve.mjs @@ -40,11 +40,11 @@ export function detectPostRejectReason(pageText) { const body = String(pageText || "").replace(/\s+/g, " ").trim(); if (!body) return null; const patterns = [ - /It looks like you already said that![^.]*\./i, - /Wait a little while before you post again[^.]*\./i, - /Something went wrong\.?\s*Try again[^.]*\./i, - /You are over the character limit[^.]*\./i, - /Whoops!\s*You already said that[^.]*\./i, + /It looks like you already said that/i, + /Wait a little while before you post again/i, + /Something went wrong\.?\s*Try again/i, + /You are over the character limit/i, + /Whoops!\s*You already said that/i, ]; for (const re of patterns) { const m = body.match(re); diff --git a/scripts/x-ship-resolve.test.mjs b/scripts/x-ship-resolve.test.mjs index 2e70f5b..95b159b 100644 --- a/scripts/x-ship-resolve.test.mjs +++ b/scripts/x-ship-resolve.test.mjs @@ -34,10 +34,10 @@ test("XPOST-094 clickPost must not re-submit root after Control+Enter", () => { const body = m[0]; assert.match(body, /Control\+Enter/); assert.equal((body.match(/btn\.click/g) || []).length, 0); - assert.match(src, /postReply\(page,\s*replyText,\s*found/); + assert.match(src, /postReply\(replyPage,\s*replyText,\s*found/); }); -test("XPOST-095 first pass: one Brave session posts root then reply (no CDP close)", () => { +test("XPOST-095 first pass: one Brave session, root tab then reply tab (no CDP close)", () => { const src = fs.readFileSync( fileURLToPath(new URL("./post-twitter.mjs", import.meta.url)), "utf8" @@ -46,10 +46,23 @@ test("XPOST-095 first pass: one Brave session posts root then reply (no CDP clos assert.equal((src.match(/browser\.close\(/g) || []).length, 0); assert.match(src, /process\.exit\(0\)/); assert.match(src, /overflow reply file empty/); - assert.equal(/findTimelineRoot|timelineLooksLikeRoot|ALREADY_SAID/.test(src), false); - const rootThenReply = src.indexOf("rootToastHref = await clickPost"); - const replyCall = src.indexOf("postReply(page, replyText, found"); - assert.ok(rootThenReply > 0 && replyCall > rootThenReply, "root Post then reply"); + assert.equal(/findOwnPostMatchingText|findTimelineRoot|timelineLooksLikeRoot|ALREADY_SAID/.test(src), false); + assert.match(src, /prepareRootTab/); + assert.match(src, /replyPage = await context\.newPage\(\)/); + const rootThenReply = + src.indexOf("await postRootTweet(") >= 0 || + src.indexOf("rootToastHref = await clickPost") >= 0; + const replyCall = src.indexOf("postReply(replyPage, replyText, found"); + assert.ok(rootThenReply && replyCall > 0, "root Post then reply on separate tab"); +}); + +test("brave lock clears stale file only when flock is free", () => { + const src = fs.readFileSync( + fileURLToPath(new URL("./lib/twitter-brave-lock.sh", import.meta.url)), + "utf8" + ); + assert.match(src, /twitter_brave_lock_unheld/); + assert.match(src, /exec 9<>"\$\{lock\}"/); }); test("ship scripts take brave.lock (no concurrent CDP steal)", () => { @@ -63,7 +76,11 @@ test("ship scripts take brave.lock (no concurrent CDP steal)", () => { "utf8" ); assert.match(src, /twitter_brave_acquire_lock/, name); - assert.match(src, /twitter_brave_pick_cdp_port/, name); + assert.match( + src, + /twitter_brave_port_free|twitter_brave_cdp_ready|twitter_brave_pick_cdp_port/, + name + ); assert.equal( /CDP_PORT="\$\{ITCY_TWITTER_CDP_PORT:-9224\}"/.test(src), false,