@shogo-ai/worker 2.3.12 → 2.4.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/dist/cli.mjs +25 -25
- package/package.json +2 -1
- package/src/lib/__tests__/helpers/fake-cloud.ts +139 -0
- package/src/lib/__tests__/resource-limits-exec-budget.test.ts +116 -0
- package/src/lib/__tests__/runtime-manager-exec-budget-env.test.ts +87 -0
- package/src/lib/__tests__/tunnel-worker-block.test.ts +243 -0
- package/src/lib/resource-limits.ts +153 -10
- package/src/lib/runtime-manager.ts +78 -3
- package/src/lib/tunnel-host.ts +253 -0
- package/src/lib/tunnel-worker.ts +223 -0
- package/src/lib/tunnel.ts +37 -2
- package/src/lib/web-worker.d.ts +23 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shogo-ai/worker",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "Shogo Cloud Agent Worker — run Shogo agents on your own machine (laptop, devbox, CI).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Shogo Technologies, Inc.",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"exports": {
|
|
19
19
|
".": "./src/cli.ts",
|
|
20
20
|
"./tunnel": "./src/lib/tunnel.ts",
|
|
21
|
+
"./tunnel-host": "./src/lib/tunnel-host.ts",
|
|
21
22
|
"./runtime-manager": "./src/lib/runtime-manager.ts",
|
|
22
23
|
"./runtime-activity": "./src/lib/runtime-activity.ts",
|
|
23
24
|
"./resource-limits": "./src/lib/resource-limits.ts",
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* A fake Shogo cloud, in its own process, for the tunnel-block test.
|
|
5
|
+
*
|
|
6
|
+
* It has to be a separate process and not an in-test server: the test blocks the
|
|
7
|
+
* main thread on purpose, and an observer on that thread cannot see the pings it
|
|
8
|
+
* is supposed to be policing. Here the pings keep flowing while the API's thread
|
|
9
|
+
* is busy, which is exactly how the real cloud behaves.
|
|
10
|
+
*
|
|
11
|
+
* Speaks the real protocol: `POST /api/instances/heartbeat` (asking for a socket
|
|
12
|
+
* and advertising its URL), `GET /api/instances/ws` upgrade, `{type:'ping'}`
|
|
13
|
+
* every 40ms answered by `{type:'pong'}`, and a close (1006) for a socket that
|
|
14
|
+
* misses the deadline — the same idle check the cloud runs.
|
|
15
|
+
*
|
|
16
|
+
* Reports over stdout, one JSON object per line: `ready`, `pong`, `kill`,
|
|
17
|
+
* `request`, `close`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const PING_INTERVAL_MS = Number(process.env.FAKE_CLOUD_PING_MS ?? 40)
|
|
21
|
+
const PONG_DEADLINE_MS = Number(process.env.FAKE_CLOUD_DEADLINE_MS ?? 120)
|
|
22
|
+
|
|
23
|
+
const say = (line: Record<string, unknown>) => {
|
|
24
|
+
process.stdout.write(`${JSON.stringify(line)}\n`)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let socket: any = null
|
|
28
|
+
let awaitingPongSince: number | null = null
|
|
29
|
+
let kills = 0
|
|
30
|
+
|
|
31
|
+
const server = Bun.serve({
|
|
32
|
+
port: 0,
|
|
33
|
+
hostname: '127.0.0.1',
|
|
34
|
+
fetch(req, srv) {
|
|
35
|
+
const url = new URL(req.url)
|
|
36
|
+
if (url.pathname === '/api/instances/ws') {
|
|
37
|
+
if (srv.upgrade(req)) return undefined as any
|
|
38
|
+
return new Response('upgrade failed', { status: 400 })
|
|
39
|
+
}
|
|
40
|
+
if (url.pathname === '/api/instances/heartbeat') {
|
|
41
|
+
return Response.json({
|
|
42
|
+
instanceId: 'inst-worker-1',
|
|
43
|
+
wsRequested: true,
|
|
44
|
+
nextPollIn: 30,
|
|
45
|
+
wsUrl: `ws://127.0.0.1:${srv.port}`,
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
return new Response('{}', { status: 404 })
|
|
49
|
+
},
|
|
50
|
+
websocket: {
|
|
51
|
+
open(ws) {
|
|
52
|
+
socket = ws
|
|
53
|
+
awaitingPongSince = null
|
|
54
|
+
},
|
|
55
|
+
message(_ws, raw) {
|
|
56
|
+
let message: any
|
|
57
|
+
try {
|
|
58
|
+
message = JSON.parse(String(raw))
|
|
59
|
+
} catch {
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
if (message.type === 'pong') {
|
|
63
|
+
awaitingPongSince = null
|
|
64
|
+
say({ event: 'pong' })
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
if (message.type === 'response') {
|
|
68
|
+
say({ event: 'request', status: message.status, body: String(message.body ?? '').slice(0, 200) })
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
if (message.type === 'stream-chunk' || message.type === 'stream-end') {
|
|
72
|
+
say({ event: 'request', status: 200, body: 'stream' })
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
close(_ws, code) {
|
|
76
|
+
socket = null
|
|
77
|
+
say({ event: 'close', code })
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
setInterval(() => {
|
|
83
|
+
if (!socket) return
|
|
84
|
+
if (awaitingPongSince === null) {
|
|
85
|
+
awaitingPongSince = Date.now()
|
|
86
|
+
} else if (Date.now() - awaitingPongSince > PONG_DEADLINE_MS) {
|
|
87
|
+
kills += 1
|
|
88
|
+
say({ event: 'kill', kills })
|
|
89
|
+
try {
|
|
90
|
+
socket.close(1006, 'no pong')
|
|
91
|
+
} catch {
|
|
92
|
+
// already gone
|
|
93
|
+
}
|
|
94
|
+
socket = null
|
|
95
|
+
awaitingPongSince = null
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
socket.send(JSON.stringify({ type: 'ping' }))
|
|
100
|
+
} catch {
|
|
101
|
+
// socket went away
|
|
102
|
+
}
|
|
103
|
+
}, PING_INTERVAL_MS)
|
|
104
|
+
|
|
105
|
+
// The test drives requests over stdin (one JSON object per line), because a
|
|
106
|
+
// spawned child has no IPC channel to listen on.
|
|
107
|
+
void (async () => {
|
|
108
|
+
let buffer = ''
|
|
109
|
+
const decoder = new TextDecoder()
|
|
110
|
+
for await (const chunk of Bun.stdin.stream()) {
|
|
111
|
+
buffer += decoder.decode(chunk as Uint8Array, { stream: true })
|
|
112
|
+
let newline = buffer.indexOf('\n')
|
|
113
|
+
while (newline !== -1) {
|
|
114
|
+
const line = buffer.slice(0, newline).trim()
|
|
115
|
+
buffer = buffer.slice(newline + 1)
|
|
116
|
+
newline = buffer.indexOf('\n')
|
|
117
|
+
if (!line) continue
|
|
118
|
+
let message: any
|
|
119
|
+
try {
|
|
120
|
+
message = JSON.parse(line)
|
|
121
|
+
} catch {
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
if (message?.type === 'request' && socket) {
|
|
125
|
+
socket.send(
|
|
126
|
+
JSON.stringify({
|
|
127
|
+
type: 'request',
|
|
128
|
+
requestId: message.requestId,
|
|
129
|
+
method: 'GET',
|
|
130
|
+
path: message.path,
|
|
131
|
+
headers: {},
|
|
132
|
+
}),
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
})()
|
|
138
|
+
|
|
139
|
+
say({ event: 'ready', port: server.port })
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* SHO-203: the agent-command budget is separate from the runtime's own leak
|
|
5
|
+
* ceiling, and the kernel cap has to sit above it so the guard (which writes a
|
|
6
|
+
* note) always trips before the kernel (which writes nothing).
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, describe, expect, it } from 'bun:test';
|
|
9
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import {
|
|
13
|
+
applyLiveMemoryCap,
|
|
14
|
+
resolveExecMemoryMB,
|
|
15
|
+
resolveResourceLimits,
|
|
16
|
+
type RuntimeResourceLimits,
|
|
17
|
+
} from '../resource-limits';
|
|
18
|
+
|
|
19
|
+
const dirs: string[] = [];
|
|
20
|
+
function limitsFile(contents: unknown): string {
|
|
21
|
+
const dir = mkdtempSync(join(tmpdir(), 'host-limits-'));
|
|
22
|
+
dirs.push(dir);
|
|
23
|
+
const file = join(dir, 'host-limits.json');
|
|
24
|
+
writeFileSync(file, typeof contents === 'string' ? contents : JSON.stringify(contents));
|
|
25
|
+
return file;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const baseEnv = { RUNTIME_MEMORY_MB: '8192' };
|
|
33
|
+
|
|
34
|
+
describe('resolveResourceLimits', () => {
|
|
35
|
+
it('keeps the runtime ceiling unchanged when no command budget is known', () => {
|
|
36
|
+
const limits = resolveResourceLimits({ ...baseEnv });
|
|
37
|
+
expect(limits?.memoryMB).toBe(8192);
|
|
38
|
+
expect(limits?.osMemoryMB).toBe(8192);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('adds an explicit SHOGO_EXEC_MEMORY_MB to the OS cap only', () => {
|
|
42
|
+
const limits = resolveResourceLimits({ ...baseEnv, SHOGO_EXEC_MEMORY_MB: '131072' });
|
|
43
|
+
expect(limits?.memoryMB).toBe(8192);
|
|
44
|
+
expect(limits?.osMemoryMB).toBe(8192 + 131072);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('reads the command budget from the watched host-limits file', () => {
|
|
48
|
+
const file = limitsFile({ execMemoryMB: 122880, totalMemMB: 131072, source: 'auto' });
|
|
49
|
+
const limits = resolveResourceLimits({ ...baseEnv, SHOGO_HOST_LIMITS_FILE: file });
|
|
50
|
+
expect(limits?.osMemoryMB).toBe(8192 + 122880);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('prefers an explicit env budget over the file', () => {
|
|
54
|
+
const file = limitsFile({ execMemoryMB: 4096 });
|
|
55
|
+
const limits = resolveResourceLimits({
|
|
56
|
+
...baseEnv,
|
|
57
|
+
SHOGO_HOST_LIMITS_FILE: file,
|
|
58
|
+
SHOGO_EXEC_MEMORY_MB: '2048',
|
|
59
|
+
});
|
|
60
|
+
expect(limits?.osMemoryMB).toBe(8192 + 2048);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('ignores a missing or malformed limits file', () => {
|
|
64
|
+
expect(resolveExecMemoryMB({ SHOGO_HOST_LIMITS_FILE: join(tmpdir(), 'nope.json') })).toBe(0);
|
|
65
|
+
expect(resolveExecMemoryMB({ SHOGO_HOST_LIMITS_FILE: limitsFile('{oops') })).toBe(0);
|
|
66
|
+
expect(resolveExecMemoryMB({ SHOGO_HOST_LIMITS_FILE: limitsFile({ execMemoryMB: -5 }) })).toBe(0);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('applyLiveMemoryCap', () => {
|
|
71
|
+
const limits: RuntimeResourceLimits = { memoryMB: 8192, osMemoryMB: 8192 + 122880, cpuPercent: 0, tasksMax: 4096 };
|
|
72
|
+
|
|
73
|
+
it('raises a live systemd scope with set-property, using the combined cap', () => {
|
|
74
|
+
const calls: Array<{ command: string; args: string[] }> = [];
|
|
75
|
+
const ok = applyLiveMemoryCap(
|
|
76
|
+
{ scopeName: 'shogo-rt-abc12345-m1', limits },
|
|
77
|
+
{ platform: 'linux', run: (command, args) => calls.push({ command, args }) },
|
|
78
|
+
);
|
|
79
|
+
expect(ok).toBe(true);
|
|
80
|
+
expect(calls).toHaveLength(1);
|
|
81
|
+
expect(calls[0]!.command).toBe('systemctl');
|
|
82
|
+
expect(calls[0]!.args).toEqual([
|
|
83
|
+
'--user',
|
|
84
|
+
'set-property',
|
|
85
|
+
'--runtime',
|
|
86
|
+
'shogo-rt-abc12345-m1',
|
|
87
|
+
'MemoryMax=131072M',
|
|
88
|
+
'MemoryHigh=117964M',
|
|
89
|
+
]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('rewrites a direct cgroup memory.max, in bytes', () => {
|
|
93
|
+
const writes: Array<{ path: string; data: string }> = [];
|
|
94
|
+
const ok = applyLiveMemoryCap(
|
|
95
|
+
{ pid: 4242, limits },
|
|
96
|
+
{
|
|
97
|
+
platform: 'linux',
|
|
98
|
+
exists: (path) => path.endsWith('memory.max') || path.endsWith('memory.high'),
|
|
99
|
+
writeFile: (path, data) => writes.push({ path, data }),
|
|
100
|
+
},
|
|
101
|
+
);
|
|
102
|
+
expect(ok).toBe(true);
|
|
103
|
+
expect(writes).toEqual([
|
|
104
|
+
{ path: '/sys/fs/cgroup/shogo/rt-4242/memory.max', data: String(131072 * 1024 * 1024) },
|
|
105
|
+
{ path: '/sys/fs/cgroup/shogo/rt-4242/memory.high', data: String(117964 * 1024 * 1024) },
|
|
106
|
+
]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('does nothing where the limit cannot be changed in place', () => {
|
|
110
|
+
expect(applyLiveMemoryCap({ scopeName: 'x', limits }, { platform: 'win32' })).toBe(false);
|
|
111
|
+
expect(applyLiveMemoryCap({ scopeName: 'x', limits }, { platform: 'darwin' })).toBe(false);
|
|
112
|
+
expect(
|
|
113
|
+
applyLiveMemoryCap({ pid: 7, limits }, { platform: 'linux', exists: () => false }),
|
|
114
|
+
).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* SHO-203: the agent-command budget must not be borrowed from the runtime's own
|
|
5
|
+
* leak ceiling. When the desktop publishes `host-limits.json`, the runtime gets
|
|
6
|
+
* the file's path and *no* `SHOGO_EXEC_MEMORY_MB` — otherwise the env value,
|
|
7
|
+
* baked in at spawn, would win and a settings change would need a restart.
|
|
8
|
+
*
|
|
9
|
+
* `buildEnv` is private; it is driven through a typed cast, the same way
|
|
10
|
+
* `runtime-manager-tree-sitter-env.test.ts` does.
|
|
11
|
+
*/
|
|
12
|
+
import { afterAll, describe, expect, it } from 'bun:test';
|
|
13
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { WorkerRuntimeManager } from '../runtime-manager';
|
|
17
|
+
|
|
18
|
+
const dir = mkdtempSync(join(tmpdir(), 'rm-exec-budget-'));
|
|
19
|
+
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
|
20
|
+
|
|
21
|
+
const BIN = '/opt/shogo/runtime/shogo-agent-runtime-linux-x64';
|
|
22
|
+
|
|
23
|
+
type EnvBuilder = { buildEnv(slot: unknown, runtimeBinPath: string): NodeJS.ProcessEnv };
|
|
24
|
+
|
|
25
|
+
/** The fields `buildEnv` reads, plus an idempotent port for the PID file path. */
|
|
26
|
+
function fakeSlot(): unknown {
|
|
27
|
+
return {
|
|
28
|
+
projectId: 'proj-exec-budget-test',
|
|
29
|
+
agentPort: 41000,
|
|
30
|
+
apiServerPort: 41100,
|
|
31
|
+
spawnConfig: { cloudUrl: 'https://cloud.test', apiKey: 'api-key' },
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function limitsFile(): string {
|
|
36
|
+
const file = join(dir, 'host-limits.json');
|
|
37
|
+
writeFileSync(file, JSON.stringify({ execMemoryMB: 122880, totalMemMB: 131072, source: 'auto' }));
|
|
38
|
+
return file;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('WorkerRuntimeManager.buildEnv — agent-command budget', () => {
|
|
42
|
+
it('hands the desktop limits file through and leaves the env budget unset', async () => {
|
|
43
|
+
const file = limitsFile();
|
|
44
|
+
const mgr = new WorkerRuntimeManager({
|
|
45
|
+
env: { RUNTIME_MEMORY_MB: '8192', SHOGO_HOST_LIMITS_FILE: file } as NodeJS.ProcessEnv,
|
|
46
|
+
});
|
|
47
|
+
const env = (mgr as unknown as EnvBuilder).buildEnv(fakeSlot(), BIN);
|
|
48
|
+
|
|
49
|
+
expect(env.SHOGO_HOST_LIMITS_FILE).toBe(file);
|
|
50
|
+
// The bug: this used to be `String(limits.memoryMB)`.
|
|
51
|
+
expect(env.SHOGO_EXEC_MEMORY_MB).toBeUndefined();
|
|
52
|
+
expect(env.SHOGO_EXEC_PIDS_FILE).toBeTruthy();
|
|
53
|
+
await mgr.stopAll();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('falls back to the runtime ceiling for a headless worker with no limits file', () => {
|
|
57
|
+
const mgr = new WorkerRuntimeManager({
|
|
58
|
+
env: { RUNTIME_MEMORY_MB: '8192' } as NodeJS.ProcessEnv,
|
|
59
|
+
});
|
|
60
|
+
const env = (mgr as unknown as EnvBuilder).buildEnv(fakeSlot(), BIN);
|
|
61
|
+
expect(env.SHOGO_EXEC_MEMORY_MB).toBe('8192');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('keeps an explicit SHOGO_EXEC_MEMORY_MB in front of the file', async () => {
|
|
65
|
+
const file = limitsFile();
|
|
66
|
+
const mgr = new WorkerRuntimeManager({
|
|
67
|
+
env: {
|
|
68
|
+
RUNTIME_MEMORY_MB: '8192',
|
|
69
|
+
SHOGO_EXEC_MEMORY_MB: '4096',
|
|
70
|
+
SHOGO_HOST_LIMITS_FILE: file,
|
|
71
|
+
} as NodeJS.ProcessEnv,
|
|
72
|
+
});
|
|
73
|
+
const env = (mgr as unknown as EnvBuilder).buildEnv(fakeSlot(), BIN);
|
|
74
|
+
expect(env.SHOGO_EXEC_MEMORY_MB).toBe('4096');
|
|
75
|
+
await mgr.stopAll();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('leaves the runtime ceiling itself alone', () => {
|
|
79
|
+
const mgr = new WorkerRuntimeManager({
|
|
80
|
+
env: { RUNTIME_MEMORY_MB: '8192' } as NodeJS.ProcessEnv,
|
|
81
|
+
});
|
|
82
|
+
const env = (mgr as unknown as EnvBuilder).buildEnv(fakeSlot(), BIN);
|
|
83
|
+
// The heap hint still comes from the runtime ceiling, not the command budget.
|
|
84
|
+
expect(env.NODE_OPTIONS).toContain('--max-old-space-size=6144');
|
|
85
|
+
expect(env.RUNTIME_MEMORY_MB).toBe('8192');
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* The tunnel's socket survives a busy main thread (SHO-185 §5).
|
|
5
|
+
*
|
|
6
|
+
* This is the test the whole Worker move exists for. The fake cloud lives in its
|
|
7
|
+
* own process (`helpers/fake-cloud.ts`) because the point of the test is to block
|
|
8
|
+
* *this* thread: an observer here would be blocked too and could not see the
|
|
9
|
+
* pings it is supposed to be policing.
|
|
10
|
+
*
|
|
11
|
+
* Two cases, same cloud, same block:
|
|
12
|
+
* • on its own thread — pongs keep flowing, the socket stays open, and a
|
|
13
|
+
* tunneled request still round-trips afterwards;
|
|
14
|
+
* • on the API's thread — the deadline passes unanswered and the cloud closes
|
|
15
|
+
* the socket (1006). That is the bug, and without it the first case would
|
|
16
|
+
* only prove that a socket somewhere stayed open.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
|
|
20
|
+
import { createServer, type Server } from 'node:http'
|
|
21
|
+
import type { AddressInfo } from 'node:net'
|
|
22
|
+
import { fileURLToPath } from 'node:url'
|
|
23
|
+
import { startTunnelInWorker } from '../tunnel-host'
|
|
24
|
+
import { WorkerTunnel, type RuntimeResolver } from '../tunnel'
|
|
25
|
+
|
|
26
|
+
const BLOCK_MS = 400
|
|
27
|
+
|
|
28
|
+
interface FakeCloud {
|
|
29
|
+
cloudUrl: string
|
|
30
|
+
pongs: number
|
|
31
|
+
kills: number
|
|
32
|
+
closeCodes: number[]
|
|
33
|
+
requests: { status: number; body: string }[]
|
|
34
|
+
sendRequest: (requestId: string, path: string) => void
|
|
35
|
+
stop: () => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Spawn the fake cloud and read its stdout, one JSON event per line.
|
|
40
|
+
*
|
|
41
|
+
* The child is `bun --no-env-file` so the workspace's `.env` cannot point it at a
|
|
42
|
+
* real cloud, and it prints `ready` with the port it bound before anything else.
|
|
43
|
+
*/
|
|
44
|
+
async function startFakeCloud(): Promise<FakeCloud> {
|
|
45
|
+
// `pathname` stays percent-encoded, and this checkout's path has a space in it.
|
|
46
|
+
const entry = fileURLToPath(new URL('./helpers/fake-cloud.ts', import.meta.url))
|
|
47
|
+
const child = Bun.spawn(['bun', '--no-env-file', entry], {
|
|
48
|
+
stdout: 'pipe',
|
|
49
|
+
stdin: 'pipe',
|
|
50
|
+
stderr: 'inherit',
|
|
51
|
+
env: { ...process.env, FAKE_CLOUD_PING_MS: '40', FAKE_CLOUD_DEADLINE_MS: '120' },
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const state = {
|
|
55
|
+
pongs: 0,
|
|
56
|
+
kills: 0,
|
|
57
|
+
closeCodes: [] as number[],
|
|
58
|
+
requests: [] as { status: number; body: string }[],
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let port = 0
|
|
62
|
+
let buffer = ''
|
|
63
|
+
let settleReady: (port: number) => void = () => {}
|
|
64
|
+
const ready = new Promise<number>((resolve) => {
|
|
65
|
+
settleReady = resolve
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const drain = (async () => {
|
|
69
|
+
const stream = child.stdout as ReadableStream<Uint8Array>
|
|
70
|
+
const reader = stream.getReader()
|
|
71
|
+
const decoder = new TextDecoder()
|
|
72
|
+
while (true) {
|
|
73
|
+
const { done, value } = await reader.read()
|
|
74
|
+
if (done) break
|
|
75
|
+
buffer += decoder.decode(value, { stream: true })
|
|
76
|
+
let newline = buffer.indexOf('\n')
|
|
77
|
+
while (newline !== -1) {
|
|
78
|
+
const line = buffer.slice(0, newline).trim()
|
|
79
|
+
buffer = buffer.slice(newline + 1)
|
|
80
|
+
newline = buffer.indexOf('\n')
|
|
81
|
+
if (!line) continue
|
|
82
|
+
let event: any
|
|
83
|
+
try {
|
|
84
|
+
event = JSON.parse(line)
|
|
85
|
+
} catch {
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
if (event.event === 'ready') {
|
|
89
|
+
port = Number(event.port)
|
|
90
|
+
settleReady(port)
|
|
91
|
+
} else if (event.event === 'pong') state.pongs += 1
|
|
92
|
+
else if (event.event === 'kill') state.kills += 1
|
|
93
|
+
else if (event.event === 'close') state.closeCodes.push(Number(event.code))
|
|
94
|
+
else if (event.event === 'request') {
|
|
95
|
+
state.requests.push({ status: Number(event.status), body: String(event.body ?? '') })
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
})().catch(() => {})
|
|
100
|
+
|
|
101
|
+
await ready
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
cloudUrl: `http://127.0.0.1:${port}`,
|
|
105
|
+
get pongs() {
|
|
106
|
+
return state.pongs
|
|
107
|
+
},
|
|
108
|
+
get kills() {
|
|
109
|
+
return state.kills
|
|
110
|
+
},
|
|
111
|
+
get closeCodes() {
|
|
112
|
+
return state.closeCodes
|
|
113
|
+
},
|
|
114
|
+
get requests() {
|
|
115
|
+
return state.requests
|
|
116
|
+
},
|
|
117
|
+
sendRequest(requestId: string, path: string) {
|
|
118
|
+
child.stdin?.write(`${JSON.stringify({ type: 'request', requestId, path })}\n`)
|
|
119
|
+
},
|
|
120
|
+
stop() {
|
|
121
|
+
try {
|
|
122
|
+
child.kill()
|
|
123
|
+
} catch {
|
|
124
|
+
// already gone
|
|
125
|
+
}
|
|
126
|
+
void drain
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let localApi: Server
|
|
132
|
+
let localPort = 0
|
|
133
|
+
|
|
134
|
+
const resolver: RuntimeResolver = {
|
|
135
|
+
async resolveLocalUrl(pathWithQuery: string) {
|
|
136
|
+
return `http://127.0.0.1:${localPort}${pathWithQuery}`
|
|
137
|
+
},
|
|
138
|
+
async deriveRuntimeToken() {
|
|
139
|
+
return 'runtime-token'
|
|
140
|
+
},
|
|
141
|
+
getActiveProjects() {
|
|
142
|
+
return ['proj-1']
|
|
143
|
+
},
|
|
144
|
+
status() {
|
|
145
|
+
return { status: 'running', agentPort: 1234 }
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
beforeAll(async () => {
|
|
150
|
+
localApi = createServer((req, res) => {
|
|
151
|
+
res.setHeader('content-type', 'application/json')
|
|
152
|
+
res.end(JSON.stringify({ answeredBy: 'local-api', path: req.url }))
|
|
153
|
+
})
|
|
154
|
+
await new Promise<void>((resolve) => localApi.listen(0, '127.0.0.1', () => resolve()))
|
|
155
|
+
localPort = (localApi.address() as AddressInfo).port
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
afterAll(async () => {
|
|
159
|
+
await new Promise<void>((resolve) => localApi.close(() => resolve()))
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
/** Burn this thread — the local API's thread — past the cloud's deadline. */
|
|
163
|
+
function blockMainThread(ms: number): void {
|
|
164
|
+
const until = Date.now() + ms
|
|
165
|
+
while (Date.now() < until) {
|
|
166
|
+
// A busy loop is the honest stand-in for a `lsof` or a big write.
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
|
171
|
+
|
|
172
|
+
async function waitFor(predicate: () => boolean, timeoutMs = 8_000): Promise<void> {
|
|
173
|
+
const started = Date.now()
|
|
174
|
+
while (Date.now() < started + timeoutMs) {
|
|
175
|
+
if (predicate()) return
|
|
176
|
+
await wait(20)
|
|
177
|
+
}
|
|
178
|
+
throw new Error(`timed out after ${timeoutMs}ms waiting for a condition that never held`)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
describe('a tunnel on its own thread', () => {
|
|
182
|
+
test('keeps its socket through a main thread blocked for 4× the ping interval', async () => {
|
|
183
|
+
const cloud = await startFakeCloud()
|
|
184
|
+
const host = startTunnelInWorker({
|
|
185
|
+
apiKey: 'test-key',
|
|
186
|
+
cloudUrl: cloud.cloudUrl,
|
|
187
|
+
name: 'worker-test',
|
|
188
|
+
kind: 'desktop',
|
|
189
|
+
resolver,
|
|
190
|
+
logger: { log: () => {}, warn: () => {}, error: () => {} },
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
await host.ready()
|
|
195
|
+
// The socket only opens once the cloud asks for it on a heartbeat.
|
|
196
|
+
await waitFor(() => cloud.pongs > 0)
|
|
197
|
+
const before = cloud.pongs
|
|
198
|
+
|
|
199
|
+
blockMainThread(BLOCK_MS)
|
|
200
|
+
|
|
201
|
+
// The cloud never reached for the close code, and the pongs never stopped.
|
|
202
|
+
expect(cloud.kills).toBe(0)
|
|
203
|
+
expect(cloud.closeCodes).toEqual([])
|
|
204
|
+
await waitFor(() => cloud.pongs > before, 3_000)
|
|
205
|
+
|
|
206
|
+
// And the tunnel still forwards afterwards: a request pushed down the
|
|
207
|
+
// socket comes back from the local API.
|
|
208
|
+
cloud.sendRequest('req-after-block', '/api/projects')
|
|
209
|
+
await waitFor(() => cloud.requests.some((entry) => entry.status === 200), 8_000)
|
|
210
|
+
expect(cloud.requests.find((entry) => entry.status === 200)?.body).toContain('local-api')
|
|
211
|
+
} finally {
|
|
212
|
+
host.stop()
|
|
213
|
+
cloud.stop()
|
|
214
|
+
}
|
|
215
|
+
}, 40_000)
|
|
216
|
+
|
|
217
|
+
test('the same block kills an in-process tunnel — the bug §5 fixes', async () => {
|
|
218
|
+
const cloud = await startFakeCloud()
|
|
219
|
+
const tunnel = new WorkerTunnel({
|
|
220
|
+
apiKey: 'test-key',
|
|
221
|
+
cloudUrl: cloud.cloudUrl,
|
|
222
|
+
name: 'in-process',
|
|
223
|
+
kind: 'desktop',
|
|
224
|
+
resolver,
|
|
225
|
+
logger: { log: () => {}, warn: () => {}, error: () => {} },
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
tunnel.start()
|
|
230
|
+
await waitFor(() => cloud.pongs > 0)
|
|
231
|
+
|
|
232
|
+
blockMainThread(BLOCK_MS)
|
|
233
|
+
|
|
234
|
+
// The cloud's deadline passed with the pong stuck behind the block, so it
|
|
235
|
+
// closed the socket: this is the 1006 the person sees as "machine offline".
|
|
236
|
+
await waitFor(() => cloud.kills > 0)
|
|
237
|
+
expect(cloud.kills).toBeGreaterThan(0)
|
|
238
|
+
} finally {
|
|
239
|
+
tunnel.stop()
|
|
240
|
+
cloud.stop()
|
|
241
|
+
}
|
|
242
|
+
}, 40_000)
|
|
243
|
+
})
|