@stage-labs/metro 0.1.0-beta.54 → 0.1.0-beta.56
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/dist/serve.js +56 -7
- package/package.json +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/boot.ts +4 -3
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/connect-hint.ts +4 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/tunnel.ts +145 -28
- package/runtime/runtime.json +1 -1
package/dist/serve.js
CHANGED
|
@@ -10,13 +10,33 @@ const SCRUBBED = new Set(['METRO_RUN_TOKEN', 'METRO_AGENT', 'DATABASE_URL']);
|
|
|
10
10
|
const PORT_FLAG = /^--port=(.*)$/;
|
|
11
11
|
const OWNER_FLAG = /^--owner=(.*)$/;
|
|
12
12
|
const ADDRESS = /^0x[0-9a-fA-F]{40}$/;
|
|
13
|
-
const USAGE = 'usage: metro serve [--port <n>] [--tunnel] [--owner <address>]';
|
|
13
|
+
const USAGE = 'usage: metro serve [--port <n>] [--tunnel [quick|tailscale]] [--owner <address>]';
|
|
14
|
+
const TUNNEL_FLAG = /^--tunnel=(.*)$/;
|
|
15
|
+
const TAILSCALE_APP = '/Applications/Tailscale.app/Contents/MacOS/Tailscale';
|
|
14
16
|
function portOf(raw) {
|
|
15
17
|
const n = Number(raw);
|
|
16
18
|
if (raw === undefined || raw === '' || !Number.isInteger(n) || n < 1 || n > 65535)
|
|
17
19
|
throw new Error(`'${raw ?? ''}' is not a port — ${USAGE}`);
|
|
18
20
|
return n;
|
|
19
21
|
}
|
|
22
|
+
function tunnelOf(raw) {
|
|
23
|
+
if (raw === undefined || raw === '' || raw === 'quick')
|
|
24
|
+
return 'quick';
|
|
25
|
+
if (raw === 'tailscale')
|
|
26
|
+
return 'tailscale';
|
|
27
|
+
throw new Error(`'${raw}' is not a tunnel kind (quick or tailscale) — ${USAGE}`);
|
|
28
|
+
}
|
|
29
|
+
function tunnelFlag(argv, i) {
|
|
30
|
+
const arg = argv[i] ?? '';
|
|
31
|
+
const inline = TUNNEL_FLAG.exec(arg);
|
|
32
|
+
if (inline)
|
|
33
|
+
return { kind: tunnelOf(inline[1]), consumed: 0 };
|
|
34
|
+
if (arg !== '--tunnel')
|
|
35
|
+
return null;
|
|
36
|
+
const next = argv[i + 1];
|
|
37
|
+
const takesValue = next !== undefined && !next.startsWith('-');
|
|
38
|
+
return { kind: tunnelOf(takesValue ? next : undefined), consumed: takesValue ? 1 : 0 };
|
|
39
|
+
}
|
|
20
40
|
function ownerOf(raw) {
|
|
21
41
|
if (raw === undefined || !ADDRESS.test(raw))
|
|
22
42
|
throw new Error(`'${raw ?? ''}' is not an Ethereum address — ${USAGE}`);
|
|
@@ -24,12 +44,14 @@ function ownerOf(raw) {
|
|
|
24
44
|
}
|
|
25
45
|
export function parseServeArgs(argv) {
|
|
26
46
|
let port = localPort();
|
|
27
|
-
let tunnel =
|
|
47
|
+
let tunnel = null;
|
|
28
48
|
let owner = null;
|
|
29
49
|
for (let i = 0; i < argv.length; i += 1) {
|
|
30
50
|
const arg = argv[i] ?? '';
|
|
31
|
-
|
|
32
|
-
|
|
51
|
+
const tunnelArg = tunnelFlag(argv, i);
|
|
52
|
+
if (tunnelArg) {
|
|
53
|
+
tunnel = tunnelArg.kind;
|
|
54
|
+
i += tunnelArg.consumed;
|
|
33
55
|
continue;
|
|
34
56
|
}
|
|
35
57
|
const inlineOwner = OWNER_FLAG.exec(arg);
|
|
@@ -64,6 +86,31 @@ export function requireOwner(owner, dir = agentsDir()) {
|
|
|
64
86
|
join(dir, '.owner') +
|
|
65
87
|
':\n metro serve --owner <address>');
|
|
66
88
|
}
|
|
89
|
+
function tailscaleStatus(bin) {
|
|
90
|
+
const run = spawnSync(bin, ['status', '--json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
91
|
+
if (run.error !== undefined || run.status !== 0)
|
|
92
|
+
return { ok: false, state: 'not running' };
|
|
93
|
+
try {
|
|
94
|
+
const parsed = JSON.parse(run.stdout);
|
|
95
|
+
return parsed.BackendState === 'Running' ? { ok: true } : { ok: false, state: String(parsed.BackendState) };
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return { ok: false, state: 'unreadable' };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
export function findTailscale(candidates = ['tailscale', TAILSCALE_APP]) {
|
|
102
|
+
const bin = candidates.find((c) => spawnSync(c, ['version'], { stdio: 'ignore' }).status === 0);
|
|
103
|
+
if (bin === undefined)
|
|
104
|
+
throw new Error('metro serve --tunnel tailscale needs Tailscale on this machine.\n' +
|
|
105
|
+
'macOS: brew install --cask tailscale (or the App Store app)\n' +
|
|
106
|
+
'Linux: curl -fsSL https://tailscale.com/install.sh | sh\n' +
|
|
107
|
+
'Then sign the machine in: tailscale up');
|
|
108
|
+
const status = tailscaleStatus(bin);
|
|
109
|
+
if (!status.ok)
|
|
110
|
+
throw new Error(`Tailscale is installed but this machine is not connected (${status.state}). Sign it in first: tailscale up\n` +
|
|
111
|
+
'Funnel also has to be enabled once on your tailnet: https://tailscale.com/kb/1223/funnel');
|
|
112
|
+
return bin;
|
|
113
|
+
}
|
|
67
114
|
function findCloudflared() {
|
|
68
115
|
const found = spawnSync('cloudflared', ['--version'], { stdio: 'ignore' });
|
|
69
116
|
if (found.error !== undefined || found.status !== 0)
|
|
@@ -94,7 +141,8 @@ export function servePlan(opts) {
|
|
|
94
141
|
? {}
|
|
95
142
|
: { METRO_RUNTIME_STORE: opts.runtime.dir, METRO_RUNTIME_MANIFEST: opts.runtime.manifest }),
|
|
96
143
|
METRO_STATE_DIR: process.env.METRO_STATE_DIR ?? serveStateDir(),
|
|
97
|
-
...(opts.tunnel ? {
|
|
144
|
+
...(opts.tunnel === null ? {} : { METRO_TUNNEL: opts.tunnel }),
|
|
145
|
+
...(opts.tailscaleBin === undefined ? {} : { METRO_TAILSCALE_BIN: opts.tailscaleBin }),
|
|
98
146
|
...(opts.owner === null ? {} : { METRO_OWNER: opts.owner }),
|
|
99
147
|
},
|
|
100
148
|
};
|
|
@@ -106,8 +154,9 @@ export function serve(argv) {
|
|
|
106
154
|
throw new Error(`a metro serve daemon is already running on this machine (pid ${String(running)}). ` +
|
|
107
155
|
'Stop it first: metro stop');
|
|
108
156
|
requireOwner(owner);
|
|
109
|
-
if (tunnel)
|
|
157
|
+
if (tunnel === 'quick')
|
|
110
158
|
findCloudflared();
|
|
159
|
+
const tailscaleBin = tunnel === 'tailscale' ? findTailscale() : undefined;
|
|
111
160
|
process.stderr.write(`Starting a metro daemon of your own on http://127.0.0.1:${String(port)}\n`);
|
|
112
|
-
return spawnPlan(() => servePlan({ runtime: prepareRuntime(), port, tunnel, owner }));
|
|
161
|
+
return spawnPlan(() => servePlan({ runtime: prepareRuntime(), port, tunnel, owner, ...(tailscaleBin === undefined ? {} : { tailscaleBin }) }));
|
|
113
162
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stage-labs/metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.56",
|
|
4
4
|
"description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -6,7 +6,7 @@ import { errMsg, log, logFatalSync } from './log.js';
|
|
|
6
6
|
import { acquireLock, isLocalMode, STATE_DIR, trainsDir } from './paths.js';
|
|
7
7
|
import { installCrashGuard, markDaemonReady } from './crash-guard.js';
|
|
8
8
|
import { METRO_VERSION } from './version.js';
|
|
9
|
-
import {
|
|
9
|
+
import { driverFor, Tunnel, tunnelKind, webhookPort } from './tunnel.js';
|
|
10
10
|
import {
|
|
11
11
|
localConnectHint,
|
|
12
12
|
publicConnectHint,
|
|
@@ -61,7 +61,8 @@ supervisor.onTrainEvent((env, train) => {
|
|
|
61
61
|
});
|
|
62
62
|
|
|
63
63
|
let webhookServer: Server | null = null;
|
|
64
|
-
const
|
|
64
|
+
const tunnelWanted = tunnelKind();
|
|
65
|
+
const tunnel = tunnelWanted === null ? null : new Tunnel(driverFor(tunnelWanted, webhookPort()), announcePublic);
|
|
65
66
|
|
|
66
67
|
function announcePublic(url: string): void {
|
|
67
68
|
process.stderr.write(`\n${publicConnectHint(url, localOwner())}`);
|
|
@@ -73,7 +74,7 @@ setTrainCallBackend((train, action, args) =>
|
|
|
73
74
|
|
|
74
75
|
function announceLocalEndpoint(): void {
|
|
75
76
|
process.stderr.write(
|
|
76
|
-
`\n${
|
|
77
|
+
`\n${tunnelWanted === null ? localConnectHint(webhookPort(), localOwner()) : tunnelPendingHint(tunnelWanted)}`,
|
|
77
78
|
);
|
|
78
79
|
const key = localAgentKey();
|
|
79
80
|
if (key === null) {
|
|
@@ -35,5 +35,7 @@ export function publicConnectHint(url: string, owner: string | null): string {
|
|
|
35
35
|
].join('\n');
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
export const tunnelPendingHint = (): string =>
|
|
39
|
-
|
|
38
|
+
export const tunnelPendingHint = (kind: 'quick' | 'tailscale'): string =>
|
|
39
|
+
kind === 'quick'
|
|
40
|
+
? 'Bringing up a public address through a Cloudflare quick tunnel…\n'
|
|
41
|
+
: 'Publishing this daemon through Tailscale Funnel… (if Tailscale asks to enable Funnel, its link is in the log)\n';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { spawn, type ChildProcess } from 'node:child_process';
|
|
1
|
+
import { execFile, spawn, type ChildProcess } from 'node:child_process';
|
|
2
2
|
import { timingSafeEqual } from 'node:crypto';
|
|
3
3
|
import { Resolver } from 'node:dns/promises';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
@@ -7,15 +7,93 @@ import { errMsg, log } from './log.js';
|
|
|
7
7
|
import { readJson } from './secure-fs.js';
|
|
8
8
|
|
|
9
9
|
const RESTART_DELAY_MS = 2_000;
|
|
10
|
+
const RESTART_DELAY_MAX_MS = 30_000;
|
|
11
|
+
const TAKEN_RE = /listener already exists/i;
|
|
12
|
+
const FUNNEL_ON_RE = /\(Funnel on\)/i;
|
|
10
13
|
|
|
11
|
-
export
|
|
12
|
-
|
|
14
|
+
export type TunnelKind = 'quick' | 'tailscale';
|
|
15
|
+
|
|
16
|
+
export function tunnelKind(): TunnelKind | null {
|
|
17
|
+
const raw = process.env.METRO_TUNNEL?.trim();
|
|
18
|
+
return raw === 'quick' || raw === 'tailscale' ? raw : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const quickTunnelWanted = (): boolean => tunnelKind() === 'quick';
|
|
13
22
|
|
|
14
23
|
const QUICK_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
|
|
24
|
+
const FUNNEL_URL_RE = /https:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)+\.ts\.net(?::\d{2,5})?(?=[\s/]|$)/i;
|
|
25
|
+
const SILENT_MS = 20_000;
|
|
15
26
|
|
|
16
27
|
export const quickTunnelUrlIn = (text: string): string | null =>
|
|
17
28
|
QUICK_URL_RE.exec(text)?.[0] ?? null;
|
|
18
29
|
|
|
30
|
+
export const funnelUrlIn = (text: string): string | null =>
|
|
31
|
+
FUNNEL_URL_RE.exec(text)?.[0]?.toLowerCase() ?? null;
|
|
32
|
+
|
|
33
|
+
export interface Adopted {
|
|
34
|
+
url: string | null;
|
|
35
|
+
hint: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TunnelDriver {
|
|
39
|
+
name: string;
|
|
40
|
+
command: string;
|
|
41
|
+
args: string[];
|
|
42
|
+
urlIn: (text: string) => string | null;
|
|
43
|
+
waitsForDns: boolean;
|
|
44
|
+
adopt?: () => Promise<Adopted>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const runText = (command: string, args: string[]): Promise<string> =>
|
|
48
|
+
new Promise((resolve) => {
|
|
49
|
+
execFile(command, args, { encoding: 'utf8', timeout: 10_000 }, (_err, stdout, stderr) => {
|
|
50
|
+
resolve(`${stdout}\n${stderr}`);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export function funnelAlreadyServing(status: string, port: number): Adopted {
|
|
55
|
+
const lines = status.split('\n');
|
|
56
|
+
const target = `http://127.0.0.1:${String(port)}`;
|
|
57
|
+
for (const [i, line] of lines.entries()) {
|
|
58
|
+
if (line.trimStart().startsWith('#')) continue;
|
|
59
|
+
const url = funnelUrlIn(line);
|
|
60
|
+
if (url === null) continue;
|
|
61
|
+
const body = lines.slice(i + 1, i + 6).join('\n');
|
|
62
|
+
if (!body.includes(target)) continue;
|
|
63
|
+
if (FUNNEL_ON_RE.test(line)) return { url, hint: `a Funnel already publishes this daemon at ${url}; using it` };
|
|
64
|
+
return {
|
|
65
|
+
url: null,
|
|
66
|
+
hint: `${url} is a tailnet-only serve config on this node, not a Funnel; run: tailscale serve reset`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return { url: null, hint: 'port 443 is held by another serve config on this node; run: tailscale serve reset' };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const quickDriver = (port: number): TunnelDriver => ({
|
|
73
|
+
name: 'cloudflared quick tunnel',
|
|
74
|
+
command: 'cloudflared',
|
|
75
|
+
args: ['--no-autoupdate', 'tunnel', '--url', `http://127.0.0.1:${String(port)}`],
|
|
76
|
+
urlIn: quickTunnelUrlIn,
|
|
77
|
+
waitsForDns: true,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
function tailscaleBin(): string {
|
|
81
|
+
const configured = process.env.METRO_TAILSCALE_BIN?.trim() ?? '';
|
|
82
|
+
return configured === '' ? 'tailscale' : configured;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const funnelDriver = (port: number, bin = tailscaleBin()): TunnelDriver => ({
|
|
86
|
+
name: 'tailscale funnel',
|
|
87
|
+
command: bin,
|
|
88
|
+
args: ['funnel', String(port)],
|
|
89
|
+
urlIn: funnelUrlIn,
|
|
90
|
+
waitsForDns: true,
|
|
91
|
+
adopt: async () => funnelAlreadyServing(await runText(bin, ['funnel', 'status']), port),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
export const driverFor = (kind: TunnelKind, port: number): TunnelDriver =>
|
|
95
|
+
kind === 'quick' ? quickDriver(port) : funnelDriver(port);
|
|
96
|
+
|
|
19
97
|
let liveUrl: string | null = null;
|
|
20
98
|
|
|
21
99
|
export const currentTunnelUrl = (): string | null => liveUrl;
|
|
@@ -104,72 +182,111 @@ export function tokenMatches(secret: string, given: string): boolean {
|
|
|
104
182
|
export class Tunnel {
|
|
105
183
|
private child: ChildProcess | null = null;
|
|
106
184
|
private closed = false;
|
|
185
|
+
private output: string[] = [];
|
|
186
|
+
private restartDelay = RESTART_DELAY_MS;
|
|
107
187
|
|
|
108
188
|
constructor(
|
|
109
|
-
private
|
|
189
|
+
private driver: TunnelDriver,
|
|
110
190
|
private onUrl: (url: string) => void = () => undefined,
|
|
111
191
|
private resolves: Resolves = resolvesAtCloudflare,
|
|
112
192
|
) {}
|
|
113
193
|
|
|
114
|
-
private origin(): string {
|
|
115
|
-
return `http://127.0.0.1:${String(this.port)}`;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
194
|
private noticeUrl(text: string): void {
|
|
119
|
-
const url =
|
|
195
|
+
const url = this.driver.urlIn(text);
|
|
120
196
|
if (url === null || url === liveUrl) return;
|
|
121
197
|
liveUrl = url;
|
|
122
|
-
|
|
198
|
+
this.restartDelay = RESTART_DELAY_MS;
|
|
199
|
+
if (!this.driver.waitsForDns) {
|
|
200
|
+
log.info({ url }, `${this.driver.name} up`);
|
|
201
|
+
this.onUrl(url);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
log.info({ url }, `${this.driver.name} up; waiting for its name to resolve`);
|
|
123
205
|
this.announceWhenResolvable(url).catch((err: unknown) => {
|
|
124
|
-
log.warn({ err: errMsg(err) },
|
|
206
|
+
log.warn({ err: errMsg(err) }, `${this.driver.name}: announce failed`);
|
|
125
207
|
});
|
|
126
208
|
}
|
|
127
209
|
|
|
128
210
|
private async announceWhenResolvable(url: string): Promise<void> {
|
|
129
211
|
const resolved = await untilResolvable(new URL(url).host, this.resolves);
|
|
130
212
|
if (liveUrl !== url) return;
|
|
131
|
-
if (!resolved) log.warn({ url },
|
|
213
|
+
if (!resolved) log.warn({ url }, `${this.driver.name} name still not resolving; announcing anyway`);
|
|
132
214
|
this.onUrl(url);
|
|
133
215
|
}
|
|
134
216
|
|
|
217
|
+
private read(d: Buffer | string): void {
|
|
218
|
+
const text = (typeof d === 'string' ? d : d.toString('utf8')).trim();
|
|
219
|
+
if (text === '') return;
|
|
220
|
+
this.output.push(text);
|
|
221
|
+
log.debug({ output: text }, this.driver.name);
|
|
222
|
+
this.noticeUrl(text);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private silentCheck(): void {
|
|
226
|
+
if (this.closed || liveUrl !== null || this.child === null) return;
|
|
227
|
+
log.warn(
|
|
228
|
+
{ output: this.output.join('\n') },
|
|
229
|
+
`${this.driver.name}: no public address after ${String(SILENT_MS / 1000)}s; its own output follows`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
135
233
|
start(): void {
|
|
136
234
|
if (this.closed) return;
|
|
137
|
-
log.info({
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
['--no-autoupdate', 'tunnel', '--url', this.origin()],
|
|
141
|
-
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
|
142
|
-
);
|
|
235
|
+
log.info({ command: this.driver.command, args: this.driver.args }, `${this.driver.name} starting`);
|
|
236
|
+
this.output = [];
|
|
237
|
+
const child = spawn(this.driver.command, this.driver.args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
143
238
|
this.child = child;
|
|
239
|
+
child.stdout?.on('data', (d: Buffer | string) => {
|
|
240
|
+
this.read(d);
|
|
241
|
+
});
|
|
144
242
|
child.stderr?.on('data', (d: Buffer | string) => {
|
|
145
|
-
|
|
146
|
-
log.debug({ cloudflared: text }, 'cloudflared');
|
|
147
|
-
this.noticeUrl(text);
|
|
243
|
+
this.read(d);
|
|
148
244
|
});
|
|
245
|
+
setTimeout(() => {
|
|
246
|
+
this.silentCheck();
|
|
247
|
+
}, SILENT_MS).unref();
|
|
149
248
|
child.on('exit', (code) => {
|
|
150
249
|
this.child = null;
|
|
151
250
|
liveUrl = null;
|
|
152
251
|
if (this.closed) return;
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}, RESTART_DELAY_MS);
|
|
252
|
+
this.afterExit(code).catch((err: unknown) => {
|
|
253
|
+
log.warn({ err: errMsg(err) }, `${this.driver.name}: exit handling failed`);
|
|
254
|
+
});
|
|
157
255
|
});
|
|
158
256
|
child.on('error', (err) => {
|
|
159
257
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
160
258
|
this.closed = true;
|
|
161
259
|
log.error(
|
|
162
|
-
|
|
260
|
+
{ command: this.driver.command },
|
|
261
|
+
`${this.driver.name}: the binary is not installed, so there is no public address; install it and restart`,
|
|
163
262
|
);
|
|
164
263
|
return;
|
|
165
264
|
}
|
|
166
|
-
log.warn({ err: errMsg(err) },
|
|
265
|
+
log.warn({ err: errMsg(err) }, `${this.driver.name} spawn error`);
|
|
167
266
|
});
|
|
168
267
|
}
|
|
169
268
|
|
|
269
|
+
private async afterExit(code: number | null): Promise<void> {
|
|
270
|
+
const output = this.output.join('\n');
|
|
271
|
+
if (this.driver.adopt !== undefined && TAKEN_RE.test(output)) {
|
|
272
|
+
const found = await this.driver.adopt();
|
|
273
|
+
if (this.closed) return;
|
|
274
|
+
if (found.url !== null) {
|
|
275
|
+
log.info({ url: found.url }, `${this.driver.name}: ${found.hint}`);
|
|
276
|
+
this.noticeUrl(found.url);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
log.error({ output }, `${this.driver.name}: ${found.hint}`);
|
|
280
|
+
} else log.warn({ code, output: this.output.slice(-5).join('\n') }, `${this.driver.name} exited; restarting`);
|
|
281
|
+
setTimeout(() => {
|
|
282
|
+
this.start();
|
|
283
|
+
}, this.restartDelay);
|
|
284
|
+
this.restartDelay = Math.min(this.restartDelay * 2, RESTART_DELAY_MAX_MS);
|
|
285
|
+
}
|
|
286
|
+
|
|
170
287
|
stop(): void {
|
|
171
288
|
this.closed = true;
|
|
172
|
-
this.child?.kill();
|
|
289
|
+
this.child?.kill('SIGINT');
|
|
173
290
|
this.child = null;
|
|
174
291
|
}
|
|
175
292
|
}
|
package/runtime/runtime.json
CHANGED