Skip to content
Closed
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion src/modules/session/session.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]),
Expand Down Expand Up @@ -375,7 +376,12 @@ describe('SessionService', () => {

const engines = (service as unknown as { engines: Map<string, unknown> }).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 () => {
Expand Down
9 changes: 8 additions & 1 deletion src/modules/session/session.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading