![]() |
Development Lifecycle »
Design and Architecture »
Methodologies
Beginner
Test Driven Prototyping - Learning About .NET RemotingBy Marc CliftonUse test driven development processes to determine the issues affecting application architecture and design with regards to .NET remoting. |
C#, Windows, .NET, Visual Studio, Architect, Dev, Design
|
|
Advanced Search Add to IE Search |
|
|
|
||||||||||||||||
I was inspired by Colin's article Test Driven / First Development by Example to use the test driven approach for investigating .NET remoting. I wanted to investigate remoting as an option for dealing with some flaky aspects of our video kiosk system, namely the DirectX AVI and DVD player code, which has a tendency to crash or lock up the application when a bad movie or some problematic DVD is encountered. These are things not under my control, and when something bad happens, I've seen it lock up all the application threads, so something external, like a watchdog app, needs to be monitoring the viewers.
I figured remoting might be an interesting way of isolating the business and data access layers from the presentation layer, which is not very reliable. I also figured that a test driven prototyping approach would be a good way to investigate and simulate the kinds of problems we've encountered and test how these are handled under remoting. To that end, I'm using my Advanced Unit Test engine and, since I've only done remoting once before and have forgotten everything I learned (which wasn't much to begin with), I'm also using Patrick Smacchia's excellent book Practical .NET2 and C#2, in particular, the stuff in Chapter 22.
You can read about the AUT engine on the following links:
Part I - Overview
Part II - Core Implementation
Part III - Testing Processes
Part IV - Fixture Setup/Teardown, etc...
and download the latest version of the engine from here.
To begin with, I wanted to verify the differences between single call activation and singleton activation, so I wrote a couple unit tests:
[Test] public void InstancePerCallTest() { ITest t1 = RemotingHelper.CreatePerCallTestObject(); t1.Value1 = 1; t1.Value2 = 2; Assertion.Assert(t1.Value1 == 0, "Expected t1.Value1 to be 0 for a single call activation remote object."); } [Test] public void SingletonActivationTest() { ITest t1 = RemotingHelper.CreateSingletonTestObject(); t1.Value1 = 1; t1.Value2 = 2; Assertion.Assert(t1.Value1 == 1, "Expected t1.Value1 to be 1 for a singleton remote object."); ITest t2 = RemotingHelper.CreateSingletonTestObject(); Assertion.Assert(t2.Value1 == 1, "Expected t2.Value1 to be 1 for a second instance of the singleton object."); }
The first test verifies that, by making an assignment to Value2, the object is instantiated again, and therefore Value1 is initialized to its default value, which is 0, and thus loses its assignment in the line above. And indeed, this is how a single call activation works.
The second test verifies that, which a singleton activation, this is not the case, and furthermore, that a call to create a second object does nothing more than actually return the first object. I decided this test was a bit too complicated and wrote a simpler one:
[Test] public void SameReferenceTest() { ITest t1 = RemotingHelper.CreateSingletonTestObject(); ITest t2 = RemotingHelper.CreateSingletonTestObject(); Assertion.Assert(t1 == t2, "Expected t1==t2."); }
Because neither the single call activation nor the singleton activation are going to be appropriate for our requirements, I expanded the tests further by using a factory class to instantiate the test object. I expected in this case that two or more instances created by the factory would be separate. The following two tests illustrate this:
[Test] public void FactoryActivationTest() { IFactory factory = RemotingHelper.CreateFactoryObject(); ITest t1 = factory.CreateTest(); t1.Value1 = 1; t1.Value2 = 2; Assertion.Assert(t1.Value1 == 1, "Expected t1.Value1 to be 1 for a factory created remote object."); ITest t2 = factory.CreateTest(); Assertion.Assert(t2.Value1 == 0, "Expected t2.Value1 to be 0 for a second instance of a factory created remote object."); } [Test] public void FactoryDifferentReferenceTest() { IFactory factory = RemotingHelper.CreateFactoryObject(); ITest t1 = factory.CreateTest(); ITest t2 = factory.CreateTest(); Assertion.Assert(t1 != t2, "Expected t1 != t2."); }
And indeed, these tests pass.
This is an important step because I now know that we can use the remote service to instantiate discrete objects of the same class, using a factory.
After studying the chapter on remoting and then writing the unit tests, I implemented the server and client code. Amazingly, the unit tests passed the first time, verifying that my understanding, and Mr. Smacchia's book, are correct.
The code so far consists of:
There are two interfaces, one for the factory, and one for the test class:
using System; namespace Interfaces { public interface IFactory { ITest CreateTest(); } public interface ITest { int Value1 { get;set;} int Value2 { get;set;} } }
The server maintains the concrete implementation for the Test class:
using System; using Interfaces; namespace Server { public class Test : MarshalByRefObject, ITest { protected int value1; protected int value2; /// <summary> /// Gets/sets value2 /// </summary> public int Value2 { get { return value2; } set { value2 = value;} } /// <summary> /// Gets/sets value1 /// </summary> public int Value1 { get { return value1; } set { value1 = value;} } public Test() { } } }
Two things to note here are:
This is a very simple class, returning a new instance of the concrete Test class. Again, the class is derived from MarshalByRefObject and implements in this case the IFactory interface.
using System; using Interfaces; namespace Server { public class Factory : MarshalByRefObject, IFactory { public ITest CreateTest() { return new Test(); } } }
To complete the server is the actual program:
using System; using System.Runtime.Remoting; using System.Runtime.Remoting.Channels; using System.Runtime.Remoting.Channels.Http; using Interfaces; namespace Server { class Program { static void Main(string[] args) { HttpChannel channel = new HttpChannel(65100); ChannelServices.RegisterChannel(channel, false); RemotingConfiguration.RegisterWellKnownServiceType(typeof(Test), "SingleCallTestService", WellKnownObjectMode.SingleCall); RemotingConfiguration.RegisterWellKnownServiceType(typeof(Test), "SingletonTestService", WellKnownObjectMode.Singleton); RemotingConfiguration.RegisterWellKnownServiceType(typeof(Factory), "FactoryService", WellKnownObjectMode.SingleCall); Console.WriteLine("Press a key to stop the server..."); Console.Read(); } } }
Of note here are the separate object URI's to distinguish between single call and singleton instantiation, and the separate URI for the factory.
Besides the unit tests, I'm using a helper class this simulates the actual instantiation process of the objects:
using System; using System.Runtime.Remoting; using System.Runtime.Remoting.Channels; using System.Runtime.Remoting.Channels.Http; using Interfaces; namespace ClientTests { public static class RemotingHelper { public static HttpChannel channel; public static void RegisterChannel() { channel = new HttpChannel(0); ChannelServices.RegisterChannel(channel, false); } public static void UnregisterChannel() { ChannelServices.UnregisterChannel(channel); } public static ITest CreatePerCallTestObject() { MarshalByRefObject objRef = (MarshalByRefObject) RemotingServices.Connect(typeof(ITest), "http://localhost:65100/SingleCallTestService"); ITest test = objRef as ITest; return test; } public static ITest CreateSingletonTestObject() { MarshalByRefObject objRef = (MarshalByRefObject) RemotingServices.Connect(typeof(ITest), "http://localhost:65100/SingletonTestService"); ITest test = objRef as ITest; return test; } public static IFactory CreateFactoryObject() { MarshalByRefObject objRef = (MarshalByRefObject) RemotingServices.Connect(typeof(IFactory), "http://localhost:65100/FactoryService"); IFactory factory = objRef as IFactory; return factory; } } }
The next thing I want to understand are server-side exceptions. What happens when an exception occurs by the code running at the server? For this, I want to test both system exceptions and custom exceptions, so I'm going to create two methods in ITest:
public interface ITest { int Value1 { get;set;} int Value2 { get;set;} void CreateSystemException(); void CreateCustomException(); }
Now, this is unknown territory, so I'm going to make some assumptions about the unit tests:
[Test] [ExpectedException(typeof(Exception))] public void SystemExceptionTest() { ITest t1 = RemotingHelper.CreateSingletonTestObject(); t1.CreateSystemException(); } [Test] public void CustomExceptionTest() { ITest t1 = RemotingHelper.CreateSingletonTestObject(); t1.CreateCustomException(); }
And the implementation:
public void CreateSystemException() { throw new Exception("The method or operation is not implemented."); } public void CreateCustomException() { throw new TestException("The method or operation is not implemented."); }
And the custom Exception class:
public class TestException : Exception { public TestException(string msg) : base(msg) { } }I'm expecting to get an Exception at the client from a system exception, but I really have no idea what I'll be getting by throwing an exception that the client doesn't know about. So we'll let the unit test engine tell me. The first test passes, however the second test actually throws a SerializationException, with the message:
The type Server.TestException in Assembly Server, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null is not marked as serializable.OK, so I'll mark it serializable:
[Serializable] public class TestException : Exception { public TestException(string msg) : base(msg) { } }Hmm. I still get a SerializationException:
Parse Error, no assembly associated with Xml key blahblahblah TestException
[Test] [ExpectedException(typeof(SerializationException))] public void CustomExceptionTest() { ITest t1 = RemotingHelper.CreateSingletonTestObject(); t1.CreateCustomException(); }
[Test] public void CreateComplexTest() { IComplexTest compTest = RemotingHelper.CreateComplexTest(); string ret = compTest.DoSomething(); Assertion.Assert(ret == "Doing something.", "Expected valid return."); }and the following test, which creates a non-marshallable, unserialized class as a property of the test class:
using System; using Interfaces; namespace Server { public class RealClass { public string DoSomething() { return "Doing something."; } } public class ComplexTest : MarshalByRefObject, IComplexTest { protected RealClass realClass; public string DoSomething() { return realClass.DoSomething(); } public ComplexTest() { realClass = new RealClass(); } } }And the test passes, proving that the server class can include other classes that are not marshallable or serializable without causing problems for the client. This is what I'd expect, but it's good to verify my expectations, no matter how obvious they may seem.
[Test] public void InfiniteLoopTest() { ITest t1 = RemotingHelper.CreateSingletonTestObject(); t1.InfiniteLoop(); }I won't bore you with the interface and server-side implementation. Sure enough, the client hangs. This is Not Good. So, we want to ignore this test:
[Test] [Ignore] public void InfiniteLoopTest() { ITest t1 = RemotingHelper.CreateSingletonTestObject(); t1.InfiniteLoop(); }The solution is to implement an asynchronous remoting call. I was surprised that Mr. Smacchia didn't discuss asynchronous remote calls, so I had to go back to some MSDN documentation. There are two things I want to test with an async callback--that both an async call that, well, does nothing in my test, and an async call that has an infinite loop--both of these should return immediately to the client.
[Test] public void AsyncReturnImmediateTest() { ITest t1 = RemotingHelper.CreateSingletonTestObject(); RemoteAsyncDelegate remoteDlgt = new RemoteAsyncDelegate( t1.ReturnImmediately); remoteDlgt.BeginInvoke(null, null); } [Test] public void AsyncReturnExpectedTest() { ITest t1 = RemotingHelper.CreateSingletonTestObject(); RemoteAsyncDelegate remoteDlgt = new RemoteAsyncDelegate(t1.InfiniteLoop); remoteDlgt.BeginInvoke(null, null); }Both of these tests return to the unit test engine, so we know that they are executing asynchronously. Note that the issues of signaling completion are not really in the scope of what I want to test here--I merely want to make sure that for methods that I choose, I can execute them asynchronously.
using System; using System.Runtime.Serialization; using Vts.UnitTest; using Interfaces; namespace ClientTests { [TestFixture, ProcessTest] public class InfiniteLoopTests { public delegate void RemoteAsyncDelegate(); public ITest infiniteObject; [Test, Sequence(0)] public void AsyncReturnExpectedTest() { infiniteObject = RemotingHelper.CreateSingletonTestObject(); RemoteAsyncDelegate remoteDlgt = new RemoteAsyncDelegate( infiniteObject.InfiniteLoop); remoteDlgt.BeginInvoke(null, null); Assertion.Assert(true, "Expected async return."); } [Test, Sequence(1)] public void ExecuteOtherMethodsOnInfiniteObjectTest() { infiniteObject.ReturnImmediately(); Assertion.Assert(true, "Expected return."); } } }And indeed, ExecuteOtherMethodsOnInfiniteObjectTest does return. This is expected, since the BeginInvoke call should be running on a separate thread. But how do we find out what that thread is? One option might be to set the thread ID in an "out" parameter of the async method, so that the client can tell the server to later on kill that thread. Let's try that with a different infinitely looping method:
public void InfiniteLoop2(out int threadId) { threadId = Thread.CurrentThread.ManagedThreadId; while (true) { }; }And the modified unit test, which first validates that we get the thread ID:
[Test, Sequence(0)] public void AsyncReturnExpectedTest() { infiniteObject = RemotingHelper.CreateSingletonTestObject(); RemoteAsyncDelegate remoteDlgt = new RemoteAsyncDelegate( infiniteObject.InfiniteLoop2); remoteDlgt.BeginInvoke(out threadId, null, null); Assertion.Assert(true, "Expected async return."); Assertion.Assert(threadId != 0, "Expected thread ID to be initialized."); }And guess what? The second assertion fails! The out parameter is not set until the method returns, which it never does. This may have seemed obvious to many readers, but the point is to test the obvious. So this mechanism for returning the thread ID will not work.
public void InfiniteLoop() { ThreadHelper.SaveThreadInformation("Test.InfiniteLoop"); while (true) { }; }Here a static class is used to manage information, at the server, about the executing thread:
using System; using System.Collections.Generic; using System.Threading; namespace Server { public static class ThreadHelper { internal static Dictionary<string, Thread> threadMap = new Dictionary<string, Thread>(); public static void SaveThreadInformation(string methodName) { threadMap[methodName] = Thread.CurrentThread; } public static void KillThread(string methodName) { Thread thread = threadMap[methodName]; thread.Abort(); } } }And by creating an AsyncManagement class and its interface:
using System; using Interfaces; namespace Server { public class AsyncManagement : MarshalByRefObject, IAsyncManagement { public void KillThread(string methodName) { ThreadHelper.KillThread(methodName); } } }I can write the following unit test (having reverted the Sequence(0) test back to its original form):
[Test, Sequence(2)] public void KillThread() { IAsyncManagement asyncMgr = RemotingHelper.CreateAsyncManagement(); asyncMgr.KillThread("Test.InfiniteLoop"); }Running this sequence of tests does confirm that the thread aborts (the CPU usage goes back to normal).
using System; using System.Diagnostics; using System.Runtime.Serialization; using Vts.UnitTest; using Interfaces; namespace ClientTests { [TestFixture, ProcessTest] public class ProcessStartStopTests { protected Process p; ITest test; [Test, Sequence(0)] public void StartProcess() { p = new Process(); p.StartInfo.FileName = @"..\..\..\Server\bin\debug\Server.exe"; p.Start(); } [Test, Sequence(1)] public void CreateObject() { test = RemotingHelper.CreateSingletonTestObject(); } [Test, Sequence(2)] public void KillProcess() { p.Kill(); } [Test, Sequence(3)] public void TestObjectOnDeadProcess() { test.Value1 = 1; } } }To run the tests, I disable the other unit tests, as this particular set of tests will launch the server:
[Test, Sequence(3)] [ExpectedException(typeof(System.Net.WebException))] public void TestObjectOnDeadProcess() { test.Value1 = 1; }The next test verifies that, if the server is restarted, that nothing else is necessary to create new remote objects:
[Test, Sequence(4)] public void ReInitializeObjectsTest() { p = new Process(); p.StartInfo.FileName = @"..\..\..\Server\bin\debug\Server.exe"; p.Start(); test = RemotingHelper.CreateSingletonTestObject(); }
using System; using System.Diagnostics; using System.Runtime.Serialization; using System.Threading; using Vts.UnitTest; using Interfaces; namespace ClientTests { [TestFixture] public class EventTests { internal delegate void RemoteAsyncDelegate(); protected bool fired = false; protected ITest test; [Test] public void EventTest() { test = RemotingHelper.CreateSingletonTestObject(); test.MyEvent += new EventHandler(OnMyEvent); RemoteAsyncDelegate remoteDlgt = new RemoteAsyncDelegate( test.FireEvent); remoteDlgt.BeginInvoke(null, null); Thread.Sleep(100); Assertion.Assert(fired, "Expected event to be fired."); } protected void OnMyEvent(object sender, EventArgs e) { fired = true; } } }And the concrete implementation:
public void FireEvent() { if (MyEvent != null) { MyEvent(this, new EventArgs()); } }You will note that the unit test calling the FireEvent method on the remote object asynchronously. In turn, if the event is wired up, it should call back to the client. This code does not work (to some this will be obvious). A SerializationException "ClientTests is not marked as serializable" is thrown when the event is wired up. Why? Because the event is being wired up across application boundaries--the client and the server--and therefore server, which actually has an instance of ITest, has no knowledge of the instance that is sinking the event. Just for giggles, let's see what happens when the class is marked [Serializable]. This is the mechanism used for reference by value remoting, and creates a clone of the object. I do not expect that marking the client-side class as [Serializable] will solve the event wire-up problem. And it doesn't. In this case I get a System.Security.SecurityException "Type System.DelegateSerializationHolder and the types derived from it (such as System.DelegateSerializationHolder) are not permitted to be deserialized at this security level." And interesting error, isn't it?
[TestFixtureSetUp] public void TestFixtureSetup() { RemotingHelper.LoadConfiguration("clientConfig.xml"); } [TestFixtureTearDown] public void TestFixtureTearDown() { RemotingHelper.UnloadConfiguration(); }And the respective helper methods are:
public static void LoadConfiguration(string configFile) { RemotingConfiguration.Configure(configFile, false); } public static void UnloadConfiguration() { IChannel[] channels=ChannelServices.RegisteredChannels; foreach (IChannel channel in channels) { ChannelServices.UnregisterChannel(channel); } }
static void Main(string[] args) { RemotingConfiguration.Configure("serverConfig.xml", false); Console.WriteLine("Press a key to stop the server..."); Console.Read(); }And the XML file is:
<!-- server.exe.config --> <configuration> <system.runtime.remoting> <application> <service> <wellknown mode="SingleCall" type="Server.Test, server" objectUri="SingleCallTestService" /> <wellknown mode="Singleton" type="Server.Test, server" objectUri="SingletonTestService" /> <wellknown mode="SingleCall" type="Server.Factory, server" objectUri="FactoryService" /> <wellknown mode="SingleCall" type="Server.AsyncManagement, server" objectUri="AsyncThreadService" /> <wellknown mode="Singleton" type="Server.ComplexTest, server" objectUri="ComplexTestService" /> </service> <channels> <channel ref="http" port="65100"> <clientProviders> <formatter ref="binary"/> </clientProviders> <serverProviders> <formatter ref="binary" typeFilterLevel="Full"/> </serverProviders> </channel> </channels> </application> </system.runtime.remoting> </configuration>What's important here is the attribute typeFilterLevel="Full". Without this attribute, the server generates a security expection.
<!-- client.exe.config --> <configuration> <system.runtime.remoting> <application> <channels> <channel ref="http" port="0"> <clientProviders> <formatter ref="binary"/> </clientProviders> <serverProviders> <formatter ref="binary" typeFilterLevel="Full"/> </serverProviders> </channel> </channels> </application> </system.runtime.remoting> </configuration>Again, of great importance is that it defines a server provider with the typeFilterLevel="Full". It is necessary for the client to itself act as a server for the event callback to succeed. Furthermore, when the EventHandler signature, which has parameters object sender, EventArgs e, the client's server typeFilterLevel attribute must also be set to Full for the sender object to correctly marshal.
using System; using Interfaces; namespace EventShimHelper { [Serializable] public class EventShim : MarshalByRefObject { private EventHandler target; private EventShim(EventHandler target) { this.target += target; } public void Sink(object sender, EventArgs e) { target(sender, e); } public override object InitializeLifetimeService() { return null; } public static EventHandler Create(EventHandler target) { EventShim shim = new EventShim(target); return new EventHandler(shim.Sink); } } }Rather than get into the details of how and why the EventShim works, which is not really the purpose of this article, I'd recommend you read Scott Stewart's article and the source code from the Developmentor link provided above.
[Test] public void EventTest() { test = RemotingHelper.CreateSingletonTestObject(); EventHandler eh = EventShim.Create(new EventHandler(OnMyEvent)); test.MyEvent += eh; RemoteAsyncDelegate remoteDlgt = new RemoteAsyncDelegate(test.FireEvent); remoteDlgt.BeginInvoke(null, null); Thread.Sleep(250); test.MyEvent -= eh; Assertion.Assert(fired, "Expected event to be fired."); } protected void OnMyEvent(object sender, EventArgs e) { fired = true; }
[Test] public void EventExceptionTest() { test = RemotingHelper.CreateSingletonTestObject(); EventHandler eh = EventShim.Create(new EventHandler(OnExceptionEvent)); test.MyEvent += eh; test.FireEvent(); test.MyEvent -= eh; } protected void OnExceptionEvent(object sender, EventArgs e) { throw new ApplicationException("Exception Handler Test"); }
In this case, both unit tests pass:
However the server catches the client-side exception because of the try-catch block around the delegate on the server:
public void FireEvent() { try { if (MyEvent != null) { Console.WriteLine("Firing event."); MyEvent(this, EventArgs.Empty); } } catch (Exception e) { Console.WriteLine(e.Message); } }Let's write a test where the delegate invoke is surrounded by a try-catch block. Here's the server-side code:
public void FireEventNoCatch() { if (MyEvent != null) { Console.WriteLine("Firing event."); MyEvent(this, EventArgs.Empty); } }
Now the client sees the exception. Now we have two problems. When running the test again (without restarting the server), the test results are different:
And as with server-side exceptions, most likely, if the client throws an exception that the server does not have the metadata for, it will cause further problems. I'll test that in a minute, but first, I need to understand why the unit tests aren't repeatable. Most likely this has to do with the unhooking of the event. Remember that this is a singleton object, so all these tests get the same instance of the remote object. When the "no server catch" test fails, the event is not unhooked, and therefore a second pass of the EventTest will fail because we now have two events wired up--one that throws an exception (by calling OnExceptionEvent) and one that calls into OnMyEvent. Adding a try-finally block makes the unit tests repeatable:
[Test] [ExpectedException(typeof(ApplicationException))] public void EventExceptionNoServerCatchTest() { test = RemotingHelper.CreateSingletonTestObject(); EventHandler eh = EventShim.Create(new EventHandler(OnExceptionEvent)); test.MyEvent += eh; try { test.FireEventNoCatch(); } finally { test.MyEvent -= eh; } }And the ExpectedException attribute is added to make the unit test turn green.
[Test] public void ThrowSpecializedClientExceptionTest() { test = RemotingHelper.CreateSingletonTestObject(); EventHandler eh = EventShim.Create(new EventHandler( OnSpecializedExceptionEvent)); test.MyEvent += eh; try { test.FireEventNoCatch(); } finally { test.MyEvent -= eh; } }In this case, the exception itself generates a SerializationException "Type 'ClientTests.ClientException' in Assembly 'ClientTests, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null' is not marked as serializable." Marking the client specialized exception [Serializable] generates another SerializationException "Unable to find assembly 'ClientTests, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null'." So, to get a green unit test, the ExpectedException attribute must be defined:
[Test]
[ExpectedException(typeof(System.Runtime.Serialization.
SerializationException))]
public void ThrowSpecializedClientExceptionTest()
{
test = RemotingHelper.CreateSingletonTestObject();
EventHandler eh = EventShim.Create(new EventHandler(
OnSpecializedExceptionEvent));
test.MyEvent += eh;
try
{
test.FireEventNoCatch();
}
finally
{
test.MyEvent -= eh;
}
}
What's interesting as well is that, unlike the "no server catch test", which throws an exception type known to both the client and the server, the SerializationException refires after "finally" block. So, the unit test EventExceptionNoServerCatchTest does not need an ExpectedException attribute because the exception is caught and consumed by the unit test's try block. The same is not true for a SerializationException.
[Test] public void SingleCallInstantiationTest() { fired = false; test = RemotingHelper.CreatePerCallTestObject(); EventHandler eh = EventShim.Create(new EventHandler(OnMyEvent)); test.MyEvent += eh; RemoteAsyncDelegate remoteDlgt = new RemoteAsyncDelegate(test.FireEvent); remoteDlgt.BeginInvoke(null, null); Thread.Sleep(250); test.MyEvent -= eh; Assertion.Assert(!fired, "Expected event to NOT be fired."); }
| You must Sign In to use this message board. | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
General
News
Question
Answer
Joke
Rant
Admin
|
PermaLink |
Privacy |
Terms of Use
Last Updated: 7 Apr 2007 Editor: Marc Clifton |
Copyright 2007 by Marc Clifton Everything else Copyright © CodeProject, 1999-2009 Web20 | Advertise on the Code Project |