Skip to content
Merged
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
41 changes: 26 additions & 15 deletions packages/agent-bundle/src/effect/boundary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,28 +132,39 @@ export const makeScopedEffectRuntime = <R, E>(
};

/**
* AbortSignal → Effect interruption, for programs that still run inside
* Effect and receive a host signal. The Promise edge also accepts `signal`
* directly via {@link runPromise}.
* Host AbortSignal → Effect interruption. Re-checks `signal.aborted` when
* the effect starts (not only when this helper is constructed) so a signal
* that aborts between construction and run still interrupts. The listener
* is registered first; aborted signals do not replay `abort`, so the
* callback rechecks immediately after `addEventListener`.
*/
export const interruptWhenAborted = <A, E, R>(
effect: Effect.Effect<A, E, R>,
signal: AbortSignal,
): Effect.Effect<A, E, R> => {
if (signal.aborted) return interruptAs();
return Effect.raceFirst(
effect,
Effect.callback<never>((resume) => {
const onAbort = () => {
export const abortToInterrupt = (signal: AbortSignal): Effect.Effect<never> =>
Effect.suspend(() => {
if (signal.aborted) return interruptAs();
return Effect.callback<never>((resume) => {
let settled = false;
const onAbort = (): void => {
if (settled) return;
settled = true;
resume(Effect.interrupt);
};
signal.addEventListener('abort', onAbort, { once: true });
if (signal.aborted) onAbort();
return Effect.sync(() => {
signal.removeEventListener('abort', onAbort);
});
}),
);
};
});
});

/**
* AbortSignal → Effect interruption, for programs that still run inside
* Effect and receive a host signal. The Promise edge also accepts `signal`
* directly via {@link runPromise}.
*/
export const interruptWhenAborted = <A, E, R>(
effect: Effect.Effect<A, E, R>,
signal: AbortSignal,
): Effect.Effect<A, E, R> => Effect.raceFirst(effect, abortToInterrupt(signal));

/**
* Effect interruption → AbortSignal, for Promise/fetch APIs that take a
Expand Down
13 changes: 13 additions & 0 deletions packages/agent-bundle/tests/effect-boundary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { DevLockError } from '../src/dev/dev-lock.ts';
import { ProjectEventHubError } from '../src/dev/events.ts';
import {
abortError,
abortToInterrupt,
interruptWhenAborted,
isAbortError,
isTypedDevError,
Expand Down Expand Up @@ -76,4 +77,16 @@ describe('effect boundary (agent-bundle dev seam)', () => {
expect(toDevError('plain')).toEqual(new Error('plain'));
expect(abortError().name).toBe('AbortError');
});

it('interrupts when the host signal aborts between construction and run', async () => {
const controller = new AbortController();
const program = interruptWhenAborted(Effect.never, controller.signal);
controller.abort();
const pending = runPromise(program);
const hung = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('interruptWhenAborted hung after abort-before-start')), 250);
});
await expect(Promise.race([pending, hung])).rejects.toSatisfy(isAbortError);
await expect(runPromise(abortToInterrupt(controller.signal))).rejects.toSatisfy(isAbortError);
});
});
12 changes: 7 additions & 5 deletions packages/rsc-runtime/src/effect/boundary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,20 +132,22 @@ export const makeScopedEffectRuntime = <R, E>(
/**
* Host AbortSignal → Effect interruption. Re-checks `signal.aborted` when
* the effect starts (not only when this helper is constructed) so a signal
* that aborts between construction and run still interrupts.
* that aborts between construction and run still interrupts. The listener
* is registered first; aborted signals do not replay `abort`, so the
* callback rechecks immediately after `addEventListener`.
*/
export const abortToInterrupt = (signal: AbortSignal): Effect.Effect<never> =>
Effect.suspend(() => {
if (signal.aborted) return interruptAs();
return Effect.callback<never>((resume) => {
if (signal.aborted) {
resume(Effect.interrupt);
return undefined;
}
let settled = false;
const onAbort = (): void => {
if (settled) return;
settled = true;
resume(Effect.interrupt);
};
signal.addEventListener('abort', onAbort, { once: true });
if (signal.aborted) onAbort();
return Effect.sync(() => {
signal.removeEventListener('abort', onAbort);
});
Expand Down
67 changes: 65 additions & 2 deletions packages/rsc-runtime/tests/effect-boundary-lint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,32 @@ import { effectBoundaryPlugin, isEffectBoundaryFile } from '../../../scripts/esl

const rule = effectBoundaryPlugin.rules['no-ad-hoc-run'];

const apply = (filename: string, visit: (listeners: ReturnType<typeof rule.create>) => void) => {
type ImportNode = {
readonly imported?: { readonly name?: string };
readonly local?: { readonly name?: string };
readonly parent?: { readonly source?: { readonly value?: unknown } };
};

type MemberNode = {
readonly computed?: boolean;
readonly object?: { readonly name?: string; readonly type?: string };
readonly property?: { readonly name?: string; readonly type?: string };
};

type LintListeners = ReturnType<typeof rule.create> & {
ImportNamespaceSpecifier?(node: ImportNode): void;
ImportSpecifier?(node: ImportNode): void;
MemberExpression?(node: MemberNode): void;
};

const apply = (filename: string, visit: (listeners: LintListeners) => void) => {
const reports: Array<{ readonly messageId: string; readonly data: { readonly name: string } }> = [];
const listeners = rule.create({
filename,
report(descriptor) {
reports.push({ data: descriptor.data, messageId: descriptor.messageId });
},
});
}) as LintListeners;
visit(listeners);
return reports;
};
Expand Down Expand Up @@ -75,4 +93,49 @@ describe('effect-boundary lint', () => {
});
expect(reports).toEqual([]);
});

it('rejects aliased Effect namespaces (named and star imports)', () => {
const named = apply('packages/rsc-runtime/src/dispatcher.ts', (listeners) => {
listeners.ImportSpecifier?.({
imported: { name: 'Effect' },
local: { name: 'Fx' },
parent: { source: { value: 'effect' } },
});
listeners.MemberExpression?.({
computed: false,
object: { name: 'Fx', type: 'Identifier' },
property: { name: 'runPromise', type: 'Identifier' },
});
});
expect(named).toEqual([{ data: { name: 'Fx.runPromise' }, messageId: 'forbiddenCall' }]);

const star = apply('packages/agent-bundle/src/dev/coordinator.ts', (listeners) => {
listeners.ImportNamespaceSpecifier?.({
local: { name: 'E' },
parent: { source: { value: 'effect' } },
});
listeners.MemberExpression?.({
computed: false,
object: { name: 'E', type: 'Identifier' },
property: { name: 'runSync', type: 'Identifier' },
});
});
expect(star).toEqual([{ data: { name: 'E.runSync' }, messageId: 'forbiddenCall' }]);
});

it('does not flag aliased Effect used for non-runners', () => {
const reports = apply('packages/rsc-runtime/src/reconciler.ts', (listeners) => {
listeners.ImportSpecifier?.({
imported: { name: 'Effect' },
local: { name: 'E' },
parent: { source: { value: 'effect' } },
});
listeners.MemberExpression?.({
computed: false,
object: { name: 'E', type: 'Identifier' },
property: { name: 'succeed', type: 'Identifier' },
});
});
expect(reports).toEqual([]);
});
});
6 changes: 5 additions & 1 deletion packages/rsc-runtime/tests/effect-boundary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,11 @@ describe('effect boundary', () => {
const controller = new AbortController();
const program = interruptWhenAborted(Effect.never, controller.signal);
controller.abort();
await expect(runPromise(program)).rejects.toSatisfy(isAbortError);
const pending = runPromise(program);
const hung = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('interruptWhenAborted hung after abort-before-start')), 250);
});
await expect(Promise.race([pending, hung])).rejects.toSatisfy(isAbortError);
await expect(runPromise(abortToInterrupt(controller.signal))).rejects.toSatisfy(isAbortError);
});

