/* * (C) Copyright 2013 Kurento (http://kurento.org/) * * All rights reserved. This program and the accompanying materials * are made available under the terms of the GNU Lesser General Public License * (LGPL) version 2.1 which accompanies this distribution, and is available at * http://www.gnu.org/licenses/lgpl-2.1.html * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * */ package com.kurento.kmf.content.internal.base; import static com.kurento.kmf.content.jsonrpc.JsonRpcConstants.METHOD_EXECUTE; import static com.kurento.kmf.content.jsonrpc.JsonRpcConstants.METHOD_POLL; import static com.kurento.kmf.content.jsonrpc.JsonRpcConstants.METHOD_START; import static com.kurento.kmf.content.jsonrpc.JsonRpcConstants.METHOD_TERMINATE; import java.io.IOException; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.concurrent.BlockingQueue; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; import javax.annotation.PostConstruct; import javax.servlet.AsyncContext; import javax.servlet.http.HttpServletRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import com.kurento.kmf.common.SecretGenerator; import com.kurento.kmf.common.exception.Assert; import com.kurento.kmf.common.exception.KurentoMediaFrameworkException; import com.kurento.kmf.content.ContentApiConfiguration; import com.kurento.kmf.content.ContentCommand; import com.kurento.kmf.content.ContentCommandResult; import com.kurento.kmf.content.ContentEvent; import com.kurento.kmf.content.ContentHandler; import com.kurento.kmf.content.ContentSession; import com.kurento.kmf.content.internal.ContentSessionManager; import com.kurento.kmf.content.internal.ControlEvent; import com.kurento.kmf.content.internal.ControlProtocolManager; import com.kurento.kmf.content.jsonrpc.Constraints; import com.kurento.kmf.content.jsonrpc.JsonRpcConstants; import com.kurento.kmf.content.jsonrpc.JsonRpcRequest; import com.kurento.kmf.content.jsonrpc.JsonRpcResponse; import com.kurento.kmf.content.jsonrpc.result.JsonRpcContentEvent; import com.kurento.kmf.content.jsonrpc.result.JsonRpcControlEvent; import com.kurento.kmf.media.Continuation; import com.kurento.kmf.media.MediaObject; import com.kurento.kmf.media.factory.MediaPipelineFactory; import com.kurento.kmf.repository.Repository; /** * * Generic content request processor. * * @author Luis López (llopez@gsyc.es) * @version 1.0.0 */ public abstract class AbstractContentSession implements ContentSession { private static final Logger log = LoggerFactory .getLogger(AbstractContentSession.class); /* * This variable is used as an indication of a client having a sessionId. * The variable is useful for the case in which a session is initiated * through a "execute" request. When registered=false, the client does not * have a sessionId, when true, the client has got one. ACTIVE state => * registered = true. However, for IDLE, HANDLING and STARTING registered * may be false (in case the "start" request is creating the session) or may * be true (in case a previous execute command created the session) */ private volatile boolean registered = false; /* * This state refers to the media exchange (media session state) and it * mainly depends on the status of execution of "start" requests. */ protected enum STATE { IDLE, // No session yet HANDLING, // onContentRequest on execution in handler STARTING, // start on execution in session ACTIVE, // start answer was sent to client TERMINATED // session is terminated, no more requests accepted }; // ///////////////////////////////////////////////////// // Attributes // ///////////////////////////////////////////////////// @Autowired protected MediaPipelineFactory mediaPipelineFactory; @Autowired protected ControlProtocolManager protocolManager; @Autowired protected SecretGenerator secretGenerator; @Autowired protected ContentApiConfiguration contentApiConfiguration; @Autowired private Repository repository; private ContentHandler<? extends ContentSession> handler; // List of Media Object to be cleaned-up (released) when this object // terminates. private Set<MediaObject> cleanupSet; private volatile STATE state; private ContentSessionManager manager; protected String sessionId; private String contentId; protected AsyncContext initialAsyncCtx; protected JsonRpcRequest initialJsonRequest; private ConcurrentHashMap<String, Object> attributes; protected BlockingQueue<Object> eventQueue; private Thread currentPollingThread; // ///////////////////////////////////////////////////// // Abstract methods to be implemented by derived classes // ///////////////////////////////////////////////////// protected abstract Logger getLogger(); // ///////////////////////////////////////////////////// // Constructor and utility methods // ///////////////////////////////////////////////////// public AbstractContentSession( ContentHandler<? extends ContentSession> handler, ContentSessionManager manager, AsyncContext asyncContext, String contentId) { state = STATE.IDLE; this.handler = handler; this.manager = manager; this.initialAsyncCtx = asyncContext; this.contentId = contentId; eventQueue = new LinkedBlockingQueue<Object>(); } @PostConstruct void onAfterBeanInitialized() { sessionId = secretGenerator.nextSecret(); } // Default implementation returns true. Some derived classes will override // this public boolean useControlProtocol() { return true; } protected ContentHandler<? extends ContentSession> getHandler() { return handler; } // ///////////////////////////////////////////////////// // Simple methods inherited from base ContentSession // ///////////////////////////////////////////////////// @Override public Object getAttribute(String name) { if (attributes == null) { return null; } else { return attributes.get(name); } } @Override public Object setAttribute(String name, Object value) { if (attributes == null) { attributes = new ConcurrentHashMap<String, Object>(); } return attributes.put(name, value); } @Override public Object removeAttribute(String name) { if (attributes == null) { return null; } return attributes.remove(name); } @Override public MediaPipelineFactory getMediaPipelineFactory() { return mediaPipelineFactory; } @Override public synchronized void releaseOnTerminate(MediaObject mediaObject) { if (state == STATE.TERMINATED) { mediaObject.release(new Continuation<Void>() { @Override public void onSuccess(Void result) { } @Override public void onError(Throwable cause) { } }); throw new KurentoMediaFrameworkException( "Session is in TERMINATED state. Cannot invoke releaseOnTerminate any longer", 1);// TODO } if (cleanupSet == null) cleanupSet = new HashSet<MediaObject>(); cleanupSet.add(mediaObject); } @Override public String getSessionId() { return sessionId; } @Override public String getContentId() { return contentId; } public Constraints getVideoConstraints() { try { return initialJsonRequest.getParams().getConstraints() .getVideoContraints(); } catch (NullPointerException e) { return null; } } public Constraints getAudioConstraints() { try { return initialJsonRequest.getParams().getConstraints() .getAudioContraints(); } catch (NullPointerException e) { return null; } } // TODO: This method makes no sense when one wish to use HttpServletRequest // on an session initiated by a "execute" request. We should make possible // to access the specific HttpServletRequest associated to a method // execution @Override public HttpServletRequest getHttpServletRequest() { if (state == STATE.ACTIVE || state == STATE.TERMINATED) { throw new KurentoMediaFrameworkException( "Cannot access initial HttpServletRequest after media negotiation phase. " + "This error means that you cannot access the original HttpServletRequest after a response to it has been sent.", 10006); } return (HttpServletRequest) initialAsyncCtx.getRequest(); } @Override public void publishEvent(ContentEvent contentEvent) { eventQueue.add(contentEvent); } protected void publishControlEvent(ControlEvent controlEvent) { eventQueue.add(controlEvent); } // ///////////////////////////////////////////////////// // Methods used by framework to reach the handler // ///////////////////////////////////////////////////// private void callOnSessionTerminatedOnHandler(int code, String description) { try { interalRawCallToOnSessionTerminated(code, description); } catch (Throwable t) { getLogger().error( "Error invoking onContentCompleted on handler. Cause " + t.getMessage(), t); callOnUncaughtExceptionThrown(t); } } protected abstract void interalRawCallToOnSessionTerminated(int code, String description) throws Exception; protected void callOnContentStartedOnHanlder() { try { interalRawCallToOnContentStarted(); } catch (Throwable t) { getLogger().error( "Error invoking onContentCompleted on handler. Cause " + t.getMessage(), t); callOnUncaughtExceptionThrown(t); } } protected abstract void interalRawCallToOnContentStarted() throws Exception; protected abstract ContentCommandResult interalRawCallToOnContentCommand( ContentCommand command) throws Exception; private void callOnContentErrorOnHandler(int code, String description) { try { interalRawCallToOnContentError(code, description); } catch (Throwable t) { getLogger().error( "Error invoking onContentError on handler. Cause " + t.getMessage(), t); callOnUncaughtExceptionThrown(t); } } protected abstract void interalRawCallToOnContentError(int code, String description) throws Exception; protected void callOnContentRequestOnHandler() { try { internalRawCallToOnContentRequest(); } catch (Throwable t) { getLogger().error( "Error invoking onContentRequest on handler. Cause " + t.getMessage(), t); callOnUncaughtExceptionThrown(t); } } protected abstract void internalRawCallToOnContentRequest() throws Exception; public void callOnUncaughtExceptionThrown(Throwable t) { try { internalRawCallToOnUncaughtExceptionThrown(t); } catch (Throwable tw) { log.error( "Uncaught exception thrown while processing a content request", t); log.error( "Exception thrown while processing the uncaught exception", tw); } } protected abstract void internalRawCallToOnUncaughtExceptionThrown( Throwable t) throws Exception; // ///////////////////////////////////////////////////// // Complex utility methods used by framework // ///////////////////////////////////////////////////// /** * Manages the JSON message depending on the state. * * @param asyncCtx * Asynchronous context * @param message * JSON message (Java object) * @throws ContentException * Exception in processing, typically when unrecognized message * @throws IOException * Input/Output exception */ public void processControlMessage(AsyncContext asyncCtx, JsonRpcRequest message) { Assert.notNull(message, "Cannot process null message", 10007); if (message.getMethod().equals(METHOD_START)) { synchronized (this) { Assert.isTrue(state == STATE.IDLE, "Illegal message with method " + message.getMethod() + " on state " + state, 10008); state = STATE.HANDLING; } initialAsyncCtx = asyncCtx; initialJsonRequest = message; // Check validity of constraints before making them accessible to // the handler Assert.notNull( initialJsonRequest.getParams().getConstraints() .getVideoContraints(), "Malfored request message specifying inexistent or invalid video contraints", 10009); Assert.notNull( initialJsonRequest.getParams().getConstraints() .getVideoContraints(), "Malfored request message specifying inexistent or invalid audio contraints", 10010); processStartJsonRpcRequest(asyncCtx, message); } else if (message.getMethod().equals(METHOD_POLL)) { Assert.isTrue(state != STATE.TERMINATED, "Cannot poll on state " + state, 10011); Assert.isTrue(registered == true, "Cannot poll on unregistered state " + state, 10011); // TODO // code ConsumeEventsResultType events = consumeEvents(); protocolManager.sendJsonAnswer(asyncCtx, JsonRpcResponse .newPollResponse(events.contentEvents, events.controlEvents, message.getId())); } else if (message.getMethod().equals(METHOD_EXECUTE)) { Assert.isTrue(state != STATE.TERMINATED, "Cannot execute command on state " + state, 10011); internalProcessCommandExecution(asyncCtx, message); } else if (message.getMethod().equals(METHOD_TERMINATE)) { internalTerminateWithoutError(asyncCtx, message.getParams() .getReason().getCode(), message.getParams().getReason() .getMessage(), message); } else { throw new KurentoMediaFrameworkException( "Unrecognized message with method " + message.getMethod(), 10012); } } private void internalProcessCommandExecution(AsyncContext asyncCtx, JsonRpcRequest message) { Assert.notNull(message.getParams(), "", 1); // TODO Assert.notNull(message.getParams().getCommand(), "", 1); // TODO ContentCommandResult result = null; try { result = interalRawCallToOnContentCommand(new ContentCommand( message.getParams().getCommand().getType(), message .getParams().getCommand().getData())); } catch (Throwable t) { getLogger().error( "Error invoking onContentCommand on handler. Cause " + t.getMessage(), t); int errorCode = 1; // TODO: define error code if (t instanceof KurentoMediaFrameworkException) { errorCode = ((KurentoMediaFrameworkException) t).getCode(); } if (!registered) { // An error with a command acting a session creation (e.g. // register) makes the session to terminate. This avoids // security problems where a client may force and exception, in // which case it should not be given access to the session. internalTerminateWithError(asyncCtx, errorCode, t.getMessage(), message); } else { // An error executing a command in other scenarion is not // considered faltal and the session may continue protocolManager.sendJsonError(asyncCtx, JsonRpcResponse .newError(errorCode, t.getMessage(), message.getId())); } callOnUncaughtExceptionThrown(t); return; } try { protocolManager.sendJsonAnswer( asyncCtx, JsonRpcResponse.newExecuteResponse(sessionId, result.getResult(), message.getId())); registered = true; } catch (Throwable t) { getLogger() .error("Error invoking sendJsonAnswer. Cause " + t.getMessage(), t); int errorCode = 1; // TODO: define error code if (t instanceof KurentoMediaFrameworkException) { errorCode = ((KurentoMediaFrameworkException) t).getCode(); } internalTerminateWithError(asyncCtx, errorCode, t.getMessage(), message); } } // Default implementation that may be overriden by derived classes protected void processStartJsonRpcRequest(AsyncContext asyncCtx, JsonRpcRequest message) { callOnContentRequestOnHandler(); } /** * Get poll events. * * @return list of received poll events */ private static class ConsumeEventsResultType { private JsonRpcContentEvent[] contentEvents; private JsonRpcControlEvent[] controlEvents; } public ConsumeEventsResultType consumeEvents() { if (currentPollingThread != null) { currentPollingThread.interrupt(); } currentPollingThread = Thread.currentThread(); List<JsonRpcContentEvent> contentEvents = null; List<JsonRpcControlEvent> controlEvents = null; while (true) { Object event; try { event = eventQueue.poll(contentApiConfiguration .getWebRtcEventQueuePollTimeout(), TimeUnit.MILLISECONDS); } catch (InterruptedException e) { break; } if (event != null && event instanceof ContentEvent) { if (contentEvents == null) contentEvents = new ArrayList<JsonRpcContentEvent>(); contentEvents.add(JsonRpcContentEvent.newEvent( ((ContentEvent) event).getType(), ((ContentEvent) event).getData())); } if (event != null && event instanceof ControlEvent) { if (controlEvents == null) controlEvents = new ArrayList<JsonRpcControlEvent>(); controlEvents.add(JsonRpcControlEvent.newEvent( ((ControlEvent) event).getType(), ((ControlEvent) event).getCode(), ((ControlEvent) event).getMessage())); } if (eventQueue.isEmpty()) { break; } } currentPollingThread = null; ConsumeEventsResultType result = new ConsumeEventsResultType(); if (contentEvents != null) { result.contentEvents = contentEvents .toArray(new JsonRpcContentEvent[1]); } if (controlEvents != null) { result.controlEvents = controlEvents .toArray(new JsonRpcControlEvent[1]); } return result; } /** * Terminates this object, completing initialAsyncCtx if necessary and * sending an answer to the initial request if necessary. * * @param code * termination code * @param description * termination description */ @Override public void terminate(int code, String description) { internalTerminateWithoutError(null, code, description, null); } /** * This method must be invoked by the framework whenever a session needs to * be terminated with an error condition. This may occur due to different * reasons, such as: * * - Exception thrown by the communication layer while processing a message.<br> * - Asynchronous error comming from the media server. <br> * - Exception thrown when processing a "start" call on the session <br> * * @param asyncCtx * represents the client request where the error has emerged. May * be null for async errors coming from the media server or when * the error occurs in the initialAsyncCtx (ex. while executing a * start call on the session). If this parameter is non-null, the * request parameter must also be non-null (except for * useControlProtocol=false). * @param code * @param description * @param request * represents the client request that is causing this * termination. May be null if termination comes from async media * event or from invocation to terminate method on session. */ public void internalTerminateWithError(AsyncContext asyncCtx, int code, String description, JsonRpcRequest request) { // This method cannot throw exceptions getLogger().info("internalTerminateWithError called"); STATE localState = null; synchronized (this) { localState = state; state = STATE.TERMINATED; try { if (asyncCtx != null) { // If we have an asyncCtx, we nust need to answer on it sendErrorAnswerOnSpecificContext(asyncCtx, code, description, request); } else { // Here, we have an error without asyncCtx specified. What // we // need to do depends on state if (localState == STATE.IDLE) { // This case represents and error without having a start // answer pending if (registered) { pushErrorEvent(code, description); } // Else, we can do nothing, just terminate silently } else if (localState == STATE.HANDLING || localState == STATE.STARTING) { // Here we have a start answer pending, initialAsyncCtx // MUST // be non-null sendErrorAnswerOnInitialContext(code, description); } else if (localState == STATE.ACTIVE) { // This case represents an async error coming from the // media // server. Variable registered MUST be true here. pushErrorEvent(code, description); } } } catch (Throwable t) { getLogger().error(t.getMessage(), t); } finally { destroy(); } } if (localState != null && localState != STATE.TERMINATED) { // We only want to call the handler once when the session terminates callOnContentErrorOnHandler(code, description); } } protected void sendErrorAnswerOnInitialContext(int code, String description) { protocolManager.sendJsonAnswer( initialAsyncCtx, JsonRpcResponse.newError(code, description, initialJsonRequest.getId())); } private void sendErrorAnswerOnSpecificContext(AsyncContext asyncCtx, int code, String description, JsonRpcRequest request) { protocolManager.sendJsonAnswer(asyncCtx, JsonRpcResponse.newError(code, description, request.getId())); } private void pushErrorEvent(int code, String description) { publishControlEvent(new ControlEvent( JsonRpcConstants.EVENT_SESSION_ERROR, code, description)); } /** * This method needs to be invoked when a session terminate without error. * This may happen due to several reasons: * * - Client sends a terminate message <br> * - Handler invokes terminate on the session <br> * * @param asyncCtx * represents the context of the client request causing this * termination. If this parameter is non-null, request must be * non-null (except for useControlProtocol=false). * @param code * @param description * @param request * represents the client request that is causing this * termination. May be null if termination comes from async media * event or from invocation to terminate method on session. */ public void internalTerminateWithoutError(AsyncContext asyncCtx, int code, String description, JsonRpcRequest request) { // This method cannot throw exceptions getLogger().info("internalTerminateWithoutError called"); STATE localState = null; synchronized (this) { localState = state; state = STATE.TERMINATED; try { if (asyncCtx != null) { // This can only happen upon execution of a client explicit // terminate command sendAckToExplicitClientTermination(asyncCtx, code, description, request); } else { if (localState == STATE.IDLE) { // This case represents an explicit terminate invocation // on // the session before start occurs. if (registered) { pushTerminateEvent(code, description); } } else if (localState == STATE.HANDLING || localState == STATE.STARTING) { // This case represents an explicit terminate invocation // on // session when processing a start request sendRejectOnInitialContext(code, description); } else if (localState == STATE.ACTIVE) { pushTerminateEvent(code, description); } } } catch (Throwable t) { getLogger().error(t.getMessage(), t); } finally { destroy(); } } if (localState != null && localState != STATE.TERMINATED) { // We only want to call the handler once when the session terminates callOnSessionTerminatedOnHandler(code, description); } } private void sendAckToExplicitClientTermination(AsyncContext asyncCtx, int code, String description, JsonRpcRequest request) { protocolManager.sendJsonAnswer( asyncCtx, JsonRpcResponse.newTerminateResponse(code, description, request.getId())); } private void pushTerminateEvent(int code, String description) { publishControlEvent(new ControlEvent( JsonRpcConstants.EVENT_SESSION_TERMINATED, code, description)); } protected void sendRejectOnInitialContext(int code, String description) { protocolManager.sendJsonAnswer(initialAsyncCtx, JsonRpcResponse .newStartRejectedResponse(code, description, initialJsonRequest.getId())); } protected synchronized void destroy() { registered = false; if (initialAsyncCtx != null) { try { initialAsyncCtx.complete(); } catch (IllegalStateException e) { log.warn("Exception try to complete initialAsyncCtx: {}", e .getClass().getName()); // FIXME: We ignore this exception because is thrown when // asyncContext in yet in COMPLETING STATE. } initialAsyncCtx = null; } if (eventQueue.isEmpty() && currentPollingThread != null) { currentPollingThread.interrupt(); } try { releaseOwnMediaServerResources(); } catch (Throwable e) { getLogger().error(e.getMessage(), e); } // FIXME: Dirty hack to avoid polling not receiving onTerminate event try { Thread.sleep(500); } catch (InterruptedException e) { } if (manager != null) { manager.remove(this.sessionId); } } private synchronized void releaseOwnMediaServerResources() { if (cleanupSet == null) { return; } for (MediaObject mediaObject : cleanupSet) { getLogger() .info("Requesting release of MediaObject " + mediaObject); try { mediaObject.release(new Continuation<Void>() { @Override public void onSuccess(Void result) { } @Override public void onError(Throwable cause) { getLogger().warn( "Error releasing MediaObject: " + cause.getMessage()); } }); } catch (Throwable t) { throw new KurentoMediaFrameworkException( "Error releasing media object " + mediaObject + ". Cause: " + t.getMessage(), t, 20022); } } cleanupSet.clear(); cleanupSet = null; } public Repository getRepository() { return repository; } protected synchronized void goToState(STATE target, String errorMessage, int errorCode) { STATE initial = state; try { if (target == STATE.HANDLING) { Assert.isTrue(initial == STATE.IDLE, errorMessage, errorCode); state = STATE.HANDLING; } else if (target == STATE.STARTING) { Assert.isTrue(initial == STATE.HANDLING, errorMessage, errorCode); state = STATE.STARTING; } else if (target == STATE.ACTIVE) { Assert.isTrue(state == STATE.STARTING, errorMessage, errorCode); state = STATE.ACTIVE; registered = true; // at this stage, user is registered with // certainty } else if (target == STATE.TERMINATED) { state = STATE.TERMINATED; } } finally { /* * Whenever a thread wants to change the session state, if that * state is already terminated, that means that another thread * terminated the session. However, the thread calling this method * may have created media elements or other resources. For this * reason, we need to call destroy to guarantee that those resources * are collected. */ if (initial == STATE.TERMINATED) { internalTerminateWithError( null, 1, "Spureous state change attemp while being already termianted", null); // TODO: error code } } } protected synchronized STATE getState() { return state; } }