Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

startDevWorker - Milestone 1 #3960

Merged
merged 44 commits into from
Oct 12, 2023
Merged

startDevWorker - Milestone 1 #3960

merged 44 commits into from
Oct 12, 2023

Conversation

RamIdeas
Copy link
Contributor

@RamIdeas RamIdeas commented Sep 15, 2023

What this PR solves / how to test:

This PR implements the first milestone for startDevWorker. This includes the ProxyController, ProxyWorker and ProxyInspectorWorker. The startDevWorker function is not exposed yet – the components implemented so far have been inserted tactfully 👀 into the existing flows for wrangler dev, wrangler dev --remote, unstable_dev(...).

There should be no breaking changes. Improvements include:

  • no more port in use errors upon reloads (except for an existing race condition we'll fix in a quick follow-up)
  • upon config/source file changes, requests are buffered to guarantee the response is from the new version of the worker

Author has included the following, where applicable:

Reviewer is to perform the following, as applicable:

  • Checked for inclusion of relevant tests
  • Checked for inclusion of a relevant changeset
  • Checked for creation of associated docs updates
  • Manually pulled down the changes and spot-tested

Note for PR author:

We want to celebrate and highlight awesome PR review! If you think this PR received a particularly high-caliber review, please assign it the label highlight pr review so future reviewers can take inspiration and learn from it.

@RamIdeas RamIdeas requested a review from a team as a code owner September 15, 2023 18:48
@changeset-bot
Copy link

changeset-bot bot commented Sep 15, 2023

🦋 Changeset detected

Latest commit: 334bfa0

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
wrangler Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@RamIdeas RamIdeas force-pushed the startDevWorker-milestone-1 branch from a179b91 to 51f8854 Compare September 15, 2023 18:53
@@ -60,7 +60,7 @@
"emit-types": "tsc -p tsconfig.emit.json && node -r esbuild-register scripts/emit-types.ts",
"prepublishOnly": "SOURCEMAPS=false npm run build",
"start": "pnpm run bundle && cross-env NODE_OPTIONS=--enable-source-maps ./bin/wrangler.js",
"test": "pnpm run assert-git-version && jest",
"test": "pnpm run assert-git-version && jest --runInBand",
Copy link
Contributor Author

@RamIdeas RamIdeas Sep 15, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Had to do this to avoid a race condition where every test thought it could start the InspectorProxyWorker on 9229 because get-port doesn't work across processes (jest workers, in this case)

We confirmed this affects wrangler dev and unstable_dev if you run multiple processes in parallel where get-port thinks the requested port is available but then there's a race for all the workerd instances to bind to it

We have a fix coming in a follow-up

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: make miniflare surface "port in use" errors so wrangler can retry instantiating Miniflare with port 0

@@ -69,6 +70,7 @@ export interface UnstableDevOptions {
export interface UnstableDevWorker {
port: number;
address: string;
proxyData: ProxyData;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding to this public interface so we can fake some startDevWorker events (call the event listeners)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO add getBindings to this interface

/**
* @internal
*/
export class DevEnv extends EventEmitter {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This class contains the glue code for all startDevWorker controllers. This is the contract between all controllers. You could override the instances of each/any controller in the constructor but we're not exposing this to users yet until we're more sure of the contract.

Copy link
Contributor

@JacobMGEvans JacobMGEvans left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First pass, tentatively, looks GREAT! I will probably go over it again maybe even jump on a call with @RamIdeas for a pair review for a 2nd pass!

This PR also should have a couple approvals minimum.

@@ -0,0 +1,41 @@
import type Protocol from "devtools-protocol/types/protocol-mapping";
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The types from devtools-protocol don't expose the full object we see in the websocket messages, just the sub-properties for some reason

This file may seem daunting but it just coerces the types into the whole object we see – e.g. with method and params matched together not as separate type unions which is basically useless

@mrbbot got nerd-sniped and got it working 👏

@@ -244,7 +255,55 @@ type DevSessionProps = DevProps & {
};

function DevSession(props: DevSessionProps) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is (essentially) the entry-point to wrangler dev – here we create a DevEnv and map the props into StartDevWorkerOptions (at least what is relevant for now – for ProxyController) and fake events by calling ProxyController event handler methods (devEnv.proxy.on*)

Comment on lines +406 to +425
initialPort={0} // hard-code for userworker, DevEnv-ProxyWorker now uses this prop value
initialIp={"127.0.0.1"} // hard-code for userworker, DevEnv-ProxyWorker now uses this prop value
rules={props.rules}
inspectorPort={props.inspectorPort}
runtimeInspectorPort={props.runtimeInspectorPort}
localPersistencePath={props.localPersistencePath}
liveReload={props.liveReload}
crons={props.crons}
queueConsumers={props.queueConsumers}
localProtocol={props.localProtocol}
localProtocol={"http"} // hard-code for userworker, DevEnv-ProxyWorker now uses this prop value
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The user worker now never starts on https, and binds to 127.0.0.1 on a random port. The ProxyWorker will start on the previous address values

Comment on lines -91 to -114
usePreviewServer({
previewToken,
assetDirectory: props.isWorkersSite
? undefined
: props.assetPaths?.assetDirectory,
localProtocol: props.localProtocol,
localPort: props.port,
ip: props.ip,
});

useInspector({
inspectorUrl:
props.inspect && previewToken
? previewToken.inspectorUrl.href
: undefined,
port: props.inspectorPort,
logToTerminal: true,
sourceMapPath: props.sourceMapPath,
host: previewToken?.host,
name: props.name,
sourceMapMetadata: props.bundle?.sourceMapMetadata,
});

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No more preview server or inspector server, replaced by ProxyWorker and ProxyInspectorWorker respectively

@@ -72,6 +74,34 @@ export async function startDevServer(
}
}

const devEnv = new DevEnv();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This outer function (startDevServer) is called internally by unstable_dev. Here we create a DevEnv and fake events to the ProxyController by calling it's event handler methods (devEnv.proxy.on*)

// `selfsigned` imports `node-forge`, which is a pretty big library.
// To reduce startup time, only load this dynamically when needed.
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const generate: typeof import("selfsigned").generate = promisify(
Copy link
Contributor Author

@RamIdeas RamIdeas Sep 15, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is one of those fake async libs where they provided a node.js callback signature for ecosystem consistency. It makes no difference to call it without a callback ("synchronously") but this way we can de-async the callstack

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively, could we take advantage of the existing async nature of this function to use an ESM await import()? Reducing synchronous require()s will be helpful with things like e.g. a future move to Viteste

Copy link
Contributor

@mrbbot mrbbot Oct 6, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think await import() segfaults in Jest, breaking our existing tests 🙁

@@ -0,0 +1,4 @@
declare module "worker:*" {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declaring the type for worker: prefix module imports. We added an esbuild plugin to find these worker files in packages/wrangler/templates/*

this.requestQueue.set(request, promise);
this.processQueue();

return promise;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Synchronously returning this promise. The promise will resolve later when we process the queue.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any possible things running that would depend on this Promise? Considering possible race conditions or the like.

processProxyControllerRequest(request: Request) {
const event = request.cf?.hostMetadata;
switch (event?.type) {
case "pause":
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pause events cause the ProxyWorker to buffer requests

this.proxyData = undefined;
break;

case "play":
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

play events cause the ProxyWorker to begin proxying requests to the UserWorker (using proxyData)

Comment on lines +167 to +176
await env.PROXY_CONTROLLER.fetch("http://dummy", {
method: "POST",
body: JSON.stringify(message),
});
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ProxyWorker can communicate back to the ProxyController via this service binding

}
}

void fetch(url, new Request(req, { headers }))
Copy link
Contributor Author

@RamIdeas RamIdeas Sep 15, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not awaiting this fetch means we replay the requests in order but we don't guarantee their delivery in order. We could await just the headers (not the body) to guarantee delivery order too but I don't think it matters too much – happy to hear other opinions

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you are chaining then & catch, why is the void necessary?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It shouldn't be a floating promise or anything, if its been chained with a catch I mean.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just being explicit about not awaiting the promise since we're in a loop and it's on purpose

But I guess I could just add a comment instead

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any reason not to await the fetch? We're already creating an ordered buffer of requests, we might as well make sure that the delivery order is preserved

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The main reason is that if the UserWorker has any long-running requests and therefore requires parallel request handling, await-ing would prevent that.

I've left a comment stating, if we choose to await, we should also race it against a timeout of e.g. 100ms

res = insertLiveReloadScript(req, res, this.env, proxyData);
}

deferred.resolve(res);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This resolves the promise returned by the initial fetch handler

},
});

deferred.reject(error);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This rejects the promise returned by the initial fetch handler

},
} as ExportedHandler<Env>;

export class InspectorProxyWorker implements DurableObject {
Copy link
Contributor Author

@RamIdeas RamIdeas Sep 15, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a big boi, sorry

It's job is to proxy messages between the runtime inspector (#websockets.runtime), ProxyController (#websockets.proxyController) and devtools client (#websockets.devtools).

Because only one client can be connected to the runtime inspector server, the InspectorProxyWorker is essentially that one client. The ProxyController and devtools then can both connect to the InspectorProxyWorker.

@RamIdeas RamIdeas force-pushed the startDevWorker-milestone-1 branch from 247cf2c to 8863445 Compare October 11, 2023 15:54

// if passed a previousDeferred, ensure it is resolved with the newDeferred
// so that await-ers of previousDeferred are now await-ing newDeferred
previousDeferred?.resolve(newPromise);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit unclear on the use-case for chaining deferreds. If a deferred gets inherited, any holder of the previous deferred loses control over its lifecycle (the resolvers become inert since its promise has now already been resolved). Similarly, if the previous deferred has already been resolved before it gets inherited, the new deferred isn't allowed to extend the lifecycle of the previous one.

So whether or not a holder of a deferred maintains control over its lifecycle, and whether or not its lifecycle has been extended is unreliable. This seems error-prone and like it would lead to some insidious bugs.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a deferred gets inherited, any holder of the previous deferred loses control over its lifecycle (the resolvers become inert since its promise has now already been resolved)

True

Similarly, if the previous deferred has already been resolved before it gets inherited, the new deferred isn't allowed to extend the lifecycle of the previous one.

True

So whether or not a holder of a deferred maintains control over its lifecycle, and whether or not its lifecycle has been extended is unreliable. This seems error-prone and like it would lead to some insidious bugs.

The point is not to extend the lifecycle of the old promise. It's to ensure the old promise is not left uncompleted. If the old promise is already completed (resolved/rejected), this line has no effect

@RamIdeas RamIdeas merged commit c36b78b into main Oct 12, 2023
@RamIdeas RamIdeas deleted the startDevWorker-milestone-1 branch October 12, 2023 15:26
penalosa added a commit that referenced this pull request Oct 12, 2023
@admah admah added this to the Wrangler as a Library milestone Jan 9, 2024
@lrapoport-cf lrapoport-cf linked an issue Jan 10, 2024 that may be closed by this pull request
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
e2e Run wrangler e2e tests on a PR
Projects
None yet
Development

Successfully merging this pull request may close these issues.

🚀 Stabilizing unstable_dev and expanding Wrangler as an API
6 participants