fix: drain account shutdown before reopening persistence

This commit is contained in:
手瓜一十雪
2026-10-18 13:52:13 +08:00
parent 0f358b2ac2
commit 1001f55a06
7 changed files with 286 additions and 109 deletions
+5 -7
View File
@@ -36,7 +36,6 @@ export class Bridge implements BridgeInterface {
// Per-account dedup for QQ NT's already-deduped system pushes (#137).
private readonly sysMsgDedup_ = new SysMsgDedup();
private onlineClients_: readonly Readonly<OnlineDeviceInfo>[] | null = null;
private disposed_ = false;
private disposePromise_: Promise<void> | null = null;
private readonly pendingPackets_ = new Set<Promise<void>>();
private rosterWarmupStarted_ = false;
@@ -150,7 +149,6 @@ export class Bridge implements BridgeInterface {
return this.disposePromise_;
}
this.disposed_ = true;
this.settleRosterWarmup({ friendsLoaded: false, groupsLoaded: false });
this.connections.clear();
this.onlineClients_ = null;
@@ -250,7 +248,7 @@ export class Bridge implements BridgeInterface {
}
onPacket(pkt: PacketInfo): Promise<void> {
if (this.disposed_) {
if (this.disposePromise_) {
return Promise.reject(new Error('Bridge is disposed'));
}
@@ -367,7 +365,7 @@ export class Bridge implements BridgeInterface {
}
private maybeStartRosterWarmup(): void {
if (this.disposed_ || this.rosterWarmupStarted_ || this.empty) {
if (this.disposePromise_ || this.rosterWarmupStarted_ || this.empty) {
return;
}
@@ -380,7 +378,7 @@ export class Bridge implements BridgeInterface {
void this.runRosterWarmup().then(
(result) =>
this.settleRosterWarmup(
this.disposed_ ? { friendsLoaded: false, groupsLoaded: false } : result,
this.disposePromise_ ? { friendsLoaded: false, groupsLoaded: false } : result,
),
(err) => {
log.warn(
@@ -394,13 +392,13 @@ export class Bridge implements BridgeInterface {
}
private stillOpen(): boolean {
return !this.disposed_;
return this.disposePromise_ === null;
}
private async runRosterWarmup(): Promise<RosterWarmupResult> {
await this.identity.initialization;
if (this.disposed_) {
if (this.disposePromise_) {
return { friendsLoaded: false, groupsLoaded: false };
}
+34 -15
View File
@@ -13,39 +13,50 @@ const runtimeLog = createLogger('Bridge.Runtime');
export class BridgeManager implements BridgeSessionSource {
private readonly bridges = new Map<string, Bridge>();
private readonly pidToUin_ = new Map<number, string>();
private readonly closingSessions_ = new Map<string, Promise<void>>();
private readonly closeErrors_: unknown[] = [];
private readonly closingSessions_ = new Map<string, Set<Promise<void>>>();
private readonly sessionSubscriptions = new Set<{ listener: BridgeSessionListener }>();
private disposal_: Promise<void> | null = null;
/** Hook callbacks remain synchronous; shutdown awaits every detached account. */
async dispose(): Promise<void> {
dispose(): Promise<void> {
this.sessionSubscriptions.clear();
// Finish synchronous lifecycle observers before collecting detached sessions.
return (this.disposal_ ??= Promise.resolve().then(() => this.disposeSessions()));
}
private async disposeSessions(): Promise<void> {
for (const [uin, session] of this.bridges) {
this.closeSession(uin, session);
}
this.bridges.clear();
this.pidToUin_.clear();
await Promise.allSettled(this.closingSessions_.values());
const results = await Promise.allSettled(
[...this.closingSessions_.values()].flatMap((sessions) => [...sessions]),
);
const errors = results.flatMap((result) =>
result.status === 'rejected' ? [result.reason] : [],
);
if (this.closeErrors_.length) {
throw new AggregateError(this.closeErrors_, 'Bridge persistence shutdown failed');
if (errors.length) {
throw new AggregateError(errors, 'Bridge persistence shutdown failed');
}
}
private closeSession(uin: string, bridge: Bridge): void {
const previous = this.closingSessions_.get(uin);
const closing = Promise.all([previous, bridge.dispose()]).then(() => undefined);
this.closingSessions_.set(uin, closing);
const sessions = this.closingSessions_.get(uin) ?? new Set<Promise<void>>();
const closing = bridge.dispose();
sessions.add(closing);
this.closingSessions_.set(uin, sessions);
void closing.then(
() => {
if (this.closingSessions_.get(uin) === closing) {
sessions.delete(closing);
if (sessions.size === 0) {
this.closingSessions_.delete(uin);
}
},
(error) => {
this.closeErrors_.push(error);
log.error(
'identity close failed: UIN=%s err=%s',
uin,
@@ -137,6 +148,10 @@ export class BridgeManager implements BridgeSessionSource {
}
onPidReceiveHealthChanged(pid: number, healthy: boolean): void {
if (this.disposal_) {
return;
}
const uin = this.pidToUin_.get(pid);
if (!uin) {
@@ -157,7 +172,7 @@ export class BridgeManager implements BridgeSessionSource {
}
onHookLogin(pid: number, uin: string, packetClient: PacketSender): void {
if (!isRealUin(uin)) {
if (this.disposal_ || !isRealUin(uin)) {
return;
}
@@ -225,6 +240,10 @@ export class BridgeManager implements BridgeSessionSource {
}
onPacket(pkt: PacketInfo): void {
if (this.disposal_) {
return;
}
runWithTraceRequest(() => this.onPacketInContext(pkt));
}
@@ -414,9 +433,9 @@ export class BridgeManager implements BridgeSessionSource {
return { session, created: false };
}
const bridge = new Bridge(
IdentityService.openForUin(uin, 'data', this.closingSessions_.get(uin)),
);
const closing = this.closingSessions_.get(uin);
const beforeOpen = closing ? Promise.all(closing).then(() => undefined) : undefined;
const bridge = new Bridge(IdentityService.openForUin(uin, 'data', beforeOpen));
this.bridges.set(uin, bridge);
return { session: bridge, created: true };
@@ -0,0 +1,136 @@
import { setImmediate as nextTurn } from 'node:timers/promises';
import { IdentityService } from '@snowluma/protocol/identity-service';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { PacketInfo } from '@snowluma/common/protocol-types';
import { BridgeManager } from '../src/bridge/manager';
function deferred() {
let resolve!: () => void;
let reject!: (error: Error) => void;
const promise = new Promise<void>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
describe('BridgeManager shutdown', () => {
let manager: BridgeManager;
const sender = { sendPacket: vi.fn() } as never;
beforeEach(() => {
vi.spyOn(IdentityService, 'openForUin').mockImplementation((uin, _root, beforeOpen) => {
void beforeOpen?.catch(() => undefined);
return IdentityService.memory(uin);
});
manager = new BridgeManager();
});
afterEach(async () => {
await manager.dispose().catch(() => undefined);
vi.restoreAllMocks();
});
it('drains every detached session for one account and reports each failure once', async () => {
const previous = deferred();
const latest = deferred();
const firstError = new Error('previous close failed');
const lastError = new Error('latest close failed');
manager.onHookLogin(101, '10001', sender);
vi.spyOn(manager.getBridge('10001')!.identity, 'close').mockReturnValue(previous.promise);
manager.onPidDisconnected(101);
previous.reject(firstError);
await nextTurn();
manager.onHookLogin(202, '10001', sender);
vi.spyOn(manager.getBridge('10001')!.identity, 'close').mockReturnValue(latest.promise);
manager.onPidDisconnected(202);
let settled = false;
const result = manager
.dispose()
.catch((error: AggregateError) => error)
.then((error) => {
settled = true;
return error;
});
try {
await nextTurn();
expect(settled).toBe(false);
} finally {
latest.reject(lastError);
}
const error = await result;
expect(error).toBeInstanceOf(AggregateError);
expect((error as AggregateError).errors).toEqual([firstError, lastError]);
});
it('includes the session whose close observer requested shutdown', async () => {
const cleanup = deferred();
let shutdown!: Promise<void>;
let settled = false;
manager.onHookLogin(101, '10001', sender);
vi.spyOn(manager.getBridge('10001')!.identity, 'close').mockReturnValue(cleanup.promise);
manager.subscribeSessions({
closed: () => {
shutdown = manager.dispose().then(() => {
settled = true;
});
},
});
manager.onPidDisconnected(101);
try {
await nextTurn();
expect(settled).toBe(false);
} finally {
cleanup.resolve();
await shutdown;
}
});
it('waits for every previous close before opening the account again', async () => {
const first = deferred();
const second = deferred();
manager.onHookLogin(101, '10001', sender);
vi.spyOn(manager.getBridge('10001')!.identity, 'close').mockReturnValue(first.promise);
manager.onPidDisconnected(101);
manager.onHookLogin(202, '10001', sender);
vi.spyOn(manager.getBridge('10001')!.identity, 'close').mockReturnValue(second.promise);
manager.onPidDisconnected(202);
manager.onHookLogin(303, '10001', sender);
const beforeOpen = vi.mocked(IdentityService.openForUin).mock.calls.at(-1)![2]!;
let ready = false;
const opening = beforeOpen.then(() => {
ready = true;
});
try {
second.resolve();
await nextTurn();
expect(ready).toBe(false);
} finally {
first.resolve();
await opening;
}
});
it('ignores late transport callbacks after shutdown starts', async () => {
const shutdown = manager.dispose();
manager.onHookLogin(101, '10001', sender);
manager.onPacket({
pid: 202,
uin: '20002',
serviceCmd: 'Test.Unhandled',
seqId: 1,
retCode: 0,
fromClient: false,
body: Buffer.alloc(0),
} satisfies PacketInfo);
manager.onPidReceiveHealthChanged(101, false);
await shutdown;
expect(IdentityService.openForUin).not.toHaveBeenCalled();
expect(manager.getBridge('10001')).toBeNull();
expect(manager.getBridge('20002')).toBeNull();
});
});
+14 -13
View File
@@ -829,7 +829,7 @@ describe('IdentityService', () => {
}
});
it('stays open and preserves pending writes when SQLite close throws', async () => {
it('keeps shutdown terminal and reports pending writes when SQLite cleanup needs a retry', async () => {
vi.useFakeTimers();
const dbPath = tempDbPath('close-failure');
const identity = new IdentityService(SELF_UIN, dbPath);
@@ -859,31 +859,32 @@ describe('IdentityService', () => {
abandonedWrites: 0,
retryAttempt: 2,
});
expect(vi.getTimerCount()).toBe(1);
expect(identity.persistenceStatus.nextRetryAt! - Date.now()).toBe(500);
expect(vi.getTimerCount()).toBe(0);
expect(identity.persistenceStatus.nextRetryAt).toBeNull();
await expect(identity.flush()).rejects.toThrow(/closed/);
expect(() =>
identity.rememberRequestIdentity({
uid: 'u_after_close_failure',
uin: 33333,
}),
).not.toThrow();
expect(identity.persistenceStatus.pendingWrites).toBe(2);
).toThrow(/closed/);
expect(identity.persistenceStatus.pendingWrites).toBe(1);
closeSpy.mockRestore();
await releaseLock();
await vi.runOnlyPendingTimersAsync();
await identity.flush();
expect(identity.persistenceStatus).toMatchObject({
state: 'healthy',
pendingWrites: 0,
});
await identity.close();
expect(identity.persistenceStatus).toMatchObject({
state: 'closed',
pendingWrites: 0,
abandonedWrites: 1,
});
const reopened = new IdentityService(SELF_UIN, dbPath);
await reopened.initialization;
expect(reopened.findFriend(22222)?.uid).toBe('u_close_retry_friend');
expect(reopened.findUinByUid('u_after_close_failure')).toBe(33333);
expect(reopened.findFriend(22222)).toBeNull();
expect(reopened.findUinByUid('u_after_close_failure')).toBeNull();
await reopened.close();
} finally {
closeSpy.mockRestore();
+54 -73
View File
@@ -1,4 +1,4 @@
import { SqliteDatabase, type SqliteStatement } from '@snowluma/sqlite';
import { SqliteDatabase } from '@snowluma/sqlite';
import path from 'path';
import { createLogger, type Logger } from '@snowluma/common/logger';
@@ -142,13 +142,6 @@ export class IdentityService {
// ─── Persistence + fetcher ───
private readonly db: SqliteDatabase | null;
// Prepared-statement cache. Every `.prepare(sql)` call in this
// service re-parses the SQL — that's the SQLite anti-pattern
// RoadMap #5 is fixing. We memoize by the SQL string itself so the
// call sites stay readable (no need to enumerate 20+ named fields
// upfront); the Map lookup is O(1) hash + string-eq, which is
// negligible next to the SQL parse it replaces.
private readonly stmtCache_ = new Map<string, SqliteStatement>();
private inTransaction = false;
private fetcher: IdentityFetcher | null = null;
private readonly log: Logger;
@@ -165,7 +158,7 @@ export class IdentityService {
private lastSkippedWriteLogAt_ = 0;
private persistenceSuspended_ = false;
private closed_ = false;
private closing_: Promise<void> | null = null;
private closing_: { drained: Promise<void>; task: Promise<void> | null } | null = null;
private flushing_: Promise<void> | null = null;
private loaded_ = false;
private replaying_ = false;
@@ -176,7 +169,7 @@ export class IdentityService {
private readonly persistedMembersByUin = new Map<string, string>();
readonly initialization: Promise<void>;
constructor(uin: string, dbPath: string | null, beforeLoad?: Promise<void>) {
constructor(uin: string, dbPath: string | null, beforeOpen?: Promise<void>) {
this.uin_ = uin;
const uinNum = Number.parseInt(uin, 10);
this.log =
@@ -193,9 +186,8 @@ export class IdentityService {
// Identity already has a bounded retry policy; do not occupy a native
// worker waiting for a lock before handing control to that policy.
this.db = new SqliteDatabase(dbPath, { busyTimeout: 0 });
this.db = new SqliteDatabase(dbPath, { busyTimeout: 0, beforeOpen });
this.initialization = this.db.serial(async () => {
await beforeLoad;
await this.initSchema();
await this.migrateSchema();
await this.loadSnapshot();
@@ -217,25 +209,11 @@ export class IdentityService {
});
}
/** Cached `prepare(sql)`. First call per unique SQL parses; later
* calls reuse the same `Statement` object. See `stmtCache_`. */
private pstmt(sql: string): SqliteStatement {
let cached = this.stmtCache_.get(sql);
if (cached !== undefined) {
return cached;
}
cached = this.db!.prepare(sql);
this.stmtCache_.set(sql, cached);
return cached;
}
static openForUin(uin: string, dataRoot = 'data', beforeLoad?: Promise<void>): IdentityService {
static openForUin(uin: string, dataRoot = 'data', beforeOpen?: Promise<void>): IdentityService {
return new IdentityService(
uin,
path.join(dataRoot, uin, 'snowluma_identity.db'),
beforeLoad,
beforeOpen,
);
}
@@ -248,36 +226,40 @@ export class IdentityService {
return Promise.resolve();
}
if (this.closing_) {
return this.closing_;
const closing = (this.closing_ ??= { drained: this.prepareClose(), task: null });
if (closing.task) {
return closing.task;
}
const closing = this.closePersistence();
this.closing_ = closing;
void closing.catch(() => {
this.closing_ = null;
this.schedulePersistenceRetry();
closing.task = this.closePersistence(closing.drained);
void closing.task.catch(() => {
closing.task = null;
});
return closing;
return closing.task;
}
private async closePersistence(): Promise<void> {
private async prepareClose(): Promise<void> {
if (this.retryTimer_) {
clearTimeout(this.retryTimer_);
}
this.retryTimer_ = null;
this.nextRetryAt_ = null;
await this.initialization;
await this.flushPendingWrites(false);
}
private async closePersistence(drained: Promise<void>): Promise<void> {
try {
try {
await this.initialization;
await drained;
} catch (error) {
await this.db?.close();
this.closed_ = true;
throw error;
}
if (this.retryTimer_) {
clearTimeout(this.retryTimer_);
}
this.retryTimer_ = null;
this.nextRetryAt_ = null;
await this.flushPendingWrites(false);
const pending = this.pendingWrites_.length;
if (pending) {
@@ -295,7 +277,6 @@ export class IdentityService {
this.abandonedWrites_ += pending;
this.pendingWrites_.length = 0;
this.closed_ = true;
this.stmtCache_.clear();
} catch (error) {
this.lastError_ =
error instanceof Error ? (error.stack ?? error.message) : String(error);
@@ -722,7 +703,7 @@ export class IdentityService {
'friends',
async () =>
await this.transaction(async () => {
await this.pstmt(
await this.db!.prepare(
'UPDATE users SET is_friend = 0, updated_at = ? WHERE is_friend = 1',
).run(nowSeconds());
@@ -763,7 +744,7 @@ export class IdentityService {
'groups',
async () =>
await this.transaction(async () => {
await this.pstmt(
await this.db!.prepare(
'UPDATE groups SET active = 0, updated_at = ? WHERE active = 1',
).run(nowSeconds());
@@ -792,10 +773,9 @@ export class IdentityService {
this.groups_.delete(groupId);
this.runWrite('forget group', async () => {
await this.pstmt('UPDATE groups SET active = 0, updated_at = ? WHERE group_id = ?').run(
nowSeconds(),
groupId,
);
await this.db!.prepare(
'UPDATE groups SET active = 0, updated_at = ? WHERE group_id = ?',
).run(nowSeconds(), groupId);
});
}
@@ -826,7 +806,7 @@ export class IdentityService {
async () =>
await this.transaction(async () => {
await this.upsertGroup({ groupId, memberCount: persisted.length });
await this.pstmt(
await this.db!.prepare(
'UPDATE group_members SET active = 0, updated_at = ? WHERE group_id = ?',
).run(nowSeconds(), groupId);
@@ -1066,7 +1046,7 @@ export class IdentityService {
const updatedAt = nowSeconds();
await this.transaction(async () => {
for (const row of rows) {
await this.pstmt(
await this.db!.prepare(
'UPDATE group_members SET active = 0, updated_at = ? WHERE id = ?',
).run(updatedAt, row.id);
}
@@ -1236,7 +1216,7 @@ export class IdentityService {
}
private async loadSnapshot(): Promise<void> {
const users = await this.pstmt(
const users = await this.db!.prepare(
'SELECT uid, uin FROM users WHERE uid IS NOT NULL AND uin IS NOT NULL',
).all();
@@ -1252,7 +1232,7 @@ export class IdentityService {
this.persistedUidByUin.set(uin, uid);
}
const members = await this.pstmt(
const members = await this.db!.prepare(
'SELECT group_id, uid, uin FROM group_members WHERE uid IS NOT NULL AND uin IS NOT NULL ORDER BY active DESC, updated_at DESC',
).all();
@@ -1276,7 +1256,7 @@ export class IdentityService {
}
}
const friendRows = (await this.pstmt(
const friendRows = (await this.db!.prepare(
`SELECT uid, uin, nickname, remark
FROM users
WHERE is_friend = 1 AND uin IS NOT NULL`,
@@ -1297,7 +1277,7 @@ export class IdentityService {
this.rememberUidUin(friend.uid, friend.uin);
}
const groups = (await this.pstmt(
const groups = (await this.db!.prepare(
`SELECT group_id, group_name, remark, member_count, member_max
FROM groups
WHERE active = 1`,
@@ -1326,7 +1306,7 @@ export class IdentityService {
return;
}
const select = this.pstmt(
const select = this.db!.prepare(
`SELECT group_id, uid, uin, nickname, card, role, level, title,
join_time, last_sent_time, shut_up_time, is_robot
FROM group_members
@@ -1383,10 +1363,10 @@ export class IdentityService {
if (primary) {
for (const duplicate of rows.slice(1)) {
await this.pstmt('DELETE FROM users WHERE id = ?').run(duplicate.id);
await this.db!.prepare('DELETE FROM users WHERE id = ?').run(duplicate.id);
}
await this.pstmt(
await this.db!.prepare(
`UPDATE users
SET uid = ?, uin = ?, nickname = ?, remark = ?, is_friend = ?, source = ?, updated_at = ?
WHERE id = ?`,
@@ -1403,7 +1383,7 @@ export class IdentityService {
return;
}
await this.pstmt(
await this.db!.prepare(
`INSERT INTO users (uid, uin, nickname, remark, is_friend, source, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
).run(
@@ -1422,7 +1402,7 @@ export class IdentityService {
return;
}
const existing = (await this.pstmt(
const existing = (await this.db!.prepare(
'SELECT group_name, remark, member_count, member_max FROM groups WHERE group_id = ?',
).get(input.groupId)) as
| {
@@ -1433,7 +1413,7 @@ export class IdentityService {
}
| undefined;
await this.pstmt(
await this.db!.prepare(
`INSERT INTO groups (group_id, group_name, remark, member_count, member_max, active, updated_at)
VALUES (?, ?, ?, ?, ?, 1, ?)
ON CONFLICT(group_id) DO UPDATE SET
@@ -1494,10 +1474,10 @@ export class IdentityService {
if (primary) {
for (const duplicate of rows.slice(1)) {
await this.pstmt('DELETE FROM group_members WHERE id = ?').run(duplicate.id);
await this.db!.prepare('DELETE FROM group_members WHERE id = ?').run(duplicate.id);
}
await this.pstmt(
await this.db!.prepare(
`UPDATE group_members
SET uid = ?, uin = ?, nickname = ?, card = ?, role = ?, level = ?, title = ?,
join_time = ?, last_sent_time = ?, shut_up_time = ?, is_robot = ?, active = ?, updated_at = ?
@@ -1521,7 +1501,7 @@ export class IdentityService {
return;
}
await this.pstmt(
await this.db!.prepare(
`INSERT INTO group_members
(group_id, uid, uin, nickname, card, role, level, title,
join_time, last_sent_time, shut_up_time, is_robot, active, updated_at)
@@ -1550,19 +1530,19 @@ export class IdentityService {
}
if (uid && uin !== null) {
return (await this.pstmt(
return (await this.db!.prepare(
'SELECT id, uid, uin, nickname, remark, is_friend, source FROM users WHERE uid = ? OR uin = ? ORDER BY id',
).all(uid, uin)) as unknown as UserRow[];
}
if (uid) {
return (await this.pstmt(
return (await this.db!.prepare(
'SELECT id, uid, uin, nickname, remark, is_friend, source FROM users WHERE uid = ? ORDER BY id',
).all(uid)) as unknown as UserRow[];
}
if (uin !== null) {
return (await this.pstmt(
return (await this.db!.prepare(
'SELECT id, uid, uin, nickname, remark, is_friend, source FROM users WHERE uin = ? ORDER BY id',
).all(uin)) as unknown as UserRow[];
}
@@ -1580,7 +1560,7 @@ export class IdentityService {
}
if (uid && uin !== null) {
return (await this.pstmt(
return (await this.db!.prepare(
`SELECT id, group_id, uid, uin, nickname, card, role, level, title,
join_time, last_sent_time, shut_up_time, is_robot, active
FROM group_members
@@ -1590,7 +1570,7 @@ export class IdentityService {
}
if (uid) {
return (await this.pstmt(
return (await this.db!.prepare(
`SELECT id, group_id, uid, uin, nickname, card, role, level, title,
join_time, last_sent_time, shut_up_time, is_robot, active
FROM group_members
@@ -1600,7 +1580,7 @@ export class IdentityService {
}
if (uin !== null) {
return (await this.pstmt(
return (await this.db!.prepare(
`SELECT id, group_id, uid, uin, nickname, card, role, level, title,
join_time, last_sent_time, shut_up_time, is_robot, active
FROM group_members
@@ -1665,6 +1645,7 @@ export class IdentityService {
/** Wait for accepted observations to reach disk (also used by shutdown). */
async flush(): Promise<void> {
this.assertOpen('flush');
await this.initialization;
await this.flushPendingWrites(false);
+6 -1
View File
@@ -19,8 +19,13 @@ export class SqliteDatabase {
private pending = 0;
private shutdown: { task: Promise<void> | null } | null = null;
constructor(filename: string, options: { readOnly?: boolean; busyTimeout?: number } = {}) {
constructor(
filename: string,
options: { readOnly?: boolean; busyTimeout?: number; beforeOpen?: Promise<void> } = {},
) {
this.initialization = (async () => {
await options.beforeOpen;
if (!options.readOnly && filename !== ':memory:') {
await mkdir(path.dirname(filename), { recursive: true });
}
+37
View File
@@ -139,6 +139,43 @@ it('rejects failed opens and safely closes a partially initialized connection',
await db.close();
});
it.each(['ready', 'failed'] as const)(
'opens only after the previous owner is %s',
async (outcome) => {
const driver = await import('../src/driver');
const open = vi.spyOn(driver, 'openDatabase');
let resolve!: () => void;
let reject!: (error: Error) => void;
const beforeOpen = new Promise<void>((yes, no) => {
resolve = yes;
reject = no;
});
const db = new SqliteDatabase(':memory:', { beforeOpen });
try {
await nextTurn();
expect(open).not.toHaveBeenCalled();
if (outcome === 'ready') {
resolve();
await db.initialization;
expect(open).toHaveBeenCalledOnce();
expect(await db.prepare('SELECT 1 AS value').get()).toEqual({ value: 1 });
} else {
const failure = new Error('previous owner could not close');
reject(failure);
await expect(db.initialization).rejects.toBe(failure);
await expect(db.exec('SELECT 1')).rejects.toBe(failure);
expect(open).not.toHaveBeenCalled();
}
} finally {
resolve();
await db.close();
open.mockRestore();
}
},
);
it('keeps writes rejected after a failed close while allowing cleanup to retry', async () => {
const db = new SqliteDatabase(':memory:');
await db.initialization;