Skip to content

serve-instrument exits when an instrument directory is deleted or a request URL has a malformed percent-escape #1804

Open

Description

@joshunrau

serve-instrument exits with an unhandled error when an instrument directory is deleted or renamed while it runs, or when it receives a URL with a malformed percent-escape. In both cases an exception escapes an async function that is called with void: this.handler.handle(...) for requests, and this.updateEncodedBundle() in the fs.watch callback. Node turns the unhandled rejection into a process exit. In updateEncodedBundle, only the bundle() call is inside the try; the readdir/readFile before it are not. In --all mode, deleting or renaming one instrument folder kills the server for all the others, and so does a git checkout that removes one. An author has to notice that the terminal died and restart it.

Where

packages/serve-instrument/src/server.tsx:45-48, 62-79 and 243-247, 312:

    this.watcher = fs.watch(target, { recursive: false }, () => {
      // ...
      void this.updateEncodedBundle();
    });
// ...
  private async updateEncodedBundle(): Promise<void> {
    const inputs: BundlerInput[] = [];
    const filepaths = await fs.promises
      .readdir(this.target, 'utf-8')
      // ...
    for (const filepath of filepaths) {
      const loader = inferLoader(filepath);
      const content: string | Uint8Array = await fs.promises.readFile(filepath, /* ... */);
      // ...
    }
    const start = Date.now();
    try {
// ...
      const decodedName = decodeURIComponent(name!);
// ...
    this.server = http.createServer((...args) => void this.handler.handle(...args));

Reproduce

  1. Build it (pnpm --filter @opendatacapture/serve-instrument build) and run node dist/cli.js --all <dir> on a directory containing forms/FOO/index.ts and forms/BAR/index.ts.
  2. Open /forms/BAR, then rm -rf <dir>/forms/BAR.
    Actual: ↺ [forms/BAR] File changed, rebuilding..., then Error: ENOENT: no such file or directory, scandir '…/forms/BAR' and the process exits (verified on main). /forms/FOO is no longer served.
  3. Restart, then curl 'http://localhost:3000/forms/%E0%A4%A'.
    Actual: URIError: URI malformed at decodeURIComponent and the process exits (verified on main).

Expected: the server logs the failure and keeps running. A deleted instrument answers 404 and a malformed URL answers 400.

Tests

packages/serve-instrument/src/__tests__/server.test.ts:

  • it('should keep serving other instruments after an instrument directory is deleted, so a rebuild failure does not kill the server'): fire the captured watch callback after removing the temp directory, and assert that the remaining instrument still returns 200.
  • it('should answer 400 to a malformed percent-escape in an instrument URL rather than crashing')

No e2e test applies: serve-instrument is not part of the stack under testing/.

Suggested fix

Move the directory listing and file reads inside the existing try in updateEncodedBundle. Wrap handle in Server's request callback with a .catch that logs the error and ends the response with 500 if headers are not yet sent. Map a URIError from decodeURIComponent to 400.

Activity

  1. added
    BugType: existing behavior is wrong
    Priority: LowNice to have, cosmetic or speculative
    Difficulty: LowIsolated change, about 2 hours or less
    Area: InstrumentsInstrument authoring API, bundling, runtime, rendering, measures and the built-in library
    Good First IssueWell-scoped, low-risk and self-contained; a good entry point for a new contributor
    on Oct 7, 2026
  2. joshunrau commented on Oct 26, 2026

    @joshunrau
    CollaboratorAuthor

    Still reproduces on main (d86740e). Neither server.tsx nor server.test.ts has changed since this was filed, and no open PR touches them. fs.watch and http.createServer appear nowhere else in the repo, so this file is the whole fix.

    The suggested fix does not give the expected 404

    Moving the reads inside the try stops the exit, but the catch never clears encodedBundle (server.tsx:84-89). getEncodedBundle only rebuilds when it is null (:55-60), so after step 2 /forms/BAR keeps returning 200 with the old bundle. Set this.encodedBundle = null in the catch. A deleted instrument then answers 404 in --all mode and 503 in single mode.

    This also changes what a broken edit does: the page errors instead of serving the last good bundle. That is what an instrument that never compiled already gets (server.test.ts:254-261, :385-393). There is no live reload, so an author who refreshes should see the failure, not their previous version.

    Two more gaps

    • On Windows, deleting a watched directory reports an EPERM error instead of a change (Node fs.watch caveats). The FSWatcher emits it as 'error', and InstrumentLoader has no 'error' listener (:45-48), so the process still exits there after the fix. Add this.watcher.on('error', ...) that logs and sets encodedBundle = null. It does not need to call close(): Node closes the handle before it emits 'error' (watchers.js).
    • All three page branches call res.writeHead(200) before await this.renderPage(...) (:163-164, :238-239, :259-260), and res.headersSent is true as soon as writeHead returns. A .catch that sends 500 only when headers are not sent skips a renderPage failure, such as a missing dist/client.js, and the request hangs. Render first, then call writeHead and end. Every await in both handle methods then runs before writeHead, so the .catch can always send 500 and needs no headersSent branch that no test could reach.

    Catch the URIError where it is thrown, at :246, and answer 400 there. Do not match on URIError in the generic .catch.

    Tests

    In server.test.ts:

    • The node:fs mock (:18-29) discards the watch target and returns { close }. Record the target with each callback, so a test fires forms/BAR's callback instead of relying on readdir order. Return an EventEmitter with close, or the new .on('error', ...) throws.
    • Deleted directory: build both instruments, remove forms/BAR, fire its callback and vi.waitFor the Failed to compile forms/BAR log. Then assert FOO answers 200 and BAR answers 404. The 404 is what catches the stale bundle.
    • Malformed escape: /forms/%E0%A4%A answers 400 and / still answers 200. fetch sends that path unchanged.
    • Handler rejection: make the client.js read reject once. Assert a 500, a logged error, and that the next request is served.
    • Watcher error: build the instrument, emit 'error' on its mocked watcher, and assert the error is logged and the next request calls bundle again.

    .agents/docs/architecture/testing-strategy.md:53 describes this mock wrongly. It says the mock is in test/ and is never closed by Server.stop(), but the file is src/__tests__/server.test.ts and Server.stop() closes every watcher (:362-363). #1660 rewrites that table and keeps the old text, so fix it there.

    Out of scope: InstrumentLoaderMap.init builds the instrument list and loaders once (:120-138). The index keeps linking to a deleted instrument, and one added after startup is not served until a restart.

    Triage note written with Claude Code. Code references point at main @ d86740e.

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

    Area: InstrumentsInstrument authoring API, bundling, runtime, rendering, measures and the built-in libraryBugType: existing behavior is wrongDifficulty: LowIsolated change, about 2 hours or lessGood First IssueWell-scoped, low-risk and self-contained; a good entry point for a new contributorPriority: LowNice to have, cosmetic or speculative

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions