diff --git a/CHANGELOG.md b/CHANGELOG.md index f1041fd2d..1b14f2dce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **A session whose `engine.initialize()` fails no longer orphans its browser process.** The crash-recovery path in `SessionService.start()` was tearing down the half-built engine with a graceful `destroy()`, but a failed `initialize()` usually means the underlying browser/CDP connection is already broken (e.g. a `TargetCloseError: Target closed` mid-injection) — `destroy()` has nothing live to talk to, so it could only time out after 10s via `teardownEngineSafely`'s race, leaving the Chromium process alive and orphaned. Every such crash left one more orphaned process behind, eventually starving the host of memory. It now uses `forceDestroy()` (the same SIGKILL-the-process recovery `POST /:id/force-kill` uses), since a failed initialize is the same "possibly-unreachable engine" state that exists for. - **`GET /api/sessions/:sessionId/channels/:channelId/messages` always returned an empty array** on the whatsapp-web.js engine (#625). The adapter called `client.getChannelById()`, which does not exist in whatsapp-web.js 1.34.x, so every call threw and the error was swallowed into `[]`. Channel messages are now read from the subscribed `Channel` instance (via `getChannels()`), and an unknown/unsubscribed channel returns a `404` (`ChannelNotFoundError`) instead of a silent empty `200` — matching `GET /channels/:channelId`. Thanks @Header9968. ## [0.8.7] - 2026-07-03 diff --git a/src/modules/session/session.service.spec.ts b/src/modules/session/session.service.spec.ts index 9af3388bd..c9c316bd6 100644 --- a/src/modules/session/session.service.spec.ts +++ b/src/modules/session/session.service.spec.ts @@ -80,6 +80,7 @@ describe('SessionService', () => { mockEngine = { initialize: jest.fn().mockResolvedValue(undefined), destroy: jest.fn().mockResolvedValue(undefined), + forceDestroy: jest.fn().mockResolvedValue(undefined), disconnect: jest.fn().mockResolvedValue(undefined), getQRCode: jest.fn().mockReturnValue(null), getGroups: jest.fn().mockResolvedValue([]), @@ -375,7 +376,12 @@ describe('SessionService', () => { const engines = (service as unknown as { engines: Map }).engines; expect(engines.has('sess-uuid-1')).toBe(false); // not left orphaned → session can be started again - expect(mockEngine.destroy).toHaveBeenCalled(); // half-built engine torn down + // forceDestroy(), not destroy(): initialize() failing usually means the browser/CDP + // connection is already broken, so only a direct SIGKILL (forceDestroy) reliably reaps the + // OS-level Chromium process — a graceful destroy() has nothing live to talk to and can only + // time out, leaving the process orphaned (the actual bug this test now guards against). + expect(mockEngine.forceDestroy).toHaveBeenCalled(); + expect(mockEngine.destroy).not.toHaveBeenCalled(); }); it('allows a fresh start after the previous one completed (reservation is cleared)', async () => { diff --git a/src/modules/session/session.service.ts b/src/modules/session/session.service.ts index 74115ef70..f06f76081 100644 --- a/src/modules/session/session.service.ts +++ b/src/modules/session/session.service.ts @@ -457,11 +457,18 @@ export class SessionService implements OnModuleDestroy, OnModuleInit, OnApplicat // initializing). Evict + tear it down so the session doesn't wedge at "already started" with a // leaked Chromium/socket permanently holding a concurrency slot. initializingSessions serializes // start(), so the engine in the map here is the one this start just created. + // + // Use forceDestroy(), not destroy(): initialize() failing usually means the underlying + // browser/CDP connection is already broken (e.g. a "Target closed" crash mid-injection), so + // a graceful destroy() has nothing live to talk to — it can only time out via + // teardownEngineSafely's race, after which the orphaned Chromium process is never actually + // killed. forceDestroy() SIGKILLs the OS process directly, the same recovery force-kill uses + // for a wedged engine, which is exactly the state this catch block is handling. const orphan = this.engines.get(id); if (orphan) { this.engines.delete(id); this.sessionErrors.set(id, err instanceof Error ? err.message : String(err)); - await this.destroyEngineSafely(id, orphan); + await this.teardownEngineSafely(id, orphan, e => e.forceDestroy(), 'force-destroy'); await this.updateStatus(id, SessionStatus.FAILED).catch(() => undefined); } throw err;