Expand Down
44 changes: 33 additions & 11 deletions scripts/eslint-plugin-effect-boundary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const RUN_NAMES = new Set([
]);

const EFFECT_MODULES = new Set(['effect']);
const EFFECT_NAMESPACES = new Set(['Effect', 'Runtime']);
const EFFECT_NAMESPACE_MODULES = new Set(['effect', 'effect/Effect', 'effect/Runtime']);

const posixPath = (filename: string): string => filename.replaceAll('\\', '/');

Expand All @@ -33,9 +35,15 @@ const importedName = (node: {
return imported.name;
};

const localName = (node: { readonly local?: { readonly name?: string } }): string | undefined =>
node.local?.name;

const moduleName = (node: { readonly source?: { readonly value?: unknown } }): string | undefined =>
typeof node.source?.value === 'string' ? node.source.value : undefined;

const isEffectModule = (source: string | undefined): boolean =>
source !== undefined && (EFFECT_MODULES.has(source) || source.startsWith('effect/'));

export const effectBoundaryPlugin = {
meta: {
name: 'effect-boundary',
Expand All @@ -60,7 +68,31 @@ export const effectBoundaryPlugin = {
report(descriptor: { readonly node: unknown; readonly messageId: string; readonly data: { readonly name: string } }): void;
}) {
if (isEffectBoundaryFile(context.filename)) return {};
const runnerNamespaces = new Set<string>(EFFECT_NAMESPACES);
const rememberNamespace = (name: string | undefined): void => {
if (name !== undefined) runnerNamespaces.add(name);
};
return {
ImportSpecifier(node: {
readonly imported?: { readonly name?: string };
readonly local?: { readonly name?: string };
readonly parent?: { readonly source?: { readonly value?: unknown } };
}) {
const name = importedName(node);
const source = moduleName(node.parent ?? {});
if (!isEffectModule(source) || name === undefined) return;
if (EFFECT_NAMESPACES.has(name)) rememberNamespace(localName(node) ?? name);
if (!RUN_NAMES.has(name)) return;
context.report({ data: { name }, messageId: 'forbiddenImport', node });
},
ImportNamespaceSpecifier(node: {
readonly local?: { readonly name?: string };
readonly parent?: { readonly source?: { readonly value?: unknown } };
}) {
const source = moduleName(node.parent ?? {});
if (source === undefined || !EFFECT_NAMESPACE_MODULES.has(source)) return;
rememberNamespace(localName(node));
},
MemberExpression(node: {
readonly computed?: boolean;
readonly object?: { readonly name?: string; readonly type?: string };
Expand All @@ -70,19 +102,9 @@ export const effectBoundaryPlugin = {
const name = node.property?.name;
if (name === undefined || !RUN_NAMES.has(name)) return;
const objectName = node.object?.name;
if (objectName !== 'Effect' && objectName !== 'Runtime') return;
if (objectName === undefined || !runnerNamespaces.has(objectName)) return;
Comment on lines 104 to +105

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Detect nested runners on root namespace imports

When code uses import * as E from 'effect', the package exports the Effect module as E.Effect (repos/effect/packages/effect/src/index.ts:157), so a valid call is E.Effect.runPromise(...); the new test's E.runSync is not an actual root-package runner. For the valid expression, the outer MemberExpression has another MemberExpression as its object, making objectName undefined here and allowing precisely this star-alias form to bypass no-ad-hoc-run. Recognize E.Effect.<runner> so root namespace imports cannot evade the boundary rule.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #199 (merged as 954a44b). The rule now walks nested static member paths, so import * as E from 'effect'; E.Effect.runPromise(...) (and deeper chains rooted at any remembered effect namespace) are flagged, while unrelated roots like Other.Effect.runPromise stay clean. Unit tests cover the nested/renamed namespace cases and the negative case.

context.report({ data: { name: `${objectName}.${name}` }, messageId: 'forbiddenCall', node });
},
ImportSpecifier(node: {
readonly imported?: { readonly name?: string };
readonly parent?: { readonly source?: { readonly value?: unknown } };
}) {
const name = importedName(node);
if (name === undefined || !RUN_NAMES.has(name)) return;
const source = moduleName(node.parent ?? {});
if (source === undefined || (!EFFECT_MODULES.has(source) && !source.startsWith('effect/'))) return;
context.report({ data: { name }, messageId: 'forbiddenImport', node });
},
};
},
},
Expand Down
Loading