Skip to content

finding: storage.resumeUpload ignores progress.status, so resuming an expired session surfaces as a 410 on the first chunk instead of a clean early exit #7870

Description

@huangyiirene

Observation

Since #7667 (PR #7844) a chunked upload session past its own expires_at is durably stamped expired, GET .../progress reports that status, and a chunk PUT / complete against it answers 410 UPLOAD_SESSION_EXPIRED.

packages/client's storage.resumeUpload polls progress first — but reads only the byte/chunk counters and ignores status. So a client resuming a dead session learns nothing from the poll it already made, starts uploading, and takes the 410 on its first chunk. Honest failure, not a regression (before #7667 it would have uploaded into a session the TTL sweep was about to delete, which is worse) — but the information to exit cleanly is now on the wire and unused.

Where

packages/client — the resumeUpload path that calls GET /api/v1/storage/upload/chunked/:uploadId/progress. Suggested shape: short-circuit when status === 'expired' (and arguably failed) with a typed error naming the expiry, instead of proceeding to the chunk loop.

Why a finding, not a queue card

No user report; the behaviour is correct-but-clumsy rather than wrong, and the fix is a client-side UX judgment (which statuses short-circuit, what the caller sees) rather than a mechanical gap. Scope: packages/client is domain:cli territory, not domain:services — filed unassigned for triage to route.

Refs

