Skip to content

plugin-hono-server: graceful close() destroys a response that has ended but is still flushing to a slow client (truncated body) #31208

Open

Description

@objectstack-fleet

domain:devx#2 · marchtian · session_017ihL2ebbZkr2QAEG1x7UC8

Filing gate: ① a defect with a measured reproduction, found while reviewing #30920 / PR #31189 (dev delta 6710694928, review record 6710280454). ⛔ Not a claim. Triage sets the grade and the lane.
Reader: packages/plugins/plugin-hono-server/src/adapter.ts, HonoHttpServer.close() (the drain-start path).
Dedupe (open AND closed): searched for "hono close truncate", "closeIdleConnections" and "graceful drain slow client". No match.
class: a · reach: every os serve / runtime shutdown or redeploy while a client is still downloading a large response.

What is wrong

When close() starts, the adapter calls server.close(). On Node 19 and later, that runs Node's own idle-connection cleanup. The adapter then calls closeIdleConnections() itself. Both treat a response as finished once it has called end(). But end() marks the response finished before its bytes are flushed (Node 22, lib/_http_server.js ~594–608, lib/_http_outgoing.js ~1146).

So a response that has ended but is still flushing to a slow reader when close() starts is destroyed mid-body. The client gets a truncated body, and with Content-Length set it sees a premature close.

Reproduction

In PR #31189's adapter-drain.test.ts, take the slow-reader pin and reorder it so the 32 MiB response calls end() before close() starts. The client receives 3,919,702 of 33,554,432 bytes, both on PR #31189's head and on main.

PR #31189 leaves this path exactly as on main. Its round 1 briefly widened the hole to the whole drain window; round 2 narrowed it back to this one moment.

Wanted

  • At drain start, do not destroy a connection whose response has ended but not yet flushed. Let each such connection close after its own 'finish', the way PR fix(plugin-hono-server): end a graceful close with its last in-flight request; cut or budget seven slow Test Core cases #31189 handles a response that ends during the drain. The drain window's force-close stays the backstop.
  • Make sure Node's own server.close() cleanup cannot destroy it first. For example, stop accepting before server.close() sweeps idle connections, or track ended-but-unflushed connections.
  • Pin it with the reordered slow-reader case, and show the pin fails on main.

Activity

  1. added
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    bugSomething isn't working
    domain:serverDomain lane — seat card indexed by label:pm:seat
    pm:queueReady for the PM dispatch loop
    and removed
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Nov 7, 2026
  2. objectstack-fleet commented on Nov 7, 2026

    @objectstack-fleet
    ContributorAuthor

    triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

    Path: none

    Triage: audit · p2 · domain:server · 2026-11-07T17:54Z

    ⛔ Not a claim.


    Generated by Claude Code

  3. objectstack-fleet commented on Nov 7, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#5 · os-project-manager · session_01FEkQ5o8PE7ZtVjcor51wBZ

    Claim: PM loop round 1 · 2026-11-07T19:09Z
    Session: session_01FEkQ5puKTX6kQoJs8iKrFr (card thread, dev) · seat session_01FEkQ5o8PE7ZtVjcor51wBZ
    Account: os-project-manager
    Branch: claude/project-thread-05yvmq
    Worktree: objectstack-issue-31208
    Domain: domain:server
    Seat: domain:server#5
    File surface: packages/plugins/plugin-hono-server/src/adapter.ts + adapter-drain.test.ts, root .changeset/
    Container & model: S, mode:thread, model: default tier (dispatch-gates --tier: no path-derived mandate), effort: thread default (order asked high; the coordinator sets effort only on the maintainer's word)
    Clause-②: no
    Metadata-protocol: none
    Responsibility: plugin-hono-server HonoHttpServer.close() drain start (objectstack) | the drain window's force-close backstop does not help: the destroy happens at drain start | every os serve / runtime shutdown or redeploy while a client is still downloading
    Thread-read: 6710965549
    Premise: holds (read, not run): PR #31189 left the drain-start path as on main; origin/main 714dda8f still calls server.close() then closeIdleConnections() at drain start; the dev re-measures the reorder red on main first
    Serial constraints cleared: cleared — PR #31189 (same file) merged; no open PR touches adapter.ts

    Scope: Fixes #31208. Order: /mnt/project-files/pm-orders/server/order-31208.md. Summons: the maintainer's 「server 再开两个座位」「并发都是5」 (this project, 2026-11-07); grading per 「定级你可以自己决定」 is triage's here (graded on the card).

  4. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Nov 7, 2026
  5. objectstack-fleet commented on Nov 7, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#5 · os-project-manager · session_01FEkQ5puKTX6kQoJs8iKrFr
    os-dev-report
    {
    "issue": 31208,
    "status": "done",
    "branch": "claude/project-thread-05yvmq",
    "pr": "#31255",
    "head": "ab2845c8",
    "session": "session_01FEkQ5puKTX6kQoJs8iKrFr",
    "premise_still_valid": true,
    "summary": "Measured on Node 22.23.4 (.nvmrc 22, engines >=22.0.0, every CI job on 22): a Socket.prototype.destroy probe in the reordered slow-reader case recorded two destroys of the flushing socket, FIRST Server.closeIdleConnections from httpServerPreClose from Server.close (adapter server.close), THEN the adapter's own closeIdleConnections() call. Fix: tryListen puts an instance closeIdleConnections on the server that wraps Node's (closeIdleConnectionsOnceFlushed): it runs Node's sweep only when no tracked response is writableEnded && !writableFinished, otherwise holds it back; each tracked response's 'close' re-asks it, so idle keep-alive connections are released once nothing is flushing. Each flushing connection closes after its own 'finish' through PR #31189's own-socket destroySoon arm, unchanged. Node's httpServerPreClose calls the instance method (seen in the probe stack), so server.close() stays whole: it stops accepting and clears its connection-check interval at once. Route chosen over 'stop accepting without the sweep' because net.Server.prototype.close skips httpServerPreClose's clearInterval of a private-symbol interval (the closed server stays alive in a timer), and a later server.close() to clear it emits 'close' twice; deferring the whole server.close() keeps accepting. Force-close backstop byte-identical.",
    "tests": "New in adapter-drain.test.ts: (1) PIN reordered slow reader, 32 MiB end() before close(), premise asserted (writableEnded true, writableFinished false), whole body vs Content-Length, plus an idle keep-alive connection released within 1 s of the flush ending. RED on main df22adc (adapter unchanged since 714dda8): 'every byte of the body arrived: expected 3919702 to be 33554432'. (2) CONTROL idle keep-alive released at drain start (close + connection end within 1 s). (3) CONTROL backstop: paused reader, 300 ms window, close() takes at least 250 ms and under 2 s, reader gets fewer than 32 MiB. Green 3/3 consecutive runs of the file (9/9). Ablations via scripts/ablation-replace.mjs (suite imports ./adapter from src, no build on the path): A1 on ab2845c, flushing check disabled ('if (false && res.writableEnded ...'): 2 failed / 7 passed, 'expected 3919702 to be 33554432' and 'expected 1 to be greater than or equal to 250'; A2 on 1502afc, held-back sweep never re-run (line deleted): 1 failed / 8, slow-reader case 'Test timed out in 5000ms'. Each restore: blob == HEAD, git diff HEAD empty. Package: pnpm --filter @objectstack/plugin-hono-server build && typecheck && test under the lock on ab2845c: VERDICT command-exit 0, Test Files 38/38, Tests 484/484 (tsconfig includes src/**/*, so typecheck compiles the test).",
    "gates": "dispatch-gates --commands --repo objectstack-ai/objectstack on ab2845c (origin/main merged once at 733efbd): 63 derived; all 63 exit 0. Three first read exit 3 (prerequisite, NOT MEASURED) and were re-run green: check-plugin-teardown-shape --self-test after git fetch --depth=1 origin 621a487; check:dual-build-cjs-loads and check:lean-entry-closure after a full pnpm turbo run build --concurrency=2 under the lock (VERDICT command-exit 0, 9m30s); check:dts-closure and check:published-files re-read on the full build, exit 0. --ran: '63 derived famil(ies) accounted for — 63 run, 0 NOT-MEASURED'. Scoped lint: eslint --no-inline-config on adapter.ts + adapter-drain.test.ts, exit 0. CI check-runs: in_progress at report time; the seat reads them. Record: /mnt/project-files/pm-orders/server/31208-gates-ran.txt.",
    "line_budget": "3 files, +151 / -11 (162 changed lines) vs origin/main three-dot; under the 3000 human-merge line",
    "files_changed": [
    ".changeset/31208-hono-drain-keeps-flushing-response.md",
    "packages/plugins/plugin-hono-server/src/adapter-drain.test.ts",
    "packages/plugins/plugin-hono-server/src/adapter.ts"
    ],
    "deviations": [
    "pr_create carried no assignees (order Standing lines); os-dev.md rule 2 reads that pr_create carries the claim Account. Followed the order; read back: assignees none.",
    "pr_create carried no labels: the order names none, and the diff carries a changeset, so no skip-changeset.",
    "The backstop control now also asserts close() takes at least 250 ms, which is red on main (the drain-start sweep destroyed the reader at once there); it pins that the reader is held for the window rather than swept."
    ],
    "dogfood_battery": "n/a — no ! in the PR title and no (narrowing) arm",
    "mcp_calls": "0",
    "api_writes": "2 relay strokes: pr_create via fleet-write dispatch.mjs (POST /repos/objectstack-ai/objectstack/pulls, PR #31255, run 42409088525, body read back 8820/8820 bytes identical); this comment via post-stamped.mjs (POST /repos//issues/31208/comments). Plus git push x1",
    "open_questions": [],
    "security_findings": "none",
    "out_of_scope_findings": [
    "none"
    ]
    }


    Generated by Claude Code

  6. objectstack-fleet commented on Nov 7, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#5 · os-project-manager · session_01FEkQ5o8PE7ZtVjcor51wBZ

    ACCEPT → PR #31255 comment 6712667832 (Head-sha ab2845c820). Ready, auto-merge armed; the card closes on merge.

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

Metadata

Metadata

Labels

bugSomething isn't workingdomain:serverDomain lane — seat card indexed by label:pm:seatpm:dispatchedDispatched to a dev agent by /pm-dispatchpriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions