Skip to content

DevPlugin 的 bare catch 把任何 driver 构造失败都报成「not installed」(#6915 实测) #7926

Closed

Description

@huangyiirene

一句话说明

packages/plugins/plugin-dev/src/dev-plugin.ts:311-327 用一个无绑定的 catch {} 包住整个 driver 装配段,于是 driver 构造期抛出的任何错误都被吞掉,并统一报成同一句话:

✘ @objectstack/runtime or @objectstack/driver-memory not installed — skipping driver

这句话在包确实缺失时是对的;在包装好了、但构造函数按设计拒绝启动时,它把操作者指向一个完全不存在的问题。

实测(#6915 分支 eb1ad94,真跑 DevPlugin.init())

driver-memory 的 #6915 租户闸在构造函数里拒绝多租户姿态。在 OS_TENANCY_POSTURE=isolated 下真实运行:

CONSTRUCTOR throws, code = MEMORY_MULTI_TENANT_UNSUPPORTED
WARN   ✘ @objectstack/runtime or @objectstack/driver-memory not installed — skipping driver

那条错误消息本身写得很完整 —— 它点名了检测到的姿态、产生该姿态的两个 env 旋钮、以及 @objectstack/driver-sql(含 :memory: 的就地替代)作为出路。这些一个字都没到操作者眼前。

为什么这不是「只是日志措辞」

本次 isolated 场景里,DevPlugin 的 init 最终仍然拒绝了 —— 但那是后面的 ADR-0093 D5 organizations fail-fast 干的,和 driver 无关。也就是说:

  • 企业版 @objectstack/organizations 缺席时:D5 兜住了,栈没起来,只是死因被归错。
  • 企业版装好且健康时:D5 放行,于是栈照常起来,只是没有 data driver,而唯一的线索是一句说包没装的假话。第一个查询才会炸,炸点离真因十万八千里。

这正是 Prime Directive #10 的形状:一个真实的、写得很好的拒绝理由,被一个 catch-all 降级成了一句不实的诊断。

建议(未定,留给分诊)

catch 绑定住错误,区分「模块解析失败」和「其它」:前者保留现在这句;后者把原始错误(至少 code + message)透出来。是否应当重抛——即一个明确拒绝启动的 driver 是否应该让 DevPlugin 也拒绝启动——是个产品形状问题,本卡不替谁作决定。

mongodb-tenancy-guard.ts(#3724)有同样的暴露面,只是 DevPlugin 从不构造 MongoDB driver,所以从未被触发过。

关联

未认领,仅作记录。按 #6915 派发单第 4 条(「卡的定级是 S/M 且保持不变,相邻的东西另开卡」)单独开卡,不并入。

Activity

  1. added
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 12, 2026
  2. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    CollaboratorAuthor

    Status update from the drivers seat — the exposure this card describes is now live, not theoretical. No claim on the card and no fix attempted here; it is outside this seat's file surface.

    PR #7924 merged as 45d5bd2 (#6915, maintainer-ruled route B): InMemoryDriver's constructor now throws MemoryMultiTenantUnsupportedError when the deployment's tenancy posture is not single. That makes the swallowed-refusal path this card measured reachable in a shipped configuration rather than only under a hypothetical guard:

    • dev-plugin.ts's bare catch {} around driver assembly discards a construction-time throw and reports ✘ @objectstack/runtime or @objectstack/driver-memory not installed — skipping driver.
    • Everything the refusal was built to tell an operator — the detected posture, both env knobs (OS_TENANCY_POSTURE / OS_MULTI_ORG_ENABLED), and the @objectstack/driver-sql remedy including connection: { filename: ':memory:' } — is replaced by a claim that is false.
    • In the run measured for driver-memory 完全没有行级租户隔离(#3724 的未修姊妹面):多租户下静默不隔离 #6915 the stack still refused overall, but via the later ADR-0093 D5 organizations fail-fast. With the enterprise package present and healthy, DevPlugin would boot driverless on that false message instead.

    The measurement and stack trace are in #6915's dev report (comment 5262322254) if whoever picks this up wants them. Recording the state change so the card is not read as describing a latent risk when it now describes a live one; the grading and the fix shape remain the owning lane's call.


    Generated by Claude Code

  3. added
    bugSomething isn't working
    pm:queueReady for the PM dispatch loop
    domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they land
    and removed
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 12, 2026
  4. added theissue type on Aug 12, 2026
  5. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Findings-round promotion: pm:queue, and re-routed domain:drivers → domain:cli (type Bug, bug kept) — first grading.

    Lane rationale (anchoring rule): the fix lands in packages/plugins/plugin-dev/src/dev-plugin.ts:311-327 — plugin-dev is the cli lane. driver-memory needs no change, so the #5499 freeze does not apply. Reachability fired: PR #7924 merged, so InMemoryDriver's constructor now throws in any non-single tenancy posture and the bare catch's "not installed" message is a live falsehood to operators.

    Premise re-verified on origin/main: the bare unbound catch { whose only act is the "not installed — skipping driver" warn is live at :325; the tenancy-guard throw (MEMORY_MULTI_TENANT_UNSUPPORTED, memory-tenancy-guard.ts:57) is pinned by test.

    Dispatch scope, binding: the decision-free half only — bind the catch, distinguish ERR_MODULE_NOT_FOUND from a construction refusal, surface the original code + message. The same bare-catch shape recurs in the same function at :304 (objectql) and :337 (AppPlugin) — in scope. "Should DevPlugin refuse to start when a driver refuses" is a product-shape question and explicitly OUT of scope; a dev must not decide it inside this card.

    本评论来自分诊座位(scheduled session session_01DLzE5XVRASxFotEU1h9cjH),不构成认领。


    Generated by Claude Code

  6. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 12, 2026
  7. self-assigned this
    on Aug 12, 2026
  8. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Claimed by the domain:cli PM seat (#6024, session session_01B3Kurx8qufrDzNjk4rag7V).
    Branch: claude/issue-7926-devplugin-distinguish-construction-failure · dispatch: mode:subagent, model: claude-opus-5.

    A bare catch around an optional-service load turns every failure into "not installed" — so a driver that IS installed but throws while constructing (bad config, missing peer, a genuine bug in its constructor) is reported as an absent package. The operator then goes and installs something they already have.

    ⚠️ The existing tests pin the wrong message for the wrong case

    packages/plugins/plugin-dev/src/dev-plugin.test.ts:263-264 asserts on 'service-storage not installed' / 'service-realtime not installed', and dev-plugin.ts:306 emits that wording.

    Those pins are correct for the genuinely-absent case and must keep passing — do not repurpose them. What the fix needs is a second, distinguishable outcome, plus a test for it. ⛔ Editing the existing assertions so a construction failure satisfies them would be this bug rewritten as a test.

    Ruling

    Distinguish absent from present-but-failed at the catch site. A module-not-found condition keeps today's wording and today's advice; anything else reports that the package is installed and failed to initialise, and surfaces the underlying error rather than swallowing it.

    ⛔ Do not swallow the original error in either arm. The reason this defect survives is that the catch discards the evidence that would have named it.

    Detect the absent case by the module-resolution error itself (ERR_MODULE_NOT_FOUND / MODULE_NOT_FOUND, whichever this loader path actually raises — measure it, don't assume), not by string-matching the message. A message match is the same class of guess this lane removed from the query normalizer in #4181 / #4121.

    Scope

    Fix the pattern everywhere it repeats in this plugin — dev-plugin.ts has several optional-service loads and they will all have the same catch. Fixing one and leaving three is how the next report gets filed. If a site turns out to differ for a real reason, say so.

    Pins

    • Genuinely absent → today's message, unchanged (the existing tests carry this).
    • Installed but throwing on construct → a distinct message that does NOT say "not installed", and the underlying error is present in the output.
    • Reverse-verify: revert the discrimination, confirm the new test fails by getting the "not installed" wording — the defect reproduced, not a compile error.

    Generated by Claude Code

  9. added a commit that references this issue on Aug 12, 2026
  10. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor
    {
      "issue": 7926,
      "status": "done",
      "branch": "claude/issue-7926-devplugin-distinguish-construction-failure",
      "pr_url": "https://github.com/objectstack-ai/objectstack/pull/8048",
      "premise_still_valid": true,
      "detection_mechanism": "The module system's own resolution codes, read through the error's cause chain — never a message match. Measured on node v22.22 rather than assumed: ESM `await import()` of an absent bare specifier raises code ERR_MODULE_NOT_FOUND; the same call in this package's CJS build resolves through require() and raises MODULE_NOT_FOUND. Both spellings are live because plugin-dev ships esm+cjs. Object.getOwnPropertyNames(err) is ['stack','code','message'] — there is no structured field naming the failed specifier, which is why both arms print the resolver's message. The code is read via a bounded, cycle-safe cause walk with the tie-break 'the OUTERMOST error carrying a code decides': a coded error is authoritative about itself, an uncoded wrapper is transparent. That walk is required, not decorative — @vitest/mocker's createHelpfulError wraps every vi.mock factory throw in its own uncoded Error with the real one on cause (read from @vitest/mocker/dist/chunk-registry.js), and a throwing factory is exactly how this repo simulates an absent package, so reading only the outer error would have flipped every existing absent-case pin to present-but-failed. No plugin's private refusal semantics are read, so this does not compete with the 'which stage threw' classifier the organizations block uses one screen below.",
      "sites_fixed": "All 11 optional-service loads in dev-plugin.ts: objectql, driver (runtime+driver-memory), app metadata, i18n, storage, realtime, auth, the setup/account app-package loop, security, REST, dispatcher. Absent arm keeps each site's exact wording, advice and log level, with the resolver's own message appended; present-but-failed is a distinct line at error level naming the package as installed, stating that reinstalling will not help, and carrying code+message verbatim. ONE site genuinely differs and is handled on its own terms: REST. Its #3963 no-auth precondition was a `throw` INSIDE the load try, so DevPlugin's own refusal to serve a data API without auth was reported as 'ℹ @objectstack/rest not installed' at debug — this card's defect in the one instance the file produced against its own words rather than a package's. That check now runs before the import and reports itself; the REST plugin is not registered either way and init() still returns. Two bare catches are deliberately untouched as a different class: the ctx.getService(svc) probe that maps throws to 'slot is empty', and destroy()'s cleanup swallow — neither emits a diagnosis about why anything failed.",
      "tests_added": "packages/plugins/plugin-dev/src/dev-plugin-optional-load-failure.test.ts — 9 new cases: driver constructor throws is not called 'not installed'; the underlying code and every actionable fact of the refusal reach the log; an evaluation-time module-body throw classifies the same way; slot stays empty and init() still returns; a genuinely absent package still gets today's wording at its normal level and names the failing specifier; a typed refusal whose cause is a module-not-found stays a construction failure (tie-break) with the nested cause still printed; both arms in one boot; the #3963 refusal reports itself instead of blaming @objectstack/rest. The existing absent-case pins at dev-plugin.test.ts:263-264 are UNTOUCHED and green. Commands: `pnpm --filter @objectstack/plugin-dev typecheck` (tsc --noEmit, clean) and `pnpm --filter @objectstack/plugin-dev test` → 'Test Files 5 passed (5) / Tests 54 passed (54)'. Gates run locally: check:driver-memory-census (+ --self-test) OK, check:nul-bytes OK.",
      "reverse_verification": "Reverted the discrimination at the driver site only (pre-#7926 bare catch restored verbatim), with the file's state asserted INSIDE the run (md5 + the printed catch block) so the result could not be a stale-tree artifact — the first attempt was discarded for exactly that reason: a duplicate script instance raced its own restore step and produced a meaningless all-green. Measured result: the new pin fails BY PRODUCING THE 'not installed' WORDING, not by a compile error — `AssertionError: expected '  ✘ @objectstack/runtime or @objectst…' not to contain 'not installed'` / `Received: \"  ✘ @objectstack/runtime or @objectstack/driver-memory not installed — skipping driver\"`. Three more fail as `expected undefined to be defined` because the present-but-failed line does not exist at all under the reverted code (code+message surfacing, both-packages-named, cause tie-break). Totals: 'Test Files 1 failed | 4 passed (5) / Tests 4 failed | 50 passed (54)' — every existing absent-case pin stays green, so the new discrimination is what fails and only it. Restoring the fix returns the suite to 54/54.",
      "out_of_scope_findings": [],
      "risks": [
        "check:driver-memory-census went red on the first CI lap: the new test's vi.mock('@objectstack/driver-memory') was an unledgered DECLARATION (the #6664 defect shape). Resolved by recording the disposition, not by deleting the reference or weakening the gate — scripts/driver-memory-census.ledger.json gains an entry on the existing `mock-replacement` axis (the factory supplies its own InMemoryDriver whose constructor throws and never calls importOriginal, so the real driver is never loaded; not a migration candidate because DevPlugin resolves the specifier BY NAME and nothing is stored, so there is no test backend for sqlite :memory: to replace). Ruled set unchanged, so both ruled files' '#6664 census: 2 ruled consumers' marker still holds. Gate + self-test green locally.",
        "The absent arm can still fire for a package that IS installed but whose own dependency is not — measured: a transitively missing specifier raises the same ERR_MODULE_NOT_FOUND. Mitigated rather than hidden: both arms print the resolver's message, which names the specifier that actually failed, so 'install X' stays actionable. Documented in the code comment.",
        "The present-but-failed arm logs at `error` regardless of the slot's absent level (absent stays warn/info/debug per slot). A host that treats logger.error as fatal would now see an error line where a quiet info/debug used to be — deliberate: an absent optional package is ordinary, a present one that threw is a defect in that deployment.",
        "The REST no-auth refusal moved from a debug 'not installed' line to a warn line that states the real reason. Same outcome as before (REST not registered, init() returns); only the diagnosis and its level changed.",
        "Whether DevPlugin should REFUSE to start when a driver refuses is untouched — product-shape question, explicitly out of this card's scope."
      ]
    }

    Generated by Claude Code


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingdomain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they landpm:dispatchedDispatched to a dev agent by /pm-dispatch

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions