diff --git a/apis/libsandbox.lua b/apis/libsandbox.lua index 79c7410..3c0a644 100644 --- a/apis/libsandbox.lua +++ b/apis/libsandbox.lua @@ -280,16 +280,24 @@ local function createSandbox() scheduleReconnect(); end); - -- CC:T passes (url, reason, code). A displacement close (code 4000) is terminal: - -- another computer registered with our traposId, so we yield rather than reconnect - -- (reconnecting would just displace it back, an endless ping-pong). + -- CC:T passes (url, reason, code). A displacement close (code 4000) is only + -- terminal when it hits our *live* (ready) socket: another computer registered + -- our traposId, so we yield rather than reconnect (reconnecting would just + -- displace it back, an endless ping-pong). A displacement that arrives while we + -- are still connecting/awaiting_hello is for a socket our own reconnect has + -- already superseded -- ignoring it keeps the healthy new socket alive instead + -- of bricking the daemon into a permanent 'displaced' state. el.register('websocket_closed', function(eventUrl, reason, code) if eventUrl ~= url then return; end if code == DISPLACED_CLOSE_CODE or reason == 'displaced' then - activeWs = nil; - state = 'displaced'; - lastError = 'displaced'; - log('warn', 'displaced by a newer registration; yielding', { traposId = traposId }); + if state == 'ready' then + activeWs = nil; + state = 'displaced'; + lastError = 'displaced'; + log('warn', 'displaced by a newer registration; yielding', { traposId = traposId }); + else + log('info', 'ignoring stale displacement during reconnect', { traposId = traposId }); + end return; end log('info', 'connection closed', { code = code, reason = reason }); diff --git a/manifest.json b/manifest.json index 3c5d7ae..dca4542 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "name": "TrapOS", - "version": "0.9.1", + "version": "0.9.2", "branch": "next", "packages": [ "trapos" diff --git a/packages/index.json b/packages/index.json index 9d5ae88..e4c3624 100644 --- a/packages/index.json +++ b/packages/index.json @@ -6,8 +6,8 @@ "trapos-net": "0.3.0", "trapos-ui": "0.2.2", "trapos-ai": "0.7.0", - "trapos-sandbox": "0.2.4", + "trapos-sandbox": "0.2.5", "trapos-sandbox-legacy": "0.2.2", - "trapos": "0.9.1" + "trapos": "0.9.2" } } diff --git a/packages/trapos-sandbox/ccpm.json b/packages/trapos-sandbox/ccpm.json index 088bb00..dd7159a 100644 --- a/packages/trapos-sandbox/ccpm.json +++ b/packages/trapos-sandbox/ccpm.json @@ -1,6 +1,6 @@ { "name": "trapos-sandbox", - "version": "0.2.4", + "version": "0.2.5", "description": "TrapOS sandbox gateway client: WS daemon (servers/sandbox) + sandbox command", "dependencies": ["trapos-core"], "files": [ diff --git a/packages/trapos/ccpm.json b/packages/trapos/ccpm.json index 90f8dcf..a1bec8f 100644 --- a/packages/trapos/ccpm.json +++ b/packages/trapos/ccpm.json @@ -1,6 +1,6 @@ { "name": "trapos", - "version": "0.9.1", + "version": "0.9.2", "description": "TrapOS full install meta-package", "dependencies": [ "trapos-boot", diff --git a/tests/sandbox.lua b/tests/sandbox.lua index 5ed1e6a..28dacdc 100644 --- a/tests/sandbox.lua +++ b/tests/sandbox.lua @@ -255,6 +255,8 @@ testlib.test('startSession treats a displacement close by reason as terminal', f local ctx = startSession(); local ws = fakes.fakeWs(); ctx.el.fire('websocket_success', ctx.url, ws); + ctx.el.fire('websocket_message', ctx.url, encode({ event = 'gateway_response', type = 'hello', ok = true, payload = {} })); + testlib.assertEquals(ctx.session.isReady(), true); local timersBefore = #ctx.el.timers; ctx.el.fire('websocket_closed', ctx.url, 'displaced'); -- reason only, code absent @@ -264,6 +266,21 @@ testlib.test('startSession treats a displacement close by reason as terminal', f testlib.assertEquals(#ctx.http.connects, 1); end); +testlib.test('startSession ignores a displacement of an already-superseded socket', function() + -- A 4000 close that arrives before our reconnect completes hello is for a socket + -- our own reconnect superseded; it must not brick the daemon into 'displaced'. + local ctx = startSession(); + local ws = fakes.fakeWs(); + ctx.el.fire('websocket_success', ctx.url, ws); + -- still awaiting_hello (no ack yet): a stale displacement arrives. + ctx.el.fire('websocket_closed', ctx.url, 'displaced', 4000); + + testlib.assertEquals(ctx.session.lastError(), nil); + -- Not terminal: a hello ack on the live socket still brings us to ready. + ctx.el.fire('websocket_message', ctx.url, encode({ event = 'gateway_response', type = 'hello', ok = true, payload = {} })); + testlib.assertEquals(ctx.session.isReady(), true); +end); + testlib.test('hello watchdog closes a socket stuck awaiting ack', function() local ctx = startSession(); local ws = fakes.fakeWs(); diff --git a/tools/trap-sandbox/src/modules/trapos-cloud-gateway/index.ts b/tools/trap-sandbox/src/modules/trapos-cloud-gateway/index.ts index bbd05e9..784933f 100644 --- a/tools/trap-sandbox/src/modules/trapos-cloud-gateway/index.ts +++ b/tools/trap-sandbox/src/modules/trapos-cloud-gateway/index.ts @@ -7,6 +7,14 @@ import type { AccountId } from "../../auth.js"; let nextConnectionId = 1; +// WS keepalive. The gateway is idle between requests, so without protocol-level +// traffic the CC:T client's socket hits its idle timeout and drops (code 1006 +// "Timed out"), churning reconnects. We ping every interval; the client auto-pongs +// at the protocol level. A socket that misses a full interval (no pong) is presumed +// dead and terminated, which also reaps a rebooted machine's stale half-open entry +// before its new daemon connects (so newest-wins doesn't fire on a phantom). +const HEARTBEAT_INTERVAL_MS = 20_000; + export type GatewayModuleOptions = { registry: GatewayRegistry; probe: OpencodeProbe; @@ -38,13 +46,36 @@ const gatewayModule: FastifyPluginAsync = (fastify, opts) log, }); + // Heartbeat: a pong (or any inbound frame) marks the socket live for the next + // interval; a silent interval terminates it. + let isAlive = true; + socket.on("pong", () => { + isAlive = true; + }); + const heartbeat = setInterval(() => { + if (!isAlive) { + log.info("gateway connection heartbeat timeout; terminating"); + socket.terminate(); + return; + } + isAlive = false; + try { + socket.ping(); + } catch { + // socket already closing; the close handler clears the interval + } + }, HEARTBEAT_INTERVAL_MS); + socket.on("message", (raw) => { + isAlive = true; void connection.onMessage(raw); }); socket.on("close", (code, reason) => { + clearInterval(heartbeat); connection.onClose(code, reason.toString()); }); socket.on("error", (err) => { + clearInterval(heartbeat); log.warn({ err }, "gateway socket error"); connection.onClose(); });