Skip to content

fix(vrowzer): restore the project after the Service Worker restarts - #72

Merged
kazupon merged 7 commits into
mainfrom
fix/sw-restart-recovery
Oct 1, 2026
Merged

kazupon merged 7 commits into
mainfrom
fix/sw-restart-recovery

Conversation

@kazupon

@kazupon kazupon commented Oct 1, 2026

Copy link
Copy Markdown
Owner

Closes #46

When the browser restarts the Service Worker process, Vrowzer now restores the project in the restarted Service Worker, without a reload of the host page: the latest files and the Web Worker channel.

Cause

The browser stops an idle Service Worker and starts it again for the next request or message. The registration and the controller stay the same, and no activate or controllerchange event fires. The restarted Service Worker had lost the project files, the Web Worker channel, the public file list and the safe module paths. It had only the files that its own script writes. The preview HTML got a 404, modules a 500 and public files a 404, until the host page was reloaded. The runtime did not notice, because it stops checking the Service Worker once ready() completes.

A browser test reproduced this with ServiceWorker.stopAllWorkers of the DevTools Protocol.

Fix

  • Detection: each evaluation of the Service Worker script creates an instance ID.
    • V_SW_LISTEN_READY carries the ID.
    • When listen() completes, the Service Worker sends V_SW_INSTANCE_STARTED with the ID to every window client that it controls.
    • The runtime remembers the ID from ready(), and restores the project when an instance with another ID starts.
    • The notice is a separate message, so that the activate broadcast of a new version does not start a recovery.
  • Files: the runtime keeps the latest files that it gave to the Workers.
    • The copy starts with the files of ready(), including the default index.html and the client files.
    • The copy is updated whenever addFile(), updateFile() or deleteFile() is sent.
    • A recovery sends the files to the Service Worker only, with V_FS_INIT and an id, which the Service Worker acknowledges.
    • In @vrowzer/fs, V_FS_INIT can carry an id, and initFiles() takes the same options as writeFile() and unlink().
  • Channel: the runtime calls establishChannel() again. When a new port connects, the Web Worker now closes the previous port and its birpc.
  • Requests in the meantime: a request within the base path waits until this Service Worker instance has received both the project files and the Web Worker channel.
    • It waits for up to 10 seconds, the same as the default fileSyncTimeout. After that, it gets a 503 with a short explanation, which the preview reports as a load error.
    • @vrowzer/vite-dev-server gets two hooks for this: beforeRequest() and onWorkerChannelReady().
    • ready() sends both the files and the channel before the preview opens, so a normal startup does not wait.
  • File operations:
    • Operations sent before the restart, and still waiting for the Service Worker, settle when it acknowledges the restored files. The files include their changes.
    • Operations called during a recovery are held, and sent in order after it. Their fileSyncTimeout counts from then.
    • An ArrayBuffer is now copied when the operation is called.
  • Events and failures:
    • serviceWorkerRecovered is emitted after a recovery.
    • A recovery fails when it does not finish within fileSyncTimeout, or when the Service Worker fails to apply the files. A failure emits serviceWorkerRecoveryError, and rejects the operations that wait for the Service Worker.
    • Later operations are sent as usual, and Vrowzer tries again at the next restart.
    • To start over right away, the host can dispose the instance and create a new one, as the README describes.
  • Edge cases:
    • When a newer instance starts during a recovery, the newer recovery replaces it.
    • An instance that starts during ready() is restored once ready() completes.
    • dispose() stops a recovery, and nothing more is sent.

Behavior changes

  • A request within the base path waits for up to 10 seconds when the Service Worker instance lacks the project files or the Web Worker channel, and then gets a 503. This also applies when the base path is opened without a runtime. Before, such a request got a 404 or a 500.
  • There are two new events: serviceWorkerRecovered and serviceWorkerRecoveryError.
  • The runtime keeps a copy of all project files in the page, to restore them.
  • V_SW_LISTEN_READY carries an instance ID, and V_WW_CONNECT_PORT carries an optional runtime ID.

There are no breaking changes.

Scope

  • Two clients that share the registration (an acceptance criterion of Recover project files and channels after Service Worker process restart #46) are left to Isolate projects sharing a Service Worker registration #45. Isolate projects sharing a Service Worker registration #45 separates the projects of clients that share one Service Worker.
  • The window before listen() completes: a request that starts the Service Worker before its server listens goes to the network. @vrowzer/vite-plugin answers it with its guard page.
    • I measured ten restarts, each started by a module request. No request hit this window.
    • The restart notice arrived 29–48 ms after the request. The response came 32–53 ms after the request, from the restored project.
    • This PR leaves the window as it is.
  • Keeping the Service Worker alive is not included. It would not prevent every restart, so the recovery is needed anyway.
  • Updates to a new Service Worker version keep the current handling (reloadSuggested and so on).

Tests

  • Unit tests:

    • service-worker-core.test.ts:
      • The instance ID is sent with V_SW_LISTEN_READY and the start notice.
      • V_FS_INIT with an id is acknowledged.
      • Requests wait for both the files and the channel, in either order, and get a 503 after 10 seconds.
      • Files that fail to apply do not count.
    • index-recovery.test.ts (new): detection, the restored files, the order of the recovery, held and settled operations, the timeout of held operations, failures, overlapping recoveries and dispose().
    • controller-ready-timeout.test.ts: the controller keeps the instance ID from V_SW_LISTEN_READY.
    • beforeRequest.test.ts (new): the hook holds requests, and a response from the hook keeps the cross-origin isolation headers.
    • web-worker.test.ts: a new port closes the previous one, including a port that a new one replaced during its handshake or while it sent the safe paths.
    • publisher.test.ts: initFiles() sets the operation id.

    The new tests failed before the change.

  • Integration tests: the new file restart.integration-test.ts uses its own fixture (integration/restart/) and browser, and a new browser context for each test. Each test stops the Service Worker with the DevTools Protocol and starts it again with a request.

    • Latest files: the HTML (the default index.html), modules, public and binary files have the latest contents. A deleted file gets a 404, and no request on the way gets a 404 or a 500.
    • After a restart, the following work without a reload of the host page:
      • HMR updates of the open preview
      • asset loads
      • reloadPreview()
      • a preview mounted afterwards
    • A request pending at the stop: the browser stops the Service Worker only after the request ends, and the recovery follows.
    • Three restarts in a row: each one is recovered.
    • An unrecoverable channel (the Web Worker is terminated):
      • serviceWorkerRecoveryError is emitted within fileSyncTimeout.
      • A file change made meanwhile rejects.
      • A request gets a 503 after 10 seconds, and a reloaded preview reports it.
    • An instance disposed during a recovery sends neither V_FS_INIT nor V_WW_CONNECT_PORT afterwards, even after another restart.

    I checked that the tests fail without parts of the fix:

    • Without the recovery in the runtime, the first test fails: the restarted Service Worker answers with a 503 after 10 seconds.
    • Without the requests held in the Service Worker, the first request after the restart gets a 500.

I ran these checks locally:

  • vp run test:unit: 121 files, 1,953 tests (3 skipped)
  • vp run test:integration:vrowzer: 76 passed and 1 conditional skip, in six runs
  • vp run test:e2e: 46 passed, 6 existing test.skip, in 87 s, about as long as before
  • oxlint (no new warnings), knip, the format check of the changed files, and typecheck

writeFile() and unlink() accept an operation id, which a Worker can acknowledge with V_FS_ACK, but V_FS_INIT had no id. FSInitMessage now has an optional id, and initFiles() takes the same options as writeFile() and unlink() and sets the id on the message for every target.

The vrowzer runtime will send V_FS_INIT with an id to restore a restarted Service Worker, and wait for its acknowledgement. The README and the API references describe the option.
…t its Web Worker channel

When the browser restarts the Service Worker process, the Service Worker loses the project files and the Web Worker channel, while the registration and the controller stay the same. Its owner has to restore both, and requests within the base path must not be answered before that.

createServer() now takes two hooks:

- beforeRequest() is called before a request within the base path is handled, and the request waits for its promise. When it resolves to a Response, that response is returned instead, rebuilt through the Hono context so that the cross-origin isolation headers are kept. The middleware runs before the configureServer hooks, so the middlewares of plugins wait as well.
- onWorkerChannelReady() is called each time the channel handshake with a Web Worker completes, before the client is told that the connection is established.

The message types add V_SW_INSTANCE_STARTED, which a Service Worker instance sends to its window clients when listen() completes, an instanceId on V_SW_LISTEN_READY, and an optional runtimeId on V_WW_CONNECT_PORT that marks the owner of the channel for when several runtimes share one Service Worker.
…ew one connects

The Web Worker accepted a new port from V_SW_CONNECT_PORT at any time, but kept the previous port and its birpc open. After the browser restarts the Service Worker process, the runtime connects a new port, and calls made over the previous one would wait for replies that never come.

When a new port arrives, the Web Worker now closes the previous port and its birpc, which rejects the calls still waiting on it. A port that a newer one replaces during its handshake, or while it sends the safe module paths, is closed without an acknowledgement, because V_SW_CONNECT_PORT_ACK does not name the port it acknowledges.
… project

When the browser restarts the Service Worker process, the new instance has no project files and no Web Worker channel, so the preview HTML got a 404, modules a 500 and public files a 404 until the host page was reloaded. The runtime could not tell that the Service Worker had restarted, because the registration and the controller stay the same.

Each evaluation of the Service Worker script now creates an instance ID. It is sent with V_SW_LISTEN_READY, and when listen() completes, V_SW_INSTANCE_STARTED with the ID is sent to every window client the instance controls. A runtime that knows another ID can then restore the project.

A request within the base path now waits until this instance has received both the project files (V_FS_INIT) and the Web Worker channel. ready() sends both before the preview opens, so normal startup does not wait. After 10 seconds, the same as the default fileSyncTimeout, the request gets a 503 with a short explanation, which the preview reports as a load error. V_FS_INIT with an id is acknowledged like V_FS_WRITE and V_FS_UNLINK, and project files that fail to apply do not count.
The browser stops an idle Service Worker and starts it again for the next request or message. The restarted Service Worker had lost the project files and the Web Worker channel, so the preview broke until the host page was reloaded, and the runtime did not notice.

The runtime now remembers the Service Worker instance ID from V_SW_LISTEN_READY, and listens for V_SW_INSTANCE_STARTED. When an instance with another ID starts, it restores the project there:

- It keeps the latest files given to the Workers: the files of ready(), including the default index.html and the client files, updated whenever addFile(), updateFile() or deleteFile() is sent. It sends them to the Service Worker only, with V_FS_INIT and an id.
- When the Service Worker acknowledges the files, operations that were sent before and still wait for the Service Worker are settled, because the files include them. The previous instance can no longer acknowledge them.
- Then it connects the Web Worker channel again with establishChannel(), and emits serviceWorkerRecovered.

File operations called during a recovery are held and sent in order after it, and their fileSyncTimeout counts from then. An ArrayBuffer is now copied when the operation is called, so a held write sends the content of that time.

A recovery that does not finish within fileSyncTimeout, or whose files the Service Worker fails to apply, emits serviceWorkerRecoveryError and rejects the operations that wait for the Service Worker. Later operations are sent as usual, and the next restart is tried again. A newer instance that starts during a recovery replaces it, an instance that starts during ready() is restored once ready() completes, and dispose() stops a recovery without sending anything more.

V_WW_CONNECT_PORT carries a runtime ID, created when ready() starts, to mark the owner of the channel for when several runtimes share one Service Worker.
The new restart fixture gives ready() a module, files to change and delete, public files and a binary file, without /index.html. The tests stop the Service Worker process with ServiceWorker.stopAllWorkers of the DevTools Protocol, which keeps the registration, and start it again with a request. They use their own browser and a new browser context for each test, because stopping affects every page that uses the Service Worker.

- After file changes and a restart, the preview HTML, modules, public files and binary files have the latest contents, a deleted file gets a 404, and no request on the way gets a 404 or a 500.
- After a restart, file changes reach the open preview by HMR, the preview loads assets, reloadPreview() loads the latest contents, and a preview mounted afterwards gets HMR updates as well, without reloading the host page.
- A request pending when the stop is requested ends, and the module is served after the recovery. The browser stops the Service Worker only after the request ends, so a test plugin holds it in the Web Worker until a file is written.
- Three restarts in a row are each recovered.
- Without the Web Worker, serviceWorkerRecoveryError is emitted within fileSyncTimeout, a file change made meanwhile rejects, a preview request gets a 503 after 10 seconds, and a reloaded preview reports the 503.
- An instance disposed during a recovery sends neither V_FS_INIT nor V_WW_CONNECT_PORT afterwards, also after another restart.

Without the recovery in the runtime, the restarted Service Worker answers with a 503 after 10 seconds and the first test fails. Without the requests held in the Service Worker, the first request after the restart gets a 500.
A new README section, "Service Worker restarts", describes how Vrowzer restores the project when the browser restarts an idle Service Worker: the restarted Service Worker notifies the pages it controls, Vrowzer sends the latest files from its copy and connects the Web Worker channel again, and emits serviceWorkerRecovered. Preview requests wait for up to 10 seconds, file operations are held until the project is restored, and mounted previews keep their HMR connection. When the restoration fails, serviceWorkerRecoveryError is emitted, and the section shows how to start over with a new instance.

The README events section describes serviceWorkerRecovered and serviceWorkerRecoveryError. The descriptions of fileSyncTimeout, the file operations and dispose(), in the README and in the JSDoc, say how they relate to the restoration. The API references are regenerated.
@kazupon
kazupon merged commit bb8ba24 into main Oct 1, 2026
4 checks passed
@kazupon
kazupon deleted the fix/sw-restart-recovery branch October 1, 2026 22:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Recover project files and channels after Service Worker process restart

1 participant