Skip to content

feat(devx): gate any Plugin implementation whose teardown the kernel never calls - #10774

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-10619-teardown-shape-guard
Aug 21, 2026
Merged

feat(devx): gate any Plugin implementation whose teardown the kernel never calls#10774
os-zhuang merged 2 commits into
mainfrom
claude/issue-10619-teardown-shape-guard

Conversation

@claude

@claudeclaudeBot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Closes#10619

Plugin (packages/core/src/types.ts) declares exactly one teardown hook — destroy?() — and both kernels call only that one: ObjectKernel.performShutdown() and LiteKernel.destroy() walk the plugins in reverse and call plugin.destroy(). Nothing anywhere calls stop(), shutdown(), close() or dispose() on a plugin. So a plugin whose teardown is spelled stop() is never torn down — await kernel.shutdown() resolves with its timers still armed.

The trap is an asymmetry, which is why review does not catch it: start?()is on the interface and does fire, so a start/stop pair reads symmetric while only one half is ever called. It survived review in two packages and was found only by merge-queue evictions (#9371: two green PRs evicted). Fixing the known instances leaves the class open — the next one arrives spelled shutdown() or close(). This gate makes the class unreachable. Ruled FOR by the domain:devx PM on the #9371 thread.

The landing hazard, and how it was resolved

Route 2 of the card's two: land with a baseline.#10371 has not landed — verified by reading the code at origin/main = 78ac958552, not by reading the card: all of its named instances still declare an alias and no destroy(). So the guard is landing with the pre-existing instances baselined in KNOWN_TEARDOWN_UNREACHED, ⛔ SHRINK-ONLY, each entry pointing at #10371 which owns the repair. The list refuses new members outright — the failure text turns the "just baseline it" path down rather than marking it, which is how check:ratchet-remedy-authority classifies this gate (refused, its fourth such instance). Repairing the six here was not an option: that is the services lane's work and it would have made this PR unreviewable against its card.

⚠️ The baseline is eleven, not six

The population was re-derived mechanically rather than adopted from #10371, and the class is larger than that card enumerates. Five instances it does not name:

  • MetadataPlugin, AppPlugin, ExternalValidationPlugin — spell the alias as an arrow property (stop = async (ctx) => …), which a method-only reading of the class misses entirely;
  • EmailServicePlugin, WebhookOutboxPlugin — spell it dispose(), i.e. the "seventh arrives spelled differently" case the card predicts is already here.

ExternalValidationPlugin is the one that raises the stakes: besides plugin-reports it is the only instance owning setInterval timers, and on shutdown those intervals are never cleared. Filed as #10772, unassigned, as a sub-issue of #10371 so the repair lane does not under-repair. Not fixed here.

The teardown roster — stop / shutdown / close / dispose

Measured over this tree's 54 Plugin implementations: stop x10, dispose x2, close x0 methods, shutdown x0. Two of the four have no instance today and that is the point — the class closes for the seventh instance, not the sixth. dispose is on the roster because it is more live than two of the card's three suggestions; excluding a name with two live instances while including two with none would not be defensible.

Deliberately off the roster, argued in the gate header and pinned as green cases by the self-test rather than asserted in prose:

  • end, release, disconnect, abort, cancel, drain, flush, unbind — each names one resource, request or period, not the object's own end of life. disconnect() on a connector plausibly drops a single connection; release() releases a lock. Each has a live domain reading in this product and none has an instance here.
  • cleanup, teardown — test-harness vocabulary; a helper named cleanup() in a plugin is ordinary.
  • deactivate, unload, unmount, finalize, quit, halt, terminate, kill — no instance, no precedent in this runtime, and each reads more plausibly as a domain verb.

The one roster member carrying a live domain reading is close (an approval or period is "closed"). It stays, because the shape rule below already removes the specimen in this tree that would otherwise have been the false positive.

What counts as a declared teardown

A non-static class member on the roster that is a function the author wrote on the class — a method with a body, or a property initialised to an arrow/function expression. Both spellings are required: 3 of the 10 stop instances are arrow properties, so a method-only scan reports a smaller, confident, wrong number.

A property that merely holds a callback is not a declared teardown, and this is measured rather than assumed: ConnectorMcpPlugin keeps private close?: () => Promise<void> — a handle taken from a bundle, which it calls from a real destroy(). Correct code, the exact shape a name-only scan would flag, pinned green.

Population is packages/**, tests included (the kernel drives fixture plugins too, and a test process is where #9371's bill actually landed). The Plugin symbol is not required to come from @objectstack/core: CloudConnectionPlugin declares its own structural Plugin on purpose, runs on the same kernel and is torn down by the same loop, so an import-anchored population would exclude exactly that plugin.

Verification

Positive control is the real fixture, not a synthetic class.MessagingServicePlugin as it stood on main immediately before PR #10375 (621a487607881c66b2899b7e3477115229a156b4, the squash merge's first parent) — async stop(), no destroy(). It is extracted with git show at self-test time and pinned to a commit, so nobody can quietly edit the fixture into passing; an unreachable revision refuses and prints the deepen command rather than skipping.

Negative controls. The same file after the #9371 repair stays green, which is the delegating alias in real life (stop() calling this.destroy()); both delegating directions are pinned synthetically as well; a plugin with destroy() and no alias stays green.

Refusals, each paired with a readable tree that still returns a verdict — so "refuses unconditionally" cannot satisfy the battery:

unreadable stateproved by
population root missingpaired with a tree that still reds
population resolves to zero sourcespaired with a tree that still returns green
zero Plugin implementations foundrefuses rather than reporting clean
a source that cannot be reada dangling symlink named p.ts, in an otherwise readable tree
a source that cannot be parsedmerge-conflict markers, asserted out of process

One deliberate deviation from the card's wording: an unparseable source exits EXIT_UNPARSEABLE (3) from scripts/ts-parse.mjs rather than 1. Routing every parse through that module is what check:parse-guard enforces, and it ends the process itself; it is a refusal that names the file either way, which is the property that matters. The self-test asserts non-zero and that the file is named.

Ablation. Predicted first, then run. Blinding the red condition (hasKernelHook: declared.has(KERNEL_HOOK)true) reddens exactly the 10 of 47 cases predicted: the real pre-#10375 fixture, all four roster names, the arrow-property spelling, the structural-Plugin case, the test-source case, the paired tree that must still red, and the live-tree ratchet agreement. The mutation was confirmed on disk by anchor count (original 1 -> 0, injected 0 -> 1), and the restore leg confirmed the same way (1, 0) with 47/47 green again.

Wiring

$ node scripts/pm/dispatch-gates.mjs packages/plugins/plugin-reports/src/reports-plugin.ts
Local gates for this card (paste into the dispatch prompt):
...
- node scripts/check-plugin-teardown-shape.mjs [lint.yml] matched via packages/plugins/plugin-reports/src/reports-plugin.ts ⇢ gate source 'packages/**'

One bounded edit outside the new file

scripts/check-ratchet-remedy-authority.mjs gains a CONTROL entry for this gate. That corpus is audited for set equality both ways, so a new gate the sweep reaches and the control does not cover fails as UNCLASSIFIED; the entry is the sanctioned fix (that file states its control is a declaration registry, not a ratchet, and needs nobody's leave). Verdict recorded as measured — refused — not chosen.

Gates

No changeset: a new gate publishes nothing. skip-changeset label applied.

Derived with node scripts/pm/dispatch-gates.mjs (no paths) and run at b97bab513d, all green:

check-plugin-teardown-shape --self-test (47 cases) · check-plugin-teardown-shape · check:entry-guard · check:parse-guard · check:ratchet-remedy-authority (+ --self-test) · check:required-contexts · check:aggregator-roster · check:workflow-status-functions · check:cross-package-test-inputs · check:node-version · check:shard-attestation · check:type-check-coverage · check:nul-bytes

Declared narrowing:check:type-check-debt (check-type-check-coverage.mjs --re-measure) was not run. It refuses without a built dependency closure, and this diff adds no package source and changes no tsconfig, so the frozen raw-error counts cannot move. The leg of that gate which actually reads the lint.yml edited here — check-type-check-coverage.mjs — is green above. CI runs the re-measure on the built closure regardless.


Generated by Claude Code

os-zhuangand others added 2 commits August 21, 2026 10:48
…utdown()/close()/dispose() with no destroy()
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
…ratchet-remedy control
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 21, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 21, 2026 11:11
@os-zhuang
os-zhuang enabled auto-merge August 21, 2026 11:11
@os-zhuang
os-zhuang added this pull request to the merge queueAug 21, 2026
Merged via the queue into main with commit e072c97Aug 21, 2026
32 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-10619-teardown-shape-guard branch August 21, 2026 11:27
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cdsize/lskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

guard: fail any Plugin implementation that declares stop() (or shutdown()/close()) with no destroy() — the kernel only ever calls destroy()

1 participant

@os-zhuang