Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,9 @@ Every error the Wavelength SDK originates extends [`WavelengthError`](/reference
string that identifies the failure reason at the machine level. Match on
`err.code` to show a specific message rather than a generic fallback. Named
SDK codes include `runtime_not_ready`, `asset_load_failed`, and
`worker_error`; daemon-originated errors currently use `wavelength_error`.
`worker_error`. When `start()` uses the default worker transport, it can raise
`runtime_locked` if another same-origin tab owns the runtime lock.
Daemon-originated errors currently use `wavelength_error`.

```tsx title="errors.tsx"
import { useWalletSend, WavelengthError } from '@lightninglabs/wavelength-react';
Expand Down
2 changes: 2 additions & 0 deletions apps/docs/src/content/docs/reference/wavelength-core.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2794,6 +2794,7 @@ export const wavelengthErrorSig = `class WavelengthError extends Error {
export const errorCodeSig = `type WavelengthErrorCode =
| 'wavelength_error'
| 'runtime_not_ready'
| 'runtime_locked'
| 'asset_load_failed'
| 'worker_error'
| 'unsupported_facade_method'
Expand Down Expand Up @@ -2829,6 +2830,7 @@ compatibility while still offering autocomplete on the known codes.
|---|---|
| `wavelength_error` | The generic default: any SDK error without a more specific code, and all daemon-originated failures today. |
| `runtime_not_ready` | The wasm runtime is not callable: it exited before signaling ready, or its call entry point is missing once loading has finished. |
| `runtime_locked` | `start()` in the default worker mode cannot acquire the runtime lock because another same-origin tab owns it. |
| `asset_load_failed` | `ready()` fails to load the runtime assets (wasm binary or its supporting files). |
| `worker_error` | The worker transport's underlying Worker fails or crashes. |
| `unsupported_facade_method` | A call names a method the daemon facade does not expose. |
Expand Down
78 changes: 78 additions & 0 deletions apps/web-wallet-demo/wavewalletdk-smoke.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,84 @@ async function createReadyWallet(
await page.getByRole("button", { name: "I saved it" }).click();
}

test("a second tab retries after the owning runtime closes", async ({
context,
page,
}, testInfo) => {
const baseURL = testInfo.project.use.baseURL;
const dataDir = `/wavewalletdk-smoke-lock-${Date.now()}`;
const swapDatabaseFileName = `/wavewalletdk-swaps-lock-${Date.now()}.db`;
const walletEntry = (targetPage) =>
targetPage.getByRole("heading", {
name: /^(Create wallet|Unlock wallet)$/,
});

await page.goto("/");
const firstStart = page.getByRole("button", { name: "Start runtime" });
await expect(firstStart).toBeVisible({ timeout: 30000 });
await configureRuntime(page, baseURL, dataDir, swapDatabaseFileName);
await firstStart.click();
await expect(walletEntry(page)).toBeVisible({ timeout: 60000 });

const secondPage = await context.newPage();
const secondPageMessages = [];
const recordSecondPageMessage = (line) => {
secondPageMessages.push(line);
if (process.env.WAVELENGTH_SMOKE_VERBOSE) {
console.log(line);
}
};
secondPage.on("console", (message) => {
recordSecondPageMessage(`[${message.type()}] ${message.text()}`);
});
secondPage.on("pageerror", (error) => {
recordSecondPageMessage(`[pageerror] ${error.message}`);
});
const expectNoRuntimeFallback = () => {
const output = secondPageMessages.join("\n");
for (const marker of [
"falling back to :memory:",
"OPFS VFS unavailable",
"SQLITE_CANTOPEN",
"apply sqlite migrations",
]) {
expect(output).not.toContain(marker);
}
};

await secondPage.goto("/");
const secondStart = secondPage.getByRole("button", {
name: "Start runtime",
});
await expect(secondStart).toBeVisible({ timeout: 30000 });
await configureRuntime(
secondPage,
baseURL,
dataDir,
swapDatabaseFileName,
);
await secondStart.click();

await expect(
secondPage.getByRole("heading", { name: "Runtime error" }),
).toBeVisible();
await expect(
secondPage.getByText(
"This wallet is already open in another tab. Close the other tab and try again.",
),
).toBeVisible();
await expect(secondPage.locator("body")).not.toContainText("SQLITE_CANTOPEN");
await expect(secondPage.locator("body")).not.toContainText(
"apply sqlite migrations",
);
expectNoRuntimeFallback();

await page.close();
await secondPage.getByRole("button", { name: "Try again" }).click();
await expect(walletEntry(secondPage)).toBeVisible({ timeout: 60000 });
expectNoRuntimeFallback();
});

test("wallet create and address state persist with OPFS SQLite", async ({
page,
}, testInfo) => {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
export type WavelengthErrorCode =
| 'wavelength_error'
| 'runtime_not_ready'
| 'runtime_locked'
| 'asset_load_failed'
| 'worker_error'
| 'unsupported_facade_method'
Expand Down
126 changes: 126 additions & 0 deletions packages/web/src/clients/runtime-lock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { WavelengthError } from '@lightninglabs/wavelength-core';

const RUNTIME_LOCK_NAME = 'lightninglabs:wavelength:worker-runtime';
const RUNTIME_LOCKED_MESSAGE =
'This wallet is already open in another tab. Close the other tab and try again.';

type RuntimeLockLease = {
release: () => void;
};

function clientDisposedError(): WavelengthError {
return new WavelengthError('Wavelength client disposed', 'worker_error');
}

/**
* Holds the worker runtime's origin-scoped Web Lock until its storage-owning
* lifetime ends. The lock is intentionally not keyed by dataDir: the daemon
* can open independently configured paths such as the swap database, plus
* paths selected by daemon defaults.
*/
export class WorkerRuntimeLock {
private lease: RuntimeLockLease | null = null;
private priorRelease: Promise<void> = Promise.resolve();
private rejectAcquisition: ((reason: unknown) => void) | null = null;
private disposed = false;

acquire(): Promise<boolean> {
if (this.disposed) {
return Promise.reject(clientDisposedError());
}
if (this.lease) {
return Promise.resolve(false);
}

return this.acquireAfterPriorRelease();
}

release(): void {
const lease = this.lease;
this.lease = null;
lease?.release();
}

dispose(): void {
this.disposed = true;
this.rejectAcquisition?.(clientDisposedError());
this.release();
}

private async acquireAfterPriorRelease(): Promise<boolean> {
await this.priorRelease;
if (this.disposed) {
throw clientDisposedError();
}
if (this.lease) {
return false;
}

const lockManager = globalThis.navigator?.locks;
if (!lockManager) {
return false;
}

let releaseLease!: () => void;
const holdLease = new Promise<void>((resolve) => {
releaseLease = resolve;
});
let resolveAvailability!: (available: boolean) => void;
let rejectAvailability!: (reason: unknown) => void;
const availability = new Promise<boolean>((resolve, reject) => {
resolveAvailability = resolve;
rejectAvailability = reject;
});
this.rejectAcquisition = rejectAvailability;

let requestCompletion: Promise<unknown>;
try {
requestCompletion = lockManager.request(
RUNTIME_LOCK_NAME,
{ mode: 'exclusive', ifAvailable: true },
async (lock) => {
if (!lock) {
resolveAvailability(false);

return;
}
if (this.disposed) {
rejectAvailability(clientDisposedError());

return;
}
this.lease = { release: releaseLease };
resolveAvailability(true);
await holdLease;
},
);
} catch (err) {
this.rejectAcquisition = null;
throw err;
}

this.priorRelease = requestCompletion.then(
() => undefined,
() => undefined,
);
void requestCompletion.catch(rejectAvailability);

let available: boolean;
try {
available = await availability;
} finally {
if (this.rejectAcquisition === rejectAvailability) {
this.rejectAcquisition = null;
}
}
if (!available) {
throw new WavelengthError(RUNTIME_LOCKED_MESSAGE, 'runtime_locked');
}
if (this.disposed) {
this.release();
throw clientDisposedError();
}

return true;
}
}
Loading