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

Ensure socket messages are always sent for all npm installs #419

Merged
merged 11 commits into from
Oct 24, 2024

Conversation

1egoman
Copy link
Contributor

@1egoman 1egoman commented Oct 23, 2024

At a high level, this change should address the first two points in #418.

We had discussed previously the notion of whether making all npm installs client initiated would be an easy way to fix the first item. However, after getting more into this work, I opted to not do it this way. This was due to a mixture of not being able to come up with an elegant way to pass the state from the creation step to the actual apps interface to invoke the install, and that it seemed like it's likely folks could have multiple instances of a srcbook open and generally getting in the habit of broadcasting events so they all remain in sync no matter which instance caused an install to happen seemed like a good idea.

In more detail, this change:

  1. Ensures that for every npm install invocation made by the app, all relevant socket events are broadcasted to all connected clients on the given channel. Previously, some calls sent everything, some sent a few events, and some sent none. At least in my anecdotal observations, a lot of complex and hard to reason about situation were coming up due to the server and client's state not being synced.
  2. Adds a new message that gets sent when a npm install starts running. This means that all connected app instances will show a "loading" state, even if the npm install in question was initiated on the server or in another app instance.
  3. When a user joins a channel initially, send a message to them giving them an initial npm install status, so that if an install is in flight, the newly connected app can display this.

… are properly sequenced, and that the process gets deleted at the end
…er dependencies are installed

Previously, events were sent bespoke in each implementation. Many missed
events and that led to cases where the client often had no idea the
state of the server.

Now, always send all events always.
Comment on lines 5 to 9
import { CreateAppSchemaType, CreateAppWithAiSchemaType } from './schemas.mjs';
import { asc, desc, eq } from 'drizzle-orm';
import { npmInstall } from '../exec.mjs';
import { npmInstall, waitForProcessToComplete } from './processes.mjs';
import { generateApp } from '../ai/generate.mjs';
import { toValidPackageName } from '../apps/utils.mjs';
Copy link
Contributor Author

@1egoman 1egoman Oct 23, 2024

Choose a reason for hiding this comment

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

It seems like this npmInstall was missed during the migration previously - so I converted this to use the same common process-managed interface as everything else. In theory, this means that two npm install commands could still run in parallel and I want to just make 100% sure that will never happen.

Copy link
Contributor

Choose a reason for hiding this comment

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

This was intentionally not done in #418 and left as a todo item. I didn't do it in that PR because I wasn't sure if I was going to delete this code entirely (so only allow the client to initiate installs) or let both initiate installs. We opted for the latter here, though I think it's probably better for now make this all client driven. Doing so requires reworking the create flow though, so I think that can be done in a separate PR.

Comment on lines 56 to 62
const packagesToInstall = getPackagesToInstall(plan);

