diff --git a/.changeset/serialize-platform-shell-lifecycle.md b/.changeset/serialize-platform-shell-lifecycle.md new file mode 100644 index 000000000..2b565902f --- /dev/null +++ b/.changeset/serialize-platform-shell-lifecycle.md @@ -0,0 +1,7 @@ +--- +'@fluojs/runtime': major +--- + +Replace uncoordinated PlatformShell lifecycle overlap with strict exclusive transitions. Every overlapping `start()` or `stop()` now rejects immediately with `PlatformLifecycleConflictError` and structured `PLATFORM_LIFECYCLE_CONFLICT` metadata. + +In 2.x, overlapping `start()` calls could start the same components more than once, and `stop()` called during an in-flight startup could return before startup settled and leave resources running. Consumers must now give one application boundary ownership of each transition, wait for that owned promise to settle, and retry explicitly when a rejected operation is still required. Lifecycle callbacks receive the same typed conflict after synchronous or arbitrarily awaited reentry. diff --git a/docs/CONTEXT.ko.md b/docs/CONTEXT.ko.md index 11e1a3019..46b6474c1 100644 --- a/docs/CONTEXT.ko.md +++ b/docs/CONTEXT.ko.md @@ -130,6 +130,8 @@ Slack discoverability도 패키지 README, governed package-surface docs, NestJS Discord discoverability도 패키지 README, governed package-surface docs, NestJS migration docs, intermediate chat book chapter로 나뉜다. `packages/discord/README.ko.md`는 `DiscordModule.forRoot(...)` / `forRootAsync({ inject, useFactory, global? })`, `global: false` local opt-out을 포함한 기본 global provider visibility, singleton compatibility token `DISCORD`와 `DISCORD_CHANNEL`, 의도적으로 private인 내부 provider helper/token, 직접 `DiscordService` 전달, `DISCORD_CHANNEL`을 통한 `@fluojs/notifications` channel 통합, lifecycle-gated send, factory-owned와 app-owned transport resource ownership, optional `verify()`를 노출하는 transport를 위한 `verifyOnModuleInit` bootstrap 검증, `DiscordTemplateRenderer` notification template rendering, webhook-first transport boundary, 그리고 `DiscordService.createPlatformStatusSnapshot()`과 `createDiscordPlatformStatusSnapshot(...)` 양쪽을 통한 status snapshot을 문서화한다. [`docs/reference/package-surface.ko.md`](./reference/package-surface.ko.md)는 직접 `process.env`를 읽지 않는 webhook-first, transport-agnostic Discord delivery에 대한 정식 `@fluojs/discord` responsibility boundary를 기록하고, [`docs/getting-started/migrate-from-nestjs.ko.md`](./getting-started/migrate-from-nestjs.ko.md)는 NestJS async module/custom-provider assumption 및 `isGlobal`에서 Discord injected factory와 `global?: boolean`로 옮기는 migration boundary를 기록하며, [`book/intermediate/ch17-slack-discord.ko.md`](../book/intermediate/ch17-slack-discord.ko.md)는 FluoShop 학습 경로에서 standalone `DiscordService`, notifications channel routing, embed payload, Discord module visibility, Discord status snapshot observability를 설명한다. +PlatformShell lifecycle exclusivity는 `packages/runtime/README.ko.md`, [`docs/architecture/platform-consistency-design.ko.md`](./architecture/platform-consistency-design.ko.md), [`docs/contracts/third-party-extension-contract.ko.md`](./contracts/third-party-extension-contract.ko.md), [`docs/contracts/platform-conformance-authoring-checklist.ko.md`](./contracts/platform-conformance-authoring-checklist.ko.md)에 동기화되어 있다. 정확히 하나의 `start()` 또는 `stop()` transition만 active일 수 있고 모든 overlap은 root-exported `PlatformLifecycleConflictError`와 `PLATFORM_LIFECYCLE_CONFLICT` metadata로 즉시 reject된다. Callback reentry도 임의의 await 이후 동일한 계약을 따르며 caller는 lifecycle work를 공유하거나 queue하지 말고 settlement 이후 명시적 retry를 조정해야 한다. + HTTP adapter raw-body portability discoverability도 testing package와 governed platform docs로 나뉜다. `packages/testing/README.md`는 byte-sensitive payload를 위한 `createHttpAdapterPortabilityHarness(...)`와 `assertPreservesExactRawBodyBytesForByteSensitivePayloads()`를 문서화한다. [`docs/contracts/platform-conformance-authoring-checklist.ko.md`](./contracts/platform-conformance-authoring-checklist.ko.md)는 HTTP adapter가 Unicode replacement, newline normalization, re-encoding 없이 정확한 `rawBody` byte를 보존해야 한다고 요구하며, [`docs/contracts/testing-guide.ko.md`](./contracts/testing-guide.ko.md)는 HTTP adapter byte preservation behavior가 바뀔 때 실행할 platform portability test와 governance command를 안내한다. Bun adapter lifecycle 및 migration discoverability는 `packages/platform-bun/README.ko.md`, [`book/intermediate/ch22-bun.ko.md`](../book/intermediate/ch22-bun.ko.md), [`docs/getting-started/migrate-from-nestjs.ko.md`](./getting-started/migrate-from-nestjs.ko.md), website runtime/realtime guide로 나뉜다. 패키지 README는 duplicate `listen()` idempotency, pre-listen realtime binding requirement, byte-exact raw-body 및 stale native handoff regression coverage, log/`process.exitCode`를 통한 signal-driven shutdown reporting, 동기 `createBunFetchHandler(...)` 사용법을 문서화한다. Intermediate book은 manual fetch-host lifecycle boundary와 `runBunApplication(...)` signal helper boundary를 담고, NestJS migration docs는 Bun fetch-style hosting이 late host mutation 또는 reflection metadata 가정을 되살리지 않는다고 기록하며, website guide는 `createBunFetchHandler(...)`가 동기임을 유지하면서 adapter-owned shutdown, raw fetch, `server.upgrade(...)` 제어가 realtime binding seam 밖에 남는다는 점을 맞춘다. diff --git a/docs/CONTEXT.md b/docs/CONTEXT.md index 38a4f4ad9..23d65af8e 100644 --- a/docs/CONTEXT.md +++ b/docs/CONTEXT.md @@ -130,6 +130,8 @@ Slack discoverability is split across the package README, governed package-surfa Discord discoverability is split across the package README, governed package-surface docs, NestJS migration docs, and the intermediate chat book chapter: `packages/discord/README.md` documents `DiscordModule.forRoot(...)` / `forRootAsync({ inject, useFactory, global? })`, default global provider visibility with `global: false` local opt-out, singleton compatibility tokens `DISCORD` and `DISCORD_CHANNEL`, intentionally private internal provider helpers/tokens, direct `DiscordService` delivery, `@fluojs/notifications` channel integration through `DISCORD_CHANNEL`, lifecycle-gated sends, factory-owned versus app-owned transport resource ownership, `verifyOnModuleInit` bootstrap verification for transports that expose optional `verify()`, `DiscordTemplateRenderer` notification template rendering, webhook-first transport boundaries, and status snapshots through both `DiscordService.createPlatformStatusSnapshot()` and `createDiscordPlatformStatusSnapshot(...)`; [`docs/reference/package-surface.md`](./reference/package-surface.md) records the canonical `@fluojs/discord` responsibility boundary for webhook-first, transport-agnostic Discord delivery without direct `process.env` reads; [`docs/getting-started/migrate-from-nestjs.md`](./getting-started/migrate-from-nestjs.md) records the migration boundary from NestJS async module/custom-provider assumptions and `isGlobal` to Discord's injected factory plus `global?: boolean`; and [`book/intermediate/ch17-slack-discord.md`](../book/intermediate/ch17-slack-discord.md) teaches standalone `DiscordService`, notifications channel routing, embed payloads, Discord module visibility, and Discord status snapshot observability in the FluoShop learning path. +PlatformShell lifecycle exclusivity is synchronized across `packages/runtime/README.md`, [`docs/architecture/platform-consistency-design.md`](./architecture/platform-consistency-design.md), [`docs/contracts/third-party-extension-contract.md`](./contracts/third-party-extension-contract.md), and [`docs/contracts/platform-conformance-authoring-checklist.md`](./contracts/platform-conformance-authoring-checklist.md): exactly one `start()` or `stop()` transition may be active, every overlap immediately rejects with root-exported `PlatformLifecycleConflictError` and `PLATFORM_LIFECYCLE_CONFLICT` metadata, callback reentry follows the same contract after arbitrary awaits, and callers must coordinate explicit retries after settlement instead of sharing or queueing lifecycle work. + HTTP adapter raw-body portability discoverability is split across the testing package and governed platform docs: `packages/testing/README.md` documents `createHttpAdapterPortabilityHarness(...)` and `assertPreservesExactRawBodyBytesForByteSensitivePayloads()` for byte-sensitive payloads; [`docs/contracts/platform-conformance-authoring-checklist.md`](./contracts/platform-conformance-authoring-checklist.md) requires HTTP adapters to preserve exact `rawBody` bytes without Unicode replacement, newline normalization, or re-encoding; and [`docs/contracts/testing-guide.md`](./contracts/testing-guide.md) identifies platform portability tests and governance commands to run when HTTP adapter byte preservation behavior changes. Bun adapter lifecycle and migration discoverability is split across `packages/platform-bun/README.md`, [`book/intermediate/ch22-bun.md`](../book/intermediate/ch22-bun.md), [`docs/getting-started/migrate-from-nestjs.md`](./getting-started/migrate-from-nestjs.md), and the website runtime/realtime guides: the package README documents duplicate `listen()` idempotency, pre-listen realtime binding requirements, byte-exact raw-body and stale native handoff regression coverage, signal-driven shutdown reporting through logs/`process.exitCode`, and synchronous `createBunFetchHandler(...)` usage; the intermediate book carries the manual fetch-host lifecycle boundary and `runBunApplication(...)` signal helper boundary; NestJS migration docs record that Bun fetch-style hosting does not restore late host mutation or reflection metadata assumptions; and the website guides keep `createBunFetchHandler(...)` synchronous while keeping adapter-owned shutdown, raw fetch, and `server.upgrade(...)` control outside the realtime binding seam. diff --git a/docs/architecture/platform-consistency-design.ko.md b/docs/architecture/platform-consistency-design.ko.md index 5a825f041..d1eeb1dc5 100644 --- a/docs/architecture/platform-consistency-design.ko.md +++ b/docs/architecture/platform-consistency-design.ko.md @@ -28,6 +28,12 @@ | `health()` | `packages/runtime/src/platform-contract.ts`의 `PlatformShell.health()` 와 `PlatformComponent.health()` | platform-managed component는 component failure를 숨기지 않고 `healthy`, `unhealthy`, `degraded`를 보고해야 한다. | | `snapshot()` | `packages/runtime/src/platform-contract.ts`의 `PlatformShell.snapshot()` 와 `PlatformComponent.snapshot()` | platform-managed component는 machine-readable state, ownership, telemetry tag, dependency metadata를 노출해야 한다. | +## Platform Shell Lifecycle Exclusivity + +`RuntimePlatformShell.start()`와 `stop()`은 정확히 하나의 active lifecycle transition만 허용한다. 두 operation 중 하나가 active인 동안 겹치는 모든 `start()` 또는 `stop()` 호출은 root-exported `PlatformLifecycleConflictError`를 담은 즉시 reject된 promise를 반환해야 한다. Error code는 `PLATFORM_LIFECYCLE_CONFLICT`이며 `activeOperation`과 `requestedOperation`은 readonly field와 일치하는 structured metadata로 제공된다. + +Shell은 겹치는 lifecycle work를 queue하면 안 된다. 또한 공유, coalesce, desired state 기록도 하면 안 된다. Runtime-neutral promise scheduling으로 component work 시작 전에 active transition을 publish하므로 synchronous callback reentry와 임의의 await 이후 reentry 모두 같은 conflict 계약을 관찰한다. Identity-checked cleanup은 성공 또는 실패 뒤 transition을 해제한다. Caller는 settlement 이후에만 명시적으로 retry할 수 있다. Private rollback과 cleanup path는 owning transition 내부에 남아 dependency ordering, diagnostics, rollback retry, sequential idempotency를 보존한다. + ## Conformance Rules - Platform 패키지는 저장소 정책이 `PlatformAdapter`라고 부르는 adapter seam을 구현해야 하며, 현재 HTTP transport 계약은 `HttpApplicationAdapter`가 담당한다. diff --git a/docs/architecture/platform-consistency-design.md b/docs/architecture/platform-consistency-design.md index 3f92e8ba1..03c7e18ec 100644 --- a/docs/architecture/platform-consistency-design.md +++ b/docs/architecture/platform-consistency-design.md @@ -28,6 +28,12 @@ This document defines the current platform adapter contract used by fluo transpo | `health()` | `PlatformShell.health()` and `PlatformComponent.health()` in `packages/runtime/src/platform-contract.ts` | Platform-managed components MUST report `healthy`, `unhealthy`, or `degraded` without hiding component failure. | | `snapshot()` | `PlatformShell.snapshot()` and `PlatformComponent.snapshot()` in `packages/runtime/src/platform-contract.ts` | Platform-managed components MUST expose machine-readable state, ownership, telemetry tags, and dependency metadata. | +## Platform Shell Lifecycle Exclusivity + +`RuntimePlatformShell.start()` and `stop()` allow exactly one active lifecycle transition. While either operation is active, every overlapping `start()` or `stop()` call MUST return an immediately rejected promise with root-exported `PlatformLifecycleConflictError`. The error code is `PLATFORM_LIFECYCLE_CONFLICT`; `activeOperation` and `requestedOperation` are available as readonly fields and matching structured metadata. + +The shell MUST NOT queue, share, coalesce, or record a desired state for overlapping lifecycle work. It publishes the active transition before component work begins through runtime-neutral promise scheduling, so synchronous callback reentry and reentry after arbitrary awaits observe the same conflict contract. Identity-checked cleanup releases the transition after success or failure. Callers may retry explicitly only after settlement. Private rollback and cleanup paths stay inside the owning transition so dependency ordering, diagnostics, rollback retry, and sequential idempotency remain intact. + ## Conformance Rules - Platform packages MUST implement the adapter seam referenced by repository policy as `PlatformAdapter`, with the current HTTP transport contract supplied by `HttpApplicationAdapter`. diff --git a/docs/contracts/platform-conformance-authoring-checklist.ko.md b/docs/contracts/platform-conformance-authoring-checklist.ko.md index 8c9bf5ce5..383c89e39 100644 --- a/docs/contracts/platform-conformance-authoring-checklist.ko.md +++ b/docs/contracts/platform-conformance-authoring-checklist.ko.md @@ -17,6 +17,9 @@ - [ ] MUST: side-effect capture를 설정한 경우 `validate()`가 장기 지속 부수 효과를 만들지 않는지 검증합니다. - [ ] MUST: `start()`가 중복 호출에서도 결정론적인지 검증합니다. - [ ] MUST: `stop()`이 중복 호출에서도 멱등적인지 검증합니다. +- [ ] MUST: 같은 operation overlap을 포함한 네 가지 `start()` / `stop()` overlap pair가 transition active 상태에서 즉시 `PlatformLifecycleConflictError`로 reject되는지 검증합니다. +- [ ] MUST: Lifecycle callback reentry가 queue, timer, runtime-specific context tracking 없이 synchronous 시점과 임의의 await 이후에 동일한 conflict를 받는지 검증합니다. +- [ ] MUST: 성공 및 실패 이후 active-transition cleanup이 settlement 뒤 명시적 retry를 허용하면서 private rollback과 cleanup의 dependency ordering 및 retry behavior를 유지하는지 검증합니다. - [ ] MUST: `snapshot()`이 degraded 상태와 failed 상태에서도 호출 가능한지 검증합니다. - [ ] MUST: diagnostics가 비어 있지 않은 안정적인 `code` 값을 유지하는지 검증합니다. - [ ] MUST: 하네스 설정에서 완화하지 않는 한 error severity diagnostics에 `fixHint`를 제공합니다. @@ -42,6 +45,7 @@ - [ ] MUST: 타입이 있는 구성을 노출하고 bootstrap 중 입력을 검증합니다. - [ ] MUST: 패키지 동작과 문서에서 health와 readiness를 구분합니다. - [ ] MUST: 호출자에게 보이는 실패 상태에 대해 안정적인 diagnostic code를 제공합니다. +- [ ] MUST: Platform shell lifecycle ownership을 조정할 때 `PLATFORM_LIFECYCLE_CONFLICT` metadata를 public error contract로 취급합니다. - [ ] MUST: 소켓, 파일 핸들, 연결 같은 소유 리소스를 선언하고 shutdown 중 해제합니다. - [ ] MUST NOT: 로그, diagnostics, snapshot을 통해 credential, token, password, API key를 노출하지 않습니다. diff --git a/docs/contracts/platform-conformance-authoring-checklist.md b/docs/contracts/platform-conformance-authoring-checklist.md index 166ed1866..88d0c01ae 100644 --- a/docs/contracts/platform-conformance-authoring-checklist.md +++ b/docs/contracts/platform-conformance-authoring-checklist.md @@ -17,6 +17,9 @@ Use this checklist when authoring or changing official platform-facing packages - [ ] MUST: Verify `validate()` does not introduce long-lived side effects when side-effect capture is configured. - [ ] MUST: Verify `start()` is deterministic across duplicate calls. - [ ] MUST: Verify `stop()` is idempotent across duplicate calls. +- [ ] MUST: Verify all four `start()` / `stop()` overlap pairs reject immediately with `PlatformLifecycleConflictError` while a transition is active, including same-operation overlaps. +- [ ] MUST: Verify lifecycle callback reentry receives the same conflict synchronously and after arbitrary awaits, without queues, timers, or runtime-specific context tracking. +- [ ] MUST: Verify active-transition cleanup after success and failure permits explicit retry after settlement while private rollback and cleanup retain dependency ordering and retry behavior. - [ ] MUST: Verify `snapshot()` stays callable in degraded and failed states. - [ ] MUST: Verify diagnostics keep stable non-empty `code` values. - [ ] MUST: Provide `fixHint` for error-severity diagnostics unless the harness configuration explicitly relaxes that requirement. @@ -42,6 +45,7 @@ Use this checklist when authoring or changing official platform-facing packages - [ ] MUST: Expose typed configuration and validate inputs during bootstrap. - [ ] MUST: Distinguish health from readiness in package behavior and package docs. - [ ] MUST: Emit stable diagnostic codes for caller-visible failure states. +- [ ] MUST: Treat `PLATFORM_LIFECYCLE_CONFLICT` metadata as a public error contract when coordinating platform shell lifecycle ownership. - [ ] MUST: Declare owned resources such as sockets, file handles, or connections, and release them during shutdown. - [ ] MUST NOT: expose credentials, tokens, passwords, or API keys through logs, diagnostics, or snapshots. diff --git a/docs/contracts/third-party-extension-contract.ko.md b/docs/contracts/third-party-extension-contract.ko.md index 7898cafa0..6411db237 100644 --- a/docs/contracts/third-party-extension-contract.ko.md +++ b/docs/contracts/third-party-extension-contract.ko.md @@ -13,7 +13,7 @@ | Official platform packages | `docs/reference/package-surface.md`, `docs/architecture/platform-consistency-design.md` | `@fluojs/platform-*`로 게시되는 패키지는 저장소 정책이 `PlatformAdapter`라고 부르는 seam을 반드시 구현해야 합니다. 현재 HTTP transport stack에서는 `@fluojs/http`의 `HttpApplicationAdapter`로 이 요구사항을 충족합니다. | | HTTP listener adapters | `packages/http/src/adapter.ts` | 어댑터는 `listen(dispatcher)`와 `close(signal?)`를 반드시 구현해야 합니다. `getServer?()`와 `getRealtimeCapability?()`는 선택 사항이지만, 노출할 경우 문서화된 capability shape를 보존해야 합니다. | | Request and response mapping | `packages/http/src/adapter.ts`, `packages/http/src/types.ts`, `docs/architecture/platform-consistency-design.md` | 어댑터는 host-native input을 `FrameworkRequest`와 `FrameworkResponse`로 변환한 뒤 제공된 `Dispatcher`에 실행을 넘겨야 합니다. 이 과정에서 request-phase ordering, response commit semantics, streaming contract를 바꾸면 안 됩니다. | -| Runtime-managed platform components | `packages/runtime/src/platform-contract.ts`, `packages/runtime/src/types.ts` | `platform.components` 아래에 등록되는 확장은 `validate()`, `start()`, `ready()`, `health()`, `snapshot()`, `stop()`을 포함하는 `PlatformComponent`를 구현해야 합니다. validation, readiness, health, snapshot payload는 문서화된 report shape를 따라야 합니다. | +| Runtime-managed platform components | `packages/runtime/src/platform-contract.ts`, `packages/runtime/src/types.ts` | `platform.components` 아래에 등록되는 확장은 `validate()`, `start()`, `ready()`, `health()`, `snapshot()`, `stop()`을 포함하는 `PlatformComponent`를 구현해야 합니다. validation, readiness, health, snapshot payload는 문서화된 report shape를 따라야 합니다. Public `PlatformShell.start()` / `stop()` transition은 엄격한 exclusive이며 모든 overlap은 즉시 `PlatformLifecycleConflictError`로 reject됩니다. | | Module-style integrations | `packages/core/src/metadata.ts`, `packages/email/src/module.ts` | 재사용 가능한 registration API를 노출하는 패키지는 `forRoot(options)`와 `forRootAsync({ inject, useFactory })` 같은 명시적 module entrypoint를 제공해야 합니다. export되는 token과 option object는 typed 상태를 유지하고 명시적으로 드러나야 합니다. | | Decorator and metadata extensions | `packages/core/src/metadata/shared.ts`, `packages/http/src/decorators.ts` | metadata를 쓰는 확장은 TC39 decorator context metadata와 namespace가 있는 `Symbol.for(...)` key를 사용해야 합니다. 임의 전역 대신 `@fluojs/core`의 shared metadata symbol boundary를 사용해야 합니다. | @@ -23,6 +23,7 @@ - 패키지 내부 구현은 `process.env`를 직접 읽으면 안 됩니다. 설정은 explicit option, DI, 또는 application boundary의 `@fluojs/config`를 통해 들어와야 합니다. - 어댑터는 framework 동작을 host-native request 또는 response type에 직접 결합하면서 `FrameworkRequest`, `FrameworkResponse`, `Dispatcher`를 우회하면 안 됩니다. - 확장은 `@fluojs/http`, `@fluojs/di`, `@fluojs/config`, `@fluojs/runtime`가 소유한 route syntax, DI resolution rule, configuration loading rule, lifecycle ordering을 재정의하면 안 됩니다. +- 확장은 `PlatformShell.start()` / `stop()` overlap을 queue하면 안 됩니다. 또한 공유, coalesce, hide하면 안 됩니다. Synchronous code 이후 또는 임의의 await 이후 reentry하는 lifecycle callback은 `PlatformLifecycleConflictError`를 propagate하거나 의도적으로 처리하고, extension-owned active transition이 settle된 뒤에만 retry해야 합니다. - metadata writer는 third-party state에 `fluo.standard.*` 또는 `fluo.metadata.*` 같은 fluo 소유 key를 재사용하면 안 됩니다. `Symbol.for('fluo..')` 형태의 package-scoped key를 사용해야 합니다. - governed public surface에 속한 패키지는 TSDoc 없는 public export를 배포하면 안 됩니다. - registration은 import-time side effect로 수행하면 안 됩니다. 호출자는 explicit bootstrap 또는 module registration API를 통해 opt in 해야 합니다. @@ -44,5 +45,5 @@ | Async module configuration | 설정이 DI 또는 runtime lookup에 의존할 때 `forRootAsync({ inject, useFactory })`를 노출해야 합니다. 해석된 option은 downstream provider가 소비하기 전에 memoize하거나 normalize해야 합니다. | | Scoped or feature registration | 패키지에 별도의 scoped contract가 있을 때만 `forFeature(...)` 또는 `register(...)`를 사용해야 합니다. `forRoot(...)` 의미를 여러 이름으로 중복 노출하면 안 됩니다. | | Token export | 확장 소유 service와 option에 대해 이름 있는 symbol 또는 typed token을 export해야 합니다. `fluo.email.options`, `fluo.queue.options` 같은 package-scoped `Symbol.for(...)` key 패턴을 따라야 합니다. | -| Runtime adapter registration | HTTP adapter는 `FluoFactory.create(rootModule, { adapter })`를 통해 등록해야 합니다. validation, readiness, health, diagnostics, shutdown orchestration에 참여하는 platform-owned infrastructure는 `platform.components`로 등록해야 합니다. | +| Runtime adapter registration | HTTP adapter는 `FluoFactory.create(rootModule, { adapter })`를 통해 등록해야 합니다. validation, readiness, health, diagnostics, shutdown orchestration에 참여하는 platform-owned infrastructure는 `platform.components`로 등록해야 합니다. 하나의 application boundary가 각 shell lifecycle transition을 담당하고 그 경계에서 명시적 post-settlement retry를 조정해야 합니다. | | Provider exposure | 호출자가 소비해야 하는 service, channel, token만 export해야 합니다. container registration은 top-level import evaluation이 아니라 module factory 또는 bootstrap option 내부에 두어야 합니다. | diff --git a/docs/contracts/third-party-extension-contract.md b/docs/contracts/third-party-extension-contract.md index 2debec7b9..58c8c0bc5 100644 --- a/docs/contracts/third-party-extension-contract.md +++ b/docs/contracts/third-party-extension-contract.md @@ -13,7 +13,7 @@ This document defines the contract for third-party adapters, integration package | Official platform packages | `docs/reference/package-surface.md`, `docs/architecture/platform-consistency-design.md` | Packages published as `@fluojs/platform-*` MUST implement the repository policy seam named `PlatformAdapter`. In the current HTTP transport stack, that seam is satisfied through `HttpApplicationAdapter` from `@fluojs/http`. | | HTTP listener adapters | `packages/http/src/adapter.ts` | Adapters MUST implement `listen(dispatcher)` and `close(signal?)`. `getServer?()` and `getRealtimeCapability?()` stay optional and must preserve the documented capability shapes when exposed. | | Request and response mapping | `packages/http/src/adapter.ts`, `packages/http/src/types.ts`, `docs/architecture/platform-consistency-design.md` | Adapters MUST translate host-native input into `FrameworkRequest` and `FrameworkResponse`, then hand execution to the provided `Dispatcher` without changing request-phase ordering, response commit semantics, or streaming contracts. | -| Runtime-managed platform components | `packages/runtime/src/platform-contract.ts`, `packages/runtime/src/types.ts` | Extensions that register under `platform.components` MUST implement `PlatformComponent` with `validate()`, `start()`, `ready()`, `health()`, `snapshot()`, and `stop()`. Validation, readiness, health, and snapshot payloads must use the documented report shapes. | +| Runtime-managed platform components | `packages/runtime/src/platform-contract.ts`, `packages/runtime/src/types.ts` | Extensions that register under `platform.components` MUST implement `PlatformComponent` with `validate()`, `start()`, `ready()`, `health()`, `snapshot()`, and `stop()`. Validation, readiness, health, and snapshot payloads must use the documented report shapes. Public `PlatformShell.start()` / `stop()` transitions are strictly exclusive; every overlap rejects immediately with `PlatformLifecycleConflictError`. | | Module-style integrations | `packages/core/src/metadata.ts`, `packages/email/src/module.ts` | Packages that expose reusable registration APIs SHOULD publish explicit module entrypoints such as `forRoot(options)` and `forRootAsync({ inject, useFactory })`. Exported tokens and option objects MUST stay typed and explicit. | | Decorator and metadata extensions | `packages/core/src/metadata/shared.ts`, `packages/http/src/decorators.ts` | Extensions that write metadata MUST use TC39 decorator context metadata plus namespaced `Symbol.for(...)` keys. Use the shared metadata symbol boundary from `@fluojs/core` rather than ad hoc globals. | @@ -23,6 +23,7 @@ This document defines the contract for third-party adapters, integration package - Package internals MUST NOT read `process.env` directly. Configuration must enter through explicit options, DI, or `@fluojs/config` at the application boundary. - Adapters MUST NOT bypass `FrameworkRequest`, `FrameworkResponse`, or `Dispatcher` by coupling framework behavior to host-native request or response types. - Extensions MUST NOT redefine route syntax, DI resolution rules, configuration loading rules, or lifecycle ordering that belong to `@fluojs/http`, `@fluojs/di`, `@fluojs/config`, or `@fluojs/runtime`. +- Extensions MUST NOT queue, share, coalesce, or hide `PlatformShell.start()` / `stop()` overlaps. A lifecycle callback that reenters after synchronous code or arbitrary awaits must propagate or deliberately handle `PlatformLifecycleConflictError`, then retry only after the extension-owned active transition settles. - Metadata writers MUST NOT reuse fluo-owned keys such as `fluo.standard.*` or `fluo.metadata.*` for third-party state. Use package-scoped `Symbol.for('fluo..')` keys. - Public exports MUST NOT ship without TSDoc when the package is part of the governed public surface. - Registration MUST NOT happen through import-time side effects. Callers must opt in through explicit bootstrap or module registration APIs. @@ -44,5 +45,5 @@ This document defines the contract for third-party adapters, integration package | Async module configuration | Expose `forRootAsync({ inject, useFactory })` when configuration depends on DI or runtime lookups. Memoize or normalize the resolved options before downstream providers consume them. | | Scoped or feature registration | Use `forFeature(...)` or `register(...)` only when the package has a distinct scoped contract. Do not duplicate `forRoot(...)` semantics under multiple names. | | Token export | Export named symbols or typed tokens for extension-owned services and options. Follow the repo pattern of package-scoped `Symbol.for(...)` keys such as `fluo.email.options` or `fluo.queue.options`. | -| Runtime adapter registration | Register HTTP adapters through `FluoFactory.create(rootModule, { adapter })`. Register platform-owned infrastructure through `platform.components` when the extension participates in validation, readiness, health, diagnostics, or shutdown orchestration. | +| Runtime adapter registration | Register HTTP adapters through `FluoFactory.create(rootModule, { adapter })`. Register platform-owned infrastructure through `platform.components` when the extension participates in validation, readiness, health, diagnostics, or shutdown orchestration. Keep one application boundary responsible for each shell lifecycle transition and coordinate explicit post-settlement retries there. | | Provider exposure | Export only the services, channels, or tokens that callers are expected to consume. Keep container registration inside module factories or bootstrap options, not in top-level import evaluation. | diff --git a/packages/runtime/README.ko.md b/packages/runtime/README.ko.md index d59eb361f..77a5f40eb 100644 --- a/packages/runtime/README.ko.md +++ b/packages/runtime/README.ko.md @@ -80,6 +80,12 @@ await userService.doWork(); await context.close(); ``` +### PlatformShell lifecycle overlap 마이그레이션 + +`RuntimePlatformShell.start()`와 `stop()`은 엄격한 exclusive transition입니다. 둘 중 하나가 active인 동안 겹치는 모든 `start()` 또는 `stop()` 호출은 `PlatformLifecycleConflictError`, code `PLATFORM_LIFECYCLE_CONFLICT`, 그리고 error field와 structured `meta` 모두에 있는 `activeOperation` / `requestedOperation`을 담은 즉시 reject된 promise를 반환합니다. Shell은 겹치는 작업을 공유하거나 queue 또는 coalesce하지 않습니다. Settle 이후의 순차 호출은 계속 idempotent하며, 실패한 transition은 exclusive gate를 해제하므로 caller가 명시적으로 retry할 수 있습니다. + +`@fluojs/runtime` 2.x에서는 겹치는 `start()` 호출이 같은 component를 두 번 이상 시작할 수 있었고, in-flight startup 중 호출한 `stop()`이 startup settlement보다 먼저 반환하여 resource가 실행 중인 채로 남을 수 있었습니다. 업그레이드할 때는 하나의 application boundary가 각 lifecycle transition의 ownership을 갖게 하세요. 다른 경로가 겹칠 수 있다면 `PlatformLifecycleConflictError`를 catch하고 boundary-owned transition의 settlement를 기다린 다음, 원하는 상태가 여전히 필요할 때만 명시적으로 retry하세요. Callback reentry 주변에 숨은 queue를 다시 만들면 안 됩니다. Component lifecycle callback도 synchronous code 이후 또는 임의의 `await` boundary 이후 동일한 즉시 conflict를 받습니다. + ### Studio Devtools Bridge `@fluojs/runtime`은 live Studio snapshot과 request trace를 publish할 수 있지만 `process.env`를 직접 읽지 않습니다. `fluo dev --studio`가 애플리케이션 경계에서 sidecar를 시작하고 tokenized Studio config를 만든 뒤, 앱이 runtime을 import하기 전에 해당 명시적 config를 Node 앱 child에 주입합니다. Runtime은 Studio bridge를 생성할 때 주입된 각 field를 한 번씩 읽고 전체 config와 HTTP(S) endpoint를 검증한 뒤 private snapshot으로 freeze합니다. 따라서 writable process-global injection이 나중에 변경되어도 instrumentation input은 바뀌지 않습니다. CLI가 제공한 config가 없거나 잘못되었거나 tokenized endpoint가 없으면 Studio instrumentation은 no-op이며 bootstrap 동작은 바뀌지 않습니다. @@ -163,6 +169,7 @@ class UsersModule {} - Runtime-connected Studio instrumentation은 명시적인 CLI 주입 Studio config로만 활성화되며 runtime package source에서 `process.env`를 직접 읽지 않습니다. Bridge 생성은 알려진 각 field를 한 번씩 읽어 검증되고 freeze된 private snapshot으로 캡처하며 HTTP(S) tokenized endpoint만 허용하므로, 이후 global object mutation이 instrumentation 대상을 바꾸거나 재인증할 수 없습니다. 유효한 config와 tokenized endpoint가 없으면 non-Node 런타임을 포함해 Studio 관점의 runtime bootstrap은 no-op입니다. - Studio request trace는 request/response body, cookie, 전체 header를 제외합니다. Trace `url`은 publish 전에 path-only 형태로 sanitize되어 query token과 fragment가 local Studio event history에 남지 않습니다. - 플랫폼 component snapshot은 런타임 소유 계약 payload입니다. 각 component는 `readiness`, `health`, dependency id, telemetry tag, diagnostic issue, 그리고 `ownership.ownsResources` / `ownership.externallyManaged`를 통해 리소스 소유권을 보고합니다. Runtime은 shell snapshot에서 이 ownership flag를 보존하므로 adapter와 package integration이 fluo가 종료해야 하는 리소스와 host가 소유한 외부 관리 리소스를 구분할 수 있습니다. +- `RuntimePlatformShell.start()`와 `stop()`은 하나의 엄격한 exclusive lifecycle transition을 강제합니다. 같은 operation 호출이나 임의의 await 이후 callback reentry를 포함한 모든 겹치는 operation은 shared/queued work 대신 즉시 `PlatformLifecycleConflictError` rejection을 받습니다. Active transition은 component work 시작 전에 publish되고 실패 시 identity 기준으로 해제됩니다. Settlement 이후의 명시적 retry는 sequential idempotency, dependency ordering, private startup rollback, cleanup retry behavior를 보존합니다. - 모듈 그래프 컴파일 결과 캐시는 `moduleGraphCache: true`를 통한 opt-in입니다. 캐시 항목은 root module identity, runtime provider, validation token, module replacement pair, core metadata version, compile algorithm version으로 식별되며, 성공한 컴파일만 저장하고 호출자 mutation이 이후 bootstrap을 오염시키지 않도록 격리된 그래프 복사본을 반환합니다. - `moduleReplacements`는 `bootstrapModule(...)` / `BootstrapModuleOptions`의 저수준 testing seam입니다. 원래 logical module identity를 보존하면서 replacement module metadata로 컴파일하고, replacement cycle은 일반 module graph validation 경로에서 거부하며, source module metadata를 mutate하지 않습니다. - `raceWithAbort(fn, signal)`은 `fn`이 settle된 후 항상 abort listener를 제거합니다. `fn`이 promise를 반환하기 전에 동기적으로 throw하는 경우도 포함합니다. 동기 throw는 settled rejection으로 변환되어 cleanup-dependent `finally` flow가 여전히 실행되고, 반복된 실패 작업에서 listener가 leak되지 않습니다. @@ -186,6 +193,7 @@ class UsersModule {} - `createRuntimeRouteInspection(...)`, `createRuntimeRouteCatalog(...)`, `createRuntimeInspectionSnapshot(...)`: HTTP route behavior를 변경하지 않고 platform snapshot에 effective compiled route diagnostics를 추가하는 runtime-owned immutable projection입니다. - `RuntimeRouteInspection`, `RuntimeInspectionSnapshot`: serializable read-only route 및 inspect artifact contract입니다. `RuntimeRouteInspection.params`에는 parameter name만 포함되고 request value는 포함되지 않습니다. - `PlatformShell`, `PlatformComponent`, `PlatformShellSnapshot`, `PlatformSnapshot`, `PlatformDiagnosticIssue` 및 관련 platform report 타입: runtime-aware package가 사용하는 공개 lifecycle diagnostics 및 resource-ownership 계약입니다. `RuntimePlatformShell`은 component가 제공한 ownership을 보존하고, consumer가 internal runtime token을 import하지 않아도 validation/readiness/health diagnostics를 내보냅니다. +- `PlatformLifecycleOperation`, `PlatformLifecycleConflictError`: root-exported lifecycle conflict 계약입니다. Error는 code `PLATFORM_LIFECYCLE_CONFLICT`를 사용하고 일치하는 `activeOperation` / `requestedOperation` field와 structured metadata를 노출합니다. - `createRequestAbortContext(...)`, `trackActiveRequestTransaction(...)`, `untrackActiveRequestTransaction(...)`: runtime-aware integration이 사용하는 request abort 및 active transaction helper입니다. - `UploadedFile`: 메모리 내 `buffer` payload를 Web 표준 `Uint8Array`로 제공하는 runtime-neutral 멀티파트 파일 descriptor입니다. diff --git a/packages/runtime/README.md b/packages/runtime/README.md index cb551343b..d1f8695fd 100644 --- a/packages/runtime/README.md +++ b/packages/runtime/README.md @@ -80,6 +80,12 @@ await userService.doWork(); await context.close(); ``` +### Migrating PlatformShell Lifecycle Overlap + +`RuntimePlatformShell.start()` and `stop()` are strictly exclusive. While either transition is active, every overlapping `start()` or `stop()` call returns an immediately rejected promise with `PlatformLifecycleConflictError`, code `PLATFORM_LIFECYCLE_CONFLICT`, and `activeOperation` / `requestedOperation` on both the error and its structured `meta`. The shell never shares, queues, or coalesces overlapping work. Sequential calls made after settlement remain idempotent, and failed transitions release the exclusive gate so callers can retry explicitly. + +In `@fluojs/runtime` 2.x, overlapping `start()` calls could start the same components more than once, and `stop()` called during an in-flight startup could return before startup settled and leave resources running. When upgrading, give one application boundary ownership of each lifecycle transition. If another path can overlap, catch `PlatformLifecycleConflictError`, wait for the boundary-owned transition to settle, and retry explicitly only if the desired state is still required. Do not recreate a hidden queue around callback reentry; component lifecycle callbacks receive the same immediate conflict after synchronous code or arbitrary `await` boundaries. + ### Studio Devtools Bridge `@fluojs/runtime` can publish live Studio snapshots and request traces, but it does not read `process.env` directly. `fluo dev --studio` is the application boundary that starts the sidecar, creates the tokenized Studio config, and injects that explicit config into the Node app child before the app imports runtime. Runtime reads each injected field once when it creates the Studio bridge, validates the complete config and its HTTP(S) endpoint, and keeps a frozen private snapshot, so later mutation of the writable process-global injection cannot change instrumentation inputs. If that CLI-provided config is absent, malformed, or missing a tokenized endpoint, Studio instrumentation is a no-op and bootstrap behavior remains unchanged. @@ -163,6 +169,7 @@ class UsersModule {} - Runtime-connected Studio instrumentation is activated only by explicit CLI-injected Studio config, never by direct `process.env` reads inside runtime package source. Bridge creation captures each known field once into a validated, frozen private snapshot and accepts only an HTTP(S) tokenized endpoint, so later global-object mutation cannot retarget or reauthorize instrumentation. Without valid config and tokenized endpoint, runtime bootstrap is a no-op for Studio, including non-Node runtimes. - Studio request traces omit request/response bodies, cookies, and full headers; the trace `url` is sanitized to path-only form before publish so query tokens and fragments are not retained in local Studio event history. - Platform component snapshots are runtime-owned contract payloads: each component reports `readiness`, `health`, dependency ids, telemetry tags, diagnostic issues, and resource ownership through `ownership.ownsResources` / `ownership.externallyManaged`. Runtime preserves those ownership flags in shell snapshots so adapters and package integrations can distinguish resources fluo must stop from externally managed resources the host owns. +- `RuntimePlatformShell.start()` and `stop()` enforce one strictly exclusive lifecycle transition. Every overlapping operation, including a same-operation call or callback reentry after arbitrary awaits, receives an immediate `PlatformLifecycleConflictError` rejection instead of shared or queued work. The active transition is published before component work begins, failed transitions release it by identity, and explicit retry after settlement preserves sequential idempotency, dependency ordering, private startup rollback, and cleanup retry behavior. - Module graph compile-result caching is opt-in through `moduleGraphCache: true`; it keys entries by root module identity, runtime providers, validation tokens, module replacement pairs, core metadata versions, and the compile algorithm version, caches only successful compilations, and returns isolated graph copies so caller mutations cannot poison later bootstraps. - `moduleReplacements` is a low-level testing seam on `bootstrapModule(...)` / `BootstrapModuleOptions`. It compiles replacement module metadata while preserving the original logical module identity, rejects replacement cycles through the normal module graph validation path, and does not mutate source module metadata. - `raceWithAbort(fn, signal)` always removes its abort listener once `fn` settles, including when `fn` throws synchronously before returning a promise. The synchronous throw is converted into a settled rejection so the cleanup-dependent `finally` flow still runs and the listener is not leaked across repeated failed operations. @@ -186,6 +193,7 @@ class UsersModule {} - `createRuntimeRouteInspection(...)`, `createRuntimeRouteCatalog(...)`, and `createRuntimeInspectionSnapshot(...)`: Runtime-owned immutable projections that add effective compiled route diagnostics to platform snapshots without changing HTTP route behavior. - `RuntimeRouteInspection` and `RuntimeInspectionSnapshot`: Serializable read-only route and inspect artifact contracts. `RuntimeRouteInspection.params` contains parameter names only, never request values. - `PlatformShell`, `PlatformComponent`, `PlatformShellSnapshot`, `PlatformSnapshot`, `PlatformDiagnosticIssue`, and related platform report types: Public lifecycle diagnostics and resource-ownership contracts used by runtime-aware packages. `RuntimePlatformShell` preserves component-provided ownership and emits validation/readiness/health diagnostics without requiring consumers to import internal runtime tokens. +- `PlatformLifecycleOperation`, `PlatformLifecycleConflictError`: Root-exported lifecycle conflict contracts. The error uses code `PLATFORM_LIFECYCLE_CONFLICT` and exposes matching `activeOperation` / `requestedOperation` fields and structured metadata. - `createRequestAbortContext(...)`, `trackActiveRequestTransaction(...)`, `untrackActiveRequestTransaction(...)`: Request abort and active transaction helpers used by runtime-aware integrations. - `UploadedFile`: Runtime-neutral multipart file descriptor whose in-memory `buffer` payload is a Web-standard `Uint8Array`. diff --git a/packages/runtime/src/errors.test.ts b/packages/runtime/src/errors.test.ts new file mode 100644 index 000000000..f7b4b7a4b --- /dev/null +++ b/packages/runtime/src/errors.test.ts @@ -0,0 +1,34 @@ +import { FluoError } from '@fluojs/core'; +import { describe, expect, it } from 'vitest'; + +import type { PlatformLifecycleOperation } from './index.js'; +import * as runtime from './index.js'; + +describe('runtime error exports', () => { + it('exports PlatformLifecycleConflictError with typed structured context from the root', () => { + // Given + const operations: readonly PlatformLifecycleOperation[] = ['start', 'stop']; + + // When + const conflictErrorExport: unknown = Reflect.get(runtime, 'PlatformLifecycleConflictError'); + + // Then + if (typeof conflictErrorExport !== 'function') { + expect(conflictErrorExport).toBeTypeOf('function'); + return; + } + + const error: unknown = Reflect.construct(conflictErrorExport, operations); + expect(error).toBeInstanceOf(FluoError); + expect(error).toMatchObject({ + activeOperation: 'start', + code: 'PLATFORM_LIFECYCLE_CONFLICT', + meta: { + activeOperation: 'start', + requestedOperation: 'stop', + }, + name: 'PlatformLifecycleConflictError', + requestedOperation: 'stop', + }); + }); +}); diff --git a/packages/runtime/src/errors.ts b/packages/runtime/src/errors.ts index c0020a240..6f0fabbea 100644 --- a/packages/runtime/src/errors.ts +++ b/packages/runtime/src/errors.ts @@ -1,5 +1,44 @@ import { FluoError, formatTokenName } from '@fluojs/core'; +/** + * Public lifecycle operations supported by {@link PlatformLifecycleConflictError}. + */ +export type PlatformLifecycleOperation = 'start' | 'stop'; + +/** + * Error returned when a platform shell lifecycle request overlaps an active transition. + * + * @remarks + * Platform shell lifecycle transitions are strictly exclusive. Callers can inspect + * {@link PlatformLifecycleConflictError.activeOperation} and + * {@link PlatformLifecycleConflictError.requestedOperation}, wait for their own active + * operation to settle, and then retry explicitly. + */ +export class PlatformLifecycleConflictError extends FluoError { + /** Lifecycle operation that currently owns the platform shell transition. */ + readonly activeOperation: PlatformLifecycleOperation; + /** Lifecycle operation rejected because another transition is active. */ + readonly requestedOperation: PlatformLifecycleOperation; + + /** + * Creates a platform lifecycle conflict error. + * + * @param activeOperation Lifecycle operation that currently owns the transition. + * @param requestedOperation Overlapping lifecycle operation that was rejected. + */ + constructor(activeOperation: PlatformLifecycleOperation, requestedOperation: PlatformLifecycleOperation) { + super( + `Cannot ${requestedOperation} the platform shell while ${activeOperation} is active.`, + { + code: 'PLATFORM_LIFECYCLE_CONFLICT', + meta: { activeOperation, requestedOperation }, + }, + ); + this.activeOperation = activeOperation; + this.requestedOperation = requestedOperation; + } +} + /** * Structured context for runtime-level errors. */ diff --git a/packages/runtime/src/platform-shell.lifecycle.test.ts b/packages/runtime/src/platform-shell.lifecycle.test.ts new file mode 100644 index 000000000..848c0a4ac --- /dev/null +++ b/packages/runtime/src/platform-shell.lifecycle.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it } from 'vitest'; + +import type { + PlatformComponent, + PlatformHealthReport, + PlatformLifecycleOperation, + PlatformReadinessReport, + PlatformShell, + PlatformSnapshot, + PlatformState, + PlatformValidationResult, +} from './index.js'; +import { RuntimePlatformShell } from './platform-shell.js'; + +class Deferred { + readonly promise: Promise; + private settle: () => void = () => { + throw new Error('Deferred promise was not initialized.'); + }; + + constructor() { + this.promise = new Promise((resolve) => { + this.settle = resolve; + }); + } + + resolve(): void { + this.settle(); + } +} + +type LifecycleHook = () => Promise | void; + +interface LifecycleControl { + readonly failStartTimes?: number; + readonly failStopTimes?: number; + readonly onStart?: LifecycleHook; + readonly onStop?: LifecycleHook; + readonly startGate?: Promise; + readonly stopGate?: Promise; +} + +class ControlledPlatformComponent implements PlatformComponent { + readonly id = 'runtime.lifecycle'; + readonly kind = 'runtime-test'; + readonly entered: Record = { + start: new Deferred(), + stop: new Deferred(), + }; + startCalls = 0; + stopCalls = 0; + + private currentState: PlatformState = 'created'; + private startFailuresRemaining: number; + private stopFailuresRemaining: number; + + constructor(private readonly control: LifecycleControl = {}) { + this.startFailuresRemaining = control.failStartTimes ?? 0; + this.stopFailuresRemaining = control.failStopTimes ?? 0; + } + + health(): Promise { + return Promise.resolve({ status: 'healthy' }); + } + + ready(): Promise { + return Promise.resolve({ critical: true, status: 'ready' }); + } + + snapshot(): PlatformSnapshot { + return { + dependencies: [], + details: {}, + health: { status: 'healthy' }, + id: this.id, + kind: this.kind, + ownership: { externallyManaged: false, ownsResources: true }, + readiness: { critical: true, status: 'ready' }, + state: this.currentState, + telemetry: { namespace: 'fluo.runtime-test', tags: {} }, + }; + } + + async start(): Promise { + this.startCalls += 1; + this.entered.start.resolve(); + await this.control.onStart?.(); + await this.control.startGate; + if (this.startFailuresRemaining > 0) { + this.startFailuresRemaining -= 1; + throw new Error('controlled start failure'); + } + this.currentState = 'ready'; + } + + async stop(): Promise { + this.stopCalls += 1; + this.entered.stop.resolve(); + await this.control.onStop?.(); + await this.control.stopGate; + if (this.stopFailuresRemaining > 0) { + this.stopFailuresRemaining -= 1; + throw new Error('controlled stop failure'); + } + this.currentState = 'stopped'; + } + + state(): PlatformState { + return this.currentState; + } + + validate(): Promise { + return Promise.resolve({ issues: [], ok: true }); + } +} + +const overlapPairs: readonly (readonly [PlatformLifecycleOperation, PlatformLifecycleOperation])[] = [ + ['start', 'start'], + ['start', 'stop'], + ['stop', 'start'], + ['stop', 'stop'], +]; + +type ImmediateResult = + | { readonly error: unknown; readonly status: 'rejected' } + | { readonly status: 'fulfilled' | 'pending' }; + +function observeImmediate(promise: Promise): Promise { + return Promise.race([ + promise.then( + () => ({ status: 'fulfilled' }) as const, + (error: unknown) => ({ error, status: 'rejected' }) as const, + ), + Promise.resolve().then(() => ({ status: 'pending' }) as const), + ]); +} + +describe('RuntimePlatformShell exclusive lifecycle transitions', () => { + it.each(overlapPairs)('rejects %s -> %s overlap immediately with typed conflict metadata', async (active, requested) => { + // Given + const gate = new Deferred(); + const component = new ControlledPlatformComponent( + active === 'start' ? { startGate: gate.promise } : { stopGate: gate.promise }, + ); + const shell: PlatformShell = RuntimePlatformShell.fromInputs([component]); + if (active === 'stop') await shell.start(); + const activeTransition = shell[active](); + await component.entered[active].promise; + + // When + const requestedTransition = shell[requested](); + const immediateResult = await observeImmediate(requestedTransition); + gate.resolve(); + await activeTransition; + + // Then + expect(immediateResult).toMatchObject({ + error: { + activeOperation: active, + code: 'PLATFORM_LIFECYCLE_CONFLICT', + meta: { activeOperation: active, requestedOperation: requested }, + name: 'PlatformLifecycleConflictError', + requestedOperation: requested, + }, + status: 'rejected', + }); + }); + + it('rejects synchronous callback reentry instead of deadlocking startup', async () => { + // Given + let reenter: () => Promise = () => Promise.reject(new Error('reentry was not initialized')); + let reentryResult: ImmediateResult = { status: 'pending' }; + const component = new ControlledPlatformComponent({ + onStart: async () => { + reentryResult = await observeImmediate(reenter()); + if (reentryResult.status === 'pending') throw new Error('synchronous callback reentry remained pending'); + }, + }); + const shell: PlatformShell = RuntimePlatformShell.fromInputs([component]); + reenter = () => shell.stop(); + + // When + await shell.start(); + + // Then + expect(reentryResult).toMatchObject({ + error: { + activeOperation: 'start', + code: 'PLATFORM_LIFECYCLE_CONFLICT', + requestedOperation: 'stop', + }, + status: 'rejected', + }); + }); + + it('rejects callback reentry after arbitrary awaits instead of deadlocking shutdown', async () => { + // Given + let reenter: () => Promise = () => Promise.reject(new Error('reentry was not initialized')); + let reentryResult: ImmediateResult = { status: 'pending' }; + const component = new ControlledPlatformComponent({ + onStop: async () => { + await Promise.resolve(); + await Promise.resolve(); + reentryResult = await observeImmediate(reenter()); + if (reentryResult.status === 'pending') throw new Error('awaited callback reentry remained pending'); + }, + }); + const shell: PlatformShell = RuntimePlatformShell.fromInputs([component]); + reenter = () => shell.start(); + await shell.start(); + + // When + await shell.stop(); + + // Then + expect(reentryResult).toMatchObject({ + error: { + activeOperation: 'stop', + code: 'PLATFORM_LIFECYCLE_CONFLICT', + requestedOperation: 'start', + }, + status: 'rejected', + }); + }); + + it.each(['start', 'stop'] as const)('clears a failed %s transition for an explicit retry after settlement', async (operation) => { + // Given + const component = new ControlledPlatformComponent( + operation === 'start' ? { failStartTimes: 1 } : { failStopTimes: 1 }, + ); + const shell: PlatformShell = RuntimePlatformShell.fromInputs([component]); + if (operation === 'stop') await shell.start(); + + // When + await expect(shell[operation]()).rejects.toThrow( + operation === 'start' ? 'controlled start failure' : 'One or more platform components failed to stop cleanly.', + ); + const retry = shell[operation](); + + // Then + await expect(retry).resolves.toBeUndefined(); + expect(operation === 'start' ? component.startCalls : component.stopCalls).toBe(2); + }); + + it('keeps settled sequential start and stop calls idempotent', async () => { + // Given + const component = new ControlledPlatformComponent(); + const shell: PlatformShell = RuntimePlatformShell.fromInputs([component]); + + // When + await shell.start(); + await shell.start(); + await shell.stop(); + await shell.stop(); + + // Then + expect({ start: component.startCalls, stop: component.stopCalls }).toEqual({ start: 1, stop: 1 }); + }); +}); diff --git a/packages/runtime/src/platform-shell.ts b/packages/runtime/src/platform-shell.ts index af49f0bf2..e972a8a8f 100644 --- a/packages/runtime/src/platform-shell.ts +++ b/packages/runtime/src/platform-shell.ts @@ -1,5 +1,6 @@ import { InvariantError } from '@fluojs/core'; +import { PlatformLifecycleConflictError, type PlatformLifecycleOperation } from './errors.js'; import type { PlatformComponent, PlatformComponentInput, @@ -18,6 +19,10 @@ interface RegisteredPlatformComponent { dependencies: readonly string[]; } +interface PlatformLifecycleTransition { + readonly operation: PlatformLifecycleOperation; +} + function isRegistration(value: PlatformComponentInput): value is PlatformComponentRegistration { return typeof value === 'object' && value !== null && 'component' in value; } @@ -158,10 +163,10 @@ interface PlatformHealthResult { */ export class RuntimePlatformShell implements PlatformShell { private started = false; - private stopped = false; private orderedComponents: RegisteredPlatformComponent[] = []; private rollbackPendingComponents: RegisteredPlatformComponent[] = []; private readonly diagnostics: PlatformDiagnosticIssue[] = []; + private activeLifecycleTransition: PlatformLifecycleTransition | undefined; constructor(private readonly registeredComponents: RegisteredPlatformComponent[]) {} @@ -179,13 +184,41 @@ export class RuntimePlatformShell implements PlatformShell { return this.registeredComponents.length > 0; } - async start(): Promise { + start(): Promise { + return this.runLifecycleTransition('start', () => this.startComponents()); + } + + stop(): Promise { + return this.runLifecycleTransition('stop', () => this.stopComponents()); + } + + private runLifecycleTransition(operation: PlatformLifecycleOperation, run: () => Promise): Promise { + const activeTransition = this.activeLifecycleTransition; + if (activeTransition) { + return Promise.reject(new PlatformLifecycleConflictError(activeTransition.operation, operation)); + } + + const transition: PlatformLifecycleTransition = { operation }; + this.activeLifecycleTransition = transition; + const promise = Promise.resolve(run()); + + const clearActiveTransition = (): void => { + if (this.activeLifecycleTransition === transition) { + this.activeLifecycleTransition = undefined; + } + }; + void promise.then(clearActiveTransition, clearActiveTransition); + + return promise; + } + + private async startComponents(): Promise { if (!this.hasRegisteredComponents() || this.started) { return; } if (this.rollbackPendingComponents.length > 0) { - await this.stop(); + await this.stopComponents(); } this.validateIdentityAndDependencies(); @@ -215,7 +248,6 @@ export class RuntimePlatformShell implements PlatformShell { await this.stopStartedComponents(startedComponents); this.rollbackPendingComponents = []; } catch (rollbackError) { - this.rollbackPendingComponents = [...startedComponents]; this.diagnostics.push(createUnknownFailureIssue(component.component.id, 'start-rollback', rollbackError)); } @@ -224,14 +256,13 @@ export class RuntimePlatformShell implements PlatformShell { } this.started = true; - this.stopped = false; this.rollbackPendingComponents = []; } - async stop(): Promise { + private async stopComponents(): Promise { const hasRollbackPending = this.rollbackPendingComponents.length > 0; - if ((!this.started && !hasRollbackPending) || this.stopped) { + if (!this.started && !hasRollbackPending) { return; } @@ -241,10 +272,10 @@ export class RuntimePlatformShell implements PlatformShell { ? [...this.orderedComponents] : [...this.registeredComponents]; + this.started = false; + await this.stopStartedComponents(toStop); this.rollbackPendingComponents = []; - this.started = false; - this.stopped = true; } async ready(): Promise { @@ -527,17 +558,20 @@ export class RuntimePlatformShell implements PlatformShell { private async stopStartedComponents(startedComponents: RegisteredPlatformComponent[]): Promise { const errors: unknown[] = []; + const pendingComponents: RegisteredPlatformComponent[] = []; for (const component of [...startedComponents].reverse()) { try { await component.component.stop(); } catch (error) { errors.push(error); + pendingComponents.unshift(component); this.diagnostics.push(createUnknownFailureIssue(component.component.id, 'stop', error)); } } if (errors.length > 0) { + this.rollbackPendingComponents = pendingComponents; throw new AggregateError(errors, 'One or more platform components failed to stop cleanly.'); } } diff --git a/tooling/governance/platform-shell-lifecycle-contract.mjs b/tooling/governance/platform-shell-lifecycle-contract.mjs new file mode 100644 index 000000000..3433d01fe --- /dev/null +++ b/tooling/governance/platform-shell-lifecycle-contract.mjs @@ -0,0 +1,37 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); + +const lifecycleContractRequirements = [ + ['packages/runtime/README.md', ['PlatformLifecycleConflictError', 'PLATFORM_LIFECYCLE_CONFLICT', 'strictly exclusive']], + ['packages/runtime/README.ko.md', ['PlatformLifecycleConflictError', 'PLATFORM_LIFECYCLE_CONFLICT', '엄격한 exclusive']], + ['docs/architecture/platform-consistency-design.md', ['PlatformLifecycleConflictError', 'MUST NOT queue']], + ['docs/architecture/platform-consistency-design.ko.md', ['PlatformLifecycleConflictError', 'queue하면 안 된다']], + ['docs/contracts/third-party-extension-contract.md', ['PlatformLifecycleConflictError', 'MUST NOT queue']], + ['docs/contracts/third-party-extension-contract.ko.md', ['PlatformLifecycleConflictError', 'queue하면 안 됩니다']], + ['docs/contracts/platform-conformance-authoring-checklist.md', ['PlatformLifecycleConflictError', 'all four `start()` / `stop()` overlap pairs']], + ['docs/contracts/platform-conformance-authoring-checklist.ko.md', ['PlatformLifecycleConflictError', '네 가지 `start()` / `stop()` overlap pair']], + ['docs/CONTEXT.md', ['PlatformShell lifecycle exclusivity', 'PlatformLifecycleConflictError']], + ['docs/CONTEXT.ko.md', ['PlatformShell lifecycle exclusivity', 'PlatformLifecycleConflictError']], +]; + +function assert(condition, message) { + if (!condition) { + throw new Error(`Platform shell lifecycle contract check failed: ${message}`); + } +} + +export function enforcePlatformShellLifecycleContract( + readText = (relativePath) => readFileSync(join(repoRoot, relativePath), 'utf8'), +) { + for (const [relativePath, requiredMarkers] of lifecycleContractRequirements) { + const content = readText(relativePath); + const missingMarkers = requiredMarkers.filter((marker) => !content.includes(marker)); + assert( + missingMarkers.length === 0, + `${relativePath} must keep strict PlatformShell lifecycle exclusivity synchronized; missing: ${missingMarkers.join(', ')}.`, + ); + } +} diff --git a/tooling/governance/verify-platform-consistency-governance.d.mts b/tooling/governance/verify-platform-consistency-governance.d.mts index 7e3d931a2..52e6e112d 100644 --- a/tooling/governance/verify-platform-consistency-governance.d.mts +++ b/tooling/governance/verify-platform-consistency-governance.d.mts @@ -51,6 +51,9 @@ export function enforceSerializerResponseOwnershipDocsSync( export function enforceExpressRuntimeMigrationDocsSync( readText?: (relativePath: string) => string, ): void; +export function enforcePlatformShellLifecycleContract( + readText?: (relativePath: string) => string, +): void; export function enforceGraphqlRuntimeBoundaryDiscoverability(): void; export function enforceMicroservicesSafetyGuidanceParity(): void; export function enforceMicroservicesSafetyRuntimeEvidence(): void; diff --git a/tooling/governance/verify-platform-consistency-governance.mjs b/tooling/governance/verify-platform-consistency-governance.mjs index 48f6e1d0b..66a737469 100644 --- a/tooling/governance/verify-platform-consistency-governance.mjs +++ b/tooling/governance/verify-platform-consistency-governance.mjs @@ -9,6 +9,7 @@ import { enforceMicroservicesSafetyGuidanceParity, enforceMicroservicesSafetyRuntimeEvidence, } from './microservices-safety-guidance.mjs'; +import { enforcePlatformShellLifecycleContract } from './platform-shell-lifecycle-contract.mjs'; import { enforceReactPageCatalogContract } from './react-page-catalog-contract.mjs'; import { enforceReactRscGraduationGovernance } from './react-rsc-graduation-policy.mjs'; @@ -17,6 +18,7 @@ export { enforceMicroservicesSafetyGuidanceParity, enforceMicroservicesSafetyRuntimeEvidence, } from './microservices-safety-guidance.mjs'; +export { enforcePlatformShellLifecycleContract } from './platform-shell-lifecycle-contract.mjs'; export { enforceReactPageCatalogContract } from './react-page-catalog-contract.mjs'; export { enforceReactRscGraduationEvidenceUpdates, @@ -2197,6 +2199,7 @@ export function main() { enforceDocsHubOfficialTransportLinks(); enforceSerializerResponseOwnershipDocsSync(); enforceCloudflareWorkersLifecycleDocsSync(); + enforcePlatformShellLifecycleContract(); enforceConfigNestjsMigrationDocs(); enforceExpressRuntimeMigrationDocsSync(); enforceCanonicalRuntimeMatrixReferences(); diff --git a/tooling/governance/verify-platform-consistency-governance.test.ts b/tooling/governance/verify-platform-consistency-governance.test.ts index f3db82d35..f4e848e03 100644 --- a/tooling/governance/verify-platform-consistency-governance.test.ts +++ b/tooling/governance/verify-platform-consistency-governance.test.ts @@ -11,6 +11,7 @@ import { enforceGraphqlRuntimeBoundaryDiscoverability, enforceNoDirectProcessEnvInOrdinaryPackageSource, enforceNoNodeGlobalBufferInDenoAndCloudflareWorkerServices, + enforcePlatformShellLifecycleContract, enforceReactClientSubpathContract, enforceReactPageCatalogContract, enforceReactServerFunctionContract, @@ -131,6 +132,12 @@ describe('enforceReactPageCatalogContract', () => { }); }); +describe('enforcePlatformShellLifecycleContract', () => { + it('keeps strict lifecycle conflicts synchronized across bilingual public contracts', () => { + expect(() => enforcePlatformShellLifecycleContract()).not.toThrow(); + }); +}); + describe('enforceReactServerFunctionContract', () => { it('keeps signed action transport, HTTP lifecycle, security limits, and bilingual docs aligned', () => { expect(() => enforceReactServerFunctionContract()).not.toThrow();