@rip-lang/swarm 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +145 -0
- package/bin/swarm +15 -0
- package/lib/worker.mjs +43 -0
- package/package.json +46 -0
- package/swarm.rip +332 -0
package/README.md
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
<img src="https://raw.githubusercontent.com/shreeve/rip-lang/main/docs/rip.svg" style="width:50px" /> <br>
|
|
2
|
+
|
|
3
|
+
# Rip Swarm - @rip-lang/swarm
|
|
4
|
+
|
|
5
|
+
> **Parallel job runner with worker threads — setup once, swarm many**
|
|
6
|
+
|
|
7
|
+
Swarm processes large batches of tasks in parallel using Bun's worker
|
|
8
|
+
threads. Define a setup function (runs once) and a perform function
|
|
9
|
+
(runs per task), and swarm handles the rest — worker pool management,
|
|
10
|
+
file-based task lifecycle, ANSI progress bars, crash recovery, and a
|
|
11
|
+
clean summary at the end.
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bun add -g @rip-lang/swarm
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Create a job script:
|
|
20
|
+
|
|
21
|
+
```coffee
|
|
22
|
+
import { swarm, init, retry, todo } from '@rip-lang/swarm'
|
|
23
|
+
|
|
24
|
+
setup = ->
|
|
25
|
+
unless retry()
|
|
26
|
+
init()
|
|
27
|
+
for i in [1..100] then todo(i)
|
|
28
|
+
{ startedAt: Date.now() }
|
|
29
|
+
|
|
30
|
+
perform = (task, ctx) ->
|
|
31
|
+
await Bun.sleep(Math.random() * 1000)
|
|
32
|
+
throw new Error("boom") if Math.random() < 0.03
|
|
33
|
+
|
|
34
|
+
swarm { setup, perform, workers: 10 }
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Run it:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
rip jobs.rip
|
|
41
|
+
rip jobs.rip -w 10 # 10 workers (default: CPU count)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## How It Works
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
┌──────────────────────────────────────────────┐
|
|
48
|
+
│ Single Bun Process │
|
|
49
|
+
│ │
|
|
50
|
+
│ Main Thread Worker Threads │
|
|
51
|
+
│ ─────────── ────────────── │
|
|
52
|
+
│ setup() perform(task, ctx) │
|
|
53
|
+
│ task dispatch perform(task, ctx) │
|
|
54
|
+
│ progress bars perform(task, ctx) │
|
|
55
|
+
│ file lifecycle ... │
|
|
56
|
+
│ │
|
|
57
|
+
│ .swarm/todo/ ──→ .swarm/done/ │
|
|
58
|
+
│ └──→ .swarm/died/ │
|
|
59
|
+
└──────────────────────────────────────────────┘
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
1. **setup()** runs once in the main thread — creates tasks and returns
|
|
63
|
+
an optional context object
|
|
64
|
+
2. **N worker threads** are spawned — each loads your script and gets
|
|
65
|
+
the `perform` function
|
|
66
|
+
3. Tasks are dispatched from `.swarm/todo/` to workers via message passing
|
|
67
|
+
4. Workers call `perform(task, ctx)` and report done or failed
|
|
68
|
+
5. Main thread moves files to `.swarm/done/` or `.swarm/died/` and
|
|
69
|
+
updates the progress display
|
|
70
|
+
6. When all tasks complete, a summary is printed
|
|
71
|
+
|
|
72
|
+
## API
|
|
73
|
+
|
|
74
|
+
### Task Queue
|
|
75
|
+
|
|
76
|
+
```coffee
|
|
77
|
+
import { init, retry, todo } from '@rip-lang/swarm'
|
|
78
|
+
|
|
79
|
+
init() # Remove old .swarm, create todo/done/died dirs
|
|
80
|
+
retry() # Move .swarm/died/* back to .swarm/todo/ for retry
|
|
81
|
+
todo('task-1') # Create empty task file
|
|
82
|
+
todo('task-2', data) # Create task file with data (string or JSON)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### swarm()
|
|
86
|
+
|
|
87
|
+
```coffee
|
|
88
|
+
swarm { setup, perform }
|
|
89
|
+
swarm { setup, perform, workers: 8, bar: 30, char: '█' }
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Options:
|
|
93
|
+
- **setup** — function, runs once in main thread, returns optional context
|
|
94
|
+
- **perform** — function `(taskPath, ctx)`, runs in worker threads
|
|
95
|
+
- **workers** — number of worker threads (default: CPU count)
|
|
96
|
+
- **bar** — progress bar width in characters (default: 20)
|
|
97
|
+
- **char** — character for progress bars (default: `•`)
|
|
98
|
+
|
|
99
|
+
### CLI Flags
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
-w, --workers <n> Number of workers (default: CPU count)
|
|
103
|
+
-b, --bar <width> Progress bar width (default: 20)
|
|
104
|
+
-c, --char <ch> Bar character (default: •)
|
|
105
|
+
-r, --reset Remove .swarm directory and quit
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
CLI flags override options passed to `swarm()`.
|
|
109
|
+
|
|
110
|
+
## Task Lifecycle
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
.swarm/
|
|
114
|
+
├── todo/ ← tasks waiting to be processed
|
|
115
|
+
├── done/ ← successfully completed tasks
|
|
116
|
+
└── died/ ← failed tasks (can be retried)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Tasks are plain files. The filename identifies the task. Files can be
|
|
120
|
+
empty (filename is the data) or contain a payload (JSON, text, etc.).
|
|
121
|
+
File moves use `renameSync` for atomic operations.
|
|
122
|
+
|
|
123
|
+
## Crash Recovery
|
|
124
|
+
|
|
125
|
+
| Failure | What Happens | Recovery |
|
|
126
|
+
|---------|-------------|----------|
|
|
127
|
+
| `perform()` throws | Worker catches it, reports failed, continues | Automatic |
|
|
128
|
+
| Unhandled rejection | Worker error handler fires, continues | Automatic |
|
|
129
|
+
| Worker thread dies | Main thread detects exit, respawns worker | Automatic |
|
|
130
|
+
| Task timeout | (planned) AbortSignal kills task | Worker continues |
|
|
131
|
+
|
|
132
|
+
Failed tasks land in `.swarm/died/`. Call `retry()` in your next
|
|
133
|
+
`setup()` to move them back to `.swarm/todo/` for reprocessing.
|
|
134
|
+
|
|
135
|
+
## Comparison with vete (Ruby)
|
|
136
|
+
|
|
137
|
+
| Feature | vete (Ruby) | swarm (Rip/Bun) |
|
|
138
|
+
|---------|------------|-----------------|
|
|
139
|
+
| Parallelism | fork() per task | Worker threads (reused) |
|
|
140
|
+
| Setup | Runs once (fork shares memory) | Runs once (context cloned) |
|
|
141
|
+
| Per-task overhead | ~100μs (fork) | ~0 (message passing) |
|
|
142
|
+
| Crash recovery | Process dies, slot freed | Exception caught, worker continues |
|
|
143
|
+
| Timeout support | None | Planned (AbortSignal) |
|
|
144
|
+
| Default workers | 1 | CPU count |
|
|
145
|
+
| Dependencies | fileutils, optparse, thread | Zero (Bun builtins) |
|
package/bin/swarm
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { execFileSync } from 'child_process';
|
|
4
|
+
|
|
5
|
+
const script = process.argv[2];
|
|
6
|
+
if (!script) {
|
|
7
|
+
console.error('usage: swarm [options] <script.rip>');
|
|
8
|
+
process.exit(1);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
execFileSync('rip', [script, ...process.argv.slice(3)], { stdio: 'inherit' });
|
|
13
|
+
} catch (e) {
|
|
14
|
+
process.exit(e.status || 1);
|
|
15
|
+
}
|
package/lib/worker.mjs
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Worker bootstrap — loaded by each worker thread
|
|
2
|
+
// The rip-loader is preloaded via Worker({ preload: [...] }), so .rip imports work.
|
|
3
|
+
// Imports the user script (which calls swarm() — a no-op in worker mode),
|
|
4
|
+
// then processes tasks via IPC from the main thread.
|
|
5
|
+
|
|
6
|
+
import { parentPort, workerData } from 'worker_threads';
|
|
7
|
+
|
|
8
|
+
const { scriptPath, context } = workerData;
|
|
9
|
+
|
|
10
|
+
let perform;
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
// Import the user script — triggers swarm() which registers perform() in worker mode
|
|
14
|
+
await import(scriptPath);
|
|
15
|
+
|
|
16
|
+
// Get perform from the swarm module (registered by swarm() in worker mode)
|
|
17
|
+
const swarmMod = await import(new URL('../swarm.rip', import.meta.url).href);
|
|
18
|
+
perform = swarmMod._getPerform();
|
|
19
|
+
|
|
20
|
+
if (typeof perform !== 'function') {
|
|
21
|
+
throw new Error('No perform() function provided to swarm()');
|
|
22
|
+
}
|
|
23
|
+
} catch (err) {
|
|
24
|
+
parentPort.postMessage({ type: 'error', error: err.message });
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Signal ready
|
|
29
|
+
parentPort.postMessage({ type: 'ready' });
|
|
30
|
+
|
|
31
|
+
// Process tasks as they arrive
|
|
32
|
+
parentPort.on('message', async (msg) => {
|
|
33
|
+
if (msg.type === 'task') {
|
|
34
|
+
try {
|
|
35
|
+
await perform(msg.taskPath, context);
|
|
36
|
+
parentPort.postMessage({ type: 'done', taskPath: msg.taskPath });
|
|
37
|
+
} catch (err) {
|
|
38
|
+
parentPort.postMessage({ type: 'failed', taskPath: msg.taskPath, error: err.message });
|
|
39
|
+
}
|
|
40
|
+
} else if (msg.type === 'shutdown') {
|
|
41
|
+
process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rip-lang/swarm",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Parallel job runner with worker threads — setup once, swarm many",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "swarm.rip",
|
|
7
|
+
"bin": {
|
|
8
|
+
"swarm": "./bin/swarm"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./swarm.rip"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "rip test/example.rip -w 5"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"parallel",
|
|
18
|
+
"workers",
|
|
19
|
+
"jobs",
|
|
20
|
+
"tasks",
|
|
21
|
+
"concurrency",
|
|
22
|
+
"progress",
|
|
23
|
+
"cli",
|
|
24
|
+
"rip"
|
|
25
|
+
],
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/shreeve/rip-lang.git",
|
|
29
|
+
"directory": "packages/swarm"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/shreeve/rip-lang/tree/main/packages/swarm#readme",
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/shreeve/rip-lang/issues"
|
|
34
|
+
},
|
|
35
|
+
"author": "Steve Shreeve <steve.shreeve@gmail.com>",
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"rip-lang": "^2.9.0"
|
|
39
|
+
},
|
|
40
|
+
"files": [
|
|
41
|
+
"swarm.rip",
|
|
42
|
+
"lib/",
|
|
43
|
+
"bin/",
|
|
44
|
+
"README.md"
|
|
45
|
+
]
|
|
46
|
+
}
|
package/swarm.rip
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# @rip-lang/swarm — Parallel job runner with worker threads
|
|
3
|
+
#
|
|
4
|
+
# Author: Steve Shreeve (steve.shreeve@gmail.com)
|
|
5
|
+
# Date: January 18, 2026
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# import { swarm, init, retry, todo } from '@rip-lang/swarm'
|
|
9
|
+
#
|
|
10
|
+
# setup = ->
|
|
11
|
+
# unless retry()
|
|
12
|
+
# init()
|
|
13
|
+
# for i in [1..100] then todo(i)
|
|
14
|
+
# { startedAt: Date.now() }
|
|
15
|
+
#
|
|
16
|
+
# perform = (task, ctx) ->
|
|
17
|
+
# await Bun.sleep(Math.random() * 1000)
|
|
18
|
+
#
|
|
19
|
+
# swarm { setup, perform }
|
|
20
|
+
# ==============================================================================
|
|
21
|
+
|
|
22
|
+
import { isMainThread } from 'worker_threads'
|
|
23
|
+
import { existsSync, mkdirSync, readdirSync, renameSync, writeFileSync, rmSync } from 'fs'
|
|
24
|
+
import { join, resolve, dirname } from 'path'
|
|
25
|
+
import { cpus } from 'os'
|
|
26
|
+
|
|
27
|
+
# ==============================================================================
|
|
28
|
+
# Module state
|
|
29
|
+
# ==============================================================================
|
|
30
|
+
|
|
31
|
+
_dir = resolve('.swarm')
|
|
32
|
+
_todo = join(_dir, 'todo')
|
|
33
|
+
_done = join(_dir, 'done')
|
|
34
|
+
_died = join(_dir, 'died')
|
|
35
|
+
|
|
36
|
+
# ==============================================================================
|
|
37
|
+
# Task queue (file-based)
|
|
38
|
+
# ==============================================================================
|
|
39
|
+
|
|
40
|
+
export init = ->
|
|
41
|
+
rmSync(_dir, { recursive: true, force: true })
|
|
42
|
+
mkdirSync(_todo, { recursive: true })
|
|
43
|
+
mkdirSync(_done, { recursive: true })
|
|
44
|
+
mkdirSync(_died, { recursive: true })
|
|
45
|
+
|
|
46
|
+
export retry = ->
|
|
47
|
+
return false unless existsSync(_died) and existsSync(_todo)
|
|
48
|
+
died = readdirSync(_died)
|
|
49
|
+
todo = readdirSync(_todo)
|
|
50
|
+
return todo.length > 0 if died.length is 0
|
|
51
|
+
for file in died
|
|
52
|
+
renameSync(join(_died, file), join(_todo, file))
|
|
53
|
+
true
|
|
54
|
+
|
|
55
|
+
export todo = (name, data) ->
|
|
56
|
+
path = join(_todo, String(name))
|
|
57
|
+
if data?
|
|
58
|
+
writeFileSync(path, if typeof data is 'string' then data else JSON.stringify(data))
|
|
59
|
+
else
|
|
60
|
+
writeFileSync(path, '')
|
|
61
|
+
|
|
62
|
+
move = (path, dest) ->
|
|
63
|
+
try renameSync(path, join(dest, path.split('/').pop()))
|
|
64
|
+
catch then null
|
|
65
|
+
|
|
66
|
+
# ==============================================================================
|
|
67
|
+
# ANSI terminal
|
|
68
|
+
# ==============================================================================
|
|
69
|
+
|
|
70
|
+
STDOUT = process.stdout
|
|
71
|
+
|
|
72
|
+
write = (s) -> STDOUT.write(s)
|
|
73
|
+
clear = (line) -> if line then "\x1b[K" else "\x1b[2J"
|
|
74
|
+
cursor = (show) -> write(if show then "\x1b[?25h" else "\x1b[?25l")
|
|
75
|
+
go = (r = 1, c = 1) -> "\x1b[#{r};#{c}H"
|
|
76
|
+
|
|
77
|
+
_hex = {}
|
|
78
|
+
|
|
79
|
+
hex = (str) ->
|
|
80
|
+
return _hex[str] if _hex[str]?
|
|
81
|
+
s = str.replace(/^#/, '')
|
|
82
|
+
m = s.match(/^([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i) or s.match(/^([0-9a-f])([0-9a-f])([0-9a-f])$/i)
|
|
83
|
+
return '' unless m
|
|
84
|
+
result = if m[1].length is 1
|
|
85
|
+
[parseInt(m[1]+m[1], 16), parseInt(m[2]+m[2], 16), parseInt(m[3]+m[3], 16)].join(';')
|
|
86
|
+
else
|
|
87
|
+
[parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)].join(';')
|
|
88
|
+
_hex[str] = result
|
|
89
|
+
|
|
90
|
+
fg = (rgb) -> if rgb then "\x1b[38;2;#{hex(rgb)}m" else "\x1b[39m"
|
|
91
|
+
bg = (rgb) -> if rgb then "\x1b[48;2;#{hex(rgb)}m" else "\x1b[49m"
|
|
92
|
+
|
|
93
|
+
# ==============================================================================
|
|
94
|
+
# Progress display
|
|
95
|
+
# ==============================================================================
|
|
96
|
+
|
|
97
|
+
_char = _wide = _len = null
|
|
98
|
+
|
|
99
|
+
drawFrame = (workers) ->
|
|
100
|
+
_len = String(workers).length
|
|
101
|
+
write clear()
|
|
102
|
+
write go(2 + workers, _len + 3) + "╰" + "─".repeat(_wide + 2) + "╯"
|
|
103
|
+
write go(1, _len + 3) + "╭" + "─".repeat(_wide + 2) + "╮"
|
|
104
|
+
for i in [0...workers]
|
|
105
|
+
write go(i + 2, 1)
|
|
106
|
+
label = String(i + 1).padStart(_len)
|
|
107
|
+
write " #{label} │ " + " ".repeat(_wide) + " │"
|
|
108
|
+
# summary bar row
|
|
109
|
+
write go(workers + 3, _len + 3) + "│ " + " ".repeat(_wide) + " │"
|
|
110
|
+
|
|
111
|
+
draw = (state) ->
|
|
112
|
+
{ live, done, died, jobs, workers, info } = state
|
|
113
|
+
|
|
114
|
+
ppct = (done + died) / jobs
|
|
115
|
+
most = Math.max(...Object.values(info), 1)
|
|
116
|
+
|
|
117
|
+
# worker bars
|
|
118
|
+
for slot, count of info
|
|
119
|
+
tpct = count / most
|
|
120
|
+
cols = Math.floor(ppct * tpct * _wide)
|
|
121
|
+
write go(parseInt(slot) + 1, _len + 5) + bg("5383ec") + _char.repeat(cols) + bg()
|
|
122
|
+
|
|
123
|
+
# summary bar
|
|
124
|
+
dpct = done / jobs
|
|
125
|
+
lpct = live / jobs
|
|
126
|
+
gcol = Math.floor(dpct * _wide)
|
|
127
|
+
ycol = Math.floor(lpct * _wide)
|
|
128
|
+
rcol = Math.max(0, _wide - gcol - ycol)
|
|
129
|
+
row = workers + 3
|
|
130
|
+
|
|
131
|
+
write go(row, _len + 5)
|
|
132
|
+
write fg("fff")
|
|
133
|
+
write bg("58a65c") + _char.repeat(gcol) # green (done)
|
|
134
|
+
write bg("f1bf42") + _char.repeat(ycol) # yellow (live)
|
|
135
|
+
write bg("d85140") + " ".repeat(rcol) # red (rest)
|
|
136
|
+
write go(row, _len + 5 + _wide + 3)
|
|
137
|
+
write bg("5383ec") + " #{(ppct * 100).toFixed(1)}% "
|
|
138
|
+
write bg() + " " + bg("58a65c") + " #{done}/#{jobs} done " if done > 0
|
|
139
|
+
write bg() + " " + bg("d85140") + " #{died} died " if died > 0
|
|
140
|
+
write fg() + bg()
|
|
141
|
+
|
|
142
|
+
# ==============================================================================
|
|
143
|
+
# Worker orchestration
|
|
144
|
+
# ==============================================================================
|
|
145
|
+
|
|
146
|
+
# Worker mode: register perform function for the worker bootstrap
|
|
147
|
+
_workerPerform = null
|
|
148
|
+
export _getPerform = -> _workerPerform
|
|
149
|
+
|
|
150
|
+
export swarm = (opts = {}) ->
|
|
151
|
+
unless isMainThread
|
|
152
|
+
_workerPerform = opts.perform
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
{ Worker } = await import('worker_threads')
|
|
156
|
+
|
|
157
|
+
# parse CLI options
|
|
158
|
+
args = process.argv.slice(2)
|
|
159
|
+
workers = parseInt(findArg(args, '-w', '--workers')) or opts.workers or cpus().length
|
|
160
|
+
barw = parseInt(findArg(args, '-b', '--bar')) or opts.bar or 20
|
|
161
|
+
char = findArg(args, '-c', '--char') or opts.char or '•'
|
|
162
|
+
doreset = args.includes('-r') or args.includes('--reset')
|
|
163
|
+
|
|
164
|
+
if workers < 1
|
|
165
|
+
console.error 'error: workers must be at least 1'
|
|
166
|
+
process.exit(1)
|
|
167
|
+
|
|
168
|
+
_wide = barw
|
|
169
|
+
_char = char[0]
|
|
170
|
+
|
|
171
|
+
if doreset
|
|
172
|
+
rmSync(_dir, { recursive: true, force: true })
|
|
173
|
+
console.log 'removed .swarm directory'
|
|
174
|
+
process.exit(0)
|
|
175
|
+
|
|
176
|
+
# run setup
|
|
177
|
+
unless typeof opts.perform is 'function'
|
|
178
|
+
console.error 'error: perform() function is required'
|
|
179
|
+
process.exit(1)
|
|
180
|
+
|
|
181
|
+
context = {}
|
|
182
|
+
if typeof opts.setup is 'function'
|
|
183
|
+
result = await opts.setup()
|
|
184
|
+
context = result if result? and typeof result is 'object'
|
|
185
|
+
|
|
186
|
+
# read task list
|
|
187
|
+
unless existsSync(_todo)
|
|
188
|
+
console.error 'error: no .swarm/todo directory found (did setup run?)'
|
|
189
|
+
process.exit(1)
|
|
190
|
+
|
|
191
|
+
tasks = readdirSync(_todo).sort().map (f) -> join(_todo, f)
|
|
192
|
+
|
|
193
|
+
if tasks.length is 0
|
|
194
|
+
console.log 'no tasks to process'
|
|
195
|
+
process.exit(0)
|
|
196
|
+
|
|
197
|
+
jobs = tasks.length
|
|
198
|
+
|
|
199
|
+
# resolve paths
|
|
200
|
+
workerPath = join(dirname(new URL(import.meta.url).pathname), 'lib', 'worker.mjs')
|
|
201
|
+
scriptPath = resolve(process.argv[1] or '')
|
|
202
|
+
|
|
203
|
+
# find rip-loader for workers
|
|
204
|
+
loaderPath = null
|
|
205
|
+
try
|
|
206
|
+
loaderPath = join(dirname(require.resolve('rip-lang')), '..', 'rip-loader.js')
|
|
207
|
+
catch
|
|
208
|
+
null
|
|
209
|
+
|
|
210
|
+
# state
|
|
211
|
+
live = 0
|
|
212
|
+
done = 0
|
|
213
|
+
died = 0
|
|
214
|
+
info = {}
|
|
215
|
+
taskIdx = 0
|
|
216
|
+
inflight = {} # slot → taskPath (track in-flight tasks for crash recovery)
|
|
217
|
+
|
|
218
|
+
# signal handlers
|
|
219
|
+
process.on 'SIGINT', ->
|
|
220
|
+
cursor(true)
|
|
221
|
+
write go(workers + 5, 1) + "\n"
|
|
222
|
+
process.exit(1)
|
|
223
|
+
process.on 'SIGWINCH', ->
|
|
224
|
+
drawFrame(workers)
|
|
225
|
+
draw({ live, done, died, jobs, workers, info })
|
|
226
|
+
|
|
227
|
+
# draw initial frame
|
|
228
|
+
startTime = Date.now()
|
|
229
|
+
cursor(false)
|
|
230
|
+
drawFrame(workers)
|
|
231
|
+
|
|
232
|
+
# create workers and dispatch tasks
|
|
233
|
+
allWorkers = []
|
|
234
|
+
|
|
235
|
+
await new Promise (resolveAll) ->
|
|
236
|
+
|
|
237
|
+
finished = false
|
|
238
|
+
checkDone = ->
|
|
239
|
+
if not finished and done + died >= jobs
|
|
240
|
+
finished = true
|
|
241
|
+
for wk in allWorkers
|
|
242
|
+
try wk.postMessage { type: 'shutdown' }
|
|
243
|
+
catch then null
|
|
244
|
+
resolveAll()
|
|
245
|
+
|
|
246
|
+
dispatchNext = (worker, slot) ->
|
|
247
|
+
if taskIdx < tasks.length
|
|
248
|
+
taskPath = tasks[taskIdx++]
|
|
249
|
+
inflight[slot] = taskPath
|
|
250
|
+
live++
|
|
251
|
+
write go(slot + 1, _len + 5 + _wide + 3) + " " + taskPath.split('/').pop() + clear(true)
|
|
252
|
+
draw({ live, done, died, jobs, workers, info })
|
|
253
|
+
worker.postMessage { type: 'task', taskPath }
|
|
254
|
+
else
|
|
255
|
+
inflight[slot] = null
|
|
256
|
+
checkDone()
|
|
257
|
+
|
|
258
|
+
spawnWorker = (slot) ->
|
|
259
|
+
info[slot] ?= 0
|
|
260
|
+
|
|
261
|
+
wopts = { workerData: { scriptPath, context } }
|
|
262
|
+
wopts.preload = [loaderPath] if loaderPath
|
|
263
|
+
w = new Worker(workerPath, wopts)
|
|
264
|
+
allWorkers.push(w)
|
|
265
|
+
|
|
266
|
+
w.on 'message', (msg) ->
|
|
267
|
+
switch msg.type
|
|
268
|
+
when 'ready'
|
|
269
|
+
dispatchNext(w, slot)
|
|
270
|
+
when 'done'
|
|
271
|
+
move(msg.taskPath, _done)
|
|
272
|
+
inflight[slot] = null
|
|
273
|
+
live--
|
|
274
|
+
done++
|
|
275
|
+
info[slot]++
|
|
276
|
+
draw({ live, done, died, jobs, workers, info })
|
|
277
|
+
dispatchNext(w, slot)
|
|
278
|
+
when 'failed'
|
|
279
|
+
move(msg.taskPath, _died)
|
|
280
|
+
inflight[slot] = null
|
|
281
|
+
live--
|
|
282
|
+
died++
|
|
283
|
+
info[slot]++
|
|
284
|
+
draw({ live, done, died, jobs, workers, info })
|
|
285
|
+
dispatchNext(w, slot)
|
|
286
|
+
|
|
287
|
+
w.on 'error', (err) ->
|
|
288
|
+
console.error "\nworker #{slot} error: #{err.message}"
|
|
289
|
+
|
|
290
|
+
w.on 'exit', (code) ->
|
|
291
|
+
# if worker crashed mid-task, count the in-flight task as died
|
|
292
|
+
if inflight[slot]
|
|
293
|
+
move(inflight[slot], _died)
|
|
294
|
+
inflight[slot] = null
|
|
295
|
+
live--
|
|
296
|
+
died++
|
|
297
|
+
info[slot]++
|
|
298
|
+
draw({ live, done, died, jobs, workers, info })
|
|
299
|
+
# respawn if there's still work to do
|
|
300
|
+
if done + died < jobs
|
|
301
|
+
spawnWorker(slot)
|
|
302
|
+
else
|
|
303
|
+
checkDone()
|
|
304
|
+
|
|
305
|
+
# spawn worker pool
|
|
306
|
+
count = Math.min(workers, jobs)
|
|
307
|
+
for slot in [1..count]
|
|
308
|
+
spawnWorker(slot)
|
|
309
|
+
|
|
310
|
+
# summary
|
|
311
|
+
cursor(true)
|
|
312
|
+
secs = (Date.now() - startTime) / 1000
|
|
313
|
+
write go(workers + 5, 1)
|
|
314
|
+
write "#{secs.toFixed(2)} secs"
|
|
315
|
+
write " for #{jobs} jobs"
|
|
316
|
+
write " by #{workers} workers"
|
|
317
|
+
write " @ #{(jobs / secs).toFixed(2)} jobs/sec" if secs > 0
|
|
318
|
+
write "\n\n"
|
|
319
|
+
|
|
320
|
+
# ==============================================================================
|
|
321
|
+
# CLI helpers
|
|
322
|
+
# ==============================================================================
|
|
323
|
+
|
|
324
|
+
findArg = (args, short, long) ->
|
|
325
|
+
for arg, i in args
|
|
326
|
+
if arg is short or arg is long
|
|
327
|
+
return args[i + 1] if args[i + 1]?
|
|
328
|
+
if arg.startsWith("#{long}=")
|
|
329
|
+
return arg.split('=')[1]
|
|
330
|
+
if arg.startsWith("#{short}=")
|
|
331
|
+
return arg.split('=')[1]
|
|
332
|
+
null
|