feat(plugins): split plugin code by runtime

Keep the entry point focused on contribution wiring so client UI and daemon handlers can use their own runtime APIs safely. Surface compilation failures and lifecycle transitions in the retained plugin log tail.
This commit is contained in:
Mohamed Boudra
2026-08-17 23:45:19 +02:00
parent 5ed1a852d1
commit 3cc8749437
17 changed files with 657 additions and 265 deletions
@@ -12,7 +12,7 @@ afterEach(async () => {
});
describe("plugin scaffold", () => {
it("creates a standalone strict TSX project that typechecks", async () => {
it("creates a standalone split-runtime project that typechecks", async () => {
const parent = await mkdtemp(path.join(process.cwd(), ".plugin-scaffold-"));
directories.push(parent);
const directory = path.join(parent, "hello-plugin");
@@ -34,6 +34,12 @@ describe("plugin scaffold", () => {
expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual({
id: "hello-plugin",
});
await expect(readFile(path.join(directory, "index.ts"), "utf8")).resolves.toContain(
'from "./main.client"',
);
await expect(readFile(path.join(directory, "main.client.tsx"), "utf8")).resolves.toContain(
"Hello from my plugin",
);
});
it("typechecks client and server Paseo API access", async () => {
@@ -41,27 +47,45 @@ describe("plugin scaffold", () => {
directories.push(parent);
const directory = path.join(parent, "paseo-api-plugin");
await scaffoldPluginDirectory(directory);
await writeFile(
path.join(directory, "index.tsx"),
`import React from "react";
import { Text } from "react-native";
await Promise.all([
writeFile(
path.join(directory, "inspect.shared.ts"),
`import { defineRpc } from "@paseo/plugin";
import { z } from "zod";
import {
defineRpc,
type PluginContext,
type PluginAgentPanelProps,
usePaseo,
useAgent,
useWorkspace,
} from "@paseo/plugin";
const inspect = defineRpc({
export const inspect = defineRpc({
name: "inspect",
input: z.object({}),
output: z.object({ configured: z.boolean() }),
});
`,
),
writeFile(
path.join(directory, "inspect.server.ts"),
`import type { PluginHandlerContext } from "@paseo/plugin";
import type { output as ZodOutput } from "zod";
import { inspect } from "./inspect.shared";
function Surface() {
export async function inspectConfig(
_input: ZodOutput<typeof inspect.input>,
{ paseo }: PluginHandlerContext,
) {
return { configured: Boolean((await paseo.config.get()).config) };
}
`,
),
writeFile(
path.join(directory, "main.client.tsx"),
`import React from "react";
import { Text } from "react-native";
import {
type PluginAgentPanelProps,
useAgent,
usePaseo,
useWorkspace,
} from "@paseo/plugin";
export function Surface() {
const paseo = usePaseo();
const createWorkspace = () => paseo.workspaces.create({
source: { kind: "directory", path: "/repo" },
@@ -70,7 +94,7 @@ function Surface() {
return <Text>Paseo API</Text>;
}
function AgentPanel({ workspaceId, agentId }: PluginAgentPanelProps) {
export function AgentPanel({ workspaceId, agentId }: PluginAgentPanelProps) {
const workspaceName = useWorkspace(workspaceId, (workspace) => {
// @ts-expect-error Plugin snapshots are readonly.
workspace.name = "mutated";
@@ -83,11 +107,17 @@ function AgentPanel({ workspaceId, agentId }: PluginAgentPanelProps) {
});
return <Text>{workspaceName}: {agentTitle}</Text>;
}
`,
),
writeFile(
path.join(directory, "index.ts"),
`import type { PluginContext } from "@paseo/plugin";
import { AgentPanel, Surface } from "./main.client";
import { inspectConfig } from "./inspect.server";
import { inspect } from "./inspect.shared";
export default function contribute(plugin: PluginContext) {
plugin.handle(inspect, async (_input, { paseo }) => ({
configured: Boolean((await paseo.config.get()).config),
}));
plugin.handle(inspect, inspectConfig);
plugin.addSurface("main", Surface);
plugin.addWorkspacePanel({
id: "review",
@@ -110,7 +140,8 @@ export default function contribute(plugin: PluginContext) {
return () => {};
}
`,
);
),
]);
const configPath = path.join(directory, "tsconfig.json");
const loaded = ts.readConfigFile(configPath, ts.sys.readFile);
+12 -8
View File
@@ -206,16 +206,11 @@ const TSCONFIG = {
esModuleInterop: true,
allowSyntheticDefaultImports: true,
},
include: ["index.tsx", "paseo-plugin.d.ts"],
include: ["**/*.ts", "**/*.tsx"],
};
const ENTRY = `import type { PluginContext } from "@paseo/plugin";
import React from "react";
import { Text } from "react-native";
function MainSurface() {
return <Text>Hello from my plugin</Text>;
}
import { MainSurface } from "./main.client";
export default function contribute(plugin: PluginContext) {
plugin.addSurface("main", MainSurface);
@@ -223,6 +218,14 @@ export default function contribute(plugin: PluginContext) {
}
`;
const CLIENT_SURFACE = `import React from "react";
import { Text } from "react-native";
export function MainSurface() {
return <Text>Hello from my plugin</Text>;
}
`;
export interface PluginScaffold {
id: string;
directory: string;
@@ -260,7 +263,8 @@ export async function scaffoldPluginDirectory(
["package.json", `${JSON.stringify(packageJson, null, 2)}\n`],
["tsconfig.json", `${JSON.stringify(TSCONFIG, null, 2)}\n`],
["paseo-plugin.d.ts", SDK_DECLARATIONS],
["index.tsx", ENTRY],
["index.ts", ENTRY],
["main.client.tsx", CLIENT_SURFACE],
]);
await Promise.all(
[...files].map(([filename, contents]) =>
+70 -8
View File
@@ -5,6 +5,11 @@ import { build, type Plugin } from "esbuild";
type PluginBuildTarget = "client" | "server";
interface SourceRange {
start: number;
end: number;
}
const REGISTRATIONS_REMOVED_BY_TARGET: Record<PluginBuildTarget, ReadonlySet<string>> = {
client: new Set(["handle"]),
server: new Set([
@@ -54,7 +59,7 @@ function defaultPluginFunction(programBody: unknown[]): { body: object; contextN
Reflect.get(statement, "type") === "ExportDefaultDeclaration",
);
if (defaultExports.length !== 1) {
throw new Error("Plugin index.tsx must have exactly one default export function");
throw new Error("Plugin entry point must have exactly one default export function");
}
const declaration = Reflect.get(defaultExports[0] as object, "declaration");
const declarationType =
@@ -88,7 +93,7 @@ function collectRemovedRegistrationRanges(
node: unknown,
contextName: string,
removedNames: ReadonlySet<string>,
ranges: Array<{ start: number; end: number }>,
ranges: SourceRange[],
): void {
if (node === null || typeof node !== "object") return;
if (Reflect.get(node, "type") === "ExpressionStatement") {
@@ -97,7 +102,9 @@ function collectRemovedRegistrationRanges(
const start = Reflect.get(node, "start");
const end = Reflect.get(node, "end");
if (typeof start !== "number" || typeof end !== "number") {
throw new Error(`Could not locate plugin context ${name} registration in index.tsx`);
throw new Error(
`Could not locate plugin context ${name} registration in plugin entry point`,
);
}
ranges.push({ start, end });
return;
@@ -113,27 +120,81 @@ function collectRemovedRegistrationRanges(
}
}
function removeOppositeTargetRegistrations(source: string, target: PluginBuildTarget): string {
function moduleTarget(specifier: string): PluginBuildTarget | null {
if (/\.client(?:\.[cm]?[jt]sx?)?$/.test(specifier)) return "client";
if (/\.server(?:\.[cm]?[jt]sx?)?$/.test(specifier)) return "server";
return null;
}
function collectOppositeTargetImportRanges(
programBody: unknown[],
target: PluginBuildTarget,
ranges: SourceRange[],
): void {
for (const statement of programBody) {
if (
statement === null ||
typeof statement !== "object" ||
Reflect.get(statement, "type") !== "ImportDeclaration"
) {
continue;
}
const source = Reflect.get(statement, "source");
const specifier =
source !== null && typeof source === "object" ? Reflect.get(source, "value") : null;
if (typeof specifier !== "string") continue;
const importedTarget = moduleTarget(specifier);
if (importedTarget === null || importedTarget === target) continue;
const start = Reflect.get(statement, "start");
const end = Reflect.get(statement, "end");
if (typeof start !== "number" || typeof end !== "number") {
throw new Error(`Could not locate ${importedTarget}-only import in plugin entry point`);
}
ranges.push({ start, end });
}
}
function filterEntrypoint(source: string, target: PluginBuildTarget): string {
const ast = parse(source, {
sourceType: "module",
plugins: ["typescript", "jsx"],
});
const pluginFunction = defaultPluginFunction(ast.program.body);
const ranges: Array<{ start: number; end: number }> = [];
const ranges: SourceRange[] = [];
collectRemovedRegistrationRanges(
pluginFunction.body,
pluginFunction.contextName,
REGISTRATIONS_REMOVED_BY_TARGET[target],
ranges,
);
collectOppositeTargetImportRanges(ast.program.body, target, ranges);
let output = source;
for (const range of ranges.toReversed()) {
for (const range of ranges.toSorted((left, right) => right.start - left.start)) {
output = `${output.slice(0, range.start)}${output.slice(range.end)}`;
}
return output;
}
function createRuntimeBoundaryPlugin(target: PluginBuildTarget): Plugin {
return {
name: `paseo-plugin-${target}-runtime-boundary`,
setup(buildContext) {
buildContext.onResolve({ filter: /\.(?:client|server)(?:\.[cm]?[jt]sx?)?$/ }, (args) => {
const importedTarget = moduleTarget(args.path);
if (importedTarget === null || importedTarget === target) return null;
return {
errors: [
{
text: `${importedTarget}-only module cannot be imported into the plugin ${target} bundle: ${args.path}`,
},
],
};
});
},
};
}
function wrapCommonJsBundle(code: string): string {
return `(function(require) {\nconst module = { exports: {} };\nconst exports = module.exports;\n${code}\nreturn module.exports;\n})`;
}
@@ -168,7 +229,7 @@ function createUnusedPlatformModulePlugin(target: PluginBuildTarget): Plugin {
async function compileTarget(entryPath: string, target: PluginBuildTarget): Promise<string> {
const source = await readFile(entryPath, "utf8");
const filteredSource = removeOppositeTargetRegistrations(source, target);
const filteredSource = filterEntrypoint(source, target);
const result = await build({
stdin: {
contents: filteredSource,
@@ -191,7 +252,8 @@ async function compileTarget(entryPath: string, target: PluginBuildTarget): Prom
"zod",
]
: ["@paseo/plugin", "zod"],
plugins: [createUnusedPlatformModulePlugin(target)],
plugins: [createRuntimeBoundaryPlugin(target), createUnusedPlatformModulePlugin(target)],
logLevel: "silent",
treeShaking: true,
write: false,
});
@@ -147,6 +147,27 @@ afterEach(async () => {
});
describe("PluginRuntime", () => {
it("records host-owned plugin lifecycle events", async () => {
const directory = await createPlugin(
"lifecycle",
`export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`,
);
const child = createReloadChild("lifecycle", []);
const runtime = createTestRuntime({ spawnChild: () => child });
await runtime.startPlugin("lifecycle", directory);
await runtime.stopPluginById("lifecycle");
expect(
runtime.getLogs("lifecycle").map(({ stream, message }) => ({ stream, message })),
).toEqual([
{ stream: "stdout", message: "[paseo] Loading plugin" },
{ stream: "stdout", message: "[paseo] Plugin ready" },
{ stream: "stdout", message: "[paseo] Stopping plugin" },
{ stream: "stdout", message: "[paseo] Plugin stopped" },
]);
});
it("frames stdout and stderr, normalizes CRLF, and flushes final fragments once", async () => {
const directory = await createPlugin(
"output",
@@ -162,13 +183,20 @@ describe("PluginRuntime", () => {
child.stdout.write("final stdout");
await runtime.stopPluginById("output");
expect(runtime.getLogs("output").map(({ stream, message }) => ({ stream, message }))).toEqual([
const logs = runtime.getLogs("output");
expect(
logs
.filter((entry) => !entry.message.startsWith("[paseo]"))
.map(({ stream, message }) => ({ stream, message })),
).toEqual([
{ stream: "stdout", message: "first" },
{ stream: "stderr", message: "problem" },
{ stream: "stderr", message: "final stderr" },
{ stream: "stdout", message: "final stdout" },
]);
expect(runtime.getLogs("output").map((entry) => entry.sequence)).toEqual([1, 2, 3, 4]);
expect(logs.map((entry) => entry.sequence)).toEqual(
Array.from({ length: logs.length }, (_, index) => index + 1),
);
});
it("writes plugin output through the daemon logger with structured identity fields", async () => {
@@ -187,14 +215,14 @@ describe("PluginRuntime", () => {
child.stderr.write("connection failed\n");
expect(records.find((record) => record.msg === "Plugin output")).toMatchObject({
expect(records.find((record) => record.message === "connection failed")).toMatchObject({
module: "plugins",
pluginId: "tagged-output",
sequence: 1,
sequence: 3,
stream: "stderr",
message: "connection failed",
});
expect(records.find((record) => record.msg === "Plugin output")?.timestamp).toEqual(
expect(records.find((record) => record.message === "connection failed")?.timestamp).toEqual(
expect.any(String),
);
await runtime.stopAll();
@@ -221,8 +249,14 @@ describe("PluginRuntime", () => {
child.stderr.write("y".repeat(20 * 1024));
await runtime.stopPluginById("noisy");
const logs = runtime.getLogs("noisy");
expect(logs).toHaveLength(16);
expect(logs.at(-1)).toMatchObject({ stream: "stderr", message: "y".repeat(16 * 1024) });
expect(logs.length).toBeLessThanOrEqual(500);
expect(
logs.reduce((bytes, entry) => bytes + Buffer.byteLength(entry.message), 0),
).toBeLessThanOrEqual(256 * 1024);
expect(logs.every((entry) => Buffer.byteLength(entry.message) <= 16 * 1024)).toBe(true);
expect(logs).toContainEqual(
expect.objectContaining({ stream: "stderr", message: "y".repeat(16 * 1024) }),
);
});
it("captures output emitted during initialization and cleanup across reloads", async () => {
@@ -263,6 +297,27 @@ describe("PluginRuntime", () => {
);
});
it("retains compilation failures in the plugin stderr tail", async () => {
const directory = await createPlugin("broken-compile", `export default function contribute( {`);
const runtime = createTestRuntime();
await expect(runtime.startPlugin("broken-compile", directory)).rejects.toThrow();
expect(
runtime.getLogs("broken-compile").map(({ stream, message }) => ({ stream, message })),
).toEqual([
{
stream: "stdout",
message: "[paseo] Loading plugin",
},
{
stream: "stderr",
message: expect.stringContaining("Plugin failed to load:"),
},
]);
await runtime.stopAll();
});
it("waits for the old subprocess to exit before starting its replacement", async () => {
const directory = await createPlugin(
"reloadable",
@@ -505,6 +560,170 @@ export default function contribute(plugin: any) {
await runtime.stopAll();
});
it("keeps client and server modules in their target runtime", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-"));
temporaryDirectories.push(directory);
await Promise.all([
writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "split-runtime" }),
"utf8",
),
writeFile(
path.join(directory, "index.ts"),
`import type { PluginContext } from "@paseo/plugin";
import { Surface } from "./surface.client";
import { inspectRpc } from "./inspect.shared";
import { inspectHost } from "./inspect.server";
export default function contribute(plugin: PluginContext) {
plugin.handle(inspectRpc, inspectHost);
plugin.addSurface("main", Surface);
return () => undefined;
}`,
"utf8",
),
writeFile(
path.join(directory, "surface.client.tsx"),
`import React from "react";
import { StyleSheet, Text } from "react-native";
const styles = StyleSheet.create({ label: { fontWeight: "600" } });
export function Surface() {
return <Text style={styles.label}>Client-only surface</Text>;
}`,
"utf8",
),
writeFile(
path.join(directory, "inspect.shared.ts"),
`import { defineRpc } from "@paseo/plugin";
import { z } from "zod";
export const inspectRpc = defineRpc({
name: "inspect",
input: z.object({}),
output: z.object({ platform: z.string() }),
});`,
"utf8",
),
writeFile(
path.join(directory, "inspect.server.ts"),
`import { platform } from "node:os";
import type { z } from "zod";
import { inspectRpc } from "./inspect.shared";
export function inspectHost(_input: z.input<typeof inspectRpc.input>) {
return { platform: platform() };
}`,
"utf8",
),
]);
const runtime = createTestRuntime();
await runtime.startPlugin("split-runtime", directory);
const plugin = runtime.catalog()[0];
expect(plugin?.clientBundle).toContain("Client-only surface");
expect(plugin?.clientBundle).not.toContain("node:os");
await expect(runtime.invoke("split-runtime", "inspect", {})).resolves.toMatchObject({
platform: expect.any(String),
});
await runtime.stopAll();
});
it("rejects server imports from client-only modules", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-"));
temporaryDirectories.push(directory);
await Promise.all([
writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "cross-runtime-import" }),
"utf8",
),
writeFile(
path.join(directory, "index.ts"),
`import type { PluginContext } from "@paseo/plugin";
import { Surface } from "./surface.client";
export default function contribute(plugin: PluginContext) {
plugin.addSurface("main", Surface);
return () => undefined;
}`,
"utf8",
),
writeFile(
path.join(directory, "surface.client.tsx"),
`import { readSecret } from "./secret.server";
export function Surface() { return readSecret(); }`,
"utf8",
),
writeFile(
path.join(directory, "secret.server.ts"),
`export function readSecret() { return null; }`,
"utf8",
),
]);
const runtime = createTestRuntime();
await expect(runtime.startPlugin("cross-runtime-import", directory)).rejects.toThrow(
"server-only module cannot be imported into the plugin client bundle",
);
await runtime.stopAll();
});
it("rejects client imports from server-only modules", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-"));
temporaryDirectories.push(directory);
await Promise.all([
writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "cross-runtime-import" }),
"utf8",
),
writeFile(
path.join(directory, "index.ts"),
`import type { PluginContext } from "@paseo/plugin";
import { inspect } from "./inspect.server";
import { inspectRpc } from "./inspect.shared";
export default function contribute(plugin: PluginContext) {
plugin.handle(inspectRpc, inspect);
return () => undefined;
}`,
"utf8",
),
writeFile(
path.join(directory, "inspect.shared.ts"),
`import { defineRpc } from "@paseo/plugin";
import { z } from "zod";
export const inspectRpc = defineRpc({
name: "inspect",
input: z.object({}),
output: z.object({}),
});`,
"utf8",
),
writeFile(
path.join(directory, "inspect.server.ts"),
`import { Surface } from "./surface.client";
export function inspect() { void Surface; return {}; }`,
"utf8",
),
writeFile(
path.join(directory, "surface.client.tsx"),
`export function Surface() { return null; }`,
"utf8",
),
]);
const runtime = createTestRuntime();
await expect(runtime.startPlugin("cross-runtime-import", directory)).rejects.toThrow(
"client-only module cannot be imported into the plugin server bundle",
);
await runtime.stopAll();
});
it("rejects a handler result that does not match its RPC output schema", async () => {
const directory = await createPlugin(
"invalid-output",
+27 -9
View File
@@ -10,7 +10,9 @@ import { readPluginManifest } from "./manifest.js";
import type { PluginProcessMessage, PluginProcessRequest } from "./plugin-process-protocol.js";
import { PluginSessionSocket } from "./session-socket.js";
const ENTRY_FILENAME = "index.tsx";
const ENTRY_FILENAME = "index.ts";
// COMPAT(plugin-index-tsx): added in v0.4, remove after 2027-02-17
const LEGACY_ENTRY_FILENAME = "index.tsx";
const REQUEST_TIMEOUT_MS = 30_000;
const MAX_LOG_ENTRIES = 500;
const MAX_LOG_BYTES = 256 * 1024;
@@ -169,6 +171,10 @@ function terminatePluginChild(child: PluginChild): void {
if (!child.killed) child.kill();
}
function describeError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function send(child: PluginChild, message: PluginProcessRequest): Promise<void> {
return new Promise((resolve, reject) => {
child.send(message, (error) => {
@@ -181,9 +187,13 @@ function send(child: PluginChild, message: PluginProcessRequest): Promise<void>
});
}
async function requireRegularFile(filePath: string, label: string): Promise<void> {
const info = await stat(filePath).catch(() => null);
if (!info?.isFile()) throw new Error(`${label} is missing: ${filePath}`);
async function resolveEntryPath(directory: string): Promise<string> {
for (const filename of [ENTRY_FILENAME, LEGACY_ENTRY_FILENAME]) {
const filePath = path.join(directory, filename);
const info = await stat(filePath).catch(() => null);
if (info?.isFile()) return filePath;
}
throw new Error(`Plugin entry point is missing: ${path.join(directory, ENTRY_FILENAME)}`);
}
export class PluginRuntime {
@@ -217,12 +227,17 @@ export class PluginRuntime {
canPublish: () => boolean = () => true,
): Promise<void> {
if (this.plugins.has(pluginId)) throw new Error(`Plugin is already running: ${pluginId}`);
const loaded = await this.loadDirectoryPlugin(pluginId, configuredPath);
this.appendLog(pluginId, "stdout", "[paseo] Loading plugin");
const loaded = await this.loadDirectoryPlugin(pluginId, configuredPath).catch((error) => {
this.appendLog(pluginId, "stderr", `[paseo] Plugin failed to load: ${describeError(error)}`);
throw error;
});
if (!canPublish()) {
await this.stopPlugin(loaded);
throw new Error(`Plugin start cancelled: ${pluginId}`);
}
this.plugins.set(pluginId, loaded);
this.appendLog(pluginId, "stdout", "[paseo] Plugin ready");
}
async stopPluginById(pluginId: string): Promise<boolean> {
@@ -290,8 +305,7 @@ export class PluginRuntime {
): Promise<LoadedPlugin> {
const directory = path.resolve(configuredPath);
await readPluginManifest(directory);
const entryPath = path.join(directory, ENTRY_FILENAME);
await requireRegularFile(entryPath, "Plugin entry point");
const entryPath = await resolveEntryPath(directory);
const bundles = await compilePlugin(entryPath);
const sessionHost = this.sessionHost;
if (!sessionHost) throw new Error("Plugin Paseo session host is not attached");
@@ -394,9 +408,11 @@ export class PluginRuntime {
}
private async stopPlugin(loaded: LoadedPlugin): Promise<void> {
this.appendLog(loaded.id, "stdout", "[paseo] Stopping plugin");
if (loaded.child.killed) {
loaded.sessionSocket.peerClosed();
await loaded.sessionClosed;
this.appendLog(loaded.id, "stdout", "[paseo] Plugin stopped");
return;
}
const closed = new Promise<void>((resolve) =>
@@ -410,6 +426,7 @@ export class PluginRuntime {
await closed;
loaded.sessionSocket.peerClosed();
await loaded.sessionClosed;
this.appendLog(loaded.id, "stdout", "[paseo] Plugin stopped");
}
private rejectPending(loaded: LoadedPlugin, message: string): void {
@@ -421,6 +438,7 @@ export class PluginRuntime {
}
private appendLog(pluginId: string, stream: PluginLogEntry["stream"], message: string): void {
const boundedMessage = Buffer.from(message).subarray(0, MAX_LOG_LINE_BYTES).toString("utf8");
let tail = this.logTails.get(pluginId);
if (!tail) {
tail = { entries: [], bytes: 0, nextSequence: 1 };
@@ -430,10 +448,10 @@ export class PluginRuntime {
sequence: tail.nextSequence++,
timestamp: new Date().toISOString(),
stream,
message,
message: boundedMessage,
};
tail.entries.push(entry);
tail.bytes += Buffer.byteLength(message);
tail.bytes += Buffer.byteLength(boundedMessage);
while (tail.entries.length > MAX_LOG_ENTRIES || tail.bytes > MAX_LOG_BYTES) {
const removed = tail.entries.shift();
if (!removed) break;