Skip to content

Commit 069e0e5

Browse files
rekmarksclaude
andauthored
feat(kernel-errors): standardize kernel errors observable in vat-land (#913)
## Summary Kernel errors surfaced to vats as promise rejections previously had no consistent format — some were plain strings, some Error objects, with no way for vat code to programmatically identify or categorize them. This PR introduces a machine-readable error format: `[KERNEL:<CODE>] detail` for expected errors (vat code can handle gracefully) and `[KERNEL:VAT_FATAL:<CODE>] detail` for fatal errors (vat gets terminated). - Add `vat-observable-errors.ts` to `@metamask/kernel-errors` with `KernelErrorCode` types, `KERNEL_ERROR_PATTERN` regex, and `isKernelError`/`getKernelErrorCode`/`isFatalKernelError` detection utilities - Add `makeKernelError` and `makeFatalKernelError` helpers to `kernel-marshal.ts`, importing shared types from `@metamask/kernel-errors` - Migrate all error sites across KernelRouter, RemoteManager, VatHandle, VatSyscall, and KernelServiceManager - Remove `makeError` (no remaining callers) - Remove kernel-internal details (peer IDs) from error messages sent to vats - Rewrite `@metamask/kernel-errors` README to delineate the three distinct error categories in the package (error classes, stream marshalling, vat-observable error codes) and explain why they exist as separate systems **Note:** Four sites previously used `kser('string')` which serialized a plain string as the rejection value. These now use `makeKernelError(...)` which serializes an Error object. This is an intentional wire-format change — vat code that deserialized the rejection and checked `typeof reason === 'string'` would need updating. ## Testing New unit tests cover the detection utilities (`vat-observable-errors.test.ts`) and factory functions (`kernel-marshal.test.ts`), including round-trip verification through the `@metamask/kernel-errors` detection utilities. All existing unit tests in `@MetaMask/ocap-kernel` and integration tests in `@ocap/kernel-test` pass with updated assertions matching the new `[KERNEL:*]` format. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes the wire format of promise rejections visible to vats (many string/opaque errors become serialized `Error`s with `[KERNEL:*]` prefixes), which may break downstream error handling and affects multiple kernel routing/termination paths. > > **Overview** > Standardizes *vat-observable* kernel promise rejections to a machine-readable message format: expected errors as `[KERNEL:<CODE>] ...` and fatal errors as `[KERNEL:VAT_FATAL:<CODE>] ...`. > > Adds `@metamask/kernel-errors` utilities and types (`KernelErrorCode`, `KERNEL_ERROR_PATTERN`, `isKernelError`, `getKernelErrorCode`, `isFatalKernelError`) plus tests, and updates docs to clearly separate error-class vs stream-marshalling vs vat-observable error domains. > > Migrates kernel sites that surface errors to vats (`KernelRouter`, `RemoteManager`, `KernelServiceManager`, `VatHandle`, `VatSyscall`) to use new `makeKernelError`/`makeFatalKernelError` helpers (replacing ad-hoc `kser('string')`/`kser(Error)`), introduces new explicit codes like `OBJECT_REVOKED`, `OBJECT_DELETED`, `BAD_PROMISE_RESOLUTION`, `ENDPOINT_UNREACHABLE`, `CONNECTION_LOST`, `PEER_RESTARTED`, `VAT_TERMINATED`, `DELIVERY_FAILED`, and updates unit/e2e assertions accordingly. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 9538b10. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 30b0e77 commit 069e0e5

19 files changed

Lines changed: 519 additions & 75 deletions

packages/kernel-errors/README.md

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,43 @@
1-
# `errors`
1+
# `@metamask/kernel-errors`
22

3-
Ocap Kernel errors.
3+
Error types, utilities, and serialization for the OCAP kernel.
4+
5+
This package contains three distinct categories of error tooling, each serving
6+
a different domain. They exist in the same package because they share base
7+
infrastructure, but they are not interchangeable.
8+
9+
## Error classes (`src/errors/`)
10+
11+
Typed `BaseError` subclasses used **kernel-side and host-side**. Each carries a
12+
structured `.code` (`ErrorCode` enum) and `.data` (JSON) property. Kernel code
13+
inspects these via `instanceof` checks and type guards like
14+
`isResourceLimitError()`.
15+
16+
These classes **never reach vat code directly** — they are thrown and caught
17+
within the kernel, platform services, and agent infrastructure.
18+
19+
## Stream error marshalling (`src/marshal/`)
20+
21+
Custom JSON serialization (`marshalError` / `unmarshalError`) that preserves
22+
`.code`, `.data`, `.cause`, and `.stack` across stream and IPC boundaries. Used
23+
by `@metamask/streams` to transport errors through message ports.
24+
25+
This is unrelated to the kernel's `@endo/marshal`-based `kser`/`kunser`
26+
serialization. The two systems operate at different layers and never interact.
27+
28+
## Vat-observable error codes (`src/vat-observable-errors.ts`)
29+
30+
Machine-readable codes embedded in the error `.message` for errors that reach
31+
**vat code as promise rejections**. These errors are serialized via `kser`
32+
(`@endo/marshal` with `errorTagging: 'off'`), which strips all `Error`
33+
properties except `.message` and `.name`. The message is therefore the only
34+
reliable channel for structured information.
35+
36+
Format: `[KERNEL:<CODE>] Human-readable detail` for expected errors,
37+
`[KERNEL:VAT_FATAL:<CODE>] detail` for fatal errors that terminate the vat.
38+
39+
Detection utilities (`isKernelError`, `getKernelErrorCode`,
40+
`isFatalKernelError`) let vat code programmatically categorize these errors.
441

542
## Installation
643

@@ -12,4 +49,5 @@ or
1249

1350
## Contributing
1451

15-
This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/ocap-kernel#readme).
52+
This package is part of a monorepo. Instructions for contributing can be found
53+
in the [monorepo README](https://github.com/MetaMask/ocap-kernel#readme).

packages/kernel-errors/src/index.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ describe('index', () => {
1111
'ErrorSentinel',
1212
'ErrorStruct',
1313
'EvaluatorError',
14+
'KERNEL_ERROR_PATTERN',
1415
'MarshaledErrorStruct',
1516
'MarshaledOcapErrorStruct',
1617
'ResourceLimitError',
@@ -20,7 +21,10 @@ describe('index', () => {
2021
'VatAlreadyExistsError',
2122
'VatDeletedError',
2223
'VatNotFoundError',
24+
'getKernelErrorCode',
2325
'getNetworkErrorCode',
26+
'isFatalKernelError',
27+
'isKernelError',
2428
'isMarshaledError',
2529
'isMarshaledOcapError',
2630
'isOcapError',

packages/kernel-errors/src/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,14 @@ export { isMarshaledOcapError } from './marshal/isMarshaledOcapError.ts';
2929
export { isRetryableNetworkError } from './utils/isRetryableNetworkError.ts';
3030
export { getNetworkErrorCode } from './utils/getNetworkErrorCode.ts';
3131
export { isResourceLimitError } from './utils/isResourceLimitError.ts';
32+
export type {
33+
ExpectedKernelErrorCode,
34+
FatalKernelErrorCode,
35+
KernelErrorCode,
36+
} from './vat-observable-errors.ts';
37+
export {
38+
KERNEL_ERROR_PATTERN,
39+
isKernelError,
40+
getKernelErrorCode,
41+
isFatalKernelError,
42+
} from './vat-observable-errors.ts';
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { describe, it, expect } from 'vitest';
2+
3+
import {
4+
KERNEL_ERROR_PATTERN,
5+
isKernelError,
6+
getKernelErrorCode,
7+
isFatalKernelError,
8+
} from './vat-observable-errors.ts';
9+
10+
describe('KERNEL_ERROR_PATTERN', () => {
11+
it.each([
12+
['[KERNEL:OBJECT_REVOKED] Target object has been revoked', true],
13+
['[KERNEL:VAT_FATAL:ILLEGAL_SYSCALL] Fatal syscall violation', true],
14+
['[KERNEL:CONNECTION_LOST] Remote connection lost', true],
15+
['Some other error', false],
16+
['KERNEL:OBJECT_REVOKED', false],
17+
['[KERNEL:lowercase] bad code', false],
18+
])('matches %j -> %j', (message, expected) => {
19+
expect(KERNEL_ERROR_PATTERN.test(message)).toBe(expected);
20+
});
21+
});
22+
23+
describe('isKernelError', () => {
24+
it('returns true for an Error with a kernel error message', () => {
25+
expect(isKernelError(Error('[KERNEL:OBJECT_DELETED] Target deleted'))).toBe(
26+
true,
27+
);
28+
});
29+
30+
it('returns true for a fatal kernel error', () => {
31+
expect(
32+
isKernelError(Error('[KERNEL:VAT_FATAL:INTERNAL_ERROR] Something broke')),
33+
).toBe(true);
34+
});
35+
36+
it('returns false for a plain Error', () => {
37+
expect(isKernelError(Error('just a normal error'))).toBe(false);
38+
});
39+
40+
it('returns false for non-Error values', () => {
41+
expect(isKernelError('string')).toBe(false);
42+
expect(isKernelError(null)).toBe(false);
43+
expect(isKernelError(undefined)).toBe(false);
44+
expect(isKernelError(42)).toBe(false);
45+
});
46+
});
47+
48+
describe('getKernelErrorCode', () => {
49+
it('extracts expected error codes', () => {
50+
expect(getKernelErrorCode(Error('[KERNEL:OBJECT_REVOKED] revoked'))).toBe(
51+
'OBJECT_REVOKED',
52+
);
53+
expect(getKernelErrorCode(Error('[KERNEL:CONNECTION_LOST] lost'))).toBe(
54+
'CONNECTION_LOST',
55+
);
56+
});
57+
58+
it('extracts fatal error codes', () => {
59+
expect(
60+
getKernelErrorCode(Error('[KERNEL:VAT_FATAL:ILLEGAL_SYSCALL] bad')),
61+
).toBe('ILLEGAL_SYSCALL');
62+
expect(
63+
getKernelErrorCode(Error('[KERNEL:VAT_FATAL:INTERNAL_ERROR] broken')),
64+
).toBe('INTERNAL_ERROR');
65+
});
66+
67+
it('returns undefined for non-kernel errors', () => {
68+
expect(getKernelErrorCode(Error('normal error'))).toBeUndefined();
69+
});
70+
});
71+
72+
describe('isFatalKernelError', () => {
73+
it('returns true for fatal kernel errors', () => {
74+
expect(
75+
isFatalKernelError(
76+
Error('[KERNEL:VAT_FATAL:ILLEGAL_SYSCALL] bad syscall'),
77+
),
78+
).toBe(true);
79+
});
80+
81+
it('returns false for expected kernel errors', () => {
82+
expect(isFatalKernelError(Error('[KERNEL:OBJECT_REVOKED] revoked'))).toBe(
83+
false,
84+
);
85+
});
86+
87+
it('returns false for non-kernel errors', () => {
88+
expect(isFatalKernelError(Error('normal error'))).toBe(false);
89+
});
90+
});
91+
92+
describe('round-trip', () => {
93+
it('constructs and detects a kernel error message', () => {
94+
const code = 'OBJECT_DELETED';
95+
const detail = 'Target object has no owner; it may have been deleted';
96+
const message = `[KERNEL:${code}] ${detail}`;
97+
const error = Error(message);
98+
99+
expect(isKernelError(error)).toBe(true);
100+
expect(getKernelErrorCode(error)).toBe(code);
101+
expect(isFatalKernelError(error)).toBe(false);
102+
expect(error.message).toBe(`[KERNEL:OBJECT_DELETED] ${detail}`);
103+
});
104+
105+
it('constructs and detects a fatal kernel error message', () => {
106+
const code = 'INTERNAL_ERROR';
107+
const detail = 'Internal kernel error';
108+
const message = `[KERNEL:VAT_FATAL:${code}] ${detail}`;
109+
const error = Error(message);
110+
111+
expect(isKernelError(error)).toBe(true);
112+
expect(getKernelErrorCode(error)).toBe(code);
113+
expect(isFatalKernelError(error)).toBe(true);
114+
});
115+
});
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* Error codes for expected kernel errors that vat code may handle gracefully.
3+
*/
4+
export type ExpectedKernelErrorCode =
5+
| 'OBJECT_REVOKED'
6+
| 'OBJECT_DELETED'
7+
| 'BAD_PROMISE_RESOLUTION'
8+
| 'ENDPOINT_UNREACHABLE'
9+
| 'CONNECTION_LOST'
10+
| 'PEER_RESTARTED'
11+
| 'VAT_TERMINATED'
12+
| 'DELIVERY_FAILED';
13+
14+
/**
15+
* Error codes for fatal kernel errors (kernel bugs or illegal operations).
16+
* These are prefixed with `VAT_FATAL:` in the error message.
17+
*/
18+
export type FatalKernelErrorCode = 'ILLEGAL_SYSCALL' | 'INTERNAL_ERROR';
19+
20+
/**
21+
* All kernel error codes.
22+
*/
23+
export type KernelErrorCode = ExpectedKernelErrorCode | FatalKernelErrorCode;
24+
25+
/**
26+
* Pattern matching kernel error messages.
27+
* Matches both `[KERNEL:<CODE>]` and `[KERNEL:VAT_FATAL:<CODE>]`.
28+
*/
29+
export const KERNEL_ERROR_PATTERN = /^\[KERNEL:(?:(VAT_FATAL):)?([A-Z_]+)\]/u;
30+
31+
/**
32+
* Check whether a value is a kernel error (an Error whose message starts with
33+
* `[KERNEL:...]`).
34+
*
35+
* @param value - The value to check.
36+
* @returns `true` if `value` is an Error with a kernel error message.
37+
*/
38+
export function isKernelError(value: unknown): value is Error {
39+
return value instanceof Error && KERNEL_ERROR_PATTERN.test(value.message);
40+
}
41+
42+
/**
43+
* Extract the kernel error code from an Error, if present.
44+
*
45+
* @param error - The error to inspect.
46+
* @returns The kernel error code, or `undefined` if the error is not a kernel error.
47+
*/
48+
export function getKernelErrorCode(error: Error): KernelErrorCode | undefined {
49+
const match = KERNEL_ERROR_PATTERN.exec(error.message);
50+
if (!match) {
51+
return undefined;
52+
}
53+
return match[2] as KernelErrorCode;
54+
}
55+
56+
/**
57+
* Check whether an Error is a fatal kernel error (its message contains the
58+
* `VAT_FATAL:` infix).
59+
*
60+
* @param error - The error to inspect.
61+
* @returns `true` if the error is a fatal kernel error.
62+
*/
63+
export function isFatalKernelError(error: Error): boolean {
64+
const match = KERNEL_ERROR_PATTERN.exec(error.message);
65+
return match !== null && match[1] === 'VAT_FATAL';
66+
}

packages/kernel-node-runtime/test/e2e/orphaned-ephemeral-exo.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,11 @@ describe('Orphaned ephemeral exo', { timeout: 30_000 }, () => {
5555
// The consumer's E(ephemeral).increment() targets an orphaned vref.
5656
// Liveslots in the provider throws "I don't remember allocating",
5757
// which terminates the provider and rejects the caller's promise.
58-
// This is surfaced to the caller as "target object has no owner".
58+
// This is surfaced to the caller as an OBJECT_DELETED kernel error.
5959
await expect(
6060
kernel.queueMessage(rootKref, 'useEphemeral', []),
6161
).rejects.toMatchObject({
62-
body: expect.stringContaining('target object has no owner'),
62+
body: expect.stringContaining('[KERNEL:OBJECT_DELETED]'),
6363
});
6464
} finally {
6565
await kernel.stop();

packages/kernel-test/src/orphaned-ephemeral-exo.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ describe('orphaned ephemeral exo', () => {
4444
await expect(
4545
kernel.queueMessage(rootKref, 'useEphemeral', []),
4646
).rejects.toMatchObject({
47-
body: expect.stringContaining('has no owner'),
47+
body: expect.stringContaining('[KERNEL:OBJECT_DELETED]'),
4848
});
4949
});
5050
});

packages/kernel-test/src/syscall-validation.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ describe('Syscall Validation & Revoked Objects', { timeout: 30_000 }, () => {
9898
await expect(
9999
kernel.queueMessage(objectKRef, 'getValue', []),
100100
).rejects.toMatchObject({
101-
body: expect.stringContaining('target object has been revoked'),
101+
body: expect.stringContaining('[KERNEL:OBJECT_REVOKED]'),
102102
});
103103
// Verify kernel doesn't crash and exporter vat remains operational
104104
const exporterStatus = await kernel.queueMessage(exporterKRef, 'noop', []);
@@ -146,7 +146,7 @@ describe('Syscall Validation & Revoked Objects', { timeout: 30_000 }, () => {
146146
await expect(
147147
kernel.queueMessage(objectKRef, 'getValue', []),
148148
).rejects.toMatchObject({
149-
body: expect.stringContaining('target object has been revoked'),
149+
body: expect.stringContaining('[KERNEL:OBJECT_REVOKED]'),
150150
});
151151
// Verify exporter vat is still operational
152152
const exporterStatus = await kernel.queueMessage(exporterKRef, 'noop', []);
@@ -156,7 +156,7 @@ describe('Syscall Validation & Revoked Objects', { timeout: 30_000 }, () => {
156156
await expect(
157157
kernel.queueMessage(objectKRef, 'getValue', []),
158158
).rejects.toMatchObject({
159-
body: expect.stringContaining('target object has been revoked'),
159+
body: expect.stringContaining('[KERNEL:OBJECT_REVOKED]'),
160160
});
161161
}
162162
// Verify kernel remains stable
@@ -218,7 +218,7 @@ describe('Syscall Validation & Revoked Objects', { timeout: 30_000 }, () => {
218218
await expect(
219219
kernel.queueMessage(objectKRef, 'getValue', []),
220220
).rejects.toMatchObject({
221-
body: expect.stringContaining('target object has been revoked'),
221+
body: expect.stringContaining('[KERNEL:OBJECT_REVOKED]'),
222222
});
223223
}
224224

packages/kernel-test/src/vat-lifecycle.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ describe('Vat Lifecycle', { timeout: 30_000 }, () => {
135135
await expect(
136136
kernel.queueMessage(deadRootObject, 'resume', []),
137137
).rejects.toMatchObject({
138-
body: expect.stringContaining('has no owner'),
138+
body: expect.stringContaining('[KERNEL:OBJECT_DELETED]'),
139139
});
140140

141141
// Verify that messaging works as expected

0 commit comments

Comments
 (0)