Activity

  1. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Triage: lands in packages/client (storage.resumeUpload) → domain:cli per the lane table (client SDK is cli-lane territory, as the filer anticipated). Graded finding, accepting the filer's own framing: behavior is honest-but-clumsy (correct 410, information unused), no user report, and the fix is a client-side UX judgment (which statuses short-circuit, what the caller sees) rather than a mechanical gap. No contract change needed — UploadProgressSchema.status already declares expired.

    Restart conditions: a user report of the confusing first-chunk 410, or the cli lane doing any resumeUpload-adjacent work (cheap to carry as a rider declared in the claim, not a silent one).


    Generated by Claude Code

  2. added
    bugSomething isn't working
    and removed on Aug 12, 2026
  3. added theissue type on Aug 12, 2026
  4. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Finding-grading round: promoted to pm:queue (domain:cli, type Bug), scoped narrow.

    Premise re-verified: resumeUpload (packages/client/src/index.ts:2947) destructures only totalChunks/uploadedChunks from the progress poll; status is unread, so the client uploads a full chunk into a session it was already told is dead. Scope for dispatch: handle status === 'expired' only (UploadProgressSchema already declares it — no contract change); leave failed and any wider status handling out. Producers: #7667 / PR #7844.


    Generated by Claude Code

  5. self-assigned this
    on Aug 13, 2026
  6. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    Contributor

    Claim: PM loop round 1
    Session: session_016pY4Xb2iDecfDtT3CWoiTW
    Branch: claude/issue-7870-resume-upload-expired
    Worktree: objectstack-issue-7870
    Domain: domain:cli
    File surface: packages/client/src/index.ts (the storage.resumeUpload arm only) + its test file (stop on breach; explain in the report)
    Container & model: S/M, mode:subagent, model: opus
    Serial constraints cleared: packages/client — #8140 (the 32-method Promise<any> sweep over packages/client/src/index.ts) is queued in this lane and would collide with this file; deliberately NOT dispatched this round. No open PR touches packages/client/src/index.ts (checked against the open-PR list at claim time). Batch is package-disjoint from #8132 (packages/types) and #8083 (packages/mcp).

    Premise re-verified on origin/main @ 43f37e1 before dispatch: resumeUpload still destructures const { totalChunks, uploadedChunks } = progress.data; and never reads progress.data.status, then proceeds straight into the chunk loop. The line number has moved from the :2947 recorded at grading — the dev must locate the arm by name, not by line.

    Scope is the narrow one set at grading: status === 'expired' only. failed and any wider status handling stay out.


    Generated by Claude Code

  7. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    Contributor
    {
      "issue": 7870,
      "status": "done",
      "branch": "claude/issue-7870-resume-upload-expired",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/8267",
      "premise_still_valid": true,
      "summary": "Premise held on current main (5d7eabc, not the 43f37e1 at dispatch — no churn in packages/client/src/index.ts or packages/spec between them). resumeUpload still destructured only totalChunks/uploadedChunks and never read progress.data.status; the arm is at :2947, the same line recorded at grading, so the PM's 'line has moved' note was wrong but harmless — located by name regardless. Added an exact `status === 'expired'` short-circuit right after the poll, before the file is read and before the chunk loop, throwing code UPLOAD_SESSION_EXPIRED + httpStatus 410 + details {uploadId, expiresAt}. All four PM mechanism assumptions verified by measurement, one of them falsified in a way worth recording: packages/client has NO typed-error class and no error module — its established idiom is what the private fetch wrapper builds on any non-2xx, `new Error(message)` with .code/.httpStatus/.category/.retryable/.details attached. I matched that shape rather than inventing a class, and deliberately did NOT populate category/retryable, because the server's sendError does not send them for this code and synthesizing them would make the two paths distinguishable. No new error code minted (UPLOAD_SESSION_EXPIRED is already in ERROR_CODE_LEDGER); packages/spec untouched, so no contract change and no domain:spec breach. Producer verified: the progress route emits `status: session.status` unconditionally after expireIfPastDeadline, so status is populated in practice, not just declared — and the strict equality means an absent status cannot misfire, which is load-bearing because the SDK's own client-url-conformance fixture drives resumeUpload with a counters-only body carrying no status at all.",
      "tests": "Build closure first: `pnpm --filter '@objectstack/client^...' build` (green). `pnpm --filter @objectstack/client test` → 'Test Files 23 passed (23) / Tests 300 passed (300)'. `pnpm --filter @objectstack/client typecheck` → tsc --noEmit clean + 'check:test-typecheck: OK — @objectstack/client's test layer compiles ... 0 file(s) / 0 error(s) held in test-typecheck-debt.json'. Four new cases in packages/client/src/storage-wire-dialect.test.ts, all green and confirmed actually running (verbose reporter, not silently skipped). REVERSE VERIFICATION, direction predicted before running (2 red / 2 green) and observed exactly: committed the fix first, then `git checkout origin/main -- packages/client/src/index.ts` → '× throws the registered code and status ... → expected null to be an instance of Error' and '× sends no chunk PUT and no complete ... → expected \"vi.fn()\" to be called 1 times, but got 3 times'; the two non-misfire cases stayed green. That 1-vs-3 is the defect stated numerically — pre-fix, resume made three requests against a session already reported dead. Restored with `git checkout HEAD -- <path>` and proved byte-identity via git hash-object (7ea8a55c9d0217ea8521ce27af7bfd814fb12294 before and after), tree clean. GATES: re-derived against my actual three changed paths with scripts/pm/dispatch-gates.mjs, which surfaced SIX families the dispatch prompt's list did not name — all triggered by the .changeset file I added: check:changeset-gate-self-tests, check:objectui-changeset, check:objectui-pin-fresh, check-changeset-no-major.mjs, check-objectui-pin-fresh.mjs, plus convention-triggered check:query-options-erasure. Ran the prompt's four plus all additions plus check:error-code-casing and check:nul-bytes. All PASS except check:objectui-pin-fresh (pre-existing, not mine — see open_questions). check:type-check-debt initially failed on a MISSING PRECONDITION, not on my diff: it re-measures against every dependency's built dist/*.d.ts and needs the FULL workspace closure, not just the package's. After `turbo run build --filter='./packages/*' --filter='./packages/*/*'` (70 tasks, 5m30s) it passes: '33 ledger entr(ies) re-measured in 227.0s, 1969 raw tsc error(s) total, none above its recorded number'.",
      "open_questions": [
        {
          "question": "check:objectui-pin-fresh reports STALE on this branch, but it is not caused by this PR and I did not file an issue for it — confirm that call. The pin .objectui-sha (6d77acfe3125) was last moved by commit a0151e98e 'chore: bump objectui to 6d77acfe3125 (#8137)' and objectui main is now 54d34d2a1ca9. The gate's verdict is a function of that pin file and the objectui remote only; my diff touches neither. It entered my gate list purely because its path trigger is '.changeset' and I added a changeset. Searched open issues for 'objectui pin', 'objectui-sha' and 'check:objectui-pin-fresh' — zero hits.",
          "options": [
            "A. Do not file: this is routine release-time drift, not a defect. The gate's own self-test asserts 'advisory mode does not block on a stale pin (ordinary code PRs)', so it will not block PR #8267, and the documented one-command remedy (scripts/bump-objectui.sh) is run as part of the release cut. An issue titled 'the pin is stale' self-resolves the next time anyone bumps.",
            "B. File it as a finding-labelled card so the staleness is tracked before the next release cut."
          ],
          "recommendation": "A, because the condition is the system working as designed rather than a defect — the gate is deliberately advisory for code PRs and blocking only at release, which is precisely the split that makes a tracking card redundant. Flagging here rather than filing, so the PM can overrule cheaply if a release is imminent. Worth knowing regardless: anyone cutting a release right now needs the bump first. One caveat on my evidence — the gate could not reach the GitHub API from this container to itemize the lag ('0 ahead, 0 behind' is the degraded reading, not a real count), so the SIZE of the drift is unmeasured here; the STALE verdict itself came from ls-remote and does not depend on the API."
        }
      ],
      "out_of_scope_findings": [
        "NOT filed (deliberate, per dispatch instruction): the Promise-of-any return types across packages/client/src/index.ts — I passed several in the storage arm alone (automation.get/create/update, and uploadPart's `body: data as any`). #8140 already covers this exact 32-method sweep over this same file, so filing would duplicate it. My own added code introduces no new `any` beyond `details: Record<string, any>`, which matches the declared StandardError.details type.",
        "NOT filed, reported above instead: check:objectui-pin-fresh STALE — repo-wide, pre-existing, unrelated to this diff. See open_questions."
      ]
    }

    Generated by Claude Code

  8. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    Contributor

    ACCEPT — PR #8267

    Reviewed against the diff, not the report.

    First, a correction I owe the dev. My claim in the dispatch that the line number had moved from the :2947 recorded at grading was wrong — the arm is still at :2947, as the diff hunk header (@@ -2943,6 +2943,11 @@) shows. I asserted a churn reading I had not actually verified. It cost nothing because the dev located the arm by name regardless, but the instruction was noise dressed as a fact, and the dev was right to say so.

    Scope: 3 files — the resumeUpload arm of packages/client/src/index.ts (+41/−1), its test file, and the changeset. Nothing else in that file was touched, so #8140's sweep stays uncollided. The dev explicitly passed several Promise<any> returns in the storage arm and left them, noting #8140 already covers them rather than filing a duplicate — correct on both counts.

    Ruling held: status === 'expired' compared exactly, never truthiness, so in_progress / completing / completed / failed all resume as before and failed stayed out of scope. packages/spec untouched — no contract change, as graded.

    The error-shape constraint I set was the load-bearing one, and it was met exactly. No new error.code was minted: the throw reuses UPLOAD_SESSION_EXPIRED + httpStatus: 410, the registered pair the server already answers this condition with, so a caller's existing err.code === 'UPLOAD_SESSION_EXPIRED' branch fires identically whether the expiry is caught client-side or server-side. That keeps the ADR-0112 closed vocabulary out of it entirely — which matters this week, with #8087's ledger-gate ruling still unimplemented.

    One assumption of mine was falsified, and handled better than the assumption deserved. I told the dev to follow the SDK's existing typed-error idiom and report if none existed. There is none — packages/client has no error class and no error module — and rather than inventing one, the dev matched the shape the private fetch wrapper already builds for any non-2xx (new Error(message) with code/httpStatus/details). It also deliberately declined to populate category / retryable, because the server's sendError does not send them for this code and synthesizing them would make the two paths distinguishable. That is the right call and a finer one than my instruction implied.

    Producer verified rather than assumed: the progress route emits status: session.status unconditionally after expireIfPastDeadline, so the field is populated in practice and not merely declared. The absent-status guard is not hypothetical either — the SDK's own URL-conformance fixture drives this method with a counters-only body, and there is a test pinning that it still resumes.

    Reverse verification, direction predicted before running: 2 red / 2 green, observed exactly. The strongest evidence here is numeric — pre-fix, fetchMock was called 3 times against a session already reported dead; post-fix, once. That is the defect stated as a measurement rather than a description, and the "no chunk PUT" test pins the property the card actually exists for, which a throws-assertion alone would not.

    Your open question — confirmed: option A, do not file.

    Your reasoning is right and your evidence is better than mine. I had already reached the same conclusion independently on #8132, whose dev hit the same red — two devs surfacing it in one round is corroboration, not duplication. Adding to the record:

    Noted too that your lag count (0 ahead, 0 behind) was a degraded reading because the container could not reach the GitHub API, and that the STALE verdict itself came from ls-remote and does not depend on it. Distinguishing which half of your own evidence was degraded is the part most reports skip.

    Landing: path face clean → normal route. Ready-flip held until the gate jobs converge.


    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

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions