Skip to content

fix: resolve async factory services in dispatcher and all framework adapters#859

Merged
hotlong merged 5 commits into
mainfrom
copilot/fix-async-service-bug
Mar 3, 2026
Merged

fix: resolve async factory services in dispatcher and all framework adapters#859
hotlong merged 5 commits into
mainfrom
copilot/fix-async-service-bug

Conversation

Copilot AI commented Mar 3, 2026

Copy link
Copy Markdown
Contributor

kernel.getService('auth'), kernel.getService('analytics'), and kernel.context.getService('protocol') throw "Service 'X' is async - use await" when registered via async factory (PluginLoader). The dispatcher's internal getService(), direct protocol service lookups, and all adapter auth lookups used the sync path, which the kernel rejects for Promise-returning factories.

Dispatcher (http-dispatcher.ts)

  • Introduced resolveService(name) helper with full fallback chain: getServiceAsyncgetService (sync) → context.getService → services Map. Each step checks for non-null results before returning, ensuring proper fallthrough.
  • getService() delegates to resolveService() for all CoreServiceName lookups.
  • getObjectQLService() simplified to use resolveService().
  • Fixed 6 occurrences of this.kernel.context.getService('protocol') in handleMetadata() and handleUi() — these used the same sync path that throws for async factory services.
private async resolveService(name: string) {
    if (typeof this.kernel.getServiceAsync === 'function') {
        try {
            const svc = await this.kernel.getServiceAsync(name);
            if (svc != null) return svc;
        } catch { }
    }
    if (typeof this.kernel.getService === 'function') {
        try {
            const svc = await this.kernel.getService(name);
            if (svc != null) return svc;
        } catch { }
    }
    if (this.kernel?.context?.getService) {
        try {
            const svc = await this.kernel.context.getService(name);
            if (svc != null) return svc;
        } catch { }
    }
    return this.getServicesMap()[name];
}

Framework Adapters (all 7)

  • Express, Fastify, Hono, Next.js, SvelteKit, NestJS, Nuxt — auth service resolution switched from sync getService to async-first pattern with graceful fallback:
let authService: AuthService | null = null;
try {
    if (typeof kernel.getServiceAsync === 'function') {
        authService = await kernel.getServiceAsync<AuthService>('auth');
    } else if (typeof kernel.getService === 'function') {
        authService = kernel.getService<AuthService>('auth');
    }
} catch {
    authService = null;
}

All affected services

  • auth, analytics, metadata, file-storage, automation — via getService()resolveService()
  • objectql — via getObjectQLService()resolveService()
  • protocol — 6 direct context.getService() calls replaced with resolveService('protocol')

Tests

  • 7 new dispatcher tests for getServiceAsync preferred path (analytics, auth, automation, file-storage, protocol, null fallthrough, error fallthrough)
  • getServiceAsync adapter tests for Hono, Next.js, SvelteKit
  • All 244 tests pass (55 dispatcher + 189 adapter)

Fully backward-compatible — falls through to sync path when getServiceAsync is unavailable.

Original prompt

This section details on the original issue you should resolve

<issue_title>Critical Bug: getService Throws for Async Factory Services ('auth', 'analytics') — Kernel/Dispatcher/Adapters must resolve async services</issue_title>
<issue_description># Synchronous getService Error for Async Auth & Analytics Services

Problem Statement

Calling getService('analytics') or getService('auth') via the ObjectStack Kernel (ObjectKernel/LiteKernel) throws the following runtime error if the service was registered with an async factory:

Error: Service 'analytics' is async - use await
Error: Service 'auth' is async - use await

Impacted Components

  • packages/runtime/src/http-dispatcher.ts — Dispatcher uses private getService() which calls sync kernel.getService()
  • Kernel & Dispatcher call chain (dispatcher.handleAnalytics, dispatcher.handleAuth)
  • All adapter plugins (Fastify, Hono, Next.js, SvelteKit, NestJS) that rely on kernel.getService('auth') or kernel.getService('analytics')
  • Plugins/services registered via async factory (e.g. PluginLoader)

Root Cause

  • getService() uses the synchronous path but some services (auth, analytics) were registered as async factories (via dependency injection in PluginLoader).
  • When the service lookup resolves to a Promise, kernel throws to avoid returning a Promise from a sync API.
  • Dispatcher and adapters frequently use sync getService, which breaks if consumers are not migrated to async path.

Code Evidence

  • kernel.ts: Throws when service is async but called via sync API.
  • http-dispatcher.ts: Dispatcher sync service lookup fails for async services.
  • Service interfaces (IAnalyticsService, IAuthService) are Promise-based, and actual plugin implementations often use async factories for setup.
  • Test files simulate both sync and async scenarios (see *.test.ts for dispatcher, adapters).

Solution Proposal

  • Refactor http-dispatcher.ts and all adapters:
    • Use an explicit getServiceAsync() method for service registry lookup when factory-based services are possible.
    • Ensure dispatcher.handleAuth and dispatcher.handleAnalytics always resolve services asynchronously.
    • Adapters must also use async resolution for service lookup instead of kernel's synchronous getService().
  • Best Practice: Consider deprecating direct getService() access for any service interface defined as Promise-based, or add documentation warnings in ObjectStack Kernel and Adapter docs.

Priority

  • High: Breaks dispatch and dynamic service composition for any plugin using async registration.

References & Context

  • ObjectStack repo: spec
  • Multiple test examples show async mocks (passing) but production sync lookups fail.

Root cause & solution verified. Please prioritize and coordinate adapter refactor/migration.

/cc @hotlong @objectstack-ai


(Merged: covers both analytics & auth, all adapters, and dispatcher)</issue_description>

Comments on the Issue (you are @copilot in this section)


✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.

@vercel

vercel Bot commented Mar 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
objectstack-play Ready Ready Preview, Comment Mar 3, 2026 6:13am
spec Ready Ready Preview, Comment Mar 3, 2026 6:13am

Request Review

Copilot AI and others added 3 commits March 3, 2026 04:58
… all adapters

Resolves runtime error "Service 'X' is async - use await" when services
like auth and analytics are registered with async factories.

- http-dispatcher.ts: getService() and getObjectQLService() now prefer
  kernel.getServiceAsync() over sync kernel.getService()
- All 7 adapters (Express, Fastify, Hono, Next.js, SvelteKit, NestJS,
  Nuxt) updated to use getServiceAsync() for auth service resolution
  with graceful fallback to sync path

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix critical bug in getService for async factory services fix: resolve async factory services in dispatcher and all framework adapters Mar 3, 2026
@hotlong

hotlong commented Mar 3, 2026

Copy link
Copy Markdown
Collaborator

@copilot 评估是否所有服务都有类似的问题,仔细检查是否已经完成了所有的开发并测试

…sync tests

- Fixed 6 occurrences of this.kernel.context.getService('protocol') in
  handleMetadata and handleUi — these used the sync path that throws
  for async factory services, same root cause as auth/analytics.
- Introduced resolveService() helper that all service lookups now use:
  getServiceAsync → getService → context.getService → services Map
  with null-check fallthrough at each step.
- Simplified getObjectQLService() to use resolveService() internally.
- Added 7 new dispatcher tests for getServiceAsync preferred path
  covering analytics, auth, automation, file-storage, protocol,
  null fallthrough, and error fallthrough.
- Added getServiceAsync tests for Hono, Next.js, and SvelteKit adapters.

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>

Copilot AI commented Mar 3, 2026

Copy link
Copy Markdown
Contributor Author

@copilot 评估是否所有服务都有类似的问题,仔细检查是否已经完成了所有的开发并测试

Completed a thorough audit of all service resolution paths. Found and fixed additional issues beyond auth/analytics:

Previously missed — protocol service (6 occurrences):

  • handleMetadata() had 5 direct calls to this.kernel.context.getService('protocol') using the sync path
  • handleUi() had 1 direct call to this.kernel.context.getService('protocol') using the sync path
  • These had the same async factory vulnerability as auth/analytics

Fix: Introduced resolveService(name) helper with full fallback chain: getServiceAsyncgetServicecontext.getService → services Map, with null-check fallthrough at each step. All service lookups now route through this single method.

All services now covered:

  • auth, analytics, metadata, file-storage, automation — via getService()resolveService()
  • objectql — via getObjectQLService()resolveService()
  • protocol — 6 direct calls replaced with resolveService('protocol')

Test coverage added (commit 77086c2):

  • 7 new dispatcher tests for getServiceAsync preferred path (analytics, auth, automation, file-storage, protocol, null fallthrough, error fallthrough)
  • getServiceAsync tests for Hono, Next.js, SvelteKit adapters
  • All 244 tests pass (55 dispatcher + 189 adapter)

@hotlong
hotlong marked this pull request as ready for review March 3, 2026 05:40
Copilot AI review requested due to automatic review settings March 3, 2026 05:40
@hotlong
hotlong merged commit 3e908da into main Mar 3, 2026
2 of 4 checks passed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Fixes runtime failures when services are registered via async factories by resolving services through an async-first path in the dispatcher and framework adapters.

Changes:

  • Added HttpDispatcher.resolveService() to prefer kernel.getServiceAsync() and updated dispatcher service lookups (including protocol) to use it.
  • Updated all framework adapters’ auth resolution to prefer getServiceAsync() with sync fallback.
  • Added/updated tests for dispatcher and selected adapters; updated ROADMAP.md entry.

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 13 comments.

Show a summary per file
File Description
packages/runtime/src/http-dispatcher.ts Introduces resolveService() and routes protocol/objectql/core services through it.
packages/runtime/src/http-dispatcher.test.ts Adds tests for async-first resolution and fallthrough behavior.
packages/adapters/express/src/index.ts Switches auth service lookup to async-first.
packages/adapters/fastify/src/index.ts Switches auth service lookup to async-first.
packages/adapters/hono/src/index.ts Switches auth service lookup to async-first.
packages/adapters/hono/src/hono.test.ts Adds adapter tests covering getServiceAsync auth resolution and fallthrough.
packages/adapters/nextjs/src/index.ts Switches auth service lookup to async-first.
packages/adapters/nextjs/src/nextjs.test.ts Adds adapter tests covering getServiceAsync auth resolution and fallthrough.
packages/adapters/sveltekit/src/index.ts Switches auth service lookup to async-first.
packages/adapters/sveltekit/src/sveltekit.test.ts Adds adapter tests covering getServiceAsync auth resolution and fallthrough.
packages/adapters/nestjs/src/index.ts Switches auth service lookup to async-first.
packages/adapters/nuxt/src/index.ts Switches auth service lookup to async-first.
ROADMAP.md Updates roadmap note to reflect adapter coverage.

Comment on lines +79 to +81
} catch {
// Service not registered — fall through to dispatcher
authService = null;

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

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

This catch-all treats any error during auth service resolution as "service not registered" and falls back to the dispatcher. That fixes the async-factory sync-call case, but it can also mask genuine auth factory/initialization failures. Consider only swallowing expected lookup errors and rethrow unexpected errors so failures remain observable.

Suggested change
} catch {
// Service not registered — fall through to dispatcher
authService = null;
} catch (err) {
// Only treat expected "service not registered" lookup errors as absence.
const isLookupError =
err instanceof Error &&
typeof err.message === 'string' &&
/not\s+(registered|found)/i.test(err.message);
if (isLookupError) {
// Service not registered — fall through to dispatcher
authService = null;
} else {
// Unexpected error during auth service resolution: rethrow so it remains observable
throw err;
}

Copilot uses AI. Check for mistakes.
Comment on lines +115 to +117
} catch {
// Service not registered — fall through to dispatcher
authService = null;

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

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

This catch-all treats any error during auth service resolution as "service not registered" and falls back to the dispatcher. That can mask genuine auth factory/initialization failures. Consider only swallowing expected lookup errors (not found / "is async") and rethrow unexpected errors so failures surface as 500s.

Suggested change
} catch {
// Service not registered — fall through to dispatcher
authService = null;
} catch (err: any) {
const message = typeof err?.message === 'string' ? err.message : '';
const lowerMessage = message.toLowerCase();
const isLookupError =
lowerMessage.includes('not registered') ||
lowerMessage.includes('not found') ||
lowerMessage.includes('is async');
if (isLookupError) {
// Service not registered or only available asynchronously — fall through to dispatcher
authService = null;
} else {
// Unexpected error — rethrow so it surfaces as a 500
throw err;
}

Copilot uses AI. Check for mistakes.
Comment on lines +125 to +127
} catch {
// Service not registered — fall through to legacy dispatcher
authService = null;

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

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

This catch-all treats any error during auth service resolution as "service not registered" and falls back to the dispatcher, which can mask real auth factory/initialization failures. Consider only swallowing expected lookup errors (not found / "is async") and rethrow unexpected errors so failures surface correctly.

Suggested change
} catch {
// Service not registered — fall through to legacy dispatcher
authService = null;
} catch (error: any) {
const message = typeof error?.message === 'string' ? error.message : '';
const isNotRegistered =
message.includes('not registered') || message.includes('not found');
const isAsyncHint = message.includes('is async');
if (isNotRegistered || isAsyncHint) {
// Auth service not registered or only available asynchronously —
// fall through to legacy dispatcher.
authService = null;
} else {
// Unexpected error during auth service resolution — surface it.
throw error;
}

Copilot uses AI. Check for mistakes.
Comment on lines +120 to +124
if (typeof kernel.getServiceAsync === 'function') {
authService = await kernel.getServiceAsync<AuthService>('auth');
} else if (typeof kernel.getService === 'function') {
authService = kernel.getService<AuthService>('auth');
}

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

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

NestJS adapter has tests, but they don’t cover the new auth resolution branch that prefers kernel.getServiceAsync(). Add a test that supplies getServiceAsync('auth') and asserts it is called (and a fallthrough test when it throws/returns null) to prevent regressions.

Copilot uses AI. Check for mistakes.
Comment on lines +92 to +93
} catch {
// Service not registered — fall through to dispatcher

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

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

This catch-all treats any error during auth service resolution as "service not registered" and falls back to the dispatcher. That can mask genuine auth factory/initialization failures. Consider only swallowing expected lookup errors (e.g. not-found / "is async - use await") and rethrow unexpected errors so failures surface as 500s.

Suggested change
} catch {
// Service not registered — fall through to dispatcher
} catch (err) {
const message =
err instanceof Error
? err.message
: typeof err === 'string'
? err
: String(err);
const isNotRegistered =
message.includes('not registered') ||
message.includes('not found') ||
message.includes('service "auth"');
const isAsyncHint =
message.includes('is async - use await') ||
message.includes('use getServiceAsync');
if (!isNotRegistered && !isAsyncHint) {
// Unexpected error during auth service resolution — surface as 500
throw err;
}
// Service not registered / not available — fall through to dispatcher

Copilot uses AI. Check for mistakes.
Comment on lines +110 to +114
if (typeof options.kernel.getServiceAsync === 'function') {
authService = await options.kernel.getServiceAsync<AuthService>('auth');
} else if (typeof options.kernel.getService === 'function') {
authService = options.kernel.getService<AuthService>('auth');
}

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

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

Nuxt adapter has tests, but they don’t cover the new auth resolution branch that prefers kernel.getServiceAsync(). Add a test that supplies getServiceAsync('auth') and asserts the auth handler is used, plus a fallthrough test when getServiceAsync throws/returns null.

Copilot uses AI. Check for mistakes.
Comment on lines +866 to +870
try {
const svc = await this.kernel.getServiceAsync(name);
if (svc != null) return svc;
} catch {
// Service not registered or async resolution failed — fall through

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

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

resolveService() currently catches all errors from kernel.getServiceAsync() and silently falls through. That can mask real service factory failures (misconfig/connection errors) and make the dispatcher behave as if the service is simply absent. Consider only falling through on "not found" (or other explicitly non-fatal) errors and rethrow unexpected errors so factory failures surface as 500s.

Copilot uses AI. Check for mistakes.
Comment on lines +877 to +878
} catch {
// Service not registered or sync resolution threw "is async" — fall through

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

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

resolveService() also swallows all errors from kernel.getService() (sync). If getServiceAsync is unavailable and getService throws "Service 'X' is async - use await", swallowing it will likely return undefined from the map and hide the actionable error. Consider detecting the "is async" case and rethrowing (or wrapping) when there is no async API available.

Suggested change
} catch {
// Service not registered or sync resolution threw "is async" — fall through
} catch (error: unknown) {
// If there is no async API and the sync call explicitly reports that the
// service is async ("Service 'X' is async - use await"), surface that
// error instead of swallowing it, as it is an actionable misconfiguration.
if (typeof this.kernel.getServiceAsync !== 'function') {
const err = error instanceof Error ? error : undefined;
if (err && /is async/i.test(err.message) && /use await/i.test(err.message)) {
throw err;
}
}
// Otherwise, service not registered or other sync resolution error — fall through

Copilot uses AI. Check for mistakes.
Comment on lines +87 to +91
if (typeof options.kernel.getServiceAsync === 'function') {
authService = await options.kernel.getServiceAsync<AuthService>('auth');
} else if (typeof options.kernel.getService === 'function') {
authService = options.kernel.getService<AuthService>('auth');
}

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

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

The Express adapter changed auth resolution to prefer kernel.getServiceAsync(), but the adapter test suite doesn’t cover this branch. Add a test that supplies getServiceAsync('auth') and asserts it is used (and another that asserts fallthrough when getServiceAsync throws/returns null) to prevent regressions.

Copilot uses AI. Check for mistakes.
Comment on lines +90 to +94
if (typeof options.kernel.getServiceAsync === 'function') {
authService = await options.kernel.getServiceAsync<AuthService>('auth');
} else if (typeof options.kernel.getService === 'function') {
authService = options.kernel.getService<AuthService>('auth');
}

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

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

Fastify adapter tests exist, but they don’t cover the new auth resolution branch that prefers kernel.getServiceAsync(). Add a test that provides getServiceAsync('auth') and asserts it is used, plus a fallthrough test when getServiceAsync throws/returns null.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Critical Bug: getService Throws for Async Factory Services ('auth', 'analytics') — Kernel/Dispatcher/Adapters must resolve async services

3 participants