@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shogo-ai/worker",
3
- "version": "2.3.12",
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
+ })