Skip to content

[Bug]: Failed preview file delivery is reported as a closed picker #35689

Closed as not planned

Description

@coygeek

Summary

When a file delivery fails in a streamed preview, the upload reports that the page's file picker is no longer open even though that picker remains answerable. The upload should report the delivery failure and keep the visible picker state consistent with the server. A focused service test reproduced the mismatch by making the first delivery fail and retrying the same chooser successfully; web and mobile clear their picker prompts before receiving the error.

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server

Steps to reproduce

  1. From the checkout at the version below, run the script with the checkout path as its sole argument. It copies the existing preview browser test harness to a uniquely named temporary test file, adds one case with a rejecting first setFiles call and a successful second call, and runs only that case.
  2. The test records the first answer after setFiles rejects. If that answer is false, it retries the same chooser ID; otherwise it opens a new chooser. It checks that the second delivery succeeds and that setFiles was called twice. The successful second delivery is the control.

The final script reproduced on its one run; an earlier script version also reproduced. The first experimental script run was inconclusive because its test passed but its output check expected a console line suppressed by the test runner. A real page or transport failure that makes Playwright reject setFiles was not exercised.

Save the following as repro.sh and run sh repro.sh /path/to/t3code from any directory. It requires Node, the checkout's installed dependencies, and vp; it prints the tool versions, puts HOME, TMPDIR, and T3CODE_HOME under a fresh temporary directory, and removes the temporary test and directory on exit. Exit 1 and RESULT: defect reproduced mean the first answer was false but the same chooser remained usable. Exit 0 and RESULT: expected behavior mean the delivery error surfaced as an error while a new chooser worked. Exit 2 and RESULT: inconclusive mean setup or the control failed.

Reproduction script: repro.sh
#!/bin/sh
set -u
if [ "$#" -ne 1 ]; then
  printf 'RESULT: inconclusive\n'
  exit 2
fi
repo=$1
scratch=$(mktemp -d)
test_file="$repo/apps/server/src/preview/ServerBrowser.x04-03.test.ts"
cleanup() {
  rm -f "$test_file"
  rm -rf "$scratch"
}
trap cleanup EXIT HUP INT TERM
export HOME="$scratch/home" TMPDIR="$scratch/tmp" T3CODE_HOME="$scratch/t3"
export X04_03_PROBE_OUT="$scratch/probe.txt"
mkdir -p "$HOME" "$TMPDIR" "$T3CODE_HOME"
if ! command -v vp >/dev/null 2>&1 || ! command -v node >/dev/null 2>&1; then
  printf 'RESULT: inconclusive\n'
  exit 2
fi
node --version
vp --version
if [ ! -f "$repo/apps/server/src/preview/ServerBrowser.test.ts" ] || [ -e "$test_file" ]; then
  printf 'RESULT: inconclusive\n'
  exit 2
fi
cp "$repo/apps/server/src/preview/ServerBrowser.test.ts" "$test_file"
cat >> "$test_file" <<'TEST'

it.live("x04-03 a rejected page file delivery is reported as a stale chooser", () =>
  Effect.scoped(
    Effect.gen(function* () {
      const { browser, tabId } = yield* ready;
      const viewer = yield* browser.attachViewer(viewerInput(tabId, true));
      yield* viewer.input({ type: "takeControl" });
      const setFiles = vi.fn()
        .mockRejectedValueOnce(new Error("file delivery failed"))
        .mockResolvedValueOnce(undefined);
      contexts[0]!.page.emit("filechooser", {
        isMultiple: () => false,
        element: () => fileChooserElement(".txt"),
        setFiles,
      });
      let offered = yield* Queue.take(viewer.output);
      while (offered._tag !== "fileChooser") offered = yield* Queue.take(viewer.output);
      const input = {
        threadId: scope.thread.threadId,
        tabId,
        chooserId: offered.id,
        files: [{ name: "a.txt", mimeType: "text/plain", buffer: Buffer.from("a") }],
      };
      const firstExit = yield* Effect.exit(browser.answerFileChooser(input));
      const first = Exit.isSuccess(firstExit) ? String(firstExit.value) : "error";
      let second: boolean;
      if (first === "false") {
        second = yield* browser.answerFileChooser(input);
      } else {
        contexts[0]!.page.emit("filechooser", {
          isMultiple: () => false,
          element: () => fileChooserElement(".txt"),
          setFiles,
        });
        let next = yield* Queue.take(viewer.output);
        while (next._tag !== "fileChooser") next = yield* Queue.take(viewer.output);
        second = yield* browser.answerFileChooser({ ...input, chooserId: next.id });
      }
      yield* Effect.promise(() => NodeFSP.writeFile(
        process.env.X04_03_PROBE_OUT!,
        `first=${first} second=${second} attempts=${setFiles.mock.calls.length}`,
      ));
      expect(second).toBe(true);
      expect(setFiles).toHaveBeenCalledTimes(2);
    }),
  ).pipe(Effect.provide(layer)),
);
TEST
cd "$repo" || { printf 'RESULT: inconclusive\n'; exit 2; }
vp test run apps/server/src/preview/ServerBrowser.x04-03.test.ts -t 'x04-03 a rejected page file delivery is reported as a stale chooser' > "$scratch/test.log" 2>&1
status=$?
cat "$scratch/test.log"
if [ "$status" -eq 0 ] && grep -Eq 'Tests[[:space:]]+1 passed' "$scratch/test.log" && [ -f "$X04_03_PROBE_OUT" ]; then
  probe=$(cat "$X04_03_PROBE_OUT")
  printf 'observed: %s\n' "$probe"
  if [ "$probe" = 'first=false second=true attempts=2' ]; then
    printf 'RESULT: defect reproduced\n'
    exit 1
  fi
  if [ "$probe" = 'first=error second=true attempts=2' ]; then
    printf 'RESULT: expected behavior\n'
    exit 0
  fi
