Skip to content

Commit

Permalink
Make elm invocations killable (#16)
Browse files Browse the repository at this point in the history
  • Loading branch information
lydell committed Aug 5, 2022
1 parent 66f32e5 commit 38a9511
Show file tree
Hide file tree
Showing 15 changed files with 714 additions and 363 deletions.
426 changes: 229 additions & 197 deletions src/Compile.ts

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions src/Env.ts
Expand Up @@ -49,3 +49,9 @@ export const __ELM_WATCH_MAX_PARALLEL = "__ELM_WATCH_MAX_PARALLEL";

// Used to test ElmWatchDummy.elm errors without affecting other tests.
export const __ELM_WATCH_TMP_DIR = "__ELM_WATCH_TMP_DIR";

// When killing `elm`, we don’t do it right away. Instead, we give it a chance
// to finish. The thinking is that this can reduce corruption of elm-stuff.
// Used in tests by not having to wait for so long.
// Type: Number.
export const __ELM_WATCH_ELM_TIMEOUT = "__ELM_WATCH_ELM_TIMEOUT";
68 changes: 54 additions & 14 deletions src/Hot.ts
Expand Up @@ -118,6 +118,7 @@ type Mutable = {
lastInfoMessage: string | undefined;
watcherTimeoutId: NodeJS.Timeout | undefined;
elmWatchStuffJsonWriteError: Error | undefined;
killInstallDependencies: (() => void) | undefined;
};

type WebSocketConnection = {
Expand Down Expand Up @@ -155,6 +156,7 @@ type Msg =
}
| {
tag: "InstallDependenciesDone";
date: Date;
installResult: Compile.InstallDependenciesResult;
}
| {
Expand Down Expand Up @@ -258,6 +260,7 @@ type Cmd =
outputPath: OutputPath;
outputState: OutputState;
}>;
killInstallDependencies: boolean;
}
| {
tag: "NoCmd";
Expand Down Expand Up @@ -484,6 +487,7 @@ const initMutable =
lastInfoMessage: undefined,
watcherTimeoutId: undefined,
elmWatchStuffJsonWriteError: undefined,
killInstallDependencies: undefined,
};

