effection icon indicating copy to clipboard operation
effection copied to clipboard

update docs with better examples

Open DetachHead opened this issue 4 months ago • 18 comments

i stumbled across this library while trying to familiarize myself with the lspx codebase, since i'm still interested in working on https://github.com/thefrontside/lspx/issues/13.

i'm having trouble wrapping my head around what this library does exactly. for example, from Thinking in Effection:

We expect synchronous functions to run completely from start to finish.

function main() {
  try {
    fn()
  } finally {
    // code here is GUARANTEED to run
  }
}

However, the same guarantee is not provided for async functions.

async function main() {
  try {
    await new Promise((resolve) => setTimeout(resolve, 100,000));
  } finally {
    // code here is NOT GUARANTEED to run
  }
}

await main();

this was initially confusing to me until i read the Async Event Horizon article linked further down, which elaborates further by mentioning a specific case where finally blocks are not guaranteed to run - when the program is exited with ctrl+C.

but from what i can tell, this issue is not unique to async code. neither of those examples will run the finally block if the program is exited with ctrl+C. if the fn function from the first example is a synchronous sleep function that waits for the same amount of time, it behaves the exact same way when the program is exited:

const fn = () =>  {
  const start = Date.now();
  while (Date.now() - start < 100_000) {}
}

function main() {
  try {
    fn()
  } finally {
    console.log("this code does not run if ctrl+c is pressed while fn is still running")
  }
}
main()

By contrast, Effection does provide this guarantee.

import { main, action } from "effection";

await main(function*() {
  try {
    yield* action(function*(resolve) { setTimeout(resolve, 100,000) });
  } finally {
    // code here is GUARANTEED to run
  }
});

this doesn't seem to work either:

import { main, action } from "effection";

await main(function*() {
  try {
    yield* action(function*(resolve) { setTimeout(resolve, 100_000) });
  } finally {
    console.log("this code does not run if ctrl+c is pressed while fn is still running")
  }
});

i assume there's something i'm missing here, but perhaps the docs could be updated with examples that more clearly demonstrate the problem that this library solves, for people like myself who may not be familiar with the problem in the first place.

DetachHead avatar Aug 18 '25 12:08 DetachHead

@DetachHead these are good questions. I'm going to follow up a bit later today with answers.

taras avatar Aug 18 '25 12:08 taras

can we also point out:

setTimeout(resolve, 100,000)

this should be 100_000

KotlinIsland avatar Aug 18 '25 14:08 KotlinIsland

@DetachHead thank you for the thoughtful question. I think there are a few things going on here.

Those examples are not particularly helpful because they don't actually demonstrate what happens because the part in the finally block doesn't produce any runtime output. The other part of it is that those examples are somewhat outdated because both Node & Deno have been tweaking what happens when you attempt to terminate the program. They're both now doing some clever releasing of resources but only in very specific use cases. In the past, we used the example of terminating a program with CTRL+c because it was convenient for demo purposes, but represents a relatively small scope for an overall problem.

I recently used another example which I think is much better at demonstrating this problem. You can find the code here https://github.com/taras/effection-generators-intro but I'll copy and paste the relevant bits. It's exactly the same program with async/await and Effection.

This example runs two concurrent asynchronous functions. One function writes to the console after 3 seconds and the other throws an error after 1 second. This is the simplest example.

Async/Await

async function delayedMessage(message: string, ms: number) {
  await sleep(ms);
  console.log(message);
}

function sleep(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function throwError(ms: number) {
  await sleep(ms);
  throw new Error("💥");
}

async function main() {
  try {
    await Promise.all([delayedMessage("delayed message", 3000), throwError(1000)]);
  } catch (e) {
    console.error(e);
  } finally {
    console.log("in finally");
  }
}

console.log("starting");
await main();
console.log("finished");

I've asked groups of experienced developers what they expect to happen here and I usually get as many answers as there are people in the group. The correct answer unfortunately comes from people who are familiar with flaws of the JavaScript runtime, not people who rely on the intuition cultivated through experience in structured programming.

Here is what happens when you run this example

starting
Error: 💥
    at throwError (file:///Users/tarasmankovski/Repositories/frontside/effection-generators-intro/async-sibling-errored.ts:17:9)
    at eventLoopTick (ext:core/01_core.js:218:9)
    at async Promise.all (index 1)
    at async main (file:///Users/tarasmankovski/Repositories/frontside/effection-generators-intro/async-sibling-errored.ts:22:5)
    at async file:///Users/tarasmankovski/Repositories/frontside/effection-generators-intro/async-sibling-errored.ts:32:1
in finally
finished
delayed message

The important thing to note here is that delayed message is logged after the program completed because one of the sibling async functions crashed. It's not possible to reproduce this example with structured programming - regular synchronous programming, but the intuitive expectations are for the error to be caught in the try/catch block and no other code to be executing when this happens. This is one of the rules of structured concurrency which is the application of structured programming primitives to concurrent programming.

Effection

Effection is designed for two things: to provide guarantees of structured concurrency and make it feel like JavaScript. So you can implement exactly the same example in Effection and it'll look almost identical, but it'll work according to rules of structured concurrency and most people's intuition. Here is what it looks like in Effection.

import { all, run, action, Operation } from "effection";

function* delayedMessage(message: string, ms: number) {
  yield* sleep(ms);
  console.log(message);
}

export function sleep(duration: number): Operation<void> {
  return action((resolve) => {
    let timeoutId = setTimeout(resolve, duration);
    return () => clearTimeout(timeoutId);
  });
}

function* throwError(ms: number) {
  yield* sleep(ms);
  throw new Error("💥");
}

function* main() {
  try {
    yield* all([delayedMessage("delayed message", 3000), throwError(1000)]);
  } catch (e) {
    console.error(e);
  } finally {
    console.log("in finally");
  }
}

console.log("starting");
await run(main);
console.log("finished");

The output looks like this,

starting
Error: 💥
    at throwError (file:///Users/tarasmankovski/Repositories/frontside/effection-generators-intro/effection-sibling-errored.ts:17:9)
    at throwError.next (<anonymous>)
    at $next (file:///Users/tarasmankovski/Library/Caches/deno/npm/registry.npmjs.org/effection/3.6.0/esm/lib/run/frame.js:152:14)
    at file:///Users/tarasmankovski/Library/Caches/deno/npm/registry.npmjs.org/effection/3.6.0/esm/lib/run/frame.js:65:28
    at Generator.next (<anonymous>)
    at getNext (file:///Users/tarasmankovski/Library/Caches/deno/npm/registry.npmjs.org/effection/3.6.0/esm/deps/jsr.io/@frontside/continuation/0.1.6/mod.js:114:25)
    at reduce (file:///Users/tarasmankovski/Library/Caches/deno/npm/registry.npmjs.org/effection/3.6.0/esm/deps/jsr.io/@frontside/continuation/0.1.6/mod.js:33:28)
    at file:///Users/tarasmankovski/Library/Caches/deno/npm/registry.npmjs.org/effection/3.6.0/esm/deps/jsr.io/@frontside/continuation/0.1.6/mod.js:66:33
    at file:///Users/tarasmankovski/Library/Caches/deno/npm/registry.npmjs.org/effection/3.6.0/esm/deps/jsr.io/@frontside/continuation/0.1.6/mod.js:134:34
    at resolve (file:///Users/tarasmankovski/Library/Caches/deno/npm/registry.npmjs.org/effection/3.60/esm/lib/instructions.js:52:38)
in finally
finished

The stack trace is a bit longer, but what you won't find is "delayed message" at the end because according to structured concurrency, a child operation cannot outlive its parent. When a sibling operation crashes, the parent operation terminates, all other children are automatically terminated and the error is sent through the catch/finally blocks. When you're in catch/finally, you can rely on the fact that the operations in the try block are no longer running.

This problem exists because JavaScript runtime does not have a way to terminate execution of async functions. The only way to interrupt async functions is to modify the actual function itself to add AbortSignal. If you're using an async function that doesn't take a signal, then there is nothing you can do to interrupt that async function.

Many feature developers don't have problems with async/await but not all applications require precise control over asynchrony. For building any highly stateful application, strict control over asynchrony requires either added complexity of threading signals through all layers of async functions (but disallowing 3rd party libraries that might not support AbortSignals), using something like Observables or using Effection. Considering that Effection is designed to align to APIs and paradigms that developers already know, we believe it's the best option.

GitHub
Contribute to taras/effection-generators-intro development by creating an account on GitHub.

taras avatar Aug 19 '25 00:08 taras

thanks so much for the for the detailed explanation! for the record i guessed correctly what would happen in that example but i never really considered that behavior to be an issue, but after playing around with it some more i can definitely see how it can be an problem. for example if both promises reject, one of the errors just gets silently ignored:

async function delayedMessage(message: string, ms: number) {
  await sleep(ms);
  console.log(message);
}

function sleep(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function throwError(ms: number) {
  await sleep(ms);
  throw new Error(`💥 ${ms}`);
}

async function main() {
  try {
    await Promise.all([throwError(3000), throwError(1000)]);
  } catch (e) {
    console.error(e);
  } finally {
    console.log("in finally");
  }
}

console.log("starting");
await main();
console.log("finished");
starting
Error: 💥 1000
    at throwError (file:///home/me/projects/node-test/foo.ts:12:9)
    at eventLoopTick (ext:core/01_core.js:218:9)
    at async Promise.all (index 1)
    at async main (file:///home/me/projects/node-test/foo.ts:17:5)
    at async file:///home/me/projects/node-test/foo.ts:26:1
in finally
finished

come to think of it, i've probably ran into issues like this before. i remember a few cases where an async function seemed to be throwing an exception that was being silently suppressed and i could never figure out why.

DetachHead avatar Aug 19 '25 09:08 DetachHead

Both children fail is a good example as well.

Yeah, I think most people have this experience with JavaScript a few times. It seeds uneasiness that they don't think about or they end up building frameworks that support structured concurrency. That's basically what happened with Effection. We were building BigTest and wanted ensure that when a user hit CTRL+c all of the tests stopped immidiately no matter how complex they were. We didn't want to use Observables, because it required changing how we think about writing code. It started us on this journey. Later we realized what Effection was doing is called structured concurrency.

taras avatar Aug 19 '25 10:08 taras

kotlin is a nice language, it has structured concurrency as the default

KotlinIsland avatar Aug 19 '25 11:08 KotlinIsland

Swift and Java have it built in too now.

taras avatar Aug 19 '25 11:08 taras

Not to detract from a great discussion, but fwiw @DetachHead When I run the code you posted in the Effection example, this is the output that I see:

% deno run -A example.ts
^Cthis code does not run if ctrl+c is pressed while fn is still running

I that not what you see? If not, we definitely want to get a handle on this. What is version of Node / Effection / OS are you using?

cowboyd avatar Aug 19 '25 12:08 cowboyd

hmm, it looks like it does work when running a normal script, but not when running a test with deno test:

// test.ts
import { main, action } from "effection";

Deno.test('asdf', async () => {
  await main(function*() {
    try {
      yield* action(function*(resolve) { setTimeout(resolve, 100_000) });
    } finally {
      console.log("this code does not run if ctrl+c is pressed while fn is still running")
    }
  });
})

DetachHead avatar Aug 19 '25 13:08 DetachHead

main is designed to be executed as a root of an Effection program. It binds to the life cycle events of the runtime to ensure graceful shut down of the operation tree. If it's not the main entry point, then you should use run instead.

Can you try it and tell me what happens?

taras avatar Aug 19 '25 13:08 taras

@DetachHead Yeah, in that case, the test runner adds a signal handler that calls Deno.exit() directly which effectively shoots the process in the heart preventing Effection (or anyone else for that matter) from ensuring a graceful shutdown.

As @taras said, the best option in this case is to use run and ensure that you cleanup the task.

Deno.test("global run", async (t) => {
  const task = run(function*() {
    try {
      yield* action(function*(resolve) { setTimeout(resolve, 100_000) });
    } finally {
      console.log("this code does not run if ctrl+c is pressed while fn is still running")
    }
  });
  try {
    await t.step("await task", async () => {
      await task;
    });
  } finally {
    await task.halt();
  }
});

cowboyd avatar Aug 19 '25 14:08 cowboyd

@DetachHead I saw that you were delving into working on https://github.com/thefrontside/lspx/issues/13

If you're interested, I'd be happy to give you a tour of that code base, talk through designs, and potential implementations you have cooking...

cowboyd avatar Aug 20 '25 15:08 cowboyd

yeah that sounds good!

DetachHead avatar Aug 22 '25 05:08 DetachHead

Great! What time zone are you in? Let's try and set something up next week...

cowboyd avatar Aug 22 '25 14:08 cowboyd

I'm in Australia. Assuming you're in Texas as it says on your profile, I can potentially do this Monday or Tuesday morning (Sunday or Monday night for you)

DetachHead avatar Aug 22 '25 23:08 DetachHead

What about Thursday night for me, Friday morning for you?

cowboyd avatar Aug 27 '25 13:08 cowboyd

Sorry I won't be available for the next few days, Monday or Tuesday were the only days I had free this week. I will be available any day from the 3rd to 10th of September though

DetachHead avatar Aug 28 '25 01:08 DetachHead

Sounds good. Shall we coordinate the rest of this on discord?

cowboyd avatar Aug 29 '25 15:08 cowboyd