if (packagesToInstall.length > 0) {
await waitForProcessToComplete(firstNpmInstallProcess);

console.log('installing packages', packagesToInstall);
npmInstall({
cwd: pathToApp(app.externalId),
npmInstall(app.externalId, {
Copy link
Contributor Author

@1egoman 1egoman Oct 23, 2024

Choose a reason for hiding this comment

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

I introduced this waitForProcessToComplete function here to ensure that these npm installs during an ai app creation always happen sequentially. In practice, almost always this function should end up being a noop.

Copy link
Contributor

Choose a reason for hiding this comment

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

Instead of waitForProcessToComplete, a nicer API might be to have a wrapper around npmInstall that returns a promise so that callers like these can just await the install call.

Copy link
Contributor

Choose a reason for hiding this comment

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

+1 we never want two concurrent ones, so that guard can be abstracted away

Comment on lines 86 to 144
export function npmInstall(
appId: string,
options: Omit<Parameters<typeof execNpmInstall>[0], 'cwd'>,
options: Omit<Partial<Parameters<typeof execNpmInstall>[0]>, 'cwd'> & { onStart?: () => void },
) {
if (!processes.has(appId, 'npm:install')) {
processes.set(appId, {
type: 'npm:install',
process: execNpmInstall({ cwd: pathToApp(appId), ...options }),
});
const runningProcess = processes.get(appId, 'npm:install');
if (runningProcess) {
return runningProcess;
}

wss.broadcast(`app:${appId}`, 'deps:install:status', { status: 'installing' });
if (options.onStart) {
options.onStart();
}

return processes.get(appId, 'npm:install');
const newlyStartedProcess: NpmInstallProcessType = {
type: 'npm:install',
process: execNpmInstall({
...options,

cwd: pathToApp(appId),
stdout: (data) => {
wss.broadcast(`app:${appId}`, 'deps:install:log', {
log: { type: 'stdout', data: data.toString('utf8') },
});

if (options.stdout) {
options.stdout(data);
}
},
stderr: (data) => {
wss.broadcast(`app:${appId}`, 'deps:install:log', {
log: { type: 'stderr', data: data.toString('utf8') },
});

if (options.stderr) {
options.stderr(data);
}
},
onExit: (code) => {
// We must clean up this process so that we can run npm install again
deleteAppProcess(appId, 'npm:install');

wss.broadcast(`app:${appId}`, 'deps:install:status', {
status: code === 0 ? 'complete' : 'failed',
code,
});

if (code === 0) {
wss.broadcast(`app:${appId}`, 'deps:status:response', {
nodeModulesExists: true,
});
}
},
}),
};
processes.set(appId, newlyStartedProcess);

return newlyStartedProcess;
}
Copy link
Contributor Author

@1egoman 1egoman Oct 23, 2024

Choose a reason for hiding this comment

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

This might overload this process abstraction a bit, but I moved all the socket message emitting logic here - this is what makes sure these always get emitted for all cases where npm install is run. Previously, implementations emitted socket messages piecemeal which meant sometimes important messages in certain situations were missed.

I could potentially make another level of abstraction here if desired, though I don't think in practice there would be a reason one would want to call npmInstall without the socket logic associated.

Comment on lines 243 to 253
.on('file:updated', FileUpdatedPayloadSchema, onFileUpdated)
.onJoin((topic, conn) => {
// FIXME: there's almost certainly a better way to get the app id than this?
const appExternalId = topic.replace(/^app:/, '');

// When connecting, send back info about an in flight npm install if one exists
const npmInstallProcess = getAppProcess(appExternalId, "npm:install");
if (npmInstallProcess) {
conn.reply(`app:${appExternalId}`, 'deps:install:status', { status: 'installing' });
}
});
Copy link
Contributor Author

Choose a reason for hiding this comment

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

When a client joins the channel, send a message letting the client know if there is a npm install in progress.

@1egoman 1egoman marked this pull request as ready for review October 23, 2024 20:50
Copy link
Contributor

@benjreinhart benjreinhart left a comment

Choose a reason for hiding this comment

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

Going to approve and we can merge this now but I think we should follow up and remove these backend npm install calls in favor of the client. There may be good reasons to kick off npm installs from BE code in the future, but right now I can't think of those reasons and it seems simpler to implement the following flow:

create app -> immediate redirect to client -> client assumes 'normal' flow, checks for deps, installs if not there, etc.

@@ -214,7 +214,7 @@ export default class WebSocketServer {
if (event === 'subscribe') {
conn.subscriptions.push(topic);
conn.reply(topic, 'subscribed', { id: payload.id });
channel.onJoinCallback(topic, conn.socket);
channel.onJoinCallback(topic, conn);
Copy link
Contributor

Choose a reason for hiding this comment

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

The special onJoinCallback was implemented as a quick hack before #332. However, now that #332 is in place, we should make the callback signature the same as all other handlers:

+type HandlerType = (
+  payload: Record<string, any>,
+  context: MessageContextType,
+  conn: ConnectionContextType,
+) => void;
+
 /**
  * Channel is responsible for dispatching incoming messages for a given topic.
  *
@@ -52,15 +58,11 @@ export class Channel {
     string,
     {
       schema: z.ZodTypeAny;
-      handler: (
-        payload: Record<string, any>,
-        context: MessageContextType,
-        conn: ConnectionContextType,
-      ) => void;
+      handler: HandlerType;
     }
   > = {};

-  onJoinCallback: (topic: string, ws: WebSocket) => void = () => {};
+  onJoinCallback: HandlerType = () => {};

   constructor(topic: string) {
     this.topic = topic;
@@ -130,7 +132,7 @@ export class Channel {
     return this;
   }

-  onJoin(callback: (topic: string, ws: WebSocket) => void) {
+  onJoin(callback: HandlerType) {
     this.onJoinCallback = callback;
     return this;
   }
@@ -214,7 +216,11 @@ export default class WebSocketServer {
     if (event === 'subscribe') {
       conn.subscriptions.push(topic);
       conn.reply(topic, 'subscribed', { id: payload.id });
-      channel.onJoinCallback(topic, conn.socket);
+      channel.onJoinCallback(
+        payload,
+        { topic: match.topic, event: event, params: match.params },
+        conn,
+      );
       return;
     }

The code in server/channels/app.mts can then do:

.onJoin((_payload, context, conn) => {
  const appExternalId = (context as AppContextType).params.appId;
  // ...
});

Comment on lines 56 to 62
const packagesToInstall = getPackagesToInstall(plan);

if (packagesToInstall.length > 0) {
await waitForProcessToComplete(firstNpmInstallProcess);

console.log('installing packages', packagesToInstall);
npmInstall({
cwd: pathToApp(app.externalId),
npmInstall(app.externalId, {
Copy link
Contributor

Choose a reason for hiding this comment

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

Instead of waitForProcessToComplete, a nicer API might be to have a wrapper around npmInstall that returns a promise so that callers like these can just await the install call.

@1egoman 1egoman merged commit 48f0b91 into main Oct 24, 2024
2 checks passed
@1egoman 1egoman deleted the npm-install-socket-messages branch October 24, 2024 18:21
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.

3 participants