From e96f42c06dffd4f336a97c560f68dd8130119ccb Mon Sep 17 00:00:00 2001 From: Dan Anderson Date: Sat, 17 Oct 2026 11:10:28 -0400 Subject: [PATCH 1/2] Style Burned row with Destroyed badge --- .../app/[lang]/[asset]/_components/activity-tabs.tsx | 11 ++++++++--- apps/web/src/locales/_source.json | 3 +++ apps/web/src/locales/es.json | 3 ++- apps/web/src/locales/fr.json | 3 ++- apps/web/src/locales/ja.json | 3 ++- apps/web/src/locales/ko.json | 3 ++- apps/web/src/locales/pt.json | 3 ++- apps/web/src/locales/ru.json | 3 ++- apps/web/src/locales/uk.json | 3 ++- apps/web/src/locales/zh-hk.json | 3 ++- apps/web/src/locales/zh-tw.json | 3 ++- apps/web/src/locales/zh.json | 3 ++- 12 files changed, 31 insertions(+), 13 deletions(-) diff --git a/apps/web/src/app/[lang]/[asset]/_components/activity-tabs.tsx b/apps/web/src/app/[lang]/[asset]/_components/activity-tabs.tsx index 8a0f182..e5f1641 100644 --- a/apps/web/src/app/[lang]/[asset]/_components/activity-tabs.tsx +++ b/apps/web/src/app/[lang]/[asset]/_components/activity-tabs.tsx @@ -864,9 +864,14 @@ export function ActivityTabs({ {from + i + 1} {isBurned ? ( - - 🔥 {t("Burned")} - + <> + + 🔥 {t("Burned")} + + + {t("Destroyed")} + + ) : isPool ? ( <> {/* The chip follows the LP, not the pool. Liquidity diff --git a/apps/web/src/locales/_source.json b/apps/web/src/locales/_source.json index 2f57d72..6d8fdb2 100644 --- a/apps/web/src/locales/_source.json +++ b/apps/web/src/locales/_source.json @@ -1539,6 +1539,9 @@ "Destination": [ "app/[lang]/activity/_components/activity-view.tsx" ], + "Destroyed": [ + "app/[lang]/[asset]/_components/activity-tabs.tsx" + ], "dev": [ "app/[lang]/[asset]/_components/address-badges.tsx" ], diff --git a/apps/web/src/locales/es.json b/apps/web/src/locales/es.json index 514ebf1..a3d00b4 100644 --- a/apps/web/src/locales/es.json +++ b/apps/web/src/locales/es.json @@ -1297,5 +1297,6 @@ "The inscription commit for {asset} never made it on-chain, and a coin it spends has since been spent by another confirmed transaction, so it never will. None of its BTC is waiting on a reveal, and you can start a new launch.": "El commit de la inscripción de {asset} nunca llegó a la cadena, y una de las monedas que gasta ya la gastó otra transacción confirmada, así que nunca llegará. Nada de su BTC espera un reveal, y puedes empezar un lanzamiento nuevo.", "No expiry": "Sin vencimiento", "Burned": "Quemado", - "Includes tokens sent to the burn address and explicitly destroyed tokens.": "Incluye los tokens enviados a la dirección de quema y los tokens destruidos explícitamente." + "Includes tokens sent to the burn address and explicitly destroyed tokens.": "Incluye los tokens enviados a la dirección de quema y los tokens destruidos explícitamente.", + "Destroyed": "Destruidos" } diff --git a/apps/web/src/locales/fr.json b/apps/web/src/locales/fr.json index a7c76d7..39b6e95 100644 --- a/apps/web/src/locales/fr.json +++ b/apps/web/src/locales/fr.json @@ -1297,5 +1297,6 @@ "The inscription commit for {asset} never made it on-chain, and a coin it spends has since been spent by another confirmed transaction, so it never will. None of its BTC is waiting on a reveal, and you can start a new launch.": "Le commit de l'inscription de {asset} n'est jamais arrivé on-chain, et une des pièces qu'il dépense a depuis été dépensée par une autre transaction confirmée : il n'y arrivera donc jamais. Aucun de ses BTC n'attend de reveal, et vous pouvez commencer un nouveau lancement.", "No expiry": "Sans expiration", "Burned": "Brûlés", - "Includes tokens sent to the burn address and explicitly destroyed tokens.": "Comprend les tokens envoyés à l’adresse de destruction et les tokens explicitement détruits." + "Includes tokens sent to the burn address and explicitly destroyed tokens.": "Comprend les tokens envoyés à l’adresse de destruction et les tokens explicitement détruits.", + "Destroyed": "Détruits" } diff --git a/apps/web/src/locales/ja.json b/apps/web/src/locales/ja.json index 8e20833..6477d9d 100644 --- a/apps/web/src/locales/ja.json +++ b/apps/web/src/locales/ja.json @@ -1297,5 +1297,6 @@ "The inscription commit for {asset} never made it on-chain, and a coin it spends has since been spent by another confirmed transaction, so it never will. None of its BTC is waiting on a reveal, and you can start a new launch.": "{asset} の刻印のコミットはオンチェーンに届かず、それが使うコインの一つはその後、別の承認済みトランザクションで使われたため、今後も届くことはありません。リビールを待っている BTC はなく、新しいローンチを始められます。", "No expiry": "有効期限なし", "Burned": "バーン済み", - "Includes tokens sent to the burn address and explicitly destroyed tokens.": "バーンアドレスに送信されたトークンと、明示的に破棄されたトークンを含みます。" + "Includes tokens sent to the burn address and explicitly destroyed tokens.": "バーンアドレスに送信されたトークンと、明示的に破棄されたトークンを含みます。", + "Destroyed": "破棄済み" } diff --git a/apps/web/src/locales/ko.json b/apps/web/src/locales/ko.json index 1aea158..5374039 100644 --- a/apps/web/src/locales/ko.json +++ b/apps/web/src/locales/ko.json @@ -1297,5 +1297,6 @@ "The inscription commit for {asset} never made it on-chain, and a coin it spends has since been spent by another confirmed transaction, so it never will. None of its BTC is waiting on a reveal, and you can start a new launch.": "{asset} 인스크립션의 커밋은 온체인에 올라가지 못했고, 커밋이 사용하는 코인 하나가 이후 다른 확정된 트랜잭션에서 사용되어 앞으로도 올라갈 수 없습니다. 리빌을 기다리는 BTC는 없으며, 새 런칭을 시작할 수 있습니다.", "No expiry": "만료 없음", "Burned": "소각됨", - "Includes tokens sent to the burn address and explicitly destroyed tokens.": "소각 주소로 전송된 토큰과 명시적으로 파기된 토큰을 포함합니다." + "Includes tokens sent to the burn address and explicitly destroyed tokens.": "소각 주소로 전송된 토큰과 명시적으로 파기된 토큰을 포함합니다.", + "Destroyed": "파기됨" } diff --git a/apps/web/src/locales/pt.json b/apps/web/src/locales/pt.json index b35b240..e414123 100644 --- a/apps/web/src/locales/pt.json +++ b/apps/web/src/locales/pt.json @@ -1297,5 +1297,6 @@ "The inscription commit for {asset} never made it on-chain, and a coin it spends has since been spent by another confirmed transaction, so it never will. None of its BTC is waiting on a reveal, and you can start a new launch.": "O commit da inscrição de {asset} nunca chegou à chain, e uma das moedas que ele gasta já foi gasta por outra transação confirmada, então nunca vai chegar. Nenhum BTC dele espera um reveal, e você pode começar um novo lançamento.", "No expiry": "Sem expiração", "Burned": "Queimados", - "Includes tokens sent to the burn address and explicitly destroyed tokens.": "Inclui tokens enviados ao endereço de queima e tokens explicitamente destruídos." + "Includes tokens sent to the burn address and explicitly destroyed tokens.": "Inclui tokens enviados ao endereço de queima e tokens explicitamente destruídos.", + "Destroyed": "Destruídos" } diff --git a/apps/web/src/locales/ru.json b/apps/web/src/locales/ru.json index 44ad49a..3f56063 100644 --- a/apps/web/src/locales/ru.json +++ b/apps/web/src/locales/ru.json @@ -1297,5 +1297,6 @@ "The inscription commit for {asset} never made it on-chain, and a coin it spends has since been spent by another confirmed transaction, so it never will. None of its BTC is waiting on a reveal, and you can start a new launch.": "Commit записи {asset} так и не попал в блокчейн, а одну из монет, которые он тратит, уже потратила другая подтверждённая транзакция, так что он туда никогда не попадёт. Никакие его BTC не ждут reveal, и можно начать новый запуск.", "No expiry": "Бессрочно", "Burned": "Сожжено", - "Includes tokens sent to the burn address and explicitly destroyed tokens.": "Включает токены, отправленные на адрес сжигания, и токены, уничтоженные явно." + "Includes tokens sent to the burn address and explicitly destroyed tokens.": "Включает токены, отправленные на адрес сжигания, и токены, уничтоженные явно.", + "Destroyed": "Уничтожено" } diff --git a/apps/web/src/locales/uk.json b/apps/web/src/locales/uk.json index 6c42b83..fb5577e 100644 --- a/apps/web/src/locales/uk.json +++ b/apps/web/src/locales/uk.json @@ -1297,5 +1297,6 @@ "The inscription commit for {asset} never made it on-chain, and a coin it spends has since been spent by another confirmed transaction, so it never will. None of its BTC is waiting on a reveal, and you can start a new launch.": "Commit інскрипції {asset} так і не потрапив у блокчейн, а одну з монет, які він витрачає, уже витратила інша підтверджена транзакція, тож він туди ніколи не потрапить. Жодні його BTC не чекають на reveal, і можна почати новий запуск.", "No expiry": "Безстроково", "Burned": "Спалено", - "Includes tokens sent to the burn address and explicitly destroyed tokens.": "Включає токени, надіслані на адресу спалювання, і токени, знищені явно." + "Includes tokens sent to the burn address and explicitly destroyed tokens.": "Включає токени, надіслані на адресу спалювання, і токени, знищені явно.", + "Destroyed": "Знищено" } diff --git a/apps/web/src/locales/zh-hk.json b/apps/web/src/locales/zh-hk.json index 11f6660..2e6ef82 100644 --- a/apps/web/src/locales/zh-hk.json +++ b/apps/web/src/locales/zh-hk.json @@ -1297,5 +1297,6 @@ "The inscription commit for {asset} never made it on-chain, and a coin it spends has since been spent by another confirmed transaction, so it never will. None of its BTC is waiting on a reveal, and you can start a new launch.": "{asset} 銘刻的 commit 始終沒有上鏈,而它花費的一枚幣已被另一筆已確認的交易花掉,所以它永遠不會上鏈。它的 BTC 沒有在等待 reveal,你可以開始新的發射。", "No expiry": "永不過期", "Burned": "已銷毀", - "Includes tokens sent to the burn address and explicitly destroyed tokens.": "包括傳送到銷毀地址的代幣及透過銷毀交易銷毀的代幣。" + "Includes tokens sent to the burn address and explicitly destroyed tokens.": "包括傳送到銷毀地址的代幣及透過銷毀交易銷毀的代幣。", + "Destroyed": "已銷毀" } diff --git a/apps/web/src/locales/zh-tw.json b/apps/web/src/locales/zh-tw.json index 9d57c38..188c616 100644 --- a/apps/web/src/locales/zh-tw.json +++ b/apps/web/src/locales/zh-tw.json @@ -1297,5 +1297,6 @@ "The inscription commit for {asset} never made it on-chain, and a coin it spends has since been spent by another confirmed transaction, so it never will. None of its BTC is waiting on a reveal, and you can start a new launch.": "{asset} 銘刻的 commit 始終沒有上鏈,而它花費的一枚幣已被另一筆已確認的交易花掉,所以它永遠不會上鏈。它的 BTC 沒有在等待 reveal,你可以開始新的發射。", "No expiry": "永不過期", "Burned": "已銷毀", - "Includes tokens sent to the burn address and explicitly destroyed tokens.": "包括傳送到銷毀地址的代幣和透過銷毀交易銷毀的代幣。" + "Includes tokens sent to the burn address and explicitly destroyed tokens.": "包括傳送到銷毀地址的代幣和透過銷毀交易銷毀的代幣。", + "Destroyed": "已銷毀" } diff --git a/apps/web/src/locales/zh.json b/apps/web/src/locales/zh.json index c3a7c43..8c16fc9 100644 --- a/apps/web/src/locales/zh.json +++ b/apps/web/src/locales/zh.json @@ -1297,5 +1297,6 @@ "The inscription commit for {asset} never made it on-chain, and a coin it spends has since been spent by another confirmed transaction, so it never will. None of its BTC is waiting on a reveal, and you can start a new launch.": "{asset} 铭刻的 commit 始终没有上链,而它花费的一枚币已被另一笔已确认的交易花掉,所以它永远不会上链。它的 BTC 没有在等待 reveal,你可以开始新的发射。", "No expiry": "永不过期", "Burned": "已销毁", - "Includes tokens sent to the burn address and explicitly destroyed tokens.": "包括发送到销毁地址的代币和通过销毁交易销毁的代币。" + "Includes tokens sent to the burn address and explicitly destroyed tokens.": "包括发送到销毁地址的代币和通过销毁交易销毁的代币。", + "Destroyed": "已销毁" } From fde2cad436a2481187ee35f5871f351fd2b9bc6e Mon Sep 17 00:00:00 2001 From: Dan Anderson Date: Sat, 17 Oct 2026 16:56:38 -0400 Subject: [PATCH 2/2] Recover expired Counterparty read slots after cancelled invocations --- apps/api/src/integrations/cooldown.ts | 38 +++++++++--- apps/api/src/integrations/counterparty.ts | 19 +++++- tests/api-counterparty-cooldown.test.ts | 34 +++++++++++ tests/api-counterparty-lifecycle.test.ts | 72 +++++++++++++++++++++++ 4 files changed, 151 insertions(+), 12 deletions(-) create mode 100644 tests/api-counterparty-lifecycle.test.ts diff --git a/apps/api/src/integrations/cooldown.ts b/apps/api/src/integrations/cooldown.ts index 35e0fd3..f41590b 100644 --- a/apps/api/src/integrations/cooldown.ts +++ b/apps/api/src/integrations/cooldown.ts @@ -3,34 +3,54 @@ * it does not claim to coordinate every Worker in the account. */ let notBefore = 0; const DEFAULT_COOLDOWN_MS = 30_000; +export const COUNTERPARTY_READ_TIMEOUT_MS = 15_000; const READ_LIMIT = 2; -let active = 0; +const active = new Map<() => void, number>(); const queued: Array<{ admit: () => void; reject: (error: unknown) => void }> = []; function drain(): void { + // Workers can cancel an invocation without running its timers or finally + // blocks. A later invocation must not wait forever on its abandoned slots. + // Reap by wall time as well as listening for aborts in a live invocation. + const now = Date.now(); + let expired = 0; + for (const [cleanup, deadline] of active) { + if (deadline <= now) { + active.delete(cleanup); + cleanup(); + expired++; + } + } + if (expired > 0) console.warn({ event: "counterparty_read_slots_expired", count: expired }); if (Date.now() < notBefore) { for (const waiting of queued.splice(0)) waiting.reject(new CounterpartyReadDeferred(notBefore)); return; } - while (active < READ_LIMIT && queued.length > 0) queued.shift()!.admit(); -} - -function release(): void { - active--; - drain(); + while (active.size < READ_LIMIT && queued.length > 0) queued.shift()!.admit(); } /** The existing read timeout includes admission, so a queue cannot add a * second timeout window to work running under the indexer's lease. Only * permits are shared; responses and fetch promises belong to their caller. */ -export function acquireCounterpartyRead(signal: AbortSignal): Promise<() => void> { +export function acquireCounterpartyRead(signal: AbortSignal, deadline: number): Promise<() => void> { assertCounterpartyReadAllowed(); signal.throwIfAborted(); return new Promise((resolve, reject) => { const waiting = { admit() { signal.removeEventListener("abort", abort); - active++; + if (signal.aborted || deadline <= Date.now()) { + reject(signal.reason ?? new DOMException("Counterparty read deadline expired", "TimeoutError")); + return; + } + const cleanup = () => signal.removeEventListener("abort", release); + const release = () => { + cleanup(); + // A late finally must not release a replacement read's slot. + if (active.delete(cleanup)) drain(); + }; + active.set(cleanup, deadline); + signal.addEventListener("abort", release, { once: true }); resolve(release); }, reject(error: unknown) { diff --git a/apps/api/src/integrations/counterparty.ts b/apps/api/src/integrations/counterparty.ts index 316e176..fe858ec 100644 --- a/apps/api/src/integrations/counterparty.ts +++ b/apps/api/src/integrations/counterparty.ts @@ -10,6 +10,7 @@ import type { CounterpartyEvent } from "@launchpad/xcp69/trades"; import { acquireCounterpartyRead, assertCounterpartyReadAllowed, + COUNTERPARTY_READ_TIMEOUT_MS, CounterpartyReadDeferred, recordCounterpartyCooldown, } from "#api/integrations/cooldown"; @@ -45,14 +46,20 @@ export function counterpartyApiBase(): string { } async function get(path: string): Promise { - const signal = AbortSignal.timeout(15_000); - const release = await acquireCounterpartyRead(signal); + const startedAt = Date.now(); + const deadline = startedAt + COUNTERPARTY_READ_TIMEOUT_MS; + const signal = AbortSignal.timeout(COUNTERPARTY_READ_TIMEOUT_MS); + let release: (() => void) | undefined; + let stage = "admission"; try { + release = await acquireCounterpartyRead(signal, deadline); // A concurrent response may establish a cooldown after this read joined // the queue. Check again immediately before starting its own request. assertCounterpartyReadAllowed(); signal.throwIfAborted(); + stage = "fetch"; const res = await fetch(freshCounterpartyUrl(`${base}${path}`), { signal }); + stage = "body"; if (!res.ok) { if (res.status === 429) recordCounterpartyCooldown(res.headers.get("retry-after")); // A Worker holds six outbound connections, and a Response whose body is @@ -68,8 +75,14 @@ async function get(path: string): Promise { // hard cap sits above. Parse losslessly so unsafe magnitudes survive as // strings instead of silently drifting, the same way the web app does. return parseJsonLossless(await res.text()); + } catch (error) { + if (signal.aborted) console.warn({ + event: "counterparty_read_timeout", path: path.split("?")[0], stage, + duration_ms: Date.now() - startedAt, + }); + throw error; } finally { - release(); + release?.(); } } diff --git a/tests/api-counterparty-cooldown.test.ts b/tests/api-counterparty-cooldown.test.ts index ea5662c..892371e 100644 --- a/tests/api-counterparty-cooldown.test.ts +++ b/tests/api-counterparty-cooldown.test.ts @@ -165,4 +165,38 @@ describe("the API refuses reads during Counterparty's cooldown without waiting", expect(timeout.mock.calls).toEqual([[15_000], [15_000], [15_000]]); timeout.mockRestore(); }); + + it("reclaims abandoned slots without letting late cleanup release their replacements", async () => { + const { acquireCounterpartyRead } = await import("#api/integrations/cooldown"); + const abandoned = await Promise.all(Array.from({ length: 2 }, () => + acquireCounterpartyRead(new AbortController().signal, NOW + 1_000))); + // An invocation ended: neither its abort timer nor its finally will run. + vi.setSystemTime(NOW + 1_001); + const replacements = await Promise.all(Array.from({ length: 2 }, () => + acquireCounterpartyRead(new AbortController().signal, NOW + 2_000))); + let admitted = false; + const next = acquireCounterpartyRead(new AbortController().signal, NOW + 2_000) + .then(release => { admitted = true; return release; }); + abandoned.forEach(release => release()); + await Promise.resolve(); + expect(admitted).toBe(false); + replacements[0]!(); + const release = await next; + expect(admitted).toBe(true); + release(); + replacements[1]!(); + }); + + it("releases an admitted read on abort even if its body never settles", async () => { + const { acquireCounterpartyRead } = await import("#api/integrations/cooldown"); + const controller = new AbortController(); + const first = await acquireCounterpartyRead(controller.signal, NOW + 1_000); + const second = await acquireCounterpartyRead(new AbortController().signal, NOW + 1_000); + const next = acquireCounterpartyRead(new AbortController().signal, NOW + 1_000); + controller.abort(); + const release = await next; + first(); + second(); + release(); + }); }); diff --git a/tests/api-counterparty-lifecycle.test.ts b/tests/api-counterparty-lifecycle.test.ts new file mode 100644 index 0000000..9221292 --- /dev/null +++ b/tests/api-counterparty-lifecycle.test.ts @@ -0,0 +1,72 @@ +import { buildSync } from "esbuild"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const require = createRequire(import.meta.url); +const { Miniflare, convertV4MiniflareOptions } = require("miniflare"); + +// Exercise the real client across separate workerd invocations. Node promises +// alone do not reproduce Workers cancelling timers and unfinished work when +// a request ends, while keeping module-level semaphore state for later runs. +const bundled = buildSync({ + stdin: { + resolveDir: fileURLToPath(new URL("..", import.meta.url)), + contents: ` + import { fetchNodeStatus } from './apps/api/src/integrations/counterparty.ts'; + const realNow = Date.now; + let offset = 0; + Date.now = () => realNow() + offset; + const realTimeout = AbortSignal.timeout; + AbortSignal.timeout = () => realTimeout(100); + let hanging = false; + let started = 0; + let completed = 0; + globalThis.fetch = async () => { + started++; + if (hanging) await new Promise(resolve => setTimeout(resolve, 60_000)); + return Response.json({ result: { counterparty_height: 123, version: '11.5.0' } }); + }; + export default { + async fetch(req) { + if (new URL(req.url).pathname === '/abandon') { + hanging = true; + for (let i = 0; i < 2; i++) { + fetchNodeStatus().finally(() => completed++).catch(() => {}); + } + await new Promise(resolve => setTimeout(resolve, 10)); + return Response.json({ started, completed }); + } + hanging = false; + // Advance past the read deadline without waiting fifteen seconds. + offset = 16_000; + try { + return Response.json({ node: await fetchNodeStatus(), started, completed }); + } catch (error) { + return Response.json({ error: error.name, started, completed }, { status: 503 }); + } + } + }; + `, + }, + bundle: true, write: false, format: "esm", platform: "node", target: "es2022", +}); +const options = { + modules: true, script: bundled.outputFiles[0].text, + compatibilityDate: "2025-01-01", compatibilityFlags: ["nodejs_compat"], +}; +const mf = new Miniflare(convertV4MiniflareOptions ? convertV4MiniflareOptions(options) : options); +beforeAll(async () => { await mf.ready; }); +afterAll(async () => { await mf.dispose(); }); + +describe("Counterparty reads after a cancelled Worker invocation", () => { + it("can reach the node after an earlier invocation abandons both read slots", async () => { + const abandoned = await mf.dispatchFetch("https://test/abandon"); + expect(await abandoned.json()).toEqual({ started: 2, completed: 0 }); + const recovered = await mf.dispatchFetch("https://test/read"); + expect(recovered.status).toBe(200); + expect(await recovered.json()).toEqual({ + node: { height: 123, version: "11.5.0" }, started: 3, completed: 0, + }); + }); +});