webSocketServer.setDispatch((msg) => {
Expand Down Expand Up @@ -520,20 +524,16 @@ const initMutable =

// istanbul ignore next
try {
if (mutable.killInstallDependencies !== undefined) {
mutable.killInstallDependencies();
}
await Promise.all(
getFlatOutputs(project).map(({ outputState }) =>
outputState.status.tag === "Postprocess"
"kill" in outputState.status
? outputState.status.kill()
: Promise.resolve()
)
);
} catch (unknownError) {
const error = toError(unknownError);
rejectPromise(toError(error));
}

// istanbul ignore next
try {
await closeAll(mutable);
} catch (unknownError) {
const error = toError(unknownError);
Expand Down Expand Up @@ -826,6 +826,11 @@ function update(
],
];

// We only kill installing dependencies when a restart is needed.
// Wait for the restart to happen.
case "Killed":
return [{ ...model, hotState: { tag: "Idle" } }, []];

case "Success": {
return [
{
Expand Down Expand Up @@ -1098,6 +1103,7 @@ function onWatcherEvent(
{
tag: "MarkAsDirty",
outputs: getFlatOutputs(project),
killInstallDependencies: false,
},
{ tag: "RestartWorkers" },
],
Expand Down Expand Up @@ -1150,7 +1156,13 @@ function onElmFileWatcherEvent(
? [
compileNextAction(nextAction),
{ ...event, affectsAnyTarget: true },
[{ tag: "MarkAsDirty", outputs: dirtyOutputs }],
[
{
tag: "MarkAsDirty",
outputs: dirtyOutputs,
killInstallDependencies: false,
},
],
]
: [nextAction, { ...event, affectsAnyTarget: false }, []];
}
Expand Down Expand Up @@ -1361,15 +1373,35 @@ const runCmd =
}
return;

case "InstallDependencies":
case "InstallDependencies": {
// If the web socket server fails to boot, don’t even bother with anything else.
mutable.webSocketServer.listening
.then(() => Compile.installDependencies(env, logger, mutable.project))
.then(() => {
const { promise, kill } = Compile.installDependencies(
env,
logger,
getNow,
mutable.project
);
mutable.killInstallDependencies = () => {
kill();
mutable.killInstallDependencies = undefined;
};
return promise;
})
.finally(() => {
mutable.killInstallDependencies = undefined;
})
.then((installResult) => {
dispatch({ tag: "InstallDependenciesDone", installResult });
dispatch({
tag: "InstallDependenciesDone",
date: getNow(),
installResult,
});
})
.catch(rejectPromise);
return;
}

case "LimitWorkers":
mutable.postprocessWorkerPool
Expand Down Expand Up @@ -1420,10 +1452,16 @@ const runCmd =
}

case "MarkAsDirty":
if (
cmd.killInstallDependencies &&
mutable.killInstallDependencies !== undefined
) {
mutable.killInstallDependencies();
}
for (const { outputPath, outputState } of cmd.outputs) {
outputState.dirty = true;
if (outputState.status.tag === "Postprocess") {
outputState.status.kill().catch(rejectPromise);
if ("kill" in outputState.status) {
Promise.resolve(outputState.status.kill()).catch(rejectPromise);
}
webSocketSendToOutput(
outputPath,
Expand Down Expand Up @@ -1773,6 +1811,7 @@ function makeRestartNextAction(
// Interrupt all compilation.
tag: "MarkAsDirty",
outputs: getFlatOutputs(project),
killInstallDependencies: true,
},
],
];
Expand Down Expand Up @@ -2213,6 +2252,7 @@ function onWebSocketRecompileNeeded(
{
tag: "MarkAsDirty",
outputs: [{ outputPath, outputState }],
killInstallDependencies: false,
},
],
];
Expand Down
8 changes: 7 additions & 1 deletion src/Make.ts
Expand Up @@ -19,10 +19,16 @@ export async function run(
): Promise<MakeResult> {
const startTimestamp = getNow().getTime();

const installResult = await Compile.installDependencies(env, logger, project);
const installResult = await Compile.installDependencies(
env,
logger,
getNow,
project
).promise;

switch (installResult.tag) {
case "Error":
case "Killed":
return { tag: "Error" };

case "Success":
Expand Down
15 changes: 7 additions & 8 deletions src/Postprocess.ts
Expand Up @@ -12,7 +12,7 @@ import {
MessageToWorker,
PostprocessResult,
} from "./PostprocessShared";
import { Command, spawnKillable, SpawnResult } from "./Spawn";
import { Command, spawn, SpawnResult } from "./Spawn";
import {
CompilationMode,
ElmWatchJsonPath,
Expand Down Expand Up @@ -74,7 +74,7 @@ export function runPostprocess({
stdin: code,
};

const { promise, kill } = spawnKillable(command);
const { promise, kill } = spawn(command);

const handleSpawnResult = (spawnResult: SpawnResult): PostprocessResult => {
switch (spawnResult.tag) {
Expand All @@ -89,6 +89,9 @@ export function runPostprocess({
command: spawnResult.command,
};

case "Killed":
return { tag: "Killed" };

case "Exit": {
const { exitReason } = spawnResult;

Expand Down Expand Up @@ -188,10 +191,6 @@ type PostprocessWorkerStatus =
tag: "Terminated";
};

export const WORKER_TERMINATED = new Error(
"`PostprocessWorker` has a `terminate` method. That was called! This error is supposed to be caught."
);

class PostprocessWorker {
private worker = new Worker(path.join(__dirname, "PostprocessWorker.js"), {
stdout: true,
Expand Down Expand Up @@ -338,11 +337,11 @@ class PostprocessWorker {
break;

case "Busy": {
const { reject } = this.status;
const { resolve } = this.status;
this.status = { tag: "Terminated" };
this.onTerminated(this);
await this.worker.terminate();
reject(WORKER_TERMINATED);
resolve({ tag: "Killed" });
break;
}

Expand Down
3 changes: 3 additions & 0 deletions src/PostprocessShared.ts
Expand Up @@ -39,6 +39,9 @@ export type MessageFromWorker = {

export type PostprocessResult =
| PostprocessError
| {
tag: "Killed";
}
| {
tag: "Success";
code: Buffer | string;
Expand Down
4 changes: 3 additions & 1 deletion src/Project.ts
Expand Up @@ -149,11 +149,13 @@ export type OutputStatus =
elmDurationMs: number;
walkerDurationMs: number;
injectDurationMs: number;
kill: () => void;
}
| {
tag: "ElmMakeTypecheckOnly";
elmDurationMs: number;
walkerDurationMs: number;
kill: () => void;
}
| {
tag: "Interrupted";
Expand All @@ -163,7 +165,7 @@ export type OutputStatus =
}
| {
tag: "Postprocess";
kill: () => Promise<void>;
kill: () => Promise<void> | void;
}
| {
tag: "QueuedForElmMake";
Expand Down
20 changes: 8 additions & 12 deletions src/Spawn.ts
Expand Up @@ -16,6 +16,10 @@ export type SpawnResult =
stderr: Buffer;
command: Command;
}
| {
tag: "Killed";
command: Command;
}
| {
tag: "OtherSpawnError";
error: Error;
Expand All @@ -37,15 +41,7 @@ export type Command = {
stdin?: Buffer | string;
};

export const SPAWN_KILLED = new Error(
"`spawnKillable` returns a `kill` function. That was called! This error is supposed to be caught."
);

export async function spawn(command: Command): Promise<SpawnResult> {
return spawnKillable(command).promise;
}

export function spawnKillable(command: Command): {
export function spawn(command: Command): {
promise: Promise<SpawnResult>;
kill: () => void;
} {
Expand All @@ -59,10 +55,10 @@ export function spawnKillable(command: Command): {
const promise = (
actualSpawn: typeof childProcess.spawn
): Promise<SpawnResult> =>
new Promise((resolve, reject) => {
new Promise((resolve) => {
// istanbul ignore if
if (killed) {
reject(SPAWN_KILLED);
resolve({ tag: "Killed", command });
return;
}

Expand Down Expand Up @@ -159,7 +155,7 @@ export function spawnKillable(command: Command): {
// istanbul ignore else
if (!killed) {
child.kill();
reject(SPAWN_KILLED);
resolve({ tag: "Killed", command });
killed = true;
}
};
Expand Down

0 comments on commit 38a9511

Please sign in to comment.