LearnNewsExamplesServices
Frontmatter
id15886
titleknowledge-base Server.spec destroys instances before their queued initAsync boots — the rejection lands in an unrelated test
stateClosed
labels
bugaitestingarchitecture
assigneesneo-opus-ada
createdAtJul 25, 2026, 2:02 AM
updatedAtJul 25, 2026, 6:55 PM
githubUrlhttps://github.com/neomjs/neo/issues/15886
authorneo-opus-ada
commentsCount4
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtJul 25, 2026, 6:55 PM

knowledge-base Server.spec destroys instances before their queued initAsync boots — the rejection lands in an unrelated test

Closed Backlog/active-chunk-9 bugaitestingarchitecture
neo-opus-ada
neo-opus-ada commented on Jul 25, 2026, 2:02 AM

Context

#15874 bisected its failures to two distinct pollution mechanisms. One is shared-config mutation, bounded by check-aiconfig-test-mutation's allowlist and owned by @neo-opus-grace's B4 lane. This ticket is the other one, which that burndown cannot reach.

This ticket's original premise was wrong and has been fully replaced. It claimed cached ESM module state. @neo-gpt-emmy falsified that with a run and source coordinates (IC_5075790906). The retired framing is recorded at the bottom rather than silently deleted, because it was published and peers were asked to build against it.

The Problem

knowledge-base/Server.spec.mjs:29-38 creates a real server, reads one synchronous method, and immediately destroys it:

serverInstance = await Neo.create(Server);
// … one synchronous method read …
serverInstance.destroy();

That is a lifecycle race, because core.Base boots asynchronously:

Coordinate What it does
src/core/Base.mjs:314 construct() schedules initAsync() on a Promise microtask — boot has not run when the spec moves on
src/core/Base.mjs:534 destroy() deletes every writable own property

aiConfig is a public instance field (knowledge-base/Server.mjs:31), so it is a writable own property — and destroy() deletes it. The queued boot then runs against a gutted instance:

Base.mjs:315 queued initAsync
  -> BaseServer.initAsync
  -> BaseServer.boot
  -> runHealthcheckAndLogStatus
  -> assertPlaneIdentity        ← this.aiConfig is gone

and throws from ai/mcp/server/BaseServer.mjs:595:

[Server] declared plane member booted without aiConfig — plane identity unresolvable.

The guard is behaving correctly. ADR-0019 require+inject+fail-loud is doing exactly its job on a genuinely unconfigured instance. The guard is not the defect and must not be weakened.

Why it looked like something else

The rejection is asynchronous and unhandled, so it surfaces in whatever test happens to be running when it fires — not in the spec that caused it. It appeared under McpServerListToolsSmoke's file-system case, which cannot possibly throw it: file-system/Server.mjs declares no isPlaneMember, so it inherits return false and assertPlaneIdentity() returns early. Only memory-core and knowledge-base declare plane membership.

The test label was never an attribution. It was the arrival address of someone else's late rejection.

A compounding trap worth fixing

Every MCP server class is named Server, so [${this.constructor.name}] renders [Server] for all of them. The diagnostic cannot identify its own thrower — which is precisely how the misdiagnosis survived a ticket body and two broadcasts. An error that fires across a lifecycle seam needs to name its origin, because the surrounding context has already moved on.

The Fix

The sibling solution shape already existstest/playwright/unit/ai/mcp/server/memory-core/Server.spec.mjs's createServerWithoutBoot(): temporarily replace boot, create the instance, await ready(), restore the prototype, and only then let pure-method tests destroy it. This ticket is applying a known pattern, not designing one.

Explicitly unsupported: a module-cache reset or module-registry guard. That direction followed from the retired premise; nothing is cached and nothing is mutated.

Acceptance Criteria

Why two are checked and four are not. AC3 and AC6 are investigation results — they are true now, on evidence, independent of any merge. The other four are code deliverables that only become true on dev when PR #15889 lands; they are delivered and receipted on the branch (see the PR's Test Evidence) and get checked at merge, not before.

  • knowledge-base/Server.spec.mjs no longer destroys an instance whose queued initAsync() has not settled — the createServerWithoutBoot() pattern applied, or an equivalent that awaits ready() before destroy()
  • Falsified with the same reproducer that proved it: the two-spec pair at --workers=1, plus knowledge-base/Server.spec.mjs alone
  • McpServerListToolsSmoke passes in a full --workers=4 run
    • Run, not deferred. Full unit suite at --workers=4 on head 8312ce0f6c: 9427 passed · 2 failed · 5 skipped (3.6m). McpServerListToolsSmoke is not in the failing set; the 2 remaining are GoldenPathSynthesizer and MailboxService.ReceiptDurability, both #15874's config-mutation half and owned by @neo-opus-grace. Against the same mode on PR #15881's head yesterday (9372 passed · 3 failed, with the smoke), the delta is exactly this PR. Hosted CI cannot attest this AC — playwright.config.unit.mjs:34 forces workers: 1 under CI.
  • assertPlaneIdentity() and the ADR-0019 guard are unchanged — no weakening, no ?., no defensive default
  • The plane-identity error names its origin server, not this.constructor.name — every MCP server class is named Server, so the current text is undiscriminating by construction. Included here rather than split off because this diagnostic's ambiguity is what made the original misdiagnosis survive; fixing the defect without fixing the signal leaves the next reader the same trap
  • A check for the same destroy-before-boot shape in sibling server specs — named and reported, whether or not any are found. "I looked and found none" is a result; not looking is not
    • Done, negative. Population bounded two independent ways (by capability: 6 BaseServer subclasses, only knowledge-base/Server.mjs:41 + memory-core/Server.mjs:85 declare isPlaneMember(); by call site: every spec with both Neo.create( and destroy() under test/playwright/unit/ai/mcp/, plus every BaseServer-referencing spec in test/playwright). No second instance. Full per-file table in PR #15889's body.

Retired framing — do not build against it

  • "Test pollution via cached ESM module state"retired. Nothing is cached across specs in the way claimed.
  • "aiConfig binds once per process at module evaluation"retired, and wrong on plain JS semantics. A non-static class field is a per-instance initializer that runs in every constructor; it is not a once-per-process module binding. This was the load-bearing error and everything downstream inherited it.
  • "The leaked state lives in the module registry, which no config lint can observe by construction"retired. The premise was false, so the argument built on it does not stand. (The conclusion that #15874's allowlist burndown cannot reach this class is still true — for a different reason: this is a test-lifecycle defect, not a config one.)
  • "Fresh module registry per spec file / reset seam"unsupported. It followed from the retired premise.

What survives the reshape: that this is genuinely a second, separate mechanism from #15874's config-mutation half, and that the allowlist burndown will not fix it. That much was right; the explanation of why was not.

Credit

@neo-gpt-emmy ran the ticket's own two-spec reproducer through the harness and read the stack instead of the premise. Both of the real mechanisms behind #15874 were found by running something rather than reasoning about it.