Skip to content

Commit 1089e12

Browse files
committed
fix(runtime): bound the shutdown drain, serialize boot migrations, and make capture failures visible (#9485, #9486, #9487)
stop() waited on in-flight work with NO deadline, so a redeploy blocked on a multi-minute AI review until the orchestrator SIGKILLed the process -- and because the killed job's lease had been heartbeated right up to the kill, reclaimExpiredProcessingJobs (which only recovers rows older than 30 minutes) left it stalled for another 20-30. Against a 1-5 minute latency target with automated redeploys, that was the largest single source of a silently stalled review. The wait is now bounded, and on expiry this process re-pends its OWN in-flight rows -- safe because activeJobIds is process-local -- turning a SIGKILL race into an immediate retry. Both backends. Boot migrations took no cross-instance lock. Two instances booting together meant one applied a migration atomically while the other's identical transaction failed "already exists", which runSelfHostMigrations treats as DRIFT and retries statement-by-statement -- re-executing any table-rebuild INSERT ... SELECT or UPDATE backfill against the already-migrated schema, then dying on the ledger INSERT's unique violation (a message matching neither tolerated shape) and crash-looping. #9027 made a single migration atomic against a crash; this makes the whole run atomic against a concurrent boot, via a Postgres session advisory lock. A session lock is held by its connection, so it cannot be taken through the pooled adapter -- hence the adapter->pool registry rather than a new statement. Visual capture failures were logged but never counted, so a browserless outage was invisible in Prometheus while it degraded every screenshot to a dash cell -- and since the screenshot gate treats absent evidence as a close signal, that outage could close legitimate visual PRs before anyone noticed.
1 parent d1c770c commit 1089e12

9 files changed

Lines changed: 260 additions & 6 deletions

File tree

src/review/visual/shot.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
// account API token. Returns null on any failure so callers degrade gracefully (the cell becomes a dash).
1919
import puppeteer from "@cloudflare/puppeteer";
2020
import { hostIsPrivateOrLocal, isSafeHttpUrl } from "../content-lane/safe-url";
21+
import { incr } from "../../selfhost/metrics";
2122

2223
export type Viewport = { width: number; height: number };
2324
/** A `prefers-color-scheme` value the renderer can emulate before capture (#3678). */
@@ -362,10 +363,15 @@ export async function captureShot(env: Env, url: string, viewport: Viewport = VI
362363
// normal review pages without letting attacker-controlled document height or PNG size drive unbounded
363364
// Chromium raster work on the public screenshot route.
364365
const shot = await captureBoundedFullPageShot(page, viewport);
366+
incr("loopover_visual_capture_total", { result: "ok" });
365367
return { png: shot, authWalled: false };
366368
} catch (error) {
367369
// Log before degrading to null — otherwise a networkidle0 timeout, a binding quota error, or a render
368370
// crash is indistinguishable from "no page" and the cell silently blanks.
371+
// #9487: also COUNTED. The log alone is invisible to Prometheus, so a browserless outage silently degraded
372+
// every screenshot to a dash cell with nothing to alert on -- and, because the screenshot-table gate treats
373+
// absent evidence as a close signal, that outage could close legitimate visual PRs before anyone noticed.
374+
incr("loopover_visual_capture_total", { result: "error" });
369375
console.log(JSON.stringify({ event: "render_screenshot_error", mode: "binding", url, message: String(error).slice(0, 200) }));
370376
return { png: null, authWalled: false };
371377
} finally {

src/selfhost/metrics.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export const DEFAULT_METRIC_META: readonly (readonly [string, MetricMeta])[] = [
5858
["loopover_config_dir_empty_acknowledged", { help: "1 when LOOPOVER_REPO_CONFIG_DIR is unset, has entries, or is acknowledged; 0 when it's configured but the mounted directory is empty.", type: "gauge" }],
5959
["loopover_http_requests_total", { help: "HTTP app requests by response status class.", type: "counter" }],
6060
["loopover_http_request_duration_seconds", { help: "HTTP app request duration in seconds.", type: "histogram" }],
61+
["loopover_visual_capture_total", { help: "Visual capture attempts by result -- a browserless outage is otherwise invisible while it silently degrades screenshots to dash cells, and the screenshot gate treats absent evidence as a close signal (#9487).", type: "counter" }],
6162
["loopover_webhook_dedup_total", { help: "Webhook deliveries deduplicated before enqueue.", type: "counter" }],
6263
["loopover_webhook_enqueue_total", { help: "Webhook enqueue outcomes by event and action.", type: "counter" }],
6364
["loopover_jobs_enqueued_total", { help: "Durable queue jobs enqueued.", type: "counter" }],

src/selfhost/pg-adapter.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,52 @@ class PgStatement implements SelfHostD1PreparedStatement {
6262
}
6363
}
6464

65+
/**
66+
* #9486: adapter -> pool registry, so a caller that needs a DEDICATED connection (not a pooled statement) can
67+
* find it. A Postgres session advisory lock is held by the connection that took it, so it cannot be acquired
68+
* through `prepare()`/`batch()` -- those hand back a pooled client per call. Kept as a WeakMap rather than a
69+
* property on the adapter so the D1Database surface stays exactly the shape every other backend implements.
70+
*/
71+
const adapterPools = new WeakMap<D1Database, Pool>();
72+
73+
/** The pool backing `db`, when it is a Postgres adapter; undefined for SQLite/D1. */
74+
export function pgPoolForAdapter(db: D1Database): Pool | undefined {
75+
return adapterPools.get(db);
76+
}
77+
78+
/**
79+
* #9486: run `fn` while holding a Postgres session advisory lock, so two instances booting concurrently
80+
* cannot apply migrations at the same time.
81+
*
82+
* Without it, instance A applies a migration atomically while instance B's identical transaction fails
83+
* "already exists" -- which `runSelfHostMigrations` treats as DRIFT and retries statement-by-statement, so a
84+
* table-rebuild `INSERT ... SELECT` or an UPDATE backfill RE-EXECUTES against the already-migrated schema.
85+
* B then dies on the ledger INSERT's unique violation, whose message matches neither "duplicate column" nor
86+
* "already exists", and crash-loops a cycle. #9027 made a single migration atomic against a crash; this makes
87+
* the whole run atomic against a concurrent boot.
88+
*
89+
* Falls through to running `fn` directly on a non-Postgres backend (SQLite is single-process by construction).
90+
*/
91+
export async function withPgMigrationLock<T>(db: D1Database, fn: () => Promise<T>): Promise<T> {
92+
const pool = adapterPools.get(db);
93+
if (!pool) return fn();
94+
const client = await pool.connect();
95+
try {
96+
// A stable, arbitrary key derived from the purpose; any instance using this same constant serializes.
97+
await client.query("SELECT pg_advisory_lock($1)", [MIGRATION_ADVISORY_LOCK_KEY]);
98+
try {
99+
return await fn();
100+
} finally {
101+
await client.query("SELECT pg_advisory_unlock($1)", [MIGRATION_ADVISORY_LOCK_KEY]).catch(() => undefined);
102+
}
103+
} finally {
104+
client.release();
105+
}
106+
}
107+
108+
/** Arbitrary but stable: every instance must use the SAME key for the lock to serialize them. */
109+
export const MIGRATION_ADVISORY_LOCK_KEY = 8_140_9486;
110+
65111
export function createPgAdapter(pool: Pool): D1Database {
66112
const adapter: SelfHostD1Database = {
67113
prepare: (sql: string) => new PgStatement(pool, sql),
@@ -116,7 +162,9 @@ export function createPgAdapter(pool: Pool): D1Database {
116162
return new ArrayBuffer(0); // unused; present for D1 surface completeness
117163
},
118164
};
119-
return adapter as unknown as D1Database;
165+
const built = adapter as unknown as D1Database;
166+
adapterPools.set(built, pool);
167+
return built;
120168
}
121169

122170
// #2543: github_rate_limit_observations receives one INSERT per outbound GitHub API response and is pruned in

src/selfhost/pg-queue.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { capturePostHogError, withPostHogMonitor } from "./posthog";
1212
import {
1313
ATTEMPT_FREE_RETRY_DEADLINE_MS,
1414
consumingRetryDelayMs,
15+
shutdownDrainDeadlineMs,
1516
isAttemptFreeRetry,
1617
deterministicJitterMs,
1718
FOREGROUND_QUEUE_PRIORITY_FLOOR,
@@ -1822,7 +1823,39 @@ export function createPgQueue(
18221823
if (timer) clearTimeout(timer);
18231824
if (deadLetterReviveTimer) clearInterval(deadLetterReviveTimer);
18241825
if (foregroundLivenessTimer) clearInterval(foregroundLivenessTimer);
1825-
while (active > 0) await new Promise((r) => setTimeout(r, 10));
1826+
// #9485: the drain wait is BOUNDED. It used to be `while (active > 0)` with no deadline, so a redeploy
1827+
// (redeploy-companion recreates the container) waited on an in-flight multi-minute AI review until the
1828+
// orchestrator's grace period expired and SIGKILLed the process. The killed job's lease had been
1829+
// heartbeated right up to the kill, so reclaimExpiredProcessingJobs -- which only recovers rows older
1830+
// than processingTimeoutMs (30 min by default) -- did not pick it up for another 20-30 minutes. Against a
1831+
// 1-5 minute latency target with AUTOMATED redeploys, that is the single largest source of "a review
1832+
// silently stalled for half an hour".
1833+
//
1834+
// On expiry we re-pend THIS PROCESS'S OWN in-flight rows before returning. That is safe precisely because
1835+
// activeJobIds is process-local: these are rows we claimed and are about to abandon, so releasing them
1836+
// converts a SIGKILL race into an immediate retry by whoever comes up next, instead of a lease-expiry
1837+
// wait. Jobs that finish normally inside the deadline are unaffected.
1838+
const deadlineMs = shutdownDrainDeadlineMs();
1839+
const waitUntil = Date.now() + deadlineMs;
1840+
while (active > 0 && Date.now() < waitUntil) await new Promise((r) => setTimeout(r, 10));
1841+
if (active > 0) {
1842+
const abandoned = [...activeJobIds];
1843+
console.warn(
1844+
JSON.stringify({
1845+
level: "warn",
1846+
event: "selfhost_queue_shutdown_drain_deadline",
1847+
active,
1848+
abandoned: abandoned.length,
1849+
deadline_ms: deadlineMs,
1850+
message: "drain deadline reached; re-pending this process's in-flight jobs so they retry immediately instead of waiting out their lease",
1851+
}),
1852+
);
1853+
if (abandoned.length > 0) {
1854+
await pool
1855+
.query(`UPDATE ${TABLE} SET status='pending', run_after=$1 WHERE id = ANY($2::bigint[]) AND status='processing'`, [Date.now(), abandoned])
1856+
.catch(() => undefined); // best-effort: a failure just falls back to the pre-#9485 lease-expiry path
1857+
}
1858+
}
18261859
},
18271860
async drain() {
18281861
while (active > 0) await new Promise((r) => setTimeout(r, 5));

src/selfhost/queue-common.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const DEFAULT_STARTUP_JITTER_MS = 3 * 60_000;
2222
const DEFAULT_RECOVERY_JITTER_MS = 60_000;
2323
const DEFAULT_SCHEDULED_ENQUEUE_JITTER_MS = 5 * 60_000;
2424
const DEFAULT_STARTUP_JITTER_MIN_JOBS = 8;
25+
const DEFAULT_SHUTDOWN_DRAIN_DEADLINE_MS = 120_000;
2526
const DEFAULT_PROCESSING_TIMEOUT_MS = 30 * 60_000;
2627
const DEFAULT_BACKGROUND_CONCURRENCY = 4;
2728
// Dead-letter auto-retry (#audit-rate-headroom): a job that exhausted its normal retry budget and landed in
@@ -725,6 +726,22 @@ export function queueProcessingTimeoutMs(): number {
725726
);
726727
}
727728

729+
/**
730+
* #9485: how long {@link stop} waits for in-flight work before re-pending it and returning.
731+
*
732+
* The wait used to be unbounded, so a redeploy blocked on a multi-minute AI review until the orchestrator's
733+
* grace period expired and SIGKILLed the process -- and because the killed job's lease had been heartbeated
734+
* right up to the kill, `reclaimExpiredProcessingJobs` (which only recovers rows older than
735+
* QUEUE_PROCESSING_TIMEOUT_MS, 30 min by default) left it stalled for another 20-30 minutes.
736+
*
737+
* The default is deliberately generous: long enough that an ordinary review finishes and releases its own
738+
* lock and lease cleanly, short enough to be well inside a typical `stop_grace_period` (this deployment's is
739+
* 300s) so the re-pend happens on OUR terms rather than as a SIGKILL race.
740+
*/
741+
export function shutdownDrainDeadlineMs(): number {
742+
return envDurationMs("QUEUE_SHUTDOWN_DRAIN_DEADLINE_MS", DEFAULT_SHUTDOWN_DRAIN_DEADLINE_MS);
743+
}
744+
728745
export function queueStartupJitterMinJobs(): number {
729746
return parsePositiveIntEnv("QUEUE_STARTUP_JITTER_MIN_JOBS", { min: 0, fallback: DEFAULT_STARTUP_JITTER_MIN_JOBS });
730747
}

src/selfhost/sqlite-queue.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { capturePostHogError, withPostHogMonitor } from "./posthog";
1313
import {
1414
ATTEMPT_FREE_RETRY_DEADLINE_MS,
1515
consumingRetryDelayMs,
16+
shutdownDrainDeadlineMs,
1617
isAttemptFreeRetry,
1718
deterministicJitterMs,
1819
FOREGROUND_QUEUE_PRIORITY_FLOOR,
@@ -1432,7 +1433,33 @@ export function createSqliteQueue(
14321433
if (timer) clearTimeout(timer);
14331434
if (deadLetterReviveTimer) clearInterval(deadLetterReviveTimer);
14341435
if (foregroundLivenessTimer) clearInterval(foregroundLivenessTimer);
1435-
while (active > 0) await new Promise((r) => setTimeout(r, 10)); // let in-flight pumps finish
1436+
// #9485: bounded, matching the pg backend. An unbounded wait meant a redeploy blocked on a multi-minute
1437+
// AI review until SIGKILL, and the killed job then sat unclaimed until its lease expired. On expiry we
1438+
// re-pend THIS PROCESS'S OWN in-flight rows (activeJobIds is process-local, so these are rows we claimed
1439+
// and are abandoning) so they retry immediately instead of waiting out the lease.
1440+
const deadlineMs = shutdownDrainDeadlineMs();
1441+
const waitUntil = Date.now() + deadlineMs;
1442+
while (active > 0 && Date.now() < waitUntil) await new Promise((r) => setTimeout(r, 10));
1443+
if (active > 0) {
1444+
const abandoned = [...activeJobIds];
1445+
console.warn(
1446+
JSON.stringify({
1447+
level: "warn",
1448+
event: "selfhost_queue_shutdown_drain_deadline",
1449+
active,
1450+
abandoned: abandoned.length,
1451+
deadline_ms: deadlineMs,
1452+
message: "drain deadline reached; re-pending this process's in-flight jobs so they retry immediately instead of waiting out their lease",
1453+
}),
1454+
);
1455+
for (const id of abandoned) {
1456+
try {
1457+
driver.query(`UPDATE ${TABLE} SET status='pending', run_after=? WHERE id=? AND status='processing'`, [Date.now(), id]);
1458+
} catch {
1459+
// best-effort: a failure just falls back to the pre-#9485 lease-expiry path
1460+
}
1461+
}
1462+
}
14361463
},
14371464
async drain() {
14381465
// send() fire-and-forgets a pump; wait for any in-flight pumps to settle, then drain to completion.

src/server.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -551,9 +551,11 @@ async function main(): Promise<void> {
551551
}),
552552
);
553553

554-
const applied = await runSelfHostMigrations(
555-
backend.db,
556-
process.env.MIGRATIONS_DIR ?? "migrations",
554+
// #9486: serialize the whole migration run across instances. On Postgres this takes a session advisory
555+
// lock; on SQLite (single-process by construction) it runs straight through.
556+
const { withPgMigrationLock } = await import("./selfhost/pg-adapter");
557+
const applied = await withPgMigrationLock(backend.db, () =>
558+
runSelfHostMigrations(backend.db, process.env.MIGRATIONS_DIR ?? "migrations"),
557559
);
558560
console.log(
559561
JSON.stringify({ event: "selfhost_migrations_applied", count: applied }),

test/unit/selfhost-migrate.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { describe, expect, it } from "vitest";
77
import { createD1Adapter, nodeSqliteDriver } from "../../src/selfhost/d1-adapter";
88
import { runSelfHostMigrations } from "../../src/selfhost/migrate";
99
import { normalizePostgresValue } from "../../scripts/migrate-selfhost-sqlite-to-postgres";
10+
import type { Pool } from "pg";
11+
import { createPgAdapter, MIGRATION_ADVISORY_LOCK_KEY, withPgMigrationLock } from "../../src/selfhost/pg-adapter";
1012

1113
const sha256 = (sql: string) => createHash("sha256").update(sql, "utf8").digest("hex");
1214

@@ -169,3 +171,73 @@ describe("SQLite-to-Postgres migrator helpers", () => {
169171
expect(normalizePostgresValue(42)).toBe(42);
170172
});
171173
});
174+
175+
// #9486: two instances booting concurrently could both apply migrations. Instance A applies one atomically;
176+
// B's identical transaction fails "already exists", which runSelfHostMigrations treats as DRIFT and retries
177+
// statement-by-statement -- so a table-rebuild INSERT ... SELECT or an UPDATE backfill RE-EXECUTES against the
178+
// already-migrated schema. B then dies on the ledger INSERT's unique violation, whose message matches neither
179+
// "duplicate column" nor "already exists", and crash-loops a cycle. #9027 made a single migration atomic
180+
// against a crash; this makes the whole RUN atomic against a concurrent boot.
181+
describe("withPgMigrationLock (#9486)", () => {
182+
it("runs straight through on a non-Postgres backend (SQLite is single-process by construction)", async () => {
183+
// Any db that was not built by createPgAdapter has no registered pool, so there is no lock to take.
184+
const notPg = {} as unknown as D1Database;
185+
let ran = false;
186+
const result = await withPgMigrationLock(notPg, async () => {
187+
ran = true;
188+
return 42;
189+
});
190+
expect(ran).toBe(true);
191+
expect(result).toBe(42);
192+
});
193+
194+
it("takes and releases a session advisory lock on the Postgres adapter, around the work", async () => {
195+
const queries: string[] = [];
196+
const client = {
197+
query: async (sql: string) => {
198+
queries.push(sql);
199+
return { rows: [], rowCount: 0 };
200+
},
201+
release: () => undefined,
202+
};
203+
const pool = { connect: async () => client } as unknown as Pool;
204+
const db = createPgAdapter(pool);
205+
206+
const order: string[] = [];
207+
const out = await withPgMigrationLock(db, async () => {
208+
order.push("work");
209+
return "done";
210+
});
211+
212+
expect(out).toBe("done");
213+
// Locked BEFORE the work and unlocked after -- an unlock-before-work ordering would serialize nothing.
214+
expect(queries[0]).toContain("pg_advisory_lock");
215+
expect(queries.at(-1)).toContain("pg_advisory_unlock");
216+
expect(order).toEqual(["work"]);
217+
});
218+
219+
it("INVARIANT: releases the lock and the client even when the work THROWS", async () => {
220+
// A migration failure must not strand the advisory lock -- every later boot would block on it forever.
221+
const queries: string[] = [];
222+
let released = false;
223+
const client = {
224+
query: async (sql: string) => {
225+
queries.push(sql);
226+
return { rows: [], rowCount: 0 };
227+
},
228+
release: () => { released = true; },
229+
};
230+
const pool = { connect: async () => client } as unknown as Pool;
231+
const db = createPgAdapter(pool);
232+
233+
await expect(withPgMigrationLock(db, async () => { throw new Error("migration blew up"); })).rejects.toThrow("migration blew up");
234+
235+
expect(queries.some((q) => q.includes("pg_advisory_unlock"))).toBe(true);
236+
expect(released).toBe(true);
237+
});
238+
239+
it("uses one stable key, so separate instances actually serialize against each other", () => {
240+
expect(typeof MIGRATION_ADVISORY_LOCK_KEY).toBe("number");
241+
expect(Number.isInteger(MIGRATION_ADVISORY_LOCK_KEY)).toBe(true);
242+
});
243+
});

test/unit/selfhost-sqlite-queue.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4542,3 +4542,51 @@ describe("attempt-free deferral on lock contention (#9465)", () => {
45424542
expect((rows as Array<{ status: string }>)[0]?.status).toBe("dead"); // an operator can see it
45434543
});
45444544
});
4545+
4546+
// #9485: stop() used to wait `while (active > 0)` with NO deadline, so a redeploy blocked on an in-flight
4547+
// multi-minute AI review until the orchestrator's grace period expired and SIGKILLed the process. Because the
4548+
// killed job's lease had been heartbeated right up to the kill, reclaimExpiredProcessingJobs -- which only
4549+
// recovers rows older than QUEUE_PROCESSING_TIMEOUT_MS (30 min by default) -- left it stalled for another
4550+
// 20-30 minutes. Against a 1-5 minute latency target with AUTOMATED redeploys, that was the single largest
4551+
// source of "a review silently stalled for half an hour".
4552+
describe("bounded shutdown drain (#9485)", () => {
4553+
beforeEach(() => { vi.spyOn(process.stdout, "write").mockImplementation(() => true); });
4554+
afterEach(() => { vi.useRealTimers(); resetMetrics(); vi.restoreAllMocks(); delete process.env["QUEUE_SHUTDOWN_DRAIN_DEADLINE_MS"]; });
4555+
4556+
it("REGRESSION: stop() returns rather than hanging on a job that outlives the deadline", async () => {
4557+
process.env["QUEUE_SHUTDOWN_DRAIN_DEADLINE_MS"] = "50";
4558+
const driver = makeDriver();
4559+
let release!: () => void;
4560+
const blocked = new Promise<void>((resolve) => { release = resolve; });
4561+
const q = createSqliteQueue(driver, async () => { await blocked; });
4562+
await q.binding.send(msg("agent-regate-pr"));
4563+
void q.drain();
4564+
await new Promise((r) => setTimeout(r, 20)); // let the job be claimed
4565+
4566+
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
4567+
await q.stop(); // must not hang on the still-running job
4568+
4569+
const logged = warn.mock.calls.map((c) => String(c[0])).join("\n");
4570+
expect(logged).toContain("selfhost_queue_shutdown_drain_deadline");
4571+
// The abandoned row is re-pended, so the next process retries it immediately instead of waiting out the
4572+
// lease -- that recovery delay is the whole defect.
4573+
const { rows } = driver.query("SELECT status FROM _selfhost_jobs LIMIT 1", []);
4574+
expect((rows as Array<{ status: string }>)[0]?.status).toBe("pending");
4575+
release();
4576+
});
4577+
4578+
it("INVARIANT: a job that finishes inside the deadline drains normally and is NOT re-pended", async () => {
4579+
process.env["QUEUE_SHUTDOWN_DRAIN_DEADLINE_MS"] = "5000";
4580+
const driver = makeDriver();
4581+
const q = createSqliteQueue(driver, async () => undefined);
4582+
await q.binding.send(msg("agent-regate-pr"));
4583+
await q.drain();
4584+
4585+
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
4586+
await q.stop();
4587+
4588+
expect(warn.mock.calls.map((c) => String(c[0])).join("\n")).not.toContain("shutdown_drain_deadline");
4589+
const { rows } = driver.query("SELECT count(*) AS n FROM _selfhost_jobs", []);
4590+
expect((rows as Array<{ n: number }>)[0]?.n).toBe(0); // completed and deleted, not re-pended
4591+
});
4592+
});

0 commit comments

Comments
 (0)