An exception raised after a stream write is swallowed, and the scheduler stops for good
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 48
- Forks
- 14
- Avg merge
- 4h 14m
- Merged PRs (30d)
- 1
Description
Repository: gren-lang/core
Found against: gren 0.6.6, gren-lang/core 7.4.2, gren-lang/node 6.1.3, node 22
Summary
If Gren code raises after a Stream.write — a Debug.todo, a Debug.crash
from Math.modBy 0, anything that throws — three things happen and none of them
is reported:
- The exception is caught by the stream kernel and relabelled
Stream.Cancelled,
although nothing was cancelled and the code that raised never touched a stream. - The scheduler's re-entrancy flag is left set, so every process in the
program stops, including ones with nothing to do with that stream. - Nothing is printed and the exit status is
0.
The program stops mid-run and looks like it finished.
Reproduction
A background process is due to print in 50ms. The main process writes a line
and then raises:
module Main exposing (main)
import Init
import Node
import Process
import Stream
import Task exposing (Task)
main : Node.SimpleProgram a
main =
Node.defineSimpleProgram
(\env -> Node.endSimpleProgram (emit env))
emit : Node.Environment -> Task Never {}
emit env =
Process.spawn
(Process.sleep 50
|> Task.andThen (\_ -> Stream.writeLineAsBytes "later" env.stdout)
|> Task.map (\_ -> {})
|> Task.onError (\_ -> Task.succeed {})
)
|> Task.andThen (\_ -> Stream.writeLineAsBytes "before" env.stdout)
|> Task.andThen (\_ -> Task.succeed (Debug.todo "boom"))
|> Task.map (\_ -> {})
|> Task.onError (\_ -> Task.succeed {})
$ gren make Main --output=app
$ node app
before
$ echo $?
0
later never prints. stderr is empty — zero bytes. The exit status is 0.
Replace Task.succeed (Debug.todo "boom") with Task.succeed 7 and the same
program prints before, then later, so the background process is well-formed
and its timer does fire; the raise is what kills it.
A Math.modBy with a zero divisor in that position behaves identically, which
is how this was found — see https://github.com/gren-lang/core/issues/141
Note the two Task.onErrors. Neither sees anything: this is not an error being
handled quietly, it is an error going nowhere.
Cause
Two separate mistakes, and it takes both to produce silence.
1. .catch attached after .then, so it covers the success handler
core/src/Gren/Kernel/Stream.js, _Stream_write:
writer.ready
.then(() => {
const writePromise = writer.write(value);
writer.releaseLock();
return writePromise;
})
.then(() => {
callback(__Scheduler_succeed(stream));
})
.catch((err) => {
callback(
__Scheduler_fail(
__Stream_Cancelled(_Stream_cancellationErrorString(err)),
),
);
});
callback is not a passive notification — it is _Scheduler_step's
continuation, so calling it re-enters the scheduler and runs whatever Gren code
comes next in the task chain. That code runs inside the .then handler, and
the .catch is attached downstream of that handler, so it catches the user's
exception along with the stream's own failures. A division by zero is reported
to the program as Stream.Cancelled.
The same promise.then(succeed).catch(fail) shape appears in _Stream_read,
_Stream_closeWritable and _Stream_pipeTo in the same file, and in every
binding in Gren/Kernel/Crypto.js.
2. _Scheduler_enqueue never clears its flag on the way out
core/src/Gren/Kernel/Scheduler.js:
function _Scheduler_enqueue(proc) {
_Scheduler_queue.push(proc);
if (_Scheduler_working) {
return;
}
_Scheduler_working = true;
while (_Scheduler_queue.length > 0) {
const activeProcs = _Scheduler_queue;
_Scheduler_queue = [];
for (const proc of activeProcs) {
_Scheduler_step(proc);
}
}
_Scheduler_working = false;
}
The exception thrown by _Scheduler_step escapes the while, so
_Scheduler_working = false is never reached. It stays true for the life of
the process, and from then on every _Scheduler_enqueue pushes onto the queue
and returns at the guard. Nothing drains it again.
That is what kills the background process in the reproduction: its timer fires,
the timer's callback enqueues, the guard returns, and the work sits in
_Scheduler_queue forever. It also swallows the Stream.Cancelled failure
from (1) — that callback enqueues too — which is why Task.onError sees
nothing either.
With nothing left on the event loop, node exits, and it exits 0.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with core/src/Gren/Kernel/Stream.js, especially _Stream_write and the related read, close, and pipe bindings, then inspect core/src/Gren/Kernel/Scheduler.js and reproduce the provided Gren program. Trace how exceptions reach the scheduler and stream callbacks. Done means raised exceptions are reported rather than relabelled or swallowed, the scheduler continues processing independent work, and the reproduction prints the expected error behavior instead of exiting successfully.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript
- Domain
- backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 48/100