fi
printf 'RESULT: inconclusive\n'
exit 2

Expected behavior

The supported preview flow lets the controlling viewer send a device file to the page's open picker (docs/user/remote-access.md#L253-L258). The existing successful-upload test expects the answer to succeed and a fileChooserClosed event; a different ID legitimately returns false (apps/server/src/preview/ServerBrowser.test.ts#L1994-L2029). A failed file delivery should report an upload failure and leave the visible picker state consistent with whether it remains answerable. “No longer open” should describe a missing or invalid picker, not a delivery exception.

Actual behavior

In the streamed preview, the client posts selected files to the preview upload route and clears its picker prompt immediately. When the underlying Playwright FileChooser.setFiles rejects, answerFileChooser returns false; the route maps that value to HTTP 409 with “The page's file picker is no longer open.” The focused test showed that the chooser remains answerable after this response: its second call with the same ID succeeded. The client shows the 409 text as an upload error while its picker prompt remains dismissed.

Impact and scope

Impact: P2? - A failed upload gives the wrong cause, dismisses the client-side picker, and forces the user to activate the page's upload control again before retrying. The service-level contradiction is reproduced; the frequency and exact real-page triggers for setFiles rejection are not established.

This affects streamed preview file uploads on web and mobile when setFiles rejects. A practical workaround is to activate the page's upload control again and retry; success depends on the cause of the original delivery failure. Desktop's native picker path was not evaluated.

Earliest failure stage

Core processing

Analysis or suspected cause

Source trace: chooser validation returns false for a genuinely missing or unauthorized chooser. The same return value is produced by catching a rejection from Playwright, before the code that closes the chooser. Thus the route cannot tell a stale chooser from a file-delivery failure.

Additional context

Interface: UI streamed preview page file picker, backed by the preview HTTP upload route.

The entry path is the apps/web/src/browser/ServerBrowserSurface.tsx#L185-L195, packages/client-runtime/src/preview/serverBrowserStream.ts#L123-L134, and apps/server/src/preview/ServerBrowserStream.ts#L246-L283. The server apps/server/src/preview/ServerBrowser.ts#L1283-L1303; its apps/server/src/preview/ServerBrowser.ts#L1332-L1333. The route treats every false as a stale chooser. The apps/mobile/src/features/browser/PreviewStreamWebView.tsx#L248-L258 also clears its prompt before sending.

Current main retains both the catch-to-false code and the 409 response text. Related picker lifecycle changes and a different open upload issue are listed under References.

Impact

Minor bug or occasional failure

Version or commit

main @ c00a373

Environment

Node.js v26.12.1; vp v1.5.1 using local vite-plus v1.4.0 and Vitest v5.0.5; macOS (Darwin arm64).

Logs or stack traces

The focused run's relevant output, with the checkout's absolute path replaced by <checkout>:

v26.12.1
vp v1.5.1
Local vite-plus:
  vite-plus  v1.4.0
  vitest           v5.0.5
RUN  v5.0.5 <checkout>
Test Files  1 passed (1)
Tests  1 passed | 73 skipped (74)
observed: first=false second=true attempts=2
RESULT: defect reproduced

Screenshots, recordings, or supporting files

No response

Workaround

Activate the page's upload control again and retry. Success depends on the cause of the original delivery failure.

References

Activity

  1. juliusmarminge commented on Nov 12, 2026

    @juliusmarminge
    Member

    I tried to reproduce this on the current build and could not.

    On origin/main @ cc97303: (1) Added a case to apps/server/src/preview/ServerBrowser.test.ts that drives the real ServerBrowser service with the existing fake page harness. The controlling viewer is offered a file chooser whose setFiles rejects once, then answerFileChooser is called twice with the same chooser id. vp test run apps/server/src/preview/ServerBrowser.test.ts -t "file delivery" fails because the first call succeeds with false (Exit is not a failure). With that assertion flipped to expect false, the rest passes: the retry with the same id returns true and setFiles was called twice. So the picker was still answerable after the server said it was gone. (2) A real trigger in real headless Chromium (playwright-core 1.64.1): a page with , click, then FileChooser.setFiles with a 60 MB buffer rejects with 'Cannot set buffer larger than 50Mb, please write it to a file and pass its path instead.' The same chooser then accepts a 1-byte file (the page sees it). The upload route accepts files up to 100 MB (UPLOAD_MAX_FILE_BYTES), so a 50-100 MB file chosen in the streamed preview reaches this rejection and gets HTTP 409 'The page's file picker is no longer open.' Not driven end to end through the web or mobile UI, so reproduced is false. Client code shows both clients clear the picker prompt before the upload resolves.

    Closing as not planned. If it still happens for you, reply with the exact steps and the version you are on and it will be looked